diff --git a/.github/workflows/auto-back-merge.yml b/.github/workflows/auto-back-merge.yml new file mode 100644 index 00000000..37a2b994 --- /dev/null +++ b/.github/workflows/auto-back-merge.yml @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# This workflow is triggered when a pull request is merged into the main branch and automatically merges the main branch back into develop to keep it up to date. +# If the merge fails (e.g., due to conflicts), a manual intervention is required. The workflow generates a Job summary of the merge attempt. +name: Auto Back-merge Main to Develop + +on: + pull_request: + types: + - closed + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + merge-main-to-develop: + + permissions: {} + + name: Back-merge Main to Develop + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + + - name: Generate Sync Token + id: sync-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_CLIENT_ID }} + private-key: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write + + - name: Merge main into develop and Generate Summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.sync-token.outputs.token }} + script: | + const prNumber = context.payload.pull_request.number; + const mergedBy = context.payload.sender.login; + const prUrl = context.payload.pull_request.html_url; + + try { + // Attempt to merge main into develop, use the api to ensure the commit + // is gpg signed. + await github.rest.repos.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'develop', + head: 'main', + commit_message: `Merge branch 'main' into 'develop' (#${prNumber})` + }); + + let summaryText = + `## Sync Main to Develop ✅ + + Successfully triggered a merge of \`main\` into \`develop\` following the closure of PR #${prNumber}. + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + } catch (error) { + const finalErrorMessage = error.message || error; + // Write failure summary + summaryText = + `## Sync Main to Develop ❌ + + Failed to trigger a merge of \`main\` into \`develop\`! This is usually due to a merge conflict. Please resolve it manually by opening a PR from \`main\` to \`develop\`. + + ### Error Details: + + \`\`\`text + ${finalErrorMessage} + \`\`\` + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + + // Fail the workflow step + core.setFailed(`Merge failed: ${finalErrorMessage}`); + } diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index 33b2096c..5d999c87 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -55,10 +55,10 @@ jobs: fi - name: Checkout repo - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Login to ghcr.io - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -68,7 +68,7 @@ jobs: run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - name: Get server and client jars - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: federator-*.jar path: target @@ -81,6 +81,16 @@ jobs: WORKSPACE: ${{ github.workspace }} run: docker build --no-cache --build-arg JAR_NAME="federator-server-${JAR_VERSION}" -t ghcr.io/${REPO}/federator-server:staged -f "$WORKSPACE/docker/Dockerfile.server" --target ${DOCKER_TARGET} . + - name: Run Trivy Scan on Server Docker Image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "ghcr.io/${{ env.REPO }}/federator-server:staged" + format: "table" + exit-code: "1" + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: true + - name: Tag Server Image with tag(s) ${{ inputs.image_tag }} env: IMAGE_TAG: ${{ inputs.image_tag }} @@ -95,6 +105,16 @@ jobs: WORKSPACE: ${{ github.workspace }} run: docker build --no-cache --build-arg JAR_NAME="federator-client-${JAR_VERSION}" -t ghcr.io/${REPO}/federator-client:staged -f "$WORKSPACE/docker/Dockerfile.client" --target ${DOCKER_TARGET} . + - name: Run Trivy Scan on Client Docker Image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "ghcr.io/${{ env.REPO }}/federator-client:staged" + format: "table" + exit-code: "1" + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: true + - name: Tag Client Image with tag(s) ${{ inputs.image_tag }} env: IMAGE_TAG: ${{ inputs.image_tag }} @@ -108,4 +128,5 @@ jobs: - name: Push Client Image if: ${{ !inputs.dry_run }} - run: docker push --all-tags ghcr.io/${REPO}/federator-client \ No newline at end of file + run: docker push --all-tags ghcr.io/${REPO}/federator-client + diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d6c00420..ddf9fa3f 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -32,6 +32,9 @@ on: env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" + DOCKER_TARGET: federator + GITHUB_REPOSITORY: ${{ github.repository }} + WORKSPACE: ${{ github.workspace }} jobs: build: @@ -74,4 +77,83 @@ jobs: - name: Lint env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS spotless:check \ No newline at end of file + run: ./mvnw $MAVEN_CLI_OPTS spotless:check + security-scanning: + permissions: + contents: read + pull-requests: read + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + server-password: 'GH_PACKAGES_PAT' + + - name: Get version + id: get_version + run: echo project_version=$(./mvnw $MAVEN_CLI_OPTS help:evaluate -Dexpression=project.version -q -DforceStdout) >> $GITHUB_OUTPUT + + - name: Checkout repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Login to ghcr.io + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Format repo name + run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + + - name: Get server and client jars + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + pattern: federator-*.jar + path: target + merge-multiple: true + + - name: Build JARs + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests + + - name: Build Server Image + env: + JAR_VERSION: ${{ steps.get_version.outputs.project_version }} + run: docker build --no-cache --build-arg JAR_NAME="federator-server-${JAR_VERSION}" -t ghcr.io/${REPO}/federator-server:staged -f "$WORKSPACE/docker/Dockerfile.server" --target ${DOCKER_TARGET} . + + - name: Run Trivy vulnerability scanner on server image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ghcr.io/${{ env.REPO }}/federator-server:staged + format: table + exit-code: 1 + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: false + + - name: Build Client Image + env: + JAR_VERSION: ${{ steps.get_version.outputs.project_version }} + run: docker build --no-cache --build-arg JAR_NAME="federator-client-${JAR_VERSION}" -t ghcr.io/${REPO}/federator-client:staged -f "$WORKSPACE/docker/Dockerfile.client" --target ${DOCKER_TARGET} . + + - name: Run Trivy vulnerability scanner on client image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ghcr.io/${{ env.REPO }}/federator-client:staged + format: table + exit-code: 1 + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: false + + - name: Clean up docker image + run: | + docker rmi ghcr.io/${REPO}/federator-server:staged + docker rmi ghcr.io/${REPO}/federator-client:staged \ No newline at end of file diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 6033c7df..80ec1a25 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Fetch GitHub App token for target repo id: target_token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -37,7 +37,7 @@ jobs: - name: Fetch GitHub App token for OSPO source repo (read-only) id: ospo_token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -103,10 +103,27 @@ jobs: core.info('Generated repository-metadata.json for policy context.'); - name: Install Conftest + env: + FALLBACK_VERSION: '0.67.1' run: | - LATEST_VERSION=$(curl --proto "=https" -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+') - curl --proto "=https" -L "https://github.com/open-policy-agent/conftest/releases/download/v${LATEST_VERSION}/conftest_${LATEST_VERSION}_Linux_x86_64.tar.gz" | tar -xz - sudo mv conftest /usr/local/bin/ + set -euo pipefail + + install_conftest() { + local version="$1" + local file_name="conftest_${version}_Linux_x86_64.deb" + curl --proto "=https" --fail -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${version}/${file_name}" -o "${file_name}" + sudo dpkg -i "${file_name}" + rm -f "${file_name}" + } + + LATEST_VERSION="$(curl --proto "=https" --fail -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+' || true)" + + if [[ -n "${LATEST_VERSION}" ]] && install_conftest "${LATEST_VERSION}"; then + echo "Installed latest Conftest version: ${LATEST_VERSION}" + else + echo "Failed to install latest Conftest. Falling back to version ${FALLBACK_VERSION}." + install_conftest "${FALLBACK_VERSION}" + fi - name: Run Policy Checks id: run_conftest @@ -449,7 +466,7 @@ jobs: - name: Upload OSS result artifacts if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: oss-checks-${{ github.run_id }} retention-days: 30 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9832c9d6..f68b86e4 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -15,6 +15,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: versioning: if: | @@ -22,6 +26,7 @@ jobs: (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) permissions: contents: read + name: Extract Release Version runs-on: ubuntu-latest outputs: @@ -62,6 +67,7 @@ jobs: generate-sbom: permissions: contents: read + name: Generate SPDX SBOM runs-on: ubuntu-latest needs: [versioning] @@ -85,7 +91,7 @@ jobs: echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: sbom path: sbom.spdx.json @@ -93,6 +99,7 @@ jobs: create-git-tag: permissions: contents: write + name: Create Git Tag needs: [versioning, generate-sbom] runs-on: ubuntu-latest @@ -117,17 +124,18 @@ jobs: create-git-release: permissions: contents: write + name: Create GitHub Release needs: [versioning, generate-sbom, create-git-tag] runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sbom - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: "v${{ needs.versioning.outputs.version }}" name: "Release v${{ needs.versioning.outputs.version }}" @@ -136,4 +144,3 @@ jobs: prerelease: false files: | sbom.spdx.json - diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b68be958..89f64012 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,10 @@ env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" on: - # Hopefully this will eventually be replaced with the release event instead + pull_request: + types: [closed] + branches: + - main workflow_dispatch: # To eventually be replaced with just using the version from the pom inputs: @@ -43,12 +46,15 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + if: | + github.event.pull_request.merged == true && + (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) outputs: project_version: ${{ steps.get-version.outputs.project_version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -61,14 +67,14 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS package - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Persist server id: persist-server with: name: federator-server-${{ steps.get-version.outputs.project_version }}.jar path: target/federator-server-${{ steps.get-version.outputs.project_version }}.jar retention-days: 1 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Persist client with: name: federator-client-${{ steps.get-version.outputs.project_version }}.jar @@ -83,13 +89,15 @@ jobs: contents: read packages: write id-token: write + outputs: + image_tag: ${{ steps.get_version.outputs.version }} env: GITHUB_ACTOR: ${{ github.actor }} GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -99,10 +107,18 @@ jobs: run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests - name: Publish package run: ./mvnw $MAVEN_CLI_OPTS deploy -DskipTests --settings .m2/settings.xml + - name: get image tag from branch + id: get_version + run: | + BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + VERSION="${BRANCH#release/}" + echo "version=$VERSION" >> $GITHUB_OUTPUT release-ghcr: - name: "Build and release docker images to GHCR with tags '${{ inputs.image_tag }}, latest'" - needs: verify + name: "Build and release docker images to GHCR with tags '${{ needs.publish.outputs.image_tag }} latest'" + needs: + - verify + - publish permissions: contents: read packages: write @@ -110,9 +126,9 @@ jobs: uses: ./.github/workflows/docker-ghcr.yml secrets: inherit with: - image_tag: "${{ inputs.image_tag }},latest" + image_tag: "${{ needs.publish.outputs.image_tag }},latest" jar_version: ${{ needs.verify.outputs.project_version }} - dry_run: ${{ inputs.dry_run }} + dry_run: false docker_target: federator cleanup: @@ -125,11 +141,11 @@ jobs: - verify if: ${{ needs.verify.result == 'success' }} steps: - - uses: geekyeggo/delete-artifact@v5 + - uses: geekyeggo/delete-artifact@f275313e70c08f6120db482d7a6b98377786765b # v5.1.0 name: Delete server artifact with: name: federator-server-${{ needs.verify.outputs.project_version }}.jar - - uses: geekyeggo/delete-artifact@v5 + - uses: geekyeggo/delete-artifact@f275313e70c08f6120db482d7a6b98377786765b # v5.1.0 name: Delete client artifact with: name: federator-client-${{ needs.verify.outputs.project_version }}.jar diff --git a/.gitignore b/.gitignore index a015316e..f38aeabc 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ server.log ###MKDOCS venv/ -.cache/ \ No newline at end of file +.cache/ +/docker/docker-grpc-resources/fake-gcs-data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8175ddec..94d881b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,46 @@ This project follows **Semantic Versioning (SemVer)** ([semver.org](https://semv --- +## 1.2.0 - 2026-03-26 + +### Added + +- Enable producer GCP streaming +- GKE support +- Missing docker attributes +- GCP cloud storage configuration for streaming files to consumer +- Ability to throw meaningful exceptions after retries +- Capabilities to Redis +- Trigger to the release workflow from main and trivy check job +- Trivy security scan to federator build pipeline +- Ability to retrieve the image tag from branch name in the release pipeline + +### Changed + +- Replaced versions with immutable hashes in GitHub workflows +- Resolve federator CVEs +- Hardened the federator client and server docker images +- Updated the readme documentation +- Remove continue on error config for the trivy scan job in the build pipelinE + +### Fixed + +- Fixed inconsistent messaging when enriching and rethrowing exception after retries +- Fixed memory leak relating to message conductors + +### Dependencies +- Added `com.google.cloud.libraries-bom` version `26.76.0` +- Added `org.codehaus.mojo.animal-sniffer-annotations` version `1.26` +- Added `com.google.j2objc.j2obc-annotations` version `3.1` +- Added `io.opentelemetry.opentelemetry-context` version `1.51.0` +- Added `io.opentelemetry.opentelementry-api` version `1.51.0` +- Added `com.google.cloud.google-cloud-storage` version `2.63.0` +- Bumped `org.apache.commons.commons-lang3` to version `3.18.0` +- Bumped `ch.qos.logback.logback-classic` to version `1.5.24` +- Bumped `org.apache.kafka.kafka-clients` to version `4.2.0` +- Bumped `com.fasterxml.jackson.jackson-bom` to version `2.18.6` + + ## 1.1.0 - 2026-02-19 ### Added diff --git a/README.md b/README.md index f0235507..a33e0308 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,32 @@ For steps to remove this repository and its dependencies, see [UNINSTALL.md](UNI The federator enables secure data exchange between Integration Architecture nodes, supporting both server (producer) and client (consumer) roles. Key features include: +### Data Federation - Secure, scalable data sharing using Kafka as both source and target. - Multiple federator servers and clients per organisation for flexible deployment. - Filtering of Kafka messages for federation is based on the `securityLabel` in the Kafka message header and the client’s credentials. The default filter performs an exact match between the client’s credentials and the `securityLabel` header (e.g., `Security-Label:nationality=GBR`). - Custom filtering logic can be configured; see [Configuring a Custom Filter](/docs/server-configuration.md) for details. - Communication between federator servers and clients uses gRPC over mTLS for secure, authenticated data transfer. - Federation currently supports RDF payloads, with extensibility hooks for other data formats on a per-topic basis. + +### File Streaming +- **File Transfer via gRPC**: Stream large files from server to client as chunked messages over the `GetFilesStream` RPC endpoint. +- **Multi-Cloud Storage Support**: Both producer (server) and consumer (client) support multiple storage backends: + - **Server (Producer)**: Read files from AWS S3, Azure Blob Storage, Google Cloud Storage (GCP), or Local filesystem + - **Client (Consumer)**: Write files to AWS S3, Azure Blob Storage, Google Cloud Storage (GCP), or Local filesystem +- **Integrity Verification**: SHA-256 checksums ensure file integrity during transfer +- **Resume Support**: Continue interrupted transfers using sequence IDs to avoid re-transferring complete files +- **Graceful Error Handling**: Server sends `StreamWarning` messages for invalid requests without terminating the stream, allowing subsequent files to be processed +- **S3-Compatible Storage**: Support for MinIO and other S3-compatible storage systems +- **Azure Support**: Works with Azure Blob Storage and Azurite emulator for local development +- **GCP Support**: Works with Google Cloud Storage and fake-gcs-server emulator for local development + +For detailed file streaming documentation, see [File Streaming README](/docs/FILE_STREAMING_README.md). + +### Common Infrastructure - Integration with Management-Node for centralised configuration, topic management, and authorisation. - Redis is used for offset tracking and short-lived configuration caching. +- JWT-based authentication with Identity Provider (e.g., Keycloak) for consumer verification and authorisation. An overview of the Federator service architecture is shown below: @@ -104,61 +122,77 @@ Additional note on connectivity and security: ### Exchange data between IA nodes -The Federator is designed to allow data exchange between Integration Architecture Nodes. Kafka brokers are used as both a source of data and a target of data that is to be moved between Integration Architecture nodes. It is run in a distributed manner with multiple servers and clients. +The Federator is designed to allow data exchange between Integration Architecture Nodes. It supports two primary modes of operation: -A simplistic view of the federator service is described below: +1. **RDF Message Streaming**: Kafka-to-Kafka message federation with filtering based on security labels +2. **File Streaming**: Large file transfer with multi-cloud storage support and integrity verification -#### Server (Producer) +Both modes use gRPC over mTLS for secure communication and are run in a distributed manner with multiple servers and clients. -1. A server (producer) reads messages from a knowledge topic within the source Kafka broker. -2. The server is configured so that it has a list of clients and the topics that they are allowed to read the messages from. -3. The server also has a configurable filter that is used to decide if a message should be sent to a client. -4. The server filters the messages based on the security label in the message header. -5. The server streams the selected filtered messages to the client(s) using the gRPC protocol over a network. +#### Server (Producer) - Simplified View -#### Client (Consumer) +**For RDF Messages:** +1. Reads messages from knowledge topics within the source Kafka broker +2. Authenticates clients using JWT tokens and verifies authorization +3. Filters messages based on security labels in message headers using configurable filters +4. Streams filtered messages to authorized clients via gRPC -1. A client (consumer) connects and then authenticates with its known server(s) using the gRPC protocol. -2. A client requests the list of topics that it is allowed to read from the server. -3. The client then requests the messages from the server for given topic(s). -4. The client reads the messages and then writes them to a target Kafka broker to a topic name that is prefixed with 'federated' +**For Files:** +1. Reads files from configured storage (S3, Azure, GCP, or Local filesystem) +2. Authenticates clients using JWT tokens and verifies authorization +3. Chunks files into manageable pieces with a configurable chunk size +4. Streams file chunks to authorized clients via gRPC with SHA-256 checksums for integrity verification -The underlying communication protocol is [gRPC](https://grpc.io/) which is used to communicate between the server and client at the network level. +#### Client (Consumer) - Simplified View -### Architecture +**For RDF Messages:** +1. Connects and authenticates with known server(s) using JWT tokens via gRPC +2. Requests message streams for authorized topics +3. Writes received messages to target Kafka broker with a configured topic prefix (e.g., 'federated') +4. Tracks offsets in Redis for resume capability -#### Federator Server (Producer) +**For Files:** +1. Connects and authenticates with known server(s) using JWT tokens via gRPC +2. Requests file streams, optionally resuming from a previous sequence ID +3. Assembles received chunks and verifies integrity using SHA-256 checksums +4. Uploads complete files to configured storage destination (S3, Azure, GCP, or Local) +5. Tracks file sequence offsets in Redis for resume capability -This app starts the data federation server that starts a gRPC service. +The underlying communication protocol is [gRPC](https://grpc.io/) over mTLS, providing secure, authenticated data transfer between servers and clients. -This process contains the federator service supplying two RPC endpoints that are called by the client: +### Architecture -- Get Kafka Topics (obtain topics) -- Get kafka Consumer (consume topic) +#### Federator Server (Producer) -##### Obtain Topics +This app starts the data federation server that starts a gRPC service. -1. Is passed a user request (a client-id and key) -2. Authenticate the given credentials -3. Returns the topics that have been assigned to the given user. +This process contains the federator service supplying RPC endpoints that are called by the client: -##### Consume Topic +- **GetKafkaConsumer** - Stream RDF messages from Kafka topics to clients +- **GetFilesStream** - Stream files as chunks to clients with integrity verification -1. Is passed a topic request (client-id, key, topic & offset) -2. Validates the given details. -3. Creates a message conductor to process the topic. -4. Consumes and returns messages until stopped. +Both endpoints authenticate clients using JWT tokens and verify authorization against the Management-Node configuration before streaming data. #### Federator Client (Consumer) -A somewhat simple app it does the following: - -1. Obtains topic(s) from the Server -2. Checks with Redis to see what the offset is for given topic -3. Obtain kafka consumer from the Server -4. Process messages from consumer, adding to destination topic and update Redis offset count. -5. Continue (4) until stopped. - If configured, it will repeat 1-5 upon failures +The client connects to one or more servers and performs the following: + +**For RDF Message Streaming (GetKafkaConsumer):** +1. Authenticates with the server using JWT tokens +2. Checks Redis for the current offset for each topic +3. Requests message stream from the server +4. Processes messages and writes them to the destination Kafka topic (with configured prefix, e.g., 'federated') +5. Updates Redis offset tracking as messages are processed +6. Continues streaming until stopped; retries on failures if configured + +**For File Streaming (GetFilesStream):** +1. Authenticates with the server using JWT tokens +2. Checks Redis for the last processed file sequence ID +3. Requests file stream from the server, optionally resuming from a previous sequence +4. Receives file chunks, assembles them locally, and verifies integrity using SHA-256 checksums +5. Uploads complete files to the configured storage destination (S3, Azure, GCP, or Local) +6. Updates Redis offset tracking as files are successfully processed +7. Handles StreamWarning messages by logging and advancing offsets to skip unrecoverable errors Please refer to this context diagram as an overview of the federator service and its components: diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client index 0419c788..ed6ba8ec 100644 --- a/docker/Dockerfile.client +++ b/docker/Dockerfile.client @@ -25,20 +25,23 @@ FROM eclipse-temurin:21 AS federator LABEL org.opencontainers.image.source=https://github.com/National-Digital-Twin/federator -RUN mkdir -p /library/ /app/ -RUN useradd -Mg root federator-service -RUN chown federator-service /library/ /app/ +RUN mkdir -p /library/ /app/ && \ + groupadd appgroup && \ + useradd -Mg appgroup federator-service && \ + chown -R :appgroup /library/ /app/ WORKDIR /app -USER federator-service ARG JAR_NAME COPY target/${JAR_NAME}.jar /app/app.jar +USER federator-service ENTRYPOINT java -cp /app/app.jar:/library/* $PROPERTIES uk.gov.dbt.ndtp.federator.FederatorClient $ARGS # Federation Client with MSK auth support FROM federator AS federator-msk USER root -RUN mkdir -p /baked-library/ -RUN chown federator-service /baked-library/ +RUN mkdir -p /baked-library/ && \ + groupadd appgroup && \ + useradd -Mg appgroup federator-service && \ + chown -R :appgroup /baked-library/ ARG MSK_VERSION=2.3.0 ADD --chmod=644 https://github.com/aws/aws-msk-iam-auth/releases/download/v${MSK_VERSION}/aws-msk-iam-auth-${MSK_VERSION}-all.jar /baked-library/ USER federator-service diff --git a/docker/Dockerfile.gh-actions b/docker/Dockerfile.gh-actions index 0c4733b3..6605a7ff 100644 --- a/docker/Dockerfile.gh-actions +++ b/docker/Dockerfile.gh-actions @@ -30,9 +30,10 @@ RUN apt-get update && \ ARG PROJECT_VERSION ARG ARTIFACT_ID=federator -RUN mkdir -p /app/ /app/lib/ /app/agents/ /opt/ianode/sbom/ -RUN useradd -Mg root ianode-service -RUN chown ianode-service /app/ /app/lib/ /app/agents/ /opt/ianode/sbom/ +RUN mkdir -p /app/ /app/lib/ /app/agents/ /opt/ianode/sbom/ && \ + groupadd appgroup && \ + useradd -Mg appgroup ianode-service && \ + chown -R :appgroup /app/ /app/lib/ /app/agents/ /opt/ianode/sbom/ WORKDIR /app USER ianode-service ENV PROJECT_VERSION=${PROJECT_VERSION} diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 8d045896..05e502db 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -25,20 +25,23 @@ FROM eclipse-temurin:21 AS federator LABEL org.opencontainers.image.source=https://github.com/National-Digital-Twin/federator -RUN mkdir -p /library/ /app/ -RUN useradd -Mg root federator-service -RUN chown federator-service /library/ /app/ +RUN mkdir -p /library/ /app/ && \ + groupadd appgroup && \ + useradd -Mg appgroup federator-service && \ + chown -R :appgroup /library/ /app/ WORKDIR /app -USER federator-service ARG JAR_NAME COPY target/${JAR_NAME}.jar /app/app.jar +USER federator-service ENTRYPOINT java -cp /app/app.jar:/library/* $PROPERTIES uk.gov.dbt.ndtp.federator.FederatorServer $ARGS # Federation Server with MSK auth support FROM federator AS federator-msk USER root -RUN mkdir -p /baked-library/ -RUN chown federator-service /baked-library/ +RUN mkdir -p /baked-library/ && \ + groupadd appgroup && \ + useradd -Mg appgroup federator-service && \ + chown -R :appgroup /baked-library/ ARG MSK_VERSION=2.3.0 ADD --chmod=644 https://github.com/aws/aws-msk-iam-auth/releases/download/v${MSK_VERSION}/aws-msk-iam-auth-${MSK_VERSION}-all.jar /baked-library/ USER federator-service diff --git a/docker/docker-compose-grpc-no-client.yml b/docker/docker-compose-grpc-no-client.yml index 72b2aebe..881cc693 100644 --- a/docker/docker-compose-grpc-no-client.yml +++ b/docker/docker-compose-grpc-no-client.yml @@ -22,6 +22,10 @@ services: federator-server: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -36,4 +40,9 @@ services: volumes: - ./docker-grpc-resources/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure diff --git a/docker/docker-compose-grpc-single-client-multi-server.yml b/docker/docker-compose-grpc-single-client-multi-server.yml index 29bdd1c1..c801e5e9 100644 --- a/docker/docker-compose-grpc-single-client-multi-server.yml +++ b/docker/docker-compose-grpc-single-client-multi-server.yml @@ -22,6 +22,10 @@ services: federator-server: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties networks: @@ -32,11 +36,20 @@ services: volumes: - ./docker-grpc-resources/single-client-multiple-servers/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties networks: @@ -47,11 +60,20 @@ services: volumes: - ./docker-grpc-resources/single-client-multiple-servers/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client + cap_drop: + - ALL + security_opt: + - no-new-privileges:true build: context: ../ dockerfile: docker/Dockerfile.client @@ -63,6 +85,11 @@ services: - core volumes: - ./docker-grpc-resources/single-client-multiple-servers/client.properties:/config/client.properties + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 depends_on: redis: condition: service_healthy @@ -81,6 +108,10 @@ services: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: kafka-bulk-test-data-loader: condition: service_completed_successfully @@ -94,4 +125,9 @@ services: networks: - core volumes: - - ../input:/usr/bin/input \ No newline at end of file + - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 \ No newline at end of file diff --git a/docker/docker-compose-grpc.yml b/docker/docker-compose-grpc.yml index 89402faa..5c55fb2a 100644 --- a/docker/docker-compose-grpc.yml +++ b/docker/docker-compose-grpc.yml @@ -28,6 +28,10 @@ services: # image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} image: ghcr.io/national-digital-twin/federator/federator-server:0.90.0 container_name: federator-server + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -40,18 +44,32 @@ services: volumes: - ./docker-grpc-resources/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client: # image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} image: ghcr.io/national-digital-twin/federator/federator-client:0.90.0 container_name: federator-client + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: - core volumes: - ./docker-grpc-resources/client.properties:/config/client.properties + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 depends_on: redis: condition: service_healthy @@ -68,6 +86,10 @@ services: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: federator-client: condition: service_started @@ -82,3 +104,8 @@ services: - core volumes: - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 \ No newline at end of file diff --git a/docker/docker-compose-multiple-clients-multiple-server.yml b/docker/docker-compose-multiple-clients-multiple-server.yml index 59783fe3..d3e574d9 100644 --- a/docker/docker-compose-multiple-clients-multiple-server.yml +++ b/docker/docker-compose-multiple-clients-multiple-server.yml @@ -28,6 +28,10 @@ services: zookeeper-target-2: image: confluentinc/cp-zookeeper:7.5.3 container_name: zookeeper-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: ZOOKEEPER_CLIENT_PORT: 32183 ZOOKEEPER_SERVER_ID: 1 @@ -35,13 +39,22 @@ services: networks: - core ports: - - "32183:32183" + - "127.0.0.1:32183:32183" hostname: zookeeper-target-2 + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32183'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-target-2: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:29094,LISTENER_DOCKER_INTERNAL://kafka-target-2:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9094 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -55,7 +68,7 @@ services: KAFKA_JMX_PORT: 9997 KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} ports: - - "29094:29094" + - "127.0.0.1:29094:29094" healthcheck: test: "kafka-topics --list --bootstrap-server localhost:9094 || exit 1" interval: 1s @@ -70,6 +83,10 @@ services: federator-server-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties networks: @@ -82,11 +99,20 @@ services: volumes: - ./docker-grpc-resources/multiple-clients-multiple-server/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties networks: @@ -99,17 +125,31 @@ services: volumes: - ./docker-grpc-resources/multiple-clients-multiple-server/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: - core volumes: - ./docker-grpc-resources/multiple-clients-multiple-server/client1.properties:/config/client.properties + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 depends_on: redis: condition: service_healthy @@ -126,12 +166,21 @@ services: federator-client-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: - core volumes: - ./docker-grpc-resources/multiple-clients-multiple-server/client2.properties:/config/client.properties + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 depends_on: redis: condition: service_healthy @@ -150,6 +199,10 @@ services: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: kafka-bulk-test-data-loader: condition: service_completed_successfully @@ -166,12 +219,21 @@ services: - core volumes: - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 kafka-message-counter-2: build: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: kafka-bulk-test-data-loader: condition: service_completed_successfully @@ -188,3 +250,8 @@ services: - core volumes: - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 diff --git a/docker/docker-compose-multiple-clients-single-server.yml b/docker/docker-compose-multiple-clients-single-server.yml index 7c713377..9a9d0ab7 100644 --- a/docker/docker-compose-multiple-clients-single-server.yml +++ b/docker/docker-compose-multiple-clients-single-server.yml @@ -28,6 +28,10 @@ services: zookeeper-target-2: image: confluentinc/cp-zookeeper:7.5.3 container_name: zookeeper-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: ZOOKEEPER_CLIENT_PORT: 32183 ZOOKEEPER_SERVER_ID: 1 @@ -35,13 +39,22 @@ services: networks: - core ports: - - "32183:32183" + - "127.0.0.1:32183:32183" hostname: zookeeper-target-2 + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32183'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-target-2: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:29094,LISTENER_DOCKER_INTERNAL://kafka-target-2:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9094 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -55,7 +68,7 @@ services: KAFKA_JMX_PORT: 9997 KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} ports: - - "29094:29094" + - "127.0.0.1:29094:29094" healthcheck: test: "kafka-topics --list --bootstrap-server localhost:9094 || exit 1" interval: 1s @@ -67,10 +80,13 @@ services: - zookeeper-target-2 restart: on-failure - federator-server: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -83,11 +99,20 @@ services: volumes: - ./docker-grpc-resources/multiple-clients-single-server/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -103,11 +128,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -123,6 +157,11 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure kafka-message-counter: @@ -130,6 +169,10 @@ services: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: kafka-bulk-test-data-loader: condition: service_completed_successfully @@ -146,12 +189,21 @@ services: - core volumes: - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 kafka-message-counter-2: build: context: kafka-message-counter dockerfile: Dockerfile container_name: kafka-message-counter-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: kafka-bulk-test-data-loader: condition: service_completed_successfully @@ -168,3 +220,8 @@ services: - core volumes: - ../input:/usr/bin/input + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 diff --git a/docker/docker-compose-single-client-ten-servers.yml b/docker/docker-compose-single-client-ten-servers.yml index a0e1eccf..fba3b3e8 100644 --- a/docker/docker-compose-single-client-ten-servers.yml +++ b/docker/docker-compose-single-client-ten-servers.yml @@ -23,6 +23,10 @@ services: zookeeper-target-2: image: confluentinc/cp-zookeeper:7.5.3 container_name: zookeeper-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: ZOOKEEPER_CLIENT_PORT: 32183 ZOOKEEPER_SERVER_ID: 1 @@ -30,13 +34,22 @@ services: networks: - core ports: - - "32183:32183" + - "127.0.0.1:32183:32183" hostname: zookeeper-target-2 + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32183'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-target-2: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:29094,LISTENER_DOCKER_INTERNAL://kafka-target-2:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9094 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -50,7 +63,7 @@ services: KAFKA_JMX_PORT: 9997 KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} ports: - - "29094:29094" + - "127.0.0.1:29094:29094" healthcheck: test: "kafka-topics --list --bootstrap-server localhost:9094 || exit 1" interval: 1s @@ -65,6 +78,10 @@ services: federator-server-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -77,11 +94,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server1.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8080 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -94,11 +120,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server2.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8081 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-3: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-3 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -111,11 +146,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server3.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8082 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-4: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-4 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -128,11 +172,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server4.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8083 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-5: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-5 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -145,11 +198,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server5.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8084 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-6: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-6 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -162,11 +224,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server6.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8085 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-7: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-7 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -179,11 +250,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server7.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8086 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-8: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-8 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -196,11 +276,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server8.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8087 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-9: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-9 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -213,11 +302,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server9.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8088 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-server-10: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server-10 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -230,17 +328,31 @@ services: volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/server10.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8089 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: - core volumes: - ./docker-grpc-resources/performance-tests/single-client-ten-servers/client1.properties:/config/client.properties + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 depends_on: redis: condition: service_healthy diff --git a/docker/docker-compose-ten-clients-single-server.yml b/docker/docker-compose-ten-clients-single-server.yml index 51470bda..b2a3add1 100644 --- a/docker/docker-compose-ten-clients-single-server.yml +++ b/docker/docker-compose-ten-clients-single-server.yml @@ -23,6 +23,10 @@ services: zookeeper-target-2: image: confluentinc/cp-zookeeper:7.5.3 container_name: zookeeper-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: ZOOKEEPER_CLIENT_PORT: 32183 ZOOKEEPER_SERVER_ID: 1 @@ -30,13 +34,22 @@ services: networks: - core ports: - - "32183:32183" + - "127.0.0.1:32183:32183" hostname: zookeeper-target-2 + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32183'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-target-2: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-target-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:29094,LISTENER_DOCKER_INTERNAL://kafka-target-2:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9094 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -50,7 +63,7 @@ services: KAFKA_JMX_PORT: 9997 KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1} ports: - - "29094:29094" + - "127.0.0.1:29094:29094" healthcheck: test: "kafka-topics --list --bootstrap-server localhost:9094 || exit 1" interval: 1s @@ -66,6 +79,10 @@ services: federator-server: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-server:${VERSION} container_name: federator-server + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_SERVER_PROPERTIES: /config/server.properties ports: @@ -78,11 +95,20 @@ services: volumes: - ./docker-grpc-resources/performance-tests/ten-clients-single-server/server.properties:/config/server.properties - ./filter:/library + healthcheck: + test: "curl -f http://localhost:8089 || exit 1" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-1: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-1 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -98,11 +124,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-2: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-2 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -118,11 +153,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-3: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-3 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -138,11 +182,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-4: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-4 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -158,51 +211,78 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-5: - image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} - container_name: federator-client-5 - environment: - FEDERATOR_CLIENT_PROPERTIES: /config/client.properties - networks: - - core - volumes: - - ./docker-grpc-resources/performance-tests/ten-clients-single-server/client5.properties:/config/client.properties - depends_on: - redis: - condition: service_healthy - kafka-target: - condition: service_healthy - kafka-topics-populator: - condition: service_completed_successfully - federator-server: - condition: service_started - restart: on-failure + image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} + container_name: federator-client-5 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + FEDERATOR_CLIENT_PROPERTIES: /config/client.properties + networks: + - core + volumes: + - ./docker-grpc-resources/performance-tests/ten-clients-single-server/client5.properties:/config/client.properties + depends_on: + redis: + condition: service_healthy + kafka-target: + condition: service_healthy + kafka-topics-populator: + condition: service_completed_successfully + federator-server: + condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 + restart: on-failure federator-client-6: - image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} - container_name: federator-client-6 - environment: - FEDERATOR_CLIENT_PROPERTIES: /config/client.properties - networks: - - core - volumes: - - ./docker-grpc-resources/performance-tests/ten-clients-single-server/client6.properties:/config/client.properties - depends_on: - redis: - condition: service_healthy - kafka-target: - condition: service_healthy - kafka-topics-populator: - condition: service_completed_successfully - federator-server: - condition: service_started - restart: on-failure + image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} + container_name: federator-client-6 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + FEDERATOR_CLIENT_PROPERTIES: /config/client.properties + networks: + - core + volumes: + - ./docker-grpc-resources/performance-tests/ten-clients-single-server/client6.properties:/config/client.properties + depends_on: + redis: + condition: service_healthy + kafka-target: + condition: service_healthy + kafka-topics-populator: + condition: service_completed_successfully + federator-server: + condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 + restart: on-failure federator-client-7: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-7 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -218,11 +298,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-8: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-8 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -238,11 +327,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-9: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-9 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -258,11 +356,20 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure federator-client-10: image: uk.gov.dbt.ndtp/${ARTIFACT_ID}-client:${VERSION} container_name: federator-client-10 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: FEDERATOR_CLIENT_PROPERTIES: /config/client.properties networks: @@ -278,4 +385,9 @@ services: condition: service_completed_successfully federator-server: condition: service_started + healthcheck: + test: "bash -c \"pgrep -f federator-client || exit 1\"" + interval: 10s + timeout: 5s + retries: 5 restart: on-failure diff --git a/docker/docker-grpc-resources/docker-compose-shared.yml b/docker/docker-grpc-resources/docker-compose-shared.yml index f74f66e8..dbf14619 100644 --- a/docker/docker-grpc-resources/docker-compose-shared.yml +++ b/docker/docker-grpc-resources/docker-compose-shared.yml @@ -32,11 +32,20 @@ services: ports: - 32181:32181 hostname: zookeeper-src + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32181'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-src: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-src + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:19093,LISTENER_DOCKER_INTERNAL://kafka-src:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -65,6 +74,10 @@ services: zookeeper-target: image: confluentinc/cp-zookeeper:7.5.3 container_name: zookeeper-target + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: ZOOKEEPER_CLIENT_PORT: 32182 ZOOKEEPER_SERVER_ID: 1 @@ -74,11 +87,20 @@ services: ports: - 32182:32182 hostname: zookeeper-target + healthcheck: + test: "bash -c 'echo ruok | nc localhost 32182'" + interval: 1s + timeout: 60s + retries: 60 restart: on-failure kafka-target: image: confluentinc/cp-kafka:7.5.3 container_name: kafka-target + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KAFKA_ADVERTISED_LISTENERS: EXTERNAL_DOCKER_INTERNAL://localhost:29093,LISTENER_DOCKER_INTERNAL://kafka-target:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9093 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT @@ -108,6 +130,14 @@ services: redis: image: redis container_name: redis + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + security_opt: + - no-new-privileges:true ports: - '6380:6379' healthcheck: @@ -125,6 +155,15 @@ services: context: ../kafka-topic-creator dockerfile: ../kafka-topic-creator/Dockerfile container_name: kafka-topics-creator + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 depends_on: zookeeper-src: condition: service_started @@ -142,6 +181,15 @@ services: context: ../kafka-topic-populator dockerfile: ../kafka-topic-populator/Dockerfile container_name: kafka-topics-populator + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 depends_on: zookeeper-src: condition: service_started @@ -164,6 +212,15 @@ services: context: ../kafka-test-data-producer dockerfile: ../kafka-test-data-producer/Dockerfile container_name: kafka-test-data-producer + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 depends_on: kafka-src: condition: service_healthy @@ -190,6 +247,15 @@ services: context: ../kafka-bulk-test-data-loader dockerfile: ../kafka-bulk-test-data-loader/Dockerfile container_name: kafka-bulk-test-data-loader + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: "kafka-topics --list --bootstrap-server localhost:9092 || exit 1" + interval: 1s + timeout: 60s + retries: 60 depends_on: zookeeper-src: condition: service_started @@ -210,6 +276,10 @@ services: kafka-ui: image: provectuslabs/kafka-ui:latest container_name: kafka-ui + cap_drop: + - ALL + security_opt: + - no-new-privileges:true ports: - "8082:8080" environment: @@ -232,6 +302,11 @@ services: - kafka-target networks: - core + healthcheck: + test: "sh -c 'wget -q --spider http://localhost:8080/ || exit 1'" + interval: 30s + timeout: 10s + retries: 5 restart: on-failure @@ -263,6 +338,23 @@ services: volumes: - ./azurite-data:/data + fake-gcs: + image: fsouza/fake-gcs-server:latest + container_name: fake-gcs + ports: + - "9010:9010" # GCS API + command: > + -scheme http + -host 0.0.0.0 + -port 9010 + -backend filesystem + -filesystem-root /data + -public-host localhost:9010 + networks: + - core + volumes: + - ./fake-gcs-data:/data + networks: core: name: core diff --git a/docker/kafka-bulk-test-data-loader/Dockerfile b/docker/kafka-bulk-test-data-loader/Dockerfile index 38c67e4f..7481de52 100644 --- a/docker/kafka-bulk-test-data-loader/Dockerfile +++ b/docker/kafka-bulk-test-data-loader/Dockerfile @@ -25,5 +25,7 @@ FROM confluentinc/cp-kafka:7.5.3 WORKDIR /usr/bin +USER appuser + COPY bulk-test-data-loader.sh bulk-test-data-loader.sh ENTRYPOINT ["./bulk-test-data-loader.sh"] diff --git a/docker/kafka-message-counter/Dockerfile b/docker/kafka-message-counter/Dockerfile index fcc6e001..a0960d4b 100644 --- a/docker/kafka-message-counter/Dockerfile +++ b/docker/kafka-message-counter/Dockerfile @@ -25,5 +25,7 @@ FROM confluentinc/cp-kafka:7.5.3 WORKDIR /usr/bin +USER appuser + COPY count-kafka-messages.sh count-kafka-messages.sh ENTRYPOINT ["./count-kafka-messages.sh"] diff --git a/docker/kafka-test-data-producer/Dockerfile b/docker/kafka-test-data-producer/Dockerfile index e9490f25..318a7ce3 100644 --- a/docker/kafka-test-data-producer/Dockerfile +++ b/docker/kafka-test-data-producer/Dockerfile @@ -24,6 +24,7 @@ FROM confluentinc/cp-kafka:7.5.3 WORKDIR /usr/bin +USER appuser # Once it is executed, this container is not needed. COPY create-test-data.sh create-test-data.sh ENTRYPOINT ["./create-test-data.sh"] diff --git a/docker/kafka-topic-creator/Dockerfile b/docker/kafka-topic-creator/Dockerfile index 6d242489..9c90660d 100644 --- a/docker/kafka-topic-creator/Dockerfile +++ b/docker/kafka-topic-creator/Dockerfile @@ -28,4 +28,6 @@ WORKDIR /usr/bin # Once it is executed, this container is not needed. COPY create-kafka-topics.sh create-kafka-topics.sh +USER appuser + ENTRYPOINT ["./create-kafka-topics.sh"] diff --git a/docker/kafka-topic-populator/Dockerfile b/docker/kafka-topic-populator/Dockerfile index a0f4c13a..d4affbbb 100644 --- a/docker/kafka-topic-populator/Dockerfile +++ b/docker/kafka-topic-populator/Dockerfile @@ -28,4 +28,6 @@ WORKDIR /usr/bin # Once it is executed, this container is not needed. COPY populate-kafka-topics.sh populate-kafka-topics.sh +USER appuser + ENTRYPOINT ["./populate-kafka-topics.sh"] diff --git a/docs/FILE_STREAMINING_README.md b/docs/FILE_STREAMING_README.md similarity index 80% rename from docs/FILE_STREAMINING_README.md rename to docs/FILE_STREAMING_README.md index db7fcdd2..1af3fcc4 100644 --- a/docs/FILE_STREAMINING_README.md +++ b/docs/FILE_STREAMING_README.md @@ -4,19 +4,21 @@ This document explains the Federator file streaming capability, which sends files from the server to clients over gRPC as a sequence of chunks. It covers the architecture, stream/chunk protocol, configuration, offsets/resume semantics, storage providers, error handling, and testing guidance. +**Note:** This document was previously named `FILE_STREAMINING_README.md` (with a typo) and has been renamed to `FILE_STREAMING_README.md`. + Key highlights: - gRPC bidirectional-style server streaming (`GetFilesStream`) delivering `FileStreamEvent` messages containing either `FileChunk` or `StreamWarning`. - Deterministic chunking with a configurable `chunkSize` on the server. - End-of-file signaled via a final chunk with `is_last_chunk = true` and `file_checksum` (SHA-256). - Graceful error handling: server sends `StreamWarning` for deserialization/validation errors without terminating the stream. - Resume support using `start_sequence_id` to continue from a previously received point. -- Producer supports multiple source providers: AWS S3 and Azure Blob Storage, plus Local file system. -- Consumer supports AWS S3 or Azure Blob Storage as the final destination; local disk may be used for temporary assembly. -- Pluggable storage providers (LOCAL, S3, AZURE) for reading and writing files or parts. +- Producer supports multiple source providers: AWS S3, Azure Blob Storage, Google Cloud Storage (GCP), plus Local file system. +- Consumer supports AWS S3, Azure Blob Storage, or Google Cloud Storage (GCP) as the final destination; local disk may be used for temporary assembly. +- Pluggable storage providers (LOCAL, S3, AZURE, GCP) for reading and writing files or parts. ## Architecture -At a high level, a client requests a file stream from the Federator server. The server locates and reads the source file (via `FileProvider` implementations) from S3, Azure, or Local and streams chunks to the client. The client assembles and verifies integrity using the final checksum. On the consumer side, the final destination is S3 or Azure, with bucket/container configured in `client.properties` and the object path resolved from database configuration. +At a high level, a client requests a file stream from the Federator server. The server locates and reads the source file (via `FileProvider` implementations) from S3, Azure, GCP, or Local and streams chunks to the client. The client assembles and verifies integrity using the final checksum. On the consumer side, the final destination is S3, Azure, or GCP, with bucket/container configured in `client.properties` and the object path resolved from database configuration. ```mermaid flowchart LR @@ -26,9 +28,10 @@ flowchart LR D -->|LOCAL| E[Local FS] D -->|S3| F[S3 Bucket] D -->|AZURE| Z[Azure Blob Container] + D -->|GCP| Y[GCS Bucket] C -- stream FileChunk --> A A -.-> G[Assembler] - G -->|Finalize| H[Consumer Destination S3 or Azure] + G -->|Finalize| H[Consumer Destination S3, Azure, or GCP] %% Using unlabeled dotted edges to avoid Mermaid lexical issues with label text H -.-> I[Bucket/Container from client.properties] H -.-> J[Destination path from database] @@ -38,11 +41,11 @@ flowchart LR - `FederatorService.proto` defines `GetFilesStream(FileStreamRequest) returns (stream FileStreamEvent)`. - `FileKafkaEventMessageProcessor` deserializes and validates Kafka messages; on error, emits `StreamWarning` instead of terminating the stream. - `FileChunkStreamer` reads the file in `chunkSize` blocks, emits `FileChunk` messages wrapped in `FileStreamEvent`, and sends a final last-chunk with checksum. -- `FileProviderFactory` resolves the concrete `FileProvider` for the configured source type (LOCAL, S3, Azure). +- `FileProviderFactory` resolves the concrete `FileProvider` for the configured source type (LOCAL, S3, Azure, GCP). ### Client-side components - A gRPC client (`ClientGRPCJob` orchestrator and related handlers) initiates the stream request, consumes chunks, writes temporary parts, and finalizes the file. -- Client storage is pluggable for temp assembly (LOCAL or S3) and final destination is S3 via implementations such as `S3ReceivedFileStorage`. +- Client storage is pluggable for temp assembly and final destination (LOCAL, S3, AZURE, or GCP) via implementations such as `S3ReceivedFileStorage`, `AzureReceivedFileStorage`, and `GCPReceivedFileStorage`. ```mermaid sequenceDiagram @@ -76,7 +79,7 @@ sequenceDiagram - gRPC + Protobuf (`FederatorService.proto` → generated stubs in `uk.gov.dbt.ndtp.grpc`) - Java (server and client components) -- Storage providers: Local filesystem, AWS S3 or S3-compatible such as MinIO, Azure Blob Storage +- Storage providers: Local filesystem, AWS S3 or S3-compatible such as MinIO, Azure Blob Storage, Google Cloud Storage (GCP) - Redis and Kafka exist in the broader system; file streaming can be used alongside other data paths ## Getting Started @@ -106,16 +109,16 @@ flowchart TD Primary client settings are in `src/configs/client.properties`: - Storage provider - - `client.files.storage.provider` = `LOCAL` | `S3` | `AZURE` (default `LOCAL`) + - `client.files.storage.provider` = `LOCAL` | `S3` | `AZURE` | `GCP` (default `LOCAL`) - Local storage - `client.files.temp.dir` — directory for received files and temporary parts ### Local temp directory (`client.files.temp.dir`) -- Purpose: This directory is used by the client to assemble incoming chunks. During transfer, parts are written to `/.parts/..part`. On the final chunk, the `.part` file is moved to `/` and then handed off to the configured storage provider (LOCAL, S3, or AZURE). +- Purpose: This directory is used by the client to assemble incoming chunks. During transfer, parts are written to `/.parts/..part`. On the final chunk, the `.part` file is moved to `/` and then handed off to the configured storage provider (LOCAL, S3, AZURE, or GCP). - Defaults: If the property is blank or missing, it falls back to `${java.io.tmpdir}/federator-files`. - Cleanup behavior: - - Success to remote (S3 or Azure): The assembled local file is best-effort deleted by the remote storage provider after upload. + - Success to remote (S3, Azure, or GCP): The assembled local file is best-effort deleted by the remote storage provider after upload. - Upload failure: The remote provider also attempts to delete the assembled local file and does not advance offsets. - Integrity failure (checksum/size): The assembler deletes the `.part` file and aborts. - Interruption/crash: A stale `.part` may remain; you can safely remove `*.part` files that are older than your retention window. @@ -132,6 +135,12 @@ Primary client settings are in `src/configs/client.properties`: - `files.azure.container` - `azure.storage.connection.string` +- GCP settings (also used by server-side components in some deployments) + - `files.gcp.bucket` + - `gcp.storage.project.id` + - `gcp.storage.credentials.file` + - `gcp.storage.endpoint.url` + ### S3 settings — when to set and when to leave blank The following guidance explains which S3 properties must be set or left blank for common deployment scenarios. The client uses an AWS SDK–style credential resolution (via `S3ClientFactory`) and supports static keys, shared profiles/SSO, or instance/role credentials. Only configure one credentials method at a time. @@ -239,11 +248,61 @@ files.azure.container=dev-container azure.storage.connection.string=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFeqCdt8x3Pp0G...==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1; ``` +### GCP settings — when to set and when to leave blank + +The client and server use GCP configuration via `GcsClientFactory` with support for service account credentials or Application Default Credentials (ADC). ADC automatically resolves credentials from environment variables, gcloud CLI, or GCE/GKE metadata. + +- files.gcp.bucket + - Set: Required for the consumer when the final destination is GCP. This is the target GCS bucket name. + - Blank: Only permissible if the consumer is not writing to GCP (e.g., using S3, Azure, or LOCAL for destination). + +- gcp.storage.project.id + - Set: Optional. If set, this project ID will be used for GCS operations. + - Blank: When not set, the project ID is resolved from the service account credentials file or from the environment (e.g., GCE/GKE metadata). + +- gcp.storage.credentials.file + - Set: Path to a service account JSON key file. Use this for explicit service account authentication. + - Blank: When not set, the system uses Application Default Credentials (ADC), which checks the `GOOGLE_APPLICATION_CREDENTIALS` environment variable, gcloud CLI config, or GCE/GKE metadata service. + +- gcp.storage.endpoint.url + - Set: Only for GCS-compatible emulators like fake-gcs-server during local testing. Example: `http://localhost:4443`. + - Blank: For real Google Cloud Storage — do not set an endpoint. When set, NoCredentials will be used automatically for emulator compatibility. + +### Common GCP scenarios and property examples + +8) GCP with service account key file +``` +client.files.storage.provider=GCP +files.gcp.bucket=my-prod-bucket +gcp.storage.project.id=my-gcp-project +gcp.storage.credentials.file=/path/to/service-account-key.json +gcp.storage.endpoint.url= +``` + +9) GCP with Application Default Credentials (production) +``` +client.files.storage.provider=GCP +files.gcp.bucket=my-prod-bucket +gcp.storage.project.id= # optional; resolved from credentials or environment +gcp.storage.credentials.file= # uses ADC: GOOGLE_APPLICATION_CREDENTIALS env var, gcloud, or GCE/GKE metadata +gcp.storage.endpoint.url= +``` + +10) GCP with fake-gcs-server (local development) +``` +client.files.storage.provider=GCP +files.gcp.bucket=test-bucket +gcp.storage.project.id=test-project +gcp.storage.credentials.file= +gcp.storage.endpoint.url=http://localhost:4443 +``` + ### Dos and Don'ts - Do configure only one credential source: static keys OR profile/SSO OR IAM role. Mixing profile and keys can cause ambiguous resolution. - Do set the correct destination property for the selected provider: - For S3: `files.s3.bucket` - For Azure: `files.azure.container` + - For GCP: `files.gcp.bucket` - Don’t set `aws.s3.endpoint.url` for real AWS S3; it is meant for S3‑compatible endpoints like MinIO. - Don’t leave both keys and profile populated; choose exactly one. @@ -252,13 +311,14 @@ azure.storage.connection.string=DefaultEndpointsProtocol=http;AccountName=devsto Producer vs Consumer specifics: - Producer - - Supports reading from S3, Azure, or Local depending on the message `sourceType`. - - For Azure, use Azurite or a real Azure Storage account; provider configuration is resolved by the platform components behind `FileProviderFactory`. + - Supports reading from S3, Azure, GCP, or Local depending on the message `sourceType`. + - For Azure, use Azurite or a real Azure Storage account; for GCP, use fake-gcs-server or real Google Cloud Storage; provider configuration is resolved by the platform components behind `FileProviderFactory`. - Consumer - - Final destination is AWS S3 or Azure Blob Storage. Temporary parts may be written to local disk depending on `client.files.storage.provider`. + - Final destination is AWS S3, Azure Blob Storage, or Google Cloud Storage (GCP). Temporary parts may be written to local disk depending on `client.files.storage.provider`. - For S3: bucket name comes from `files.s3.bucket` in `client.properties`. - For Azure: container name comes from `files.azure.container` in `client.properties`. - - The object destination key/prefix (for S3) or blob path (for Azure) comes from database-resident consumer configuration. + - For GCP: bucket name comes from `files.gcp.bucket` in `client.properties`. + - The object destination key/prefix (for S3), blob path (for Azure), or object name (for GCP) comes from database-resident consumer configuration. Networking and TLS (if enabled): - `client.p12FilePath`, `client.p12Password`, `client.truststoreFilePath`, `client.truststorePassword` @@ -278,19 +338,21 @@ Messages pushed to the topic use a JSON payload indicating the source of the fil ``` Fields: -- `sourceType` — the origin provider of the file. Allowed values: `S3`, `AZURE`, `LOCAL` (case-sensitive; all caps). +- `sourceType` — the origin provider of the file. Allowed values: `S3`, `AZURE`, `GCP`, `LOCAL` (case-sensitive; all caps). - `storageContainer` — container name for the file source: - For `S3`: the bucket name. - For `AZURE`: the blob container name. + - For `GCP`: the GCS bucket name. - For `LOCAL`: optional label; actual base directory is resolved by the Local provider configuration. - `path` — the object key or file path within the container: - For `S3`: the object key within the bucket. - For `AZURE`: the blob name within the container. + - For `GCP`: the object name within the GCS bucket. - For `LOCAL`: the file path relative to the configured base directory. Notes: - The message schema selects the producer-side `FileProvider`. The gRPC API remains the same regardless of source. -- Consumer S3 destination is configured separately: bucket from properties, destination key or prefix from database configuration. +- Consumer destination (S3, Azure, or GCP) is configured separately: bucket/container from properties, destination key/path/prefix from database configuration. ## gRPC APIs @@ -350,22 +412,26 @@ classDiagram Server read path uses `FileProviderFactory` → `FileProvider`: - LOCAL: reads from a local filesystem path. - S3: reads from a configured S3 bucket and key. -- Azure: reads from a configured Azure Blob container and blob name. +- AZURE: reads from a configured Azure Blob container and blob name. +- GCP: reads from a configured GCS bucket and object name. Client write path uses pluggable storage implementations: - LOCAL: write parts to `client.files.temp.dir`, then assemble. - S3: `S3ReceivedFileStorage` uploads to Amazon S3 (or S3-compatible) using bucket from `files.s3.bucket`. - AZURE: `AzureReceivedFileStorage` uploads to Azure Blob Storage using container from `files.azure.container`. +- GCP: `GCPReceivedFileStorage` uploads to Google Cloud Storage using bucket from `files.gcp.bucket`. ```mermaid flowchart LR FP[FileProviderFactory] -->|LOCAL| L[LocalFileProvider] FP -->|S3| S[S3FileProvider] FP -->|AZURE| A[AzureBlobFileProvider] + FP -->|GCP| G[GCPFileProvider] subgraph Client Storage CS1[LocalReceivedFileStorage] CS2[S3ReceivedFileStorage] CS3[AzureReceivedFileStorage] + CS4[GCPReceivedFileStorage] end ``` @@ -387,8 +453,9 @@ flowchart LR - LOCAL: the final file remains under `client.files.temp.dir`. - S3: the final file is uploaded and then deleted locally by the S3 provider (best-effort). - AZURE: the final file is uploaded and then deleted locally by the Azure provider (best-effort). + - GCP: the final file is uploaded and then deleted locally by the GCP provider (best-effort). -Provider note: Chunking and checksumming are provider-agnostic; the same streaming protocol applies whether the source is `S3`, `Azure`, or `Local`. +Provider note: Chunking and checksumming are provider-agnostic; the same streaming protocol applies whether the source is `S3`, `Azure`, `GCP`, or `Local`. ```mermaid sequenceDiagram @@ -489,6 +556,7 @@ flowchart TD ### Additional considerations - Azure producer source: handle authentication and container resolution errors, including SAS token expiry, missing containers, or network timeouts. +- GCP producer source: handle authentication and bucket resolution errors, including service account credential issues, missing buckets, permission errors, or network timeouts. - Consumer destination: if the bucket from `client.properties` is missing or the destination path from the database cannot be resolved, treat as configuration error and fail fast with clear logs for remediation. - Fatal stream errors: Network failures or unrecoverable gRPC errors still produce `Status.INTERNAL` and terminate the stream, requiring client reconnection. @@ -513,7 +581,8 @@ stateDiagram-v2 - Integration tests - End-to-end stream with LOCAL storage. - End-to-end stream with MinIO S3 using `aws.s3.endpoint.url` and `files.s3.bucket`. - - End-to-end stream with Azurite Azure as producer source; consumer still writes to S3. + - End-to-end stream with Azurite Azure as producer source; consumer can write to S3, Azure, GCP, or LOCAL. + - End-to-end stream with fake-gcs-server as GCP producer source; consumer can write to S3, Azure, GCP, or LOCAL. - Resume behavior using `start_sequence_id`. - Validate topic JSON schema parsing and routing to correct `FileProvider` based on `sourceType`. - Performance testing @@ -527,17 +596,19 @@ Key locations related to file streaming: - Client gRPC job/handlers: `src/main/java/.../client/jobs/handlers/ClientGRPCJob.java` - Client storage (S3 example): `src/main/java/.../client/storage/impl/S3ReceivedFileStorage.java` - S3 client factory: `src/main/java/.../common/storage/provider/file/client/S3ClientFactory.java` -- Configuration: `src/configs/client.properties` -- Docker examples: `docker/docker-grpc-resources/*` including `azurite-data` for Azure local dev and `minio-data` for S3-compatible local dev +- GCS client factory: `src/main/java/.../common/storage/provider/file/client/GcsClientFactory.java` +- Configuration: `src/configs/client.properties` and `src/configs/server.properties` +- Docker examples: `docker/docker-grpc-resources/*` including `azurite-data` for Azure local dev, `minio-data` for S3-compatible local dev, and `fake-gcs-data` for GCP local dev ```mermaid flowchart LR Proto[FederatorService.proto] --> Gen[Generated gRPC Stubs] Gen --> Server[FileChunkStreamer] Gen --> Client[ClientGRPCJob] - Client --> CStore[Client Storage LOCAL or S3] + Client --> CStore[Client Storage: LOCAL, S3, AZURE, or GCP] Server --> SProv[FileProviderFactory] SProv --> LProv[Local Provider] SProv --> S3Prov[S3 Provider] SProv --> AzProv[Azure Provider] + SProv --> GCPProv[GCP Provider] ``` diff --git a/docs/index.md b/docs/index.md index d230b573..32a7f110 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,14 +66,32 @@ For steps to remove this repository and its dependencies, see [UNINSTALL.md](UNI The federator enables secure data exchange between Integration Architecture nodes, supporting both server (producer) and client (consumer) roles. Key features include: +### Data Federation - Secure, scalable data sharing using Kafka as both source and target. - Multiple federator servers and clients per organisation for flexible deployment. - Filtering of Kafka messages for federation is based on the `securityLabel` in the Kafka message header and the client’s credentials. The default filter performs an exact match between the client’s credentials and the `securityLabel` header (e.g., `Security-Label:nationality=GBR`). -- Custom filtering logic can be configured; see [Configuring a Custom Filter](/docs/server-configuration.md) for details. +- Custom filtering logic can be configured; see [Configuring a Custom Filter](server-configuration.md) for details. - Communication between federator servers and clients uses gRPC over mTLS for secure, authenticated data transfer. - Federation currently supports RDF payloads, with extensibility hooks for other data formats on a per-topic basis. + +### File Streaming +- **File Transfer via gRPC**: Stream large files from server to client as chunked messages over the `GetFilesStream` RPC endpoint. +- **Multi-Cloud Storage Support**: Both producer (server) and consumer (client) support multiple storage backends: + - **Server (Producer)**: Read files from AWS S3, Azure Blob Storage, Google Cloud Storage (GCP), or Local filesystem + - **Client (Consumer)**: Write files to AWS S3, Azure Blob Storage, Google Cloud Storage (GCP), or Local filesystem +- **Integrity Verification**: SHA-256 checksums ensure file integrity during transfer +- **Resume Support**: Continue interrupted transfers using sequence IDs to avoid re-transferring complete files +- **Graceful Error Handling**: Server sends `StreamWarning` messages for invalid requests without terminating the stream, allowing subsequent files to be processed +- **S3-Compatible Storage**: Support for MinIO and other S3-compatible storage systems +- **Azure Support**: Works with Azure Blob Storage and Azurite emulator for local development +- **GCP Support**: Works with Google Cloud Storage and fake-gcs-server emulator for local development + +For detailed file streaming documentation, see [File Streaming README](FILE_STREAMING_README.md). + +### Common Infrastructure - Integration with Management-Node for centralised configuration, topic management, and authorisation. - Redis is used for offset tracking and short-lived configuration caching. +- JWT-based authentication with Identity Provider (e.g., Keycloak) for consumer verification and authorisation. An overview of the Federator service architecture is shown below: @@ -97,61 +115,77 @@ Additional note on connectivity and security: ### Exchange data between IA nodes -The Federator is designed to allow data exchange between Integration Architecture Nodes. Kafka brokers are used as both a source of data and a target of data that is to be moved between Integration Architecture nodes. It is run in a distributed manner with multiple servers and clients. +The Federator is designed to allow data exchange between Integration Architecture Nodes. It supports two primary modes of operation: -A simplistic view of the federator service is described below: +1. **RDF Message Streaming**: Kafka-to-Kafka message federation with filtering based on security labels +2. **File Streaming**: Large file transfer with multi-cloud storage support and integrity verification -#### Server (Producer) +Both modes use gRPC over mTLS for secure communication and are run in a distributed manner with multiple servers and clients. -1. A server (producer) reads messages from a knowledge topic within the source Kafka broker. -2. The server is configured so that it has a list of clients and the topics that they are allowed to read the messages from. -3. The server also has a configurable filter that is used to decide if a message should be sent to a client. -4. The server filters the messages based on the security label in the message header. -5. The server streams the selected filtered messages to the client(s) using the gRPC protocol over a network. +#### Server (Producer) - Simplified View -#### Client (Consumer) +**For RDF Messages:** +1. Reads messages from knowledge topics within the source Kafka broker +2. Authenticates clients using JWT tokens and verifies authorization +3. Filters messages based on security labels in message headers using configurable filters +4. Streams filtered messages to authorized clients via gRPC -1. A client (consumer) connects and then authenticates with its known server(s) using the gRPC protocol. -2. A client requests the list of topics that it is allowed to read from the server. -3. The client then requests the messages from the server for given topic(s). -4. The client reads the messages and then writes them to a target Kafka broker to a topic name that is prefixed with 'federated' +**For Files:** +1. Reads files from configured storage (S3, Azure, GCP, or Local filesystem) +2. Authenticates clients using JWT tokens and verifies authorization +3. Chunks files into manageable pieces with a configurable chunk size +4. Streams file chunks to authorized clients via gRPC with SHA-256 checksums for integrity verification -The underlying communication protocol is [gRPC](https://grpc.io/) which is used to communicate between the server and client at the network level. +#### Client (Consumer) - Simplified View -### Architecture +**For RDF Messages:** +1. Connects and authenticates with known server(s) using JWT tokens via gRPC +2. Requests message streams for authorized topics +3. Writes received messages to target Kafka broker with a configured topic prefix (e.g., 'federated') +4. Tracks offsets in Redis for resume capability -#### Federator Server (Producer) +**For Files:** +1. Connects and authenticates with known server(s) using JWT tokens via gRPC +2. Requests file streams, optionally resuming from a previous sequence ID +3. Assembles received chunks and verifies integrity using SHA-256 checksums +4. Uploads complete files to configured storage destination (S3, Azure, GCP, or Local) +5. Tracks file sequence offsets in Redis for resume capability -This app starts the data federation server that starts a gRPC service. +The underlying communication protocol is [gRPC](https://grpc.io/) over mTLS, providing secure, authenticated data transfer between servers and clients. -This process contains the federator service supplying two RPC endpoints that are called by the client: +### Architecture -- Get Kafka Topics (obtain topics) -- Get kafka Consumer (consume topic) +#### Federator Server (Producer) -##### Obtain Topics +This app starts the data federation server that starts a gRPC service. -1. Is passed a user request (a client-id and key) -2. Authenticate the given credentials -3. Returns the topics that have been assigned to the given user. +This process contains the federator service supplying RPC endpoints that are called by the client: -##### Consume Topic +- **GetKafkaConsumer** - Stream RDF messages from Kafka topics to clients +- **GetFilesStream** - Stream files as chunks to clients with integrity verification -1. Is passed a topic request (client-id, key, topic & offset) -2. Validates the given details. -3. Creates a message conductor to process the topic. -4. Consumes and returns messages until stopped. +Both endpoints authenticate clients using JWT tokens and verify authorization against the Management-Node configuration before streaming data. #### Federator Client (Consumer) -A somewhat simple app it does the following: - -1. Obtains topic(s) from the Server -2. Checks with Redis to see what the offset is for given topic -3. Obtain kafka consumer from the Server -4. Process messages from consumer, adding to destination topic and update Redis offset count. -5. Continue (4) until stopped. - If configured, it will repeat 1-5 upon failures +The client connects to one or more servers and performs the following: + +**For RDF Message Streaming (GetKafkaConsumer):** +1. Authenticates with the server using JWT tokens +2. Checks Redis for the current offset for each topic +3. Requests message stream from the server +4. Processes messages and writes them to the destination Kafka topic (with configured prefix, e.g., 'federated') +5. Updates Redis offset tracking as messages are processed +6. Continues streaming until stopped; retries on failures if configured + +**For File Streaming (GetFilesStream):** +1. Authenticates with the server using JWT tokens +2. Checks Redis for the last processed file sequence ID +3. Requests file stream from the server, optionally resuming from a previous sequence +4. Receives file chunks, assembles them locally, and verifies integrity using SHA-256 checksums +5. Uploads complete files to the configured storage destination (S3, Azure, GCP, or Local) +6. Updates Redis offset tracking as files are successfully processed +7. Handles StreamWarning messages by logging and advancing offsets to skip unrecoverable errors Please refer to this context diagram as an overview of the federator service and its components: diff --git a/pom.xml b/pom.xml index 268e730e..fd26000d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 uk.gov.dbt.ndtp federator - 1.1.0 + 1.2.0 jar Federator Provides a federator client and server to allow for the sharing of data between IA Nodes @@ -60,9 +60,9 @@ 4.3.0 1.79.0 1.81 - 2.18.4 + 2.18.6 5.12.1 - 1.5.18 + 1.5.24 5.17.0 1.18.38 10.4.2 @@ -79,16 +79,21 @@ 2.3.5 4.1.127.Final 1.9.0 + 26.76.0 3.2.0 4.4.16 2.17.0 1.17.1 - 3.17.0 + 3.18.0 2.36.0 2.3.0 2.12.1 5.17.0 1.23.1 + 1.26 + 3.1 + 1.51.0 + 4.2.0 @@ -172,6 +177,13 @@ pom import + + com.google.cloud + libraries-bom + ${dependency.google-cloud-bom} + pom + import + software.amazon.awssdk aws-core @@ -258,6 +270,32 @@ gson ${gson.version} + + + org.codehaus.mojo + animal-sniffer-annotations + ${dependency.animal-sniffer-annotations} + + + com.google.j2objc + j2objc-annotations + ${dependency.j2objc-annotations} + + + io.opentelemetry + opentelemetry-context + ${dependency.opentelemetry} + + + io.opentelemetry + opentelemetry-api + ${dependency.opentelemetry} + + + org.apache.kafka + kafka-clients + ${dependency.kafka-client} + @@ -387,7 +425,10 @@ com.azure azure-core-http-okhttp - + + com.google.cloud + google-cloud-storage + org.junit.jupiter junit-jupiter diff --git a/src/configs/client.properties b/src/configs/client.properties index 0c4bc32c..73f1b6d4 100644 --- a/src/configs/client.properties +++ b/src/configs/client.properties @@ -174,4 +174,24 @@ azure.storage.connection.string= # Set this when running in production without a connection string. Example: # https://.blob.core.windows.net # If both connection string and endpoint are set, the connection string takes precedence. -azure.storage.endpoint= \ No newline at end of file +azure.storage.endpoint= + +## ============================================ +## Google Cloud Storage (GCP) (Consumer Destination) +## ============================================ +# When using GCP as the storage provider, configure the target bucket (shared key for client and server) +files.gcp.bucket= + +# GCP Storage client configuration (also used by server components) +# These properties are used by GcsClientFactory to create the client. + +# GCP project ID (optional; resolved from credentials or environment if not set) +gcp.storage.project.id= + +# Path to service account JSON key file (optional; uses Application Default Credentials if not set) +# When not set, the system uses ADC (GOOGLE_APPLICATION_CREDENTIALS env var, gcloud CLI, or GCE/GKE metadata) +gcp.storage.credentials.file= + +# Optional GCS-compatible endpoint for local testing (e.g., fake-gcs-server): http://localhost:4443 +# For real Google Cloud Storage, leave this blank. +gcp.storage.endpoint.url= \ No newline at end of file diff --git a/src/configs/server.properties b/src/configs/server.properties index 524a24e3..567438b2 100755 --- a/src/configs/server.properties +++ b/src/configs/server.properties @@ -127,8 +127,6 @@ redis.aes.key= file.stream.chunk.size= -# When using S3, configure the target bucket (shared key for client and server) -files.s3.bucket= # AWS S3 client configuration (also used by server components) # These properties are used by S3ClientFactory to create the client. For Static IAM User aws.s3.region=us-east-1 @@ -148,3 +146,12 @@ azure.storage.connection.string= # https://.blob.core.windows.net # If both connection string and endpoint are set, the connection string takes precedence. azure.storage.endpoint= + +# Google Cloud Storage (GCP) Configuration +# GCP project ID (optional; if not set, will use default from credentials or environment) +gcp.storage.project.id= +# Authentication uses Application Default Credentials (ADC) +# ADC will check: GOOGLE_APPLICATION_CREDENTIALS env var, gcloud CLI, or GCE/GKE metadata +# Optional GCS-compatible endpoint (e.g., for fake-gcs-server during local testing) +# Example: http://localhost:4443 +gcp.storage.endpoint.url= diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/FederatorService.java b/src/main/java/uk/gov/dbt/ndtp/federator/FederatorService.java index cafcb950..8945556a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/FederatorService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/FederatorService.java @@ -1,12 +1,14 @@ package uk.gov.dbt.ndtp.federator; import java.util.Set; +import java.util.concurrent.ExecutorService; import org.apache.kafka.common.errors.InvalidTopicException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import uk.gov.dbt.ndtp.federator.common.service.file.FileStreamService; import uk.gov.dbt.ndtp.federator.common.service.kafka.KafkaStreamService; -import uk.gov.dbt.ndtp.federator.common.service.stream.FederatorStreamService; +import uk.gov.dbt.ndtp.federator.common.service.stream.CloseableFederatorStreamService; +import uk.gov.dbt.ndtp.federator.common.utils.ThreadUtil; import uk.gov.dbt.ndtp.federator.server.interfaces.StreamObservable; import uk.gov.dbt.ndtp.grpc.FileStreamEvent; import uk.gov.dbt.ndtp.grpc.FileStreamRequest; @@ -16,11 +18,15 @@ /** * Federator service that provides methods to get Kafka consumers and file consumers. */ -public class FederatorService { +public class FederatorService implements AutoCloseable { public static final Logger LOGGER = LoggerFactory.getLogger("FederatorService"); - private final FederatorStreamService kafkaStreamService; - private final FederatorStreamService fileStreamService; + private static final ExecutorService THREADED_FILE_STREAM_SERVICE_EXECUTOR = + ThreadUtil.threadExecutor("FileStreamService"); + private static final ExecutorService THREADED_KAFKA_STREAM_SERVICE_EXECUTOR = + ThreadUtil.threadExecutor("KafkaStreamService"); + private final CloseableFederatorStreamService kafkaStreamService; + private final CloseableFederatorStreamService fileStreamService; public FederatorService(Set sharedHeaders) { this.kafkaStreamService = new KafkaStreamService(sharedHeaders); @@ -35,7 +41,7 @@ public FederatorService(Set sharedHeaders) { */ public void getKafkaConsumer(TopicRequest request, StreamObservable streamObservable) throws InvalidTopicException { - kafkaStreamService.streamToClient(request, streamObservable); + kafkaStreamService.streamToClient(request, streamObservable, THREADED_KAFKA_STREAM_SERVICE_EXECUTOR); } /** @@ -44,6 +50,14 @@ public void getKafkaConsumer(TopicRequest request, StreamObservable streamObservable) { - fileStreamService.streamToClient(request, streamObservable); + fileStreamService.streamToClient(request, streamObservable, THREADED_FILE_STREAM_SERVICE_EXECUTOR); + } + + @Override + public void close() { + fileStreamService.close(); + kafkaStreamService.close(); + THREADED_FILE_STREAM_SERVICE_EXECUTOR.shutdown(); + THREADED_KAFKA_STREAM_SERVICE_EXECUTOR.shutdown(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssembler.java b/src/main/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssembler.java index b5495a83..8dfeeb4f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssembler.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssembler.java @@ -26,6 +26,7 @@ import uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorage; import uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorageFactory; import uk.gov.dbt.ndtp.federator.client.storage.StoredFileResult; +import uk.gov.dbt.ndtp.federator.client.storage.impl.GCPReceivedFileStorage; import uk.gov.dbt.ndtp.federator.client.storage.impl.S3ReceivedFileStorage; import uk.gov.dbt.ndtp.federator.common.utils.GRPCUtils; import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; @@ -176,18 +177,20 @@ private Path handleLastChunk(FileChunk chunk, String fileName, String key, Assem Path finalTarget = moveToFinalTarget(state, fileName); - // Delegate storage (LOCAL or S3) based on configuration + // Delegate storage (LOCAL, S3, AZURE, or GCP) based on configuration ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); StoredFileResult storeResult = storage.store(finalTarget, fileName, destination); storeResult.remoteUriOpt().ifPresent(uri -> log.info("Remote location: {}", uri)); - // If provider is S3 and remote URI is absent, treat as failure: do NOT signal completion to caller - if (storage instanceof S3ReceivedFileStorage + // If provider is S3/GCP and remote URI is absent, treat as failure: do NOT signal completion to caller + if ((storage instanceof S3ReceivedFileStorage || storage instanceof GCPReceivedFileStorage) && storeResult.remoteUriOpt().isEmpty()) { assemblies.remove(key); Path failedPath = storeResult.localPath().toAbsolutePath(); + String providerName = storage instanceof S3ReceivedFileStorage ? "S3" : "GCP"; log.info( - "S3 upload failed for file '{}'; local temp at '{}' may be removed by provider. Will not update Redis offset.", + "{} upload failed for file '{}'; local temp at '{}' may be removed by provider. Will not update Redis offset.", + providerName, fileName, failedPath); return null; // signal to GRPCFileClient that offset must NOT be advanced diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactory.java b/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactory.java index a812c0fb..241b8bb1 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactory.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactory.java @@ -2,20 +2,21 @@ import lombok.extern.slf4j.Slf4j; import uk.gov.dbt.ndtp.federator.client.storage.impl.AzureReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.impl.GCPReceivedFileStorage; import uk.gov.dbt.ndtp.federator.client.storage.impl.LocalReceivedFileStorage; import uk.gov.dbt.ndtp.federator.client.storage.impl.S3ReceivedFileStorage; import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; /** * Factory for selecting a {@link uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorage} - * implementation based on {@code client.files.storage.provider} (LOCAL | S3 | AZURE). + * implementation based on {@code client.files.storage.provider} (LOCAL | S3 | AZURE | GCP). * *

Defaults to LOCAL when the property is missing or has an unknown value.

*/ @Slf4j public final class ReceivedFileStorageFactory { - private static final String STORAGE_PROVIDER_PROP = "client.files.storage.provider"; // LOCAL | S3 | AZURE + private static final String STORAGE_PROVIDER_PROP = "client.files.storage.provider"; // LOCAL | S3 | AZURE | GCP private ReceivedFileStorageFactory() {} @@ -26,6 +27,7 @@ private ReceivedFileStorageFactory() {} *
    *
  • {@code S3} – returns {@link S3ReceivedFileStorage}
  • *
  • {@code AZURE} – returns {@link AzureReceivedFileStorage}
  • + *
  • {@code GCP} – returns {@link GCPReceivedFileStorage}
  • *
  • {@code LOCAL} or any other value – returns {@link LocalReceivedFileStorage}
  • *
* @@ -46,6 +48,9 @@ public static ReceivedFileStorage get() { if ("AZURE".equalsIgnoreCase(provider)) { return new AzureReceivedFileStorage(); } + if ("GCP".equalsIgnoreCase(provider)) { + return new GCPReceivedFileStorage(); + } // Default to LOCAL for unknown values as well return new LocalReceivedFileStorage(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorage.java b/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorage.java new file mode 100644 index 00000000..2d6c3ae9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorage.java @@ -0,0 +1,96 @@ +package uk.gov.dbt.ndtp.federator.client.storage.impl; + +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import java.nio.file.Path; +import lombok.extern.slf4j.Slf4j; +import uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.StoredFileResult; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.GcsClientFactory; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; + +/** + * Stores assembled files to Google Cloud Storage using the shared {@link uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.GcsClientFactory}. + * + *

Configuration is sourced from properties (shared by client and server): + *

    + *
  • {@code files.gcp.bucket} – target bucket (required)
  • + *
+ * GCP client credentials and endpoint are read by {@code GcsClientFactory} via properties: + * {@code gcp.storage.project.id}, {@code gcp.storage.credentials.file}, and optional {@code gcp.storage.endpoint.url}. + */ +@Slf4j +public class GCPReceivedFileStorage implements ReceivedFileStorage { + + /** + * Shared property key for target GCS bucket used by both client and server components. + */ + private static final String GCP_BUCKET_PROP = "files.gcp.bucket"; + + /** + * Uploads the assembled file to GCS (if bucket is configured) and returns the result. + * + * @param localFile absolute path of the assembled file on the local filesystem + * @param originalFileName original file name from the stream (used to form the GCS object key) + * @param destination destination or prefix used to build the object key + * @return {@link StoredFileResult} containing the local path and the GCS URI if upload succeeded + */ + @Override + public StoredFileResult store(Path localFile, String originalFileName, String destination) { + String bucket = resolveBucket(); + if (bucket.isBlank()) { + log.warn("Storage provider is GCP but bucket is not provided. Skipping upload."); + return new StoredFileResult(localFile.toAbsolutePath(), null); + } + + String key = ReceivedFileStorage.super.resolveKey(destination, originalFileName); + try { + var uri = upload(localFile, bucket, key); + if (uri != null) { + // Success path: we manage local temp cleanup here to satisfy tests + ReceivedFileStorage.super.deleteLocalTempQuietly(localFile); + return new StoredFileResult(localFile.toAbsolutePath(), uri); + } + // upload() may return null (and may have already attempted deletion). Ensure it's deleted. + ReceivedFileStorage.super.deleteLocalTempQuietly(localFile); + return new StoredFileResult(localFile.toAbsolutePath(), null); + } catch (Exception e) { + // If an overriding implementation of upload() throws, we must still clean up and return gracefully + log.error( + "Upload threw an exception; deleting temp file {} and returning without remote URI", localFile, e); + ReceivedFileStorage.super.deleteLocalTempQuietly(localFile); + return new StoredFileResult(localFile.toAbsolutePath(), null); + } + } + + // -------- Helper methods (extracted for testability) -------- + + String resolveBucket() { + String bucket = PropertyUtil.getPropertyValue(GCP_BUCKET_PROP, ""); + return bucket == null ? "" : bucket; + } + + // Use default key resolution from interface + + String upload(Path localFile, String bucket, String key) { + try { + var storage = GcsClientFactory.getClient(); + BlobId blobId = BlobId.of(bucket, key); + BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build(); + storage.createFrom(blobInfo, localFile); + String uri = String.format("gs://%s/%s", bucket, key); + log.info("Uploaded file to GCS at {}", uri); + return uri; + } catch (Exception e) { + log.error( + "Failed to upload file to GCS; deleting temp file {} and skipping any Redis updates", localFile, e); + // On failure, ensure the temporary local file is cleaned up + ReceivedFileStorage.super.deleteLocalTempQuietly(localFile); + return null; + } + } + + // Use default deletion from interface + + // Use default sanitize/buildKey/normalizeKey from interface +} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/annotations/ExcludeFromJacocoGeneratedReport.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/annotations/ExcludeFromJacocoGeneratedReport.java new file mode 100644 index 00000000..d97fdd5d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/annotations/ExcludeFromJacocoGeneratedReport.java @@ -0,0 +1,10 @@ +package uk.gov.dbt.ndtp.federator.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ExcludeFromJacocoGeneratedReport {} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/model/SourceType.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/model/SourceType.java index de2d9c51..b6b835c9 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/model/SourceType.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/model/SourceType.java @@ -3,5 +3,6 @@ public enum SourceType { S3, AZURE, + GCP, LOCAL } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/ConfigService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/ConfigService.java index 7b693d4e..c261638b 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/ConfigService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/ConfigService.java @@ -35,9 +35,10 @@ public interface ConfigService { */ default T fetchWithResilience() { final String componentName = getKeyPrefix(); // single shared per service type + final String operation = "fetch configuration"; Supplier supplier = this::fetchConfiguration; try { - return ResilienceSupport.decorateAndExecute(componentName, supplier); + return ResilienceSupport.decorateAndExecute(componentName, operation, null, supplier); } catch (RuntimeException ex) { throw new ConfigFetchException( "Failed to fetch configuration after resilience protections for component: " + componentName, ex); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/exception/ConfigFetchException.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/exception/ConfigFetchException.java index 72786ac1..c10c43bf 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/exception/ConfigFetchException.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/config/exception/ConfigFetchException.java @@ -6,10 +6,12 @@ package uk.gov.dbt.ndtp.federator.common.service.config.exception; +import uk.gov.dbt.ndtp.federator.exceptions.RebuildableRuntimeException; + /** * Exception indicating configuration fetch failure after retries or due to circuit breaker state. */ -public class ConfigFetchException extends RuntimeException { +public class ConfigFetchException extends RebuildableRuntimeException { public ConfigFetchException(String message) { super(message); } @@ -17,4 +19,15 @@ public ConfigFetchException(String message) { public ConfigFetchException(String message, Throwable cause) { super(message, cause); } + + /** + * Rebuilds this exception with the given message and cause. + * @param message the enriched error message + * @param cause the original exception + * @return a new instance of {@link ConfigFetchException} + */ + @Override + public ConfigFetchException rebuild(String message, Throwable cause) { + return new ConfigFetchException(message, cause); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamService.java index be58028c..a2887264 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamService.java @@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory; import uk.gov.dbt.ndtp.federator.common.model.dto.AttributesDTO; import uk.gov.dbt.ndtp.federator.common.model.dto.ProducerConfigDTO; -import uk.gov.dbt.ndtp.federator.common.service.stream.FederatorStreamService; +import uk.gov.dbt.ndtp.federator.common.service.stream.CloseableFederatorStreamService; import uk.gov.dbt.ndtp.federator.common.utils.ThreadUtil; import uk.gov.dbt.ndtp.federator.server.conductor.FileConductor; import uk.gov.dbt.ndtp.federator.server.conductor.MessageConductor; @@ -18,18 +18,14 @@ import uk.gov.dbt.ndtp.grpc.FileStreamEvent; import uk.gov.dbt.ndtp.grpc.FileStreamRequest; -public class FileStreamService implements FederatorStreamService { +public class FileStreamService extends CloseableFederatorStreamService { private static final Logger LOGGER = LoggerFactory.getLogger(FileStreamService.class); - private static final ExecutorService THREADED_EXECUTOR = ThreadUtil.threadExecutor("FileStreamService"); - /** - * Streams file chunks to the client based on the file request. - * @param fileRequest - * @param streamObservable - */ @Override - public void streamToClient(FileStreamRequest fileRequest, StreamObservable streamObservable) { - + public void streamToClient( + FileStreamRequest fileRequest, + StreamObservable streamObservable, + ExecutorService executorService) { long offset = fileRequest.getStartSequenceId(); String consumerId = GRPCContextKeys.CLIENT_ID.get(); streamObservable.setOnCancelHandler(() -> LOGGER.info("Cancel called by client: {}", consumerId)); @@ -39,19 +35,29 @@ public void streamToClient(FileStreamRequest fileRequest, StreamObservable> futures = new ArrayList<>(); - futures.add(THREADED_EXECUTOR.submit(messageConductor::processMessages)); - LOGGER.info( - "Awaiting FileStreamRequest finished for Client: {}, Topic: {}, Offset: {}", - consumerId, - topicData.getTopic(), - topicData.getOffset()); - ThreadUtil.awaitShutdown(futures, messageConductor, THREADED_EXECUTOR); - LOGGER.info( - "Finished FileStreamRequest processed for Client: {}, Topic: {}, Offset: {}", - consumerId, - topicData.getTopic(), - topicData.getOffset()); + futures.add(executorService.submit(messageConductor::processMessages)); + + try { + LOGGER.info( + "Awaiting FileStreamRequest finished for Client: {}, Topic: {}, Offset: {}", + consumerId, + topicData.getTopic(), + topicData.getOffset()); + + ThreadUtil.awaitFutures(futures); + + LOGGER.info( + "Finished FileStreamRequest processed for Client: {}, Topic: {}, Offset: {}", + consumerId, + topicData.getTopic(), + topicData.getOffset()); + } finally { + messageConductors.remove(messageConductor); + } + streamObservable.onCompleted(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/AbstractIdpTokenService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/AbstractIdpTokenService.java index 41aab120..01db28dc 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/AbstractIdpTokenService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/AbstractIdpTokenService.java @@ -43,10 +43,16 @@ protected AbstractIdpTokenService(String idpJwksUrl, HttpClient httpClient, Obje @Override public boolean verifyToken(String token) { final String componentName = "idp-jwks-service"; + final String operation = "verify token"; try { - return ResilienceSupport.decorateAndExecute(componentName, () -> verifyTokenInternal(token)); + return ResilienceSupport.decorateAndExecute( + componentName, operation, null, () -> verifyTokenInternal(token)); } catch (RuntimeException ex) { - log.error("Token verification failed after resilience protections", ex); + + String msg = ResilienceSupport.buildFailureMessage( + "Token verification failed after resilience protections", ex, componentName, operation, null); + + log.error(msg, ex); return false; } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/IdpTokenService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/IdpTokenService.java index e2c7c9ea..63c22eab 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/IdpTokenService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/idp/IdpTokenService.java @@ -80,9 +80,10 @@ private String maskToken(String token) { */ default String fetchTokenWithResilience(String managementNodeId) { final String componentName = "idp-token-service"; + final String operation = "fetch token"; Supplier supplier = () -> fetchToken(managementNodeId); try { - return ResilienceSupport.decorateAndExecute(componentName, supplier); + return ResilienceSupport.decorateAndExecute(componentName, operation, managementNodeId, supplier); } catch (RuntimeException ex) { throw new FederatorTokenException( "Failed to fetch token after resilience protections for management node: " + managementNodeId, ex); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/kafka/KafkaStreamService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/kafka/KafkaStreamService.java index 94940002..e852d1e7 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/kafka/KafkaStreamService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/kafka/KafkaStreamService.java @@ -14,7 +14,7 @@ import uk.gov.dbt.ndtp.federator.common.model.dto.ConsumerDTO; import uk.gov.dbt.ndtp.federator.common.model.dto.ProducerConfigDTO; import uk.gov.dbt.ndtp.federator.common.model.dto.ProductDTO; -import uk.gov.dbt.ndtp.federator.common.service.stream.FederatorStreamService; +import uk.gov.dbt.ndtp.federator.common.service.stream.CloseableFederatorStreamService; import uk.gov.dbt.ndtp.federator.common.utils.ThreadUtil; import uk.gov.dbt.ndtp.federator.server.conductor.MessageConductor; import uk.gov.dbt.ndtp.federator.server.conductor.RdfMessageConductor; @@ -24,25 +24,17 @@ import uk.gov.dbt.ndtp.grpc.KafkaByteBatch; import uk.gov.dbt.ndtp.grpc.TopicRequest; -public class KafkaStreamService implements FederatorStreamService { +public class KafkaStreamService extends CloseableFederatorStreamService { public static final Logger LOGGER = LoggerFactory.getLogger("KafkaStreamService"); - private static final ExecutorService THREADED_EXECUTOR = ThreadUtil.threadExecutor("KafkaStream"); private final Set sharedHeaders; public KafkaStreamService(Set sharedHeaders) { this.sharedHeaders = sharedHeaders; } - /** - * Takes a request with the topic, client id, key, offset and the streamObservable object to write - * into. - * - * @param request that contains the details required to get data from a specific topic. - * @param streamObservable used to write the data into. - * @throws InvalidTopicException if the topic is not valid for a specific client. - */ @Override - public void streamToClient(TopicRequest request, StreamObservable streamObservable) + public void streamToClient( + TopicRequest request, StreamObservable streamObservable, ExecutorService executorService) throws InvalidTopicException { String topic = request.getTopic(); long offset = request.getOffset(); @@ -61,19 +53,28 @@ public void streamToClient(TopicRequest request, StreamObservable> futures = new ArrayList<>(); - futures.add(THREADED_EXECUTOR.submit(messageConductor::processMessages)); - LOGGER.info( - "Awaiting TopicRequest finished for Client: {}, Topic: {}, Offset: {}", - consumerId, - topicData.getTopic(), - topicData.getOffset()); - ThreadUtil.awaitShutdown(futures, messageConductor, THREADED_EXECUTOR); - LOGGER.info( - "Finished TopicRequest processed for Client: {}, Topic: {}, Offset: {}", - consumerId, - topicData.getTopic(), - topicData.getOffset()); + futures.add(executorService.submit(messageConductor::processMessages)); + + try { + LOGGER.info( + "Awaiting TopicRequest finished for Client: {}, Topic: {}, Offset: {}", + consumerId, + topicData.getTopic(), + topicData.getOffset()); + + ThreadUtil.awaitFutures(futures); + + LOGGER.info( + "Finished TopicRequest processed for Client: {}, Topic: {}, Offset: {}", + consumerId, + topicData.getTopic(), + topicData.getOffset()); + } finally { + messageConductors.remove(messageConductor); + } streamObservable.onCompleted(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/CloseableFederatorStreamService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/CloseableFederatorStreamService.java new file mode 100644 index 00000000..80a894c8 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/CloseableFederatorStreamService.java @@ -0,0 +1,22 @@ +package uk.gov.dbt.ndtp.federator.common.service.stream; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import uk.gov.dbt.ndtp.federator.server.conductor.MessageConductor; + +/** + * An abstract class that implements both the {@link FederatorStreamService} and {@link AutoCloseable} + */ +public abstract class CloseableFederatorStreamService implements FederatorStreamService, AutoCloseable { + protected final List messageConductors = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void close() { + for (MessageConductor messageConductor : messageConductors) { + messageConductor.close(); + } + + messageConductors.clear(); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/FederatorStreamService.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/FederatorStreamService.java index b130e2bb..481493e3 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/FederatorStreamService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/service/stream/FederatorStreamService.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.concurrent.ExecutorService; import java.util.stream.Stream; import org.slf4j.Logger; import uk.gov.dbt.ndtp.federator.common.model.dto.AttributesDTO; @@ -15,7 +16,14 @@ public interface FederatorStreamService { Logger LOGGER = org.slf4j.LoggerFactory.getLogger(FederatorStreamService.class); - void streamToClient(R request, StreamObservable streamObservable); + /** + * A method which streams data to a client as outlined in the request. + * + * @param request the details of the message streaming request. + * @param streamObservable the {@link StreamObservable} involved in the request. + * @param executorService the {@link ExecutorService} to submit tasks involved in processing messages invovled in the kafka message streaming to the client. + */ + void streamToClient(R request, StreamObservable streamObservable, ExecutorService executorService); default ProducerConfigDTO getProducerConfiguration() { return ProducerConsumerConfigServiceFactory.getProducerConfigService().getProducerConfiguration(); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactory.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactory.java index 2c6e8be6..7dba823c 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactory.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactory.java @@ -2,8 +2,10 @@ import uk.gov.dbt.ndtp.federator.common.model.SourceType; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.AzureBlobClientFactory; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.GcsClientFactory; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.S3ClientFactory; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.AzureFileProvider; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.GCPFileProvider; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.LocalFileProvider; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.S3FileProvider; @@ -16,13 +18,14 @@ private FileProviderFactory() {} /** * Returns a file provider suitable for the given source type. * - * @param sourceType the remote source type (S3, AZURE, LOCAL) + * @param sourceType the remote source type (S3, AZURE, GCP, LOCAL) * @return a {@link FileProvider} capable of fetching from that source */ public static FileProvider getProvider(SourceType sourceType) { return switch (sourceType) { case S3 -> new S3FileProvider(S3ClientFactory.getClient()); case AZURE -> new AzureFileProvider(AzureBlobClientFactory.getClient()); + case GCP -> new GCPFileProvider(GcsClientFactory.getClient()); case LOCAL -> new LocalFileProvider(); }; } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactory.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactory.java new file mode 100644 index 00000000..72fa655f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactory.java @@ -0,0 +1,130 @@ +package uk.gov.dbt.ndtp.federator.common.storage.provider.file.client; + +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.NoCredentials; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; +import lombok.extern.slf4j.Slf4j; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; + +/** + * Factory for a singleton Google Cloud Storage client used across client and server components. + * + * Supported configuration via {@link PropertyUtil} keys: + * - {@code gcp.storage.project.id} – GCP project ID (optional; falls back to default) + * - {@code gcp.storage.endpoint.url} – optional GCS-compatible endpoint (e.g., fake-gcs-server) + * + * Credential resolution: + * - Uses {@link GoogleCredentials#getApplicationDefault()} for authentication. + * - This supports Service Accounts via ADC (Application Default Credentials): env var, gcloud, GCE/GKE metadata, etc. + * - For emulator endpoints, uses {@link NoCredentials}. + * + * Project ID resolution: + * - If {@code gcp.storage.project.id} provided, use it; otherwise fall back to default from credentials or environment. + * + * Custom endpoint support for local testing (e.g., fake-gcs-server). + */ +@Slf4j +public final class GcsClientFactory { + + // Lazily initialized singleton to avoid class-load failures if configuration is bad + private static final AtomicReference gcsClient = new AtomicReference<>(); + + private GcsClientFactory() {} + + // Orchestrates the modular steps to create the GCS client + private static Storage createClient() { + GcsSettings settings = GcsSettings.fromProperties(); + return buildClient(settings); + } + + // Build the GCS client using resolved components + private static Storage buildClient(GcsSettings settings) { + Credentials credentials = resolveCredentials(settings); + String projectId = resolveProjectId(settings); + + StorageOptions.Builder builder = StorageOptions.newBuilder().setCredentials(credentials); + + if (projectId != null && !projectId.isBlank()) { + builder = builder.setProjectId(projectId); + } + + builder = applyEndpointOverride(builder, settings); + + return builder.build().getService(); + } + + // Select credentials based on settings (application default or no credentials for emulator) + private static Credentials resolveCredentials(GcsSettings settings) { + if (settings.endpointUrl != null && !settings.endpointUrl.isBlank()) { + log.info("GCS emulator endpoint configured; using NoCredentials"); + return NoCredentials.getInstance(); + } + + try { + log.info("Using Application Default Credentials for GCS"); + return GoogleCredentials.getApplicationDefault(); + } catch (IOException e) { + throw new IllegalStateException("Failed to obtain Application Default Credentials for GCS", e); + } + } + + // Determine project ID from explicit configuration + private static String resolveProjectId(GcsSettings settings) { + if (settings.projectId != null && !settings.projectId.isBlank()) { + return settings.projectId; + } + log.info("No explicit GCP project ID configured; will use default from environment"); + return null; + } + + // Optionally apply endpoint override, useful for fake-gcs-server or custom GCS endpoints + private static StorageOptions.Builder applyEndpointOverride(StorageOptions.Builder builder, GcsSettings settings) { + if (settings.endpointUrl != null && !settings.endpointUrl.isBlank()) { + log.info("Using custom GCS endpoint: {}", settings.endpointUrl); + return builder.setHost(settings.endpointUrl); + } + return builder; + } + + /** Returns the singleton {@link Storage} instance configured from properties. */ + public static Storage getClient() { + return gcsClient.updateAndGet(current -> { + if (current != null) { + return current; + } + try { + return createClient(); + } catch (Exception e) { + log.error("Failed to initialize GCS Storage client from properties.", e); + throw new IllegalStateException("Failed to initialize GCS Storage client from properties", e); + } + }); + } + + /** Resets the singleton instance (primarily for testing). */ + static void resetClient() { + gcsClient.set(null); + } + + // Encapsulates all properties used to configure the GCS client + private static final class GcsSettings { + private final String projectId; + private final String endpointUrl; + + private GcsSettings(String projectId, String endpointUrl) { + this.projectId = projectId; + this.endpointUrl = endpointUrl; + } + + static GcsSettings fromProperties() { + // These properties are optional; use null defaults to avoid exceptions when absent + String projectId = PropertyUtil.getPropertyValue("gcp.storage.project.id", null); + String endpointUrl = PropertyUtil.getPropertyValue("gcp.storage.endpoint.url", ""); + return new GcsSettings(projectId, endpointUrl); + } + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProvider.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProvider.java new file mode 100644 index 00000000..a8922a0f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProvider.java @@ -0,0 +1,92 @@ +package uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl; + +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import java.io.InputStream; +import java.nio.channels.Channels; +import uk.gov.dbt.ndtp.federator.common.exception.FileTransferException; +import uk.gov.dbt.ndtp.federator.common.model.FileTransferRequest; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.FileProvider; +import uk.gov.dbt.ndtp.federator.exceptions.FileFetcherException; +import uk.gov.dbt.ndtp.federator.server.processor.file.FileTransferResult; + +/** + * {@link FileProvider} implementation that fetches files from Google Cloud Storage using an injected {@link com.google.cloud.storage.Storage}. + * Resolves object size via a metadata call before opening the GET stream. + */ +public class GCPFileProvider implements FileProvider { + + private final Storage storage; + + public GCPFileProvider(Storage storage) { + this.storage = storage; + } + + /** + * Fetches the file specified in the FileTransferRequest from GCS. + * @param request + * @return + */ + @Override + public FileTransferResult get(FileTransferRequest request) { + try { + BlobId blobId = BlobId.of(request.storageContainer(), request.path()); + + Blob blob = storage.get(blobId); + if (blob == null || !blob.exists()) { + throw new FileFetcherException( + "File not found in GCS: " + request.storageContainer() + "/" + request.path()); + } + + long size = blob.getSize(); + InputStream stream = Channels.newInputStream(blob.reader()); + + return new FileTransferResult(stream, size); + + } catch (FileFetcherException e) { + throw e; + } catch (StorageException e) { + if (e.getCode() == 404) { + throw new FileFetcherException( + "File not found in GCS: " + request.storageContainer() + "/" + request.path()); + } + throw new FileFetcherException( + "GCS error fetching: " + request.storageContainer() + "/" + request.path(), e); + } catch (Exception e) { + throw new FileFetcherException( + "Failed to fetch from GCS: " + request.storageContainer() + "/" + request.path(), e); + } + } + + /** + * Validates that the GCS object exists by checking its metadata. + * @param request the file transfer request containing the GCS bucket and object path to validate + * @throws FileTransferException if the GCS object does not exist or cannot be accessed + */ + @Override + public void validatePath(FileTransferRequest request) { + validateStorageContainer(request, "GCS bucket"); + + executeValidation( + () -> { + try { + BlobId blobId = BlobId.of(request.storageContainer(), request.path()); + Blob blob = storage.get(blobId); + if (blob == null || !blob.exists()) { + throw new FileTransferException( + "GCS object not found: " + request.storageContainer() + "/" + request.path()); + } + } catch (StorageException e) { + if (e.getCode() == 404) { + throw new FileTransferException( + "GCS object not found: " + request.storageContainer() + "/" + request.path()); + } + throw new FileTransferException( + "GCS validation error: " + request.storageContainer() + "/" + request.path(), e); + } + }, + "Invalid GCS path: " + request.storageContainer() + "/" + request.path()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupport.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupport.java index 5738a1a9..388e3fbc 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupport.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupport.java @@ -22,10 +22,13 @@ import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import redis.clients.jedis.exceptions.JedisException; +import uk.gov.dbt.ndtp.federator.exceptions.RebuildableRuntimeException; /** * Centralized Resilience4j configuration and decoration helpers. - * Uses PropertyUtil for configuration under prefix: management.node.resilience.* + * Uses PropertyUtil for configuration under prefix: + * management.node.resilience.* */ public final class ResilienceSupport { @@ -62,7 +65,8 @@ private static CircuitBreakerRegistry getCircuitBreakerRegistry() { private static RetryConfig buildRetryConfig() { int maxAttempts = PropertyUtil.getPropertyIntValue(PROP_RETRY_MAX_ATTEMPTS, "10"); - // Requirement: 5 attempts within 5 minutes. Some versions may not support maxDuration; we always enforce + // Requirement: 5 attempts within 5 minutes. Some versions may not support + // maxDuration; we always enforce // attempts. // Exponential backoff with a cap at 5 minutes between attempts Duration maxBackoff = PropertyUtil.getPropertyDurationValue( @@ -139,20 +143,28 @@ public static CircuitBreaker getCircuitBreaker(String name) { } /** - * Testing helper to clear registries so tests can reconfigure policies per test. + * Testing helper to clear registries so tests can reconfigure policies per + * test. */ public static void clearForTests() { retryRegistry.set(null); circuitBreakerRegistry.set(null); } - public static T decorateAndExecute(String componentName, Supplier supplier) { + public static T decorateAndExecute( + String componentName, String operation, String targetId, Supplier supplier) { Retry retry = getRetry(componentName); CircuitBreaker circuitBreaker = getCircuitBreaker(componentName); Supplier withCb = CircuitBreaker.decorateSupplier(circuitBreaker, supplier); Supplier withRetry = Retry.decorateSupplier(retry, withCb); - return withRetry.get(); + + try { + return withRetry.get(); + } catch (RuntimeException ex) { + enrichAndRethrow(ex, componentName, operation, targetId); + return null; + } } private static Class[] parseExceptionClasses(String csv) { @@ -175,4 +187,107 @@ private static Class[] parseExceptionClasses(String csv) { } return classes.toArray(new Class[0]); } + + /** + * A helper method that builds a detailed error message + * + * @param baseMessage the base message for the exception + * @param ex the exception thrown + * @param componentName the name of the component throwing the exception + * @param operation the name of the operation throwing the exception + * @param targetId the target id for the operation + * @return an enriched failure message + */ + public static String buildFailureMessage( + String baseMessage, Throwable ex, String componentName, String operation, String targetId) { + return buildFailureMessage(baseMessage, getExceptionDetails(ex, componentName, operation), targetId); + } + + /** + * A helper method that builds a detailed error message + * + * @param ex the exception thrown + * @param componentName the name of the component throwing the exception + * @param operation the name of the operation throwing the exception + * @param targetId the target id for the operation + * @return an enriched failure message + */ + private static String buildFailureMessage(Throwable ex, String componentName, String operation, String targetId) { + return buildFailureMessage(ex.getMessage(), getExceptionDetails(ex, componentName, operation), targetId); + } + + /** + * A helper method to fetch human readabe details from parameters + * + * @param ex the exception throw + * @param componentName the name of the component throwing the exception + * @param operation the name of the operation where the exception is thrown + * @return a string with human readable details from the provided parameters + */ + private static String getExceptionDetails(Throwable ex, String componentName, String operation) { + Throwable root = getRootCause(ex); + + return switch (root) { + case java.net.SocketTimeoutException ignored -> "timeout while calling " + componentName; + + case java.net.http.HttpTimeoutException ignored -> "timeout while calling " + componentName; + + case java.io.InterruptedIOException ignored -> { + Thread.currentThread().interrupt(); + yield "request was interrupted"; + } + + case InterruptedException ignored -> { + Thread.currentThread().interrupt(); + yield "request was interrupted"; + } + + case java.io.IOException ignored -> "I/O error while calling " + componentName; + + case JedisException ignored -> "redis cache failure"; + + default -> "unexpected failure during " + operation; + }; + } + + /** + * A helper method that creates a detailed formatted error message + * + * @param baseMessage the base message included in the original exception + * @param detail the human readable detailed message for the exception + * @param targetId the target id for the operation + * @return a detailed formatted error message + */ + private static String buildFailureMessage(String baseMessage, String detail, String targetId) { + + String targetSuffix = (targetId != null && !targetId.isBlank()) ? " for " + targetId : ""; + + return "%s (%s%s)".formatted((baseMessage == null ? "" : baseMessage), detail, targetSuffix); + } + + private static Throwable getRootCause(Throwable ex) { + Throwable current = ex; + while (current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + /** + * Enriches the message of a RebuildableRuntimeException and rethrows it. + * If the exception is not a RebuildableRuntimeException it is rethrown as-is. + * + * @param e the exception to enrich + * @param componentName the name of the component involved in the exception + * @param operation the name of the operation attempted before the exception was raised + * @param targetId the id of the target involved in the exception + */ + private static void enrichAndRethrow(RuntimeException ex, String componentName, String operation, String targetId) { + if (!(ex instanceof RebuildableRuntimeException rebuildableRuntimeException)) { + throw ex; + } + + String enrichedMessage = buildFailureMessage(ex, componentName, operation, targetId); + throw rebuildableRuntimeException.rebuild(enrichedMessage, ex); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ThreadUtil.java b/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ThreadUtil.java index c3ac8260..f3631abc 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ThreadUtil.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/common/utils/ThreadUtil.java @@ -61,7 +61,11 @@ public static void awaitShutdown( LOGGER.info("Exception occurred during shutdown, ignoring.", e); } })); - for (Future future : futureList) { + awaitFutures(futureList); + } + + public static void awaitFutures(List> futures) { + for (Future future : futures) { try { future.get(); LOGGER.info("Future processed: {}", future); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/FederatorTokenException.java b/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/FederatorTokenException.java index 25927af7..2916d6d5 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/FederatorTokenException.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/FederatorTokenException.java @@ -2,7 +2,7 @@ /** * Exception thrown when there is an error related to federator tokens. */ -public class FederatorTokenException extends RuntimeException { +public class FederatorTokenException extends RebuildableRuntimeException { public FederatorTokenException(String message) { super(message); @@ -11,4 +11,15 @@ public FederatorTokenException(String message) { public FederatorTokenException(String message, Throwable cause) { super(message, cause); } + + /** + * Rebuilds this exception with the given message and cause. + * @param message the enriched error message + * @param cause the original exception + * @return a new instance of {@link FederatorTokenException} + */ + @Override + public FederatorTokenException rebuild(String message, Throwable cause) { + return new FederatorTokenException(message, cause); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/RebuildableRuntimeException.java b/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/RebuildableRuntimeException.java new file mode 100644 index 00000000..283787f6 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/federator/exceptions/RebuildableRuntimeException.java @@ -0,0 +1,18 @@ +package uk.gov.dbt.ndtp.federator.exceptions; + +/** + * Abstract base for runtime exceptions that enforce rebuildability. + * Subclasses must implement {@link #rebuild(String, Throwable)} to return + * a new instance of themselves with the given message and cause. + */ +public abstract class RebuildableRuntimeException extends RuntimeException { + protected RebuildableRuntimeException(String message) { + super(message); + } + + protected RebuildableRuntimeException(String message, Throwable cause) { + super(message, cause); + } + + public abstract RebuildableRuntimeException rebuild(String message, Throwable cause); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractKafkaEventMessageConductor.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractKafkaEventMessageConductor.java index ad95f574..8a2e68ce 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractKafkaEventMessageConductor.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractKafkaEventMessageConductor.java @@ -67,6 +67,8 @@ public void processMessages() throws MessageProcessingException { } } catch (Exception e) { throw new MessageProcessingException(e); + } finally { + super.close(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductor.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductor.java index a878eeb6..6632c058 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductor.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductor.java @@ -73,6 +73,8 @@ public void processMessages() throws MessageProcessingException { } } catch (Exception e) { throw new MessageProcessingException(e); + } finally { + close(); } } @@ -84,10 +86,13 @@ public boolean continueProcessing() { @Override public void close() { try { - messageConsumer.close(); + if (messageConsumer.stillAvailable()) { + messageConsumer.close(); + } } catch (Exception ex) { LOGGER.info("Error whilst closing consumer, ignoring.", ex); } + try { messageProcessor.close(); } catch (Exception ex) { diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductor.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductor.java index 2565f652..702a049f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductor.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductor.java @@ -56,7 +56,6 @@ private FileConductor( public boolean continueProcessing() { if (serverCallStreamObserver.isCancelled()) { LOGGER.info("Observer is closed on client end. Stop further processing."); - messageConsumer.close(); return false; } return messageConsumer.stillAvailable(); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/RdfMessageConductor.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/RdfMessageConductor.java index 84ca0423..dcddb049 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/RdfMessageConductor.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/conductor/RdfMessageConductor.java @@ -83,7 +83,6 @@ private RdfMessageConductor( public boolean continueProcessing() { if (serverCallStreamObserver.isCancelled()) { LOGGER.info("Observer is closed on client end. Stop further processing."); - messageConsumer.close(); return false; } return messageConsumer.stillAvailable(); diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCFederatorService.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCFederatorService.java index b45ae91d..40f2e7fd 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCFederatorService.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCFederatorService.java @@ -34,6 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import uk.gov.dbt.ndtp.federator.FederatorService; +import uk.gov.dbt.ndtp.federator.common.annotations.ExcludeFromJacocoGeneratedReport; import uk.gov.dbt.ndtp.federator.server.interfaces.StreamObservable; import uk.gov.dbt.ndtp.grpc.FederatorServiceGrpc; import uk.gov.dbt.ndtp.grpc.FileStreamEvent; @@ -44,7 +45,7 @@ /** * GRPC specific federator service that uses the POJO federator service and wrappers. */ -public class GRPCFederatorService extends FederatorServiceGrpc.FederatorServiceImplBase { +public class GRPCFederatorService extends FederatorServiceGrpc.FederatorServiceImplBase implements AutoCloseable { public static final Logger LOGGER = LoggerFactory.getLogger("GRPCFederatorService"); @@ -90,4 +91,10 @@ public void getFilesStream(FileStreamRequest request, StreamObserver(serverCallStreamObserver); federator.getFileConsumer(request, streamObservable); } + + @ExcludeFromJacocoGeneratedReport + @Override + public void close() { + federator.close(); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCServer.java b/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCServer.java index c0f8e9ce..1a3bcd66 100644 --- a/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCServer.java +++ b/src/main/java/uk/gov/dbt/ndtp/federator/server/grpc/GRPCServer.java @@ -43,6 +43,7 @@ import lombok.SneakyThrows; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import uk.gov.dbt.ndtp.federator.common.annotations.ExcludeFromJacocoGeneratedReport; import uk.gov.dbt.ndtp.federator.common.service.idp.IdpTokenService; import uk.gov.dbt.ndtp.federator.common.utils.GRPCUtils; import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; @@ -78,8 +79,10 @@ public class GRPCServer implements AutoCloseable { private final Server server; private ServerCredentials creds; + private GRPCFederatorService grpcFederatorService; public GRPCServer(Set sharedHeaders) { + grpcFederatorService = new GRPCFederatorService(sharedHeaders); if (PropertyUtil.getPropertyBooleanValue(SERVER_MTLS_ENABLED, FALSE)) { creds = generateServerCredentials(); server = generateSecureServer(creds, sharedHeaders); @@ -122,7 +125,8 @@ private ServerCredentials generateServerCredentials() { String trustStorePassword = PropertyUtil.getPropertyValue(SERVER_TRUSTSTORE_PASSWORD); LOGGER.info( - "Using p12 file path: {}, truststore file path: {}, p12 password is set: {}, truststore password is set: {}", + "Using p12 file path: {}, truststore file path: {}, p12 password is set: {}, truststore password is" + + " set: {}", p12FilePath, trustStoreFilePath, p12Password != null, @@ -148,10 +152,12 @@ public void start() { } } + @ExcludeFromJacocoGeneratedReport @Override public void close() { try { LOGGER.info("GRPCServer close called"); + grpcFederatorService.close(); server.shutdown().awaitTermination(30, TimeUnit.SECONDS); LOGGER.info("GRPCServer closed"); } catch (InterruptedException e) { diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/FederatorServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/FederatorServiceTest.java index 1d4fddf6..efede0f0 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/FederatorServiceTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/FederatorServiceTest.java @@ -31,7 +31,7 @@ import java.util.Set; import org.apache.kafka.common.errors.InvalidTopicException; import org.junit.jupiter.api.Test; -import uk.gov.dbt.ndtp.federator.common.service.stream.FederatorStreamService; +import uk.gov.dbt.ndtp.federator.common.service.stream.CloseableFederatorStreamService; import uk.gov.dbt.ndtp.federator.server.interfaces.StreamObservable; import uk.gov.dbt.ndtp.grpc.TopicRequest; @@ -55,7 +55,7 @@ void test_getKafkaConsumer_delegatesToKafkaStreamService() throws Exception { FederatorService cut = new FederatorService(headers); @SuppressWarnings("rawtypes") - FederatorStreamService mockKafka = mock(FederatorStreamService.class); + CloseableFederatorStreamService mockKafka = mock(CloseableFederatorStreamService.class); setPrivateField(cut, "kafkaStreamService", mockKafka); TopicRequest request = @@ -66,7 +66,7 @@ void test_getKafkaConsumer_delegatesToKafkaStreamService() throws Exception { cut.getKafkaConsumer(request, observable); // Assert - verify(mockKafka, times(1)).streamToClient(request, observable); + verify(mockKafka, times(1)).streamToClient(eq(request), eq(observable), any()); verifyNoMoreInteractions(mockKafka); } @@ -75,13 +75,15 @@ void test_getKafkaConsumer_propagatesInvalidTopicException() { // Arrange FederatorService cut = new FederatorService(Set.of()); @SuppressWarnings("rawtypes") - FederatorStreamService mockKafka = mock(FederatorStreamService.class); + CloseableFederatorStreamService mockKafka = mock(CloseableFederatorStreamService.class); setPrivateField(cut, "kafkaStreamService", mockKafka); TopicRequest request = TopicRequest.newBuilder().setTopic("forbidden").build(); StreamObservable observable = mock(StreamObservable.class); - doThrow(new InvalidTopicException("not allowed")).when(mockKafka).streamToClient(request, observable); + doThrow(new InvalidTopicException("not allowed")) + .when(mockKafka) + .streamToClient(eq(request), eq(observable), any()); // Act + Assert assertThrows(InvalidTopicException.class, () -> cut.getKafkaConsumer(request, observable)); @@ -92,7 +94,7 @@ void test_getFileConsumer_delegatesToFileStreamService() { // Arrange FederatorService cut = new FederatorService(Set.of()); @SuppressWarnings("rawtypes") - FederatorStreamService mockFile = mock(FederatorStreamService.class); + CloseableFederatorStreamService mockFile = mock(CloseableFederatorStreamService.class); setPrivateField(cut, "fileStreamService", mockFile); uk.gov.dbt.ndtp.grpc.FileStreamRequest request = uk.gov.dbt.ndtp.grpc.FileStreamRequest.newBuilder() @@ -104,7 +106,26 @@ void test_getFileConsumer_delegatesToFileStreamService() { cut.getFileConsumer(request, observable); // Assert - verify(mockFile, times(1)).streamToClient(request, observable); + verify(mockFile, times(1)).streamToClient(eq(request), eq(observable), any()); verifyNoMoreInteractions(mockFile); } + + @Test + void test_close_ClosesBothTheKafkaStreamServiceAndTheFileStreamService() { + // Arrange + FederatorService cut = new FederatorService(Set.of()); + @SuppressWarnings("rawtypes") + CloseableFederatorStreamService mockKafka = mock(CloseableFederatorStreamService.class); + setPrivateField(cut, "kafkaStreamService", mockKafka); + @SuppressWarnings("rawtypes") + CloseableFederatorStreamService mockFile = mock(CloseableFederatorStreamService.class); + setPrivateField(cut, "fileStreamService", mockFile); + + // Act + cut.close(); + + // Assert + verify(mockKafka, times(1)).close(); + verify(mockFile, times(1)).close(); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssemblerTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssemblerTest.java index 0485772d..5d517351 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssemblerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/client/grpc/file/FileChunkAssemblerTest.java @@ -10,6 +10,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.ReceivedFileStorageFactory; +import uk.gov.dbt.ndtp.federator.client.storage.StoredFileResult; +import uk.gov.dbt.ndtp.federator.client.storage.impl.GCPReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.impl.S3ReceivedFileStorage; import uk.gov.dbt.ndtp.federator.common.utils.GRPCUtils; import uk.gov.dbt.ndtp.federator.exceptions.FileAssemblyException; import uk.gov.dbt.ndtp.grpc.FileChunk; @@ -268,4 +275,103 @@ void testHandleLastChunk_MoveFails() throws Exception { // If that also fails, it will throw the IOException. assertThrows(java.io.IOException.class, () -> assembler.accept(c1)); } + + @Test + void testHandleLastChunk_GCPStorageSuccess_returnsPath() { + FileChunkAssembler assembler = new FileChunkAssembler(tempDir); + String fileName = "gcptest.txt"; + long seq = 7L; + String emptyChecksum = GRPCUtils.calculateSha256Checksum(new byte[0]); + + FileChunk last = FileChunk.newBuilder() + .setFileName(fileName) + .setFileSequenceId(seq) + .setIsLastChunk(true) + .setFileSize(0) + .setTotalChunks(1) + .setFileChecksum(emptyChecksum) + .build(); + + // Mock ReceivedFileStorageFactory to return a mock GCP storage that succeeds + try (MockedStatic factoryMock = + Mockito.mockStatic(ReceivedFileStorageFactory.class)) { + ReceivedFileStorage mockGCPStorage = Mockito.mock(GCPReceivedFileStorage.class); + factoryMock.when(ReceivedFileStorageFactory::get).thenReturn(mockGCPStorage); + + // Mock successful storage with remote URI present + Path mockPath = tempDir.resolve(fileName); + StoredFileResult successResult = new StoredFileResult(mockPath, "gs://my-bucket/gcptest.txt"); + Mockito.when(mockGCPStorage.store(Mockito.any(), Mockito.eq(fileName), Mockito.any())) + .thenReturn(successResult); + + Path result = assembler.accept(last); + assertNotNull(result, "Should return path when GCP storage succeeds"); + } + } + + @Test + void testHandleLastChunk_GCPStorageFailure_returnsNull() { + FileChunkAssembler assembler = new FileChunkAssembler(tempDir); + String fileName = "gcpfail.txt"; + long seq = 8L; + String emptyChecksum = GRPCUtils.calculateSha256Checksum(new byte[0]); + + FileChunk last = FileChunk.newBuilder() + .setFileName(fileName) + .setFileSequenceId(seq) + .setIsLastChunk(true) + .setFileSize(0) + .setTotalChunks(1) + .setFileChecksum(emptyChecksum) + .build(); + + // Mock ReceivedFileStorageFactory to return a mock GCP storage that fails (no remote URI) + try (MockedStatic factoryMock = + Mockito.mockStatic(ReceivedFileStorageFactory.class)) { + ReceivedFileStorage mockGCPStorage = Mockito.mock(GCPReceivedFileStorage.class); + factoryMock.when(ReceivedFileStorageFactory::get).thenReturn(mockGCPStorage); + + // Mock failed storage with no remote URI + Path mockPath = tempDir.resolve(fileName); + StoredFileResult failureResult = new StoredFileResult(mockPath, null); + Mockito.when(mockGCPStorage.store(Mockito.any(), Mockito.eq(fileName), Mockito.any())) + .thenReturn(failureResult); + + Path result = assembler.accept(last); + assertNull(result, "Should return null when GCP storage fails (no remote URI)"); + } + } + + @Test + void testHandleLastChunk_S3StorageFailure_returnsNull() { + FileChunkAssembler assembler = new FileChunkAssembler(tempDir); + String fileName = "s3fail.txt"; + long seq = 9L; + String emptyChecksum = GRPCUtils.calculateSha256Checksum(new byte[0]); + + FileChunk last = FileChunk.newBuilder() + .setFileName(fileName) + .setFileSequenceId(seq) + .setIsLastChunk(true) + .setFileSize(0) + .setTotalChunks(1) + .setFileChecksum(emptyChecksum) + .build(); + + // Mock ReceivedFileStorageFactory to return a mock S3 storage that fails (no remote URI) + try (MockedStatic factoryMock = + Mockito.mockStatic(ReceivedFileStorageFactory.class)) { + ReceivedFileStorage mockS3Storage = Mockito.mock(S3ReceivedFileStorage.class); + factoryMock.when(ReceivedFileStorageFactory::get).thenReturn(mockS3Storage); + + // Mock failed storage with no remote URI + Path mockPath = tempDir.resolve(fileName); + StoredFileResult failureResult = new StoredFileResult(mockPath, null); + Mockito.when(mockS3Storage.store(Mockito.any(), Mockito.eq(fileName), Mockito.any())) + .thenReturn(failureResult); + + Path result = assembler.accept(last); + assertNull(result, "Should return null when S3 storage fails (no remote URI)"); + } + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactoryTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactoryTest.java new file mode 100644 index 00000000..1b044427 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/ReceivedFileStorageFactoryTest.java @@ -0,0 +1,158 @@ +package uk.gov.dbt.ndtp.federator.client.storage; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import uk.gov.dbt.ndtp.federator.client.storage.impl.AzureReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.impl.GCPReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.impl.LocalReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.client.storage.impl.S3ReceivedFileStorage; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; + +class ReceivedFileStorageFactoryTest { + + @AfterEach + void tearDown() { + // Ensure no lingering global state between tests + try { + PropertyUtil.clear(); + } catch (Exception ignored) { + // ignore if not initialized + } + } + + @Test + void get_returnsS3Storage_whenProviderIsS3() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("S3"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(S3ReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsS3Storage_whenProviderIsS3CaseInsensitive() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("s3"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(S3ReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsAzureStorage_whenProviderIsAzure() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("AZURE"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(AzureReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsAzureStorage_whenProviderIsAzureCaseInsensitive() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("Azure"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(AzureReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsGCPStorage_whenProviderIsGCP() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("GCP"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(GCPReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsGCPStorage_whenProviderIsGCPCaseInsensitive() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("gcp"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(GCPReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenProviderIsLocal() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("LOCAL"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenProviderIsEmpty() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn(""); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenProviderIsNull() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn(null); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenProviderIsUnknown() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenReturn("UNKNOWN_PROVIDER"); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenPropertyUtilThrowsException() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenThrow(new RuntimeException("Property not available")); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } + + @Test + void get_returnsLocalStorage_whenPropertyUtilThrowsRuntimeException() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("client.files.storage.provider", "LOCAL")) + .thenThrow(new RuntimeException("Configuration error")); + + ReceivedFileStorage storage = ReceivedFileStorageFactory.get(); + assertInstanceOf(LocalReceivedFileStorage.class, storage); + } + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorageTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorageTest.java new file mode 100644 index 00000000..4d5df784 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/client/storage/impl/GCPReceivedFileStorageTest.java @@ -0,0 +1,174 @@ +package uk.gov.dbt.ndtp.federator.client.storage.impl; + +import static java.nio.file.Files.createTempFile; +import static java.nio.file.Files.deleteIfExists; +import static java.nio.file.Files.exists; +import static java.nio.file.Files.writeString; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import uk.gov.dbt.ndtp.federator.client.storage.StoredFileResult; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; + +class GCPReceivedFileStorageTest { + @AfterEach + void tearDown() { + // Ensure no lingering global state between tests + try { + PropertyUtil.clear(); + } catch (Exception ignored) { + // ignore if not initialized + } + } + + @Test + void resolveBucket_returnsEmptyWhenNotConfigured() { + // Mock PropertyUtil to return blank bucket + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn(""); + + GCPReceivedFileStorage gcp = new GCPReceivedFileStorage(); + assertEquals("", gcp.resolveBucket()); + } + } + + @Test + void resolveBucket_returnsConfiguredBucket() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn("my-team-docs"); + + GCPReceivedFileStorage gcp = new GCPReceivedFileStorage(); + assertEquals("my-team-docs", gcp.resolveBucket()); + } + } + + @Test + void resolveKey_handlesNullBlankAndPrefixesAndNormalization() { + GCPReceivedFileStorage gcp = new GCPReceivedFileStorage(); + + // null destination -> sanitized original file name + assertEquals("name.txt", gcp.resolveKey(null, "dir/name.txt")); + // blank destination -> sanitized + assertEquals("name.txt", gcp.resolveKey(" ", "x/../name.txt")); + // prefix with trailing slash -> append sanitized file name + assertEquals("a/b/name.txt", gcp.resolveKey("a/b/", "c/d/name.txt")); + // full key without trailing slash -> normalize leading slashes are removed + assertEquals("a/b/c.txt", gcp.resolveKey("/a/b/c.txt", "ignored.txt")); + } + + // We avoid direct testing of upload() to prevent static initialization of GcsClientFactory. + // Instead, we exercise store() behavior with a subclass overriding upload(). + + @Test + void store_bucketBlank_skipsUpload_andKeepsLocalFile() throws IOException { + Path temp = createTempFile("gcprfst-", ".bin"); + writeString(temp, "data"); + + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn(""); + GCPReceivedFileStorage gcp = new GCPReceivedFileStorage(); + StoredFileResult res = gcp.store(temp, "f.txt", null); + assertTrue(exists(res.localPath())); + assertFalse(res.remoteUriOpt().isPresent()); + } finally { + deleteIfExists(temp); + } + } + + @Test + void store_success_deletesLocal_andReturnsRemoteUri() throws IOException { + Path temp = createTempFile("gcprfst-", ".bin"); + writeString(temp, "data"); + + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + // Bucket resolution + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn("my-team-docs"); + // Use a test subclass that fakes upload success + class TestGCP extends GCPReceivedFileStorage { + @Override + String upload(Path localFile, String bucket, String key) { + return String.format("gs://%s/%s", bucket, key); + } + } + GCPReceivedFileStorage gcp = new TestGCP(); + StoredFileResult res = gcp.store(temp, "file.txt", "prefix/"); + + assertTrue(res.remoteUriOpt().isPresent()); + assertFalse(exists(temp), "Temp file should be deleted after successful upload"); + } finally { + deleteIfExists(temp); + } + } + + @Test + void store_failure_deletesLocal_andNoRemoteUri() throws IOException { + Path temp = createTempFile("gcprfst-", ".bin"); + writeString(temp, "data"); + + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + // Bucket resolution + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn("my-team-docs"); + // Use a test subclass that simulates upload failure by throwing an exception + class TestGCP extends GCPReceivedFileStorage { + @Override + String upload(Path localFile, String bucket, String key) { + throw new RuntimeException("simulated GCS error"); + } + } + GCPReceivedFileStorage gcp = new TestGCP(); + StoredFileResult res = gcp.store(temp, "file.txt", "prefix/"); + + assertFalse(res.remoteUriOpt().isPresent()); + assertFalse(exists(temp), "Temp file should be deleted when upload fails"); + } finally { + deleteIfExists(temp); + } + } + + @Test + void resolveBucket_returnsEmptyWhenNullReturned() { + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn(null); + + GCPReceivedFileStorage gcp = new GCPReceivedFileStorage(); + assertEquals("", gcp.resolveBucket()); + } + } + + @Test + void store_uploadReturnsNull_deletesLocal_andNoRemoteUri() throws IOException { + Path temp = createTempFile("gcprfst-", ".bin"); + writeString(temp, "data"); + + try (MockedStatic prop = Mockito.mockStatic(PropertyUtil.class)) { + // Bucket resolution + prop.when(() -> PropertyUtil.getPropertyValue("files.gcp.bucket", "")) + .thenReturn("my-team-docs"); + // Use a test subclass that simulates upload failure by returning null + class TestGCP extends GCPReceivedFileStorage { + @Override + String upload(Path localFile, String bucket, String key) { + return null; // Simulate upload failure without exception + } + } + GCPReceivedFileStorage gcp = new TestGCP(); + StoredFileResult res = gcp.store(temp, "file.txt", "prefix/"); + + assertFalse(res.remoteUriOpt().isPresent()); + assertFalse(exists(temp), "Temp file should be deleted when upload returns null"); + } finally { + deleteIfExists(temp); + } + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/service/KafkaStreamServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/KafkaStreamServiceTest.java index 67828eb8..227a3770 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/common/service/KafkaStreamServiceTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/KafkaStreamServiceTest.java @@ -5,12 +5,18 @@ import static org.mockito.Mockito.*; import io.grpc.Context; +import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import org.apache.kafka.common.errors.InvalidTopicException; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -23,6 +29,7 @@ import uk.gov.dbt.ndtp.federator.common.service.config.ProducerConfigService; import uk.gov.dbt.ndtp.federator.common.service.kafka.KafkaStreamService; import uk.gov.dbt.ndtp.federator.common.utils.ProducerConsumerConfigServiceFactory; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; import uk.gov.dbt.ndtp.federator.server.grpc.GRPCContextKeys; import uk.gov.dbt.ndtp.federator.server.interfaces.StreamObservable; import uk.gov.dbt.ndtp.grpc.TopicRequest; @@ -122,6 +129,7 @@ void test_streamToClient_throwsInvalidTopic_whenAccessDenied() { TopicRequest req = TopicRequest.newBuilder().setTopic("not-allowed").setOffset(0L).build(); StreamObservable observer = mock(StreamObservable.class); + ExecutorService executorService = mock(ExecutorService.class); ProducerConfigService mockService = mock(ProducerConfigService.class); ProducerConfigDTO emptyCfg = @@ -138,10 +146,88 @@ void test_streamToClient_throwsInvalidTopic_whenAccessDenied() { Context ctx = Context.current().withValue(GRPCContextKeys.CLIENT_ID, "consumer-1"); Context previous = ctx.attach(); try { - assertThrows(InvalidTopicException.class, () -> cut.streamToClient(req, observer)); + assertThrows(InvalidTopicException.class, () -> cut.streamToClient(req, observer, executorService)); } finally { ctx.detach(previous); } } } + + // -------------------- Positive test for streamToClient -------------------- + + @Test + void test_streamToClient_awaitsTheFutureSubmittedToTheExecutorService() throws IOException { + KafkaStreamService cut = new KafkaStreamService(EMPTY_SHARED_HEADERS); + TopicRequest req = + TopicRequest.newBuilder().setTopic("test").setOffset(0L).build(); + StreamObservable observer = mock(StreamObservable.class); + ExecutorService executorService = mock(ExecutorService.class); + + ProductDTO mockProductDto = mock(ProductDTO.class); + ConsumerDTO mockConsumerDto = mock(ConsumerDTO.class); + ArrayList mockConumerDtos = new ArrayList<>(); + mockConumerDtos.add(mockConsumerDto); + + when(mockProductDto.getTopic()).thenReturn("test"); + when(mockConsumerDto.getIdpClientId()).thenReturn("consumer-1"); + when(mockProductDto.getConsumers()).thenReturn(mockConumerDtos); + + ArrayList mockProductDtos = new ArrayList<>(); + mockProductDtos.add(mockProductDto); + + ProducerDTO mockProducerDto = mock(ProducerDTO.class); + ArrayList mockProducerDtos = new ArrayList<>(); + mockProducerDtos.add(mockProducerDto); + + when(mockProducerDto.getProducts()).thenReturn(mockProductDtos); + + ProducerConfigService mockService = mock(ProducerConfigService.class); + ProducerConfigDTO producerCfg = + ProducerConfigDTO.builder().producers(mockProducerDtos).build(); + + try (MockedStatic mockedFactory = + Mockito.mockStatic(ProducerConsumerConfigServiceFactory.class)) { + mockedFactory + .when(ProducerConsumerConfigServiceFactory::getProducerConfigService) + .thenReturn(mockService); + when(mockService.getProducerConfiguration()).thenReturn(producerCfg); + + Future mockFuture = mock(Future.class); + + when(executorService.submit(any(Runnable.class))).thenReturn(mockFuture); + + // Set the gRPC context key so KafkaStreamService can read the consumer id + Context ctx = Context.current().withValue(GRPCContextKeys.CLIENT_ID, "consumer-1"); + Context previous = ctx.attach(); + + // Prepare a temporary properties file with minimal configuration + Path tmp = Files.createTempFile("s3clientfactory-test-", ".properties"); + try { + String props = String.join( + "\n", + "kafka.defaultKeyDeserializerClass=org.apache.kafka.common.serialization.StringDeserializer", + "kafka.defaultValueDeserializerClass=uk.gov.dbt.ndtp.federator.access.AccessMessageDeserializer", + "kafka.bootstrapServers=localhost:9092", + "kafka.consumerGroup=test", + "kafka.pollRecords=100"); + + Files.writeString(tmp, props); + // Initialize PropertyUtil + PropertyUtil.init(tmp.toFile()); + cut.streamToClient(req, observer, executorService); + } finally { + Files.deleteIfExists(tmp); + PropertyUtil.clear(); + ctx.detach(previous); + } + + verify(executorService, times(1)).submit(any(Runnable.class)); + + try { + verify(mockFuture, times(1)).get(); + } catch (InterruptedException | ExecutionException ignored) { + // ignored + } + } + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamServiceTest.java index eb431203..e60ca435 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamServiceTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/file/FileStreamServiceTest.java @@ -5,6 +5,9 @@ import static org.mockito.Mockito.*; import io.grpc.Context; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import org.junit.jupiter.api.Test; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; @@ -26,6 +29,11 @@ void test_streamToClient_invokesConductorAndCompletes() { StreamObservable observer = mock(StreamObservable.class); + ExecutorService executorService = mock(ExecutorService.class); + Future mockFuture = mock(Future.class); + + when(executorService.submit(any(Runnable.class))).thenReturn(mockFuture); + FileStreamRequest req = FileStreamRequest.newBuilder() .setTopic("files-topic") .setStartSequenceId(0L) @@ -50,7 +58,7 @@ void test_streamToClient_invokesConductorAndCompletes() { Context grpcCtx = Context.current().withValue(GRPCContextKeys.CLIENT_ID, "client-xyz"); Context prev = grpcCtx.attach(); try { - cut.streamToClient(req, observer); + cut.streamToClient(req, observer, executorService); } finally { grpcCtx.detach(prev); } @@ -58,6 +66,12 @@ void test_streamToClient_invokesConductorAndCompletes() { // One FileConductor constructed assertEquals(1, mocked.constructed().size()); + try { + verify(mockFuture, times(1)).get(); + } catch (InterruptedException | ExecutionException ignored) { + // ignored + } + // Cancel handler is set and onCompleted called verify(observer, times(1)).setOnCancelHandler(any()); verify(observer, times(1)).onCompleted(); diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/service/stream/ClosableFederatorStreamServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/stream/ClosableFederatorStreamServiceTest.java new file mode 100644 index 00000000..86ee6e80 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/service/stream/ClosableFederatorStreamServiceTest.java @@ -0,0 +1,29 @@ +package uk.gov.dbt.ndtp.federator.common.service.stream; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import uk.gov.dbt.ndtp.federator.common.service.file.FileStreamService; +import uk.gov.dbt.ndtp.federator.server.conductor.MessageConductor; + +/** + * Tests for {@link CloseableFederatorStreamService} + */ +class ClosableFederatorStreamServiceTest { + + @Test + void shouldCloseAllMessageConductorsAndClearTheList_whenClosed() { + CloseableFederatorStreamService service = new FileStreamService(); + + MessageConductor messageConductor1 = mock(MessageConductor.class); + service.messageConductors.add(messageConductor1); + + service.close(); + + verify(messageConductor1, times(1)).close(); + assertTrue(service.messageConductors.isEmpty()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactoryTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactoryTest.java index 9e12da77..2d8b85b4 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactoryTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/FileProviderFactoryTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.*; import com.azure.storage.blob.BlobServiceClient; +import com.google.cloud.storage.Storage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,8 +18,10 @@ import software.amazon.awssdk.services.s3.S3Client; import uk.gov.dbt.ndtp.federator.common.model.SourceType; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.AzureBlobClientFactory; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.GcsClientFactory; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.client.S3ClientFactory; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.AzureFileProvider; +import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.GCPFileProvider; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.LocalFileProvider; import uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl.S3FileProvider; @@ -26,17 +29,20 @@ class FileProviderFactoryTest { private MockedStatic s3FactoryMock; private MockedStatic azureFactoryMock; + private MockedStatic gcsFactoryMock; @BeforeEach void setUp() { s3FactoryMock = mockStatic(S3ClientFactory.class); azureFactoryMock = mockStatic(AzureBlobClientFactory.class); + gcsFactoryMock = mockStatic(GcsClientFactory.class); } @AfterEach void tearDown() { s3FactoryMock.close(); azureFactoryMock.close(); + gcsFactoryMock.close(); } @Test @@ -53,6 +59,13 @@ void testGetProvider_Azure() { assertTrue(provider instanceof AzureFileProvider); } + @Test + void testGetProvider_GCP() { + gcsFactoryMock.when(GcsClientFactory::getClient).thenReturn(mock(Storage.class)); + FileProvider provider = FileProviderFactory.getProvider(SourceType.GCP); + assertTrue(provider instanceof GCPFileProvider); + } + @Test void testGetProvider_Local() { FileProvider provider = FileProviderFactory.getProvider(SourceType.LOCAL); diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactoryTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactoryTest.java new file mode 100644 index 00000000..1b7d00f6 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/GcsClientFactoryTest.java @@ -0,0 +1,234 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.federator.common.storage.provider.file.client; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.cloud.storage.Storage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; + +/** + * Basic smoke test for GcsClientFactory ensuring a client can be created from properties. + * + * Note: We intentionally limit to a single construction scenario because the factory + * holds a static singleton instance which cannot be reset between tests. This test + * verifies the expected happy-path initialization using different credential approaches + * and a custom endpoint (e.g., fake-gcs-server/local), without making any network calls. + */ +class GcsClientFactoryTest { + + @AfterEach + void tearDown() { + try { + GcsClientFactory.resetClient(); + PropertyUtil.clear(); + } catch (Exception ignored) { + // ignore if not initialized + } + } + + @Test + void getClient_withEndpointUrl_buildsSuccessfully() throws IOException { + // Prepare a temporary properties file with custom endpoint (fake-gcs-server) + Path tmp = Files.createTempFile("gcsclientfactory-test-", ".properties"); + try { + String props = String.join( + "\n", "gcp.storage.endpoint.url=http://localhost:4443", "gcp.storage.project.id=test-project"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When + var client = GcsClientFactory.getClient(); + + // Then + assertNotNull(client, "GcsClientFactory should return a non-null Storage instance"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_withProjectIdOnly_buildsSuccessfully() throws IOException { + // Prepare a temporary properties file with only project ID and endpoint to avoid ADC + Path tmp = Files.createTempFile("gcsclientfactory-project-test-", ".properties"); + try { + String props = String.join( + "\n", "gcp.storage.project.id=test-project", "gcp.storage.endpoint.url=http://localhost:4443"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When + var client = GcsClientFactory.getClient(); + + // Then + assertNotNull(client, "GcsClientFactory should return a non-null Storage instance with project ID only"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_withNoProperties_buildsSuccessfully() throws IOException { + // Prepare a temporary properties file with endpoint to avoid ADC + Path tmp = Files.createTempFile("gcsclientfactory-default-test-", ".properties"); + try { + String props = "gcp.storage.endpoint.url=http://localhost:4443"; + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When + var client = GcsClientFactory.getClient(); + + // Then + assertNotNull( + client, "GcsClientFactory should return a non-null Storage instance with default credentials"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_withEndpointAndNoProjectId_buildsSuccessfully() throws IOException { + // Prepare a temporary properties file with endpoint but no project ID + Path tmp = Files.createTempFile("gcsclientfactory-endpoint-no-project-test-", ".properties"); + try { + String props = String.join("\n", "gcp.storage.endpoint.url=http://localhost:4443"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When + var client = GcsClientFactory.getClient(); + + // Then + assertNotNull( + client, + "GcsClientFactory should return a non-null Storage instance with endpoint but no project ID"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_returnsSameInstance_whenCalledMultipleTimes() throws IOException { + // Prepare a temporary properties file + Path tmp = Files.createTempFile("gcsclientfactory-singleton-test-", ".properties"); + try { + String props = String.join( + "\n", "gcp.storage.endpoint.url=http://localhost:4443", "gcp.storage.project.id=test-project"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When - call getClient multiple times + Storage client1 = GcsClientFactory.getClient(); + Storage client2 = GcsClientFactory.getClient(); + Storage client3 = GcsClientFactory.getClient(); + + // Then - all references should point to the same instance + assertAll( + "Singleton behavior verification", + () -> assertNotNull(client1, "First client should not be null"), + () -> assertSame(client1, client2, "Second call should return same instance as first"), + () -> assertSame(client1, client3, "Third call should return same instance as first"), + () -> assertSame(client2, client3, "All instances should be identical")); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_withBlankEndpointUrl_usesDefaultEndpoint() throws IOException { + // Prepare a temporary properties file with blank endpoint URL but valid endpoint to avoid ADC + Path tmp = Files.createTempFile("gcsclientfactory-blank-endpoint-test-", ".properties"); + try { + String props = String.join( + "\n", "gcp.storage.project.id=test-project", "gcp.storage.endpoint.url=http://localhost:4443"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When - test that blank values in properties are handled + var client = GcsClientFactory.getClient(); + + // Then - should successfully create client + assertNotNull(client, "GcsClientFactory should handle configuration gracefully"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void getClient_withBlankProjectId_usesDefaultProjectId() throws IOException { + // Prepare a temporary properties file with blank project ID + Path tmp = Files.createTempFile("gcsclientfactory-blank-project-test-", ".properties"); + try { + String props = + String.join("\n", "gcp.storage.endpoint.url=http://localhost:4443", "gcp.storage.project.id="); + Files.writeString(tmp, props); + + // Initialize PropertyUtil before touching GcsClientFactory + PropertyUtil.init(tmp.toFile()); + + // When + var client = GcsClientFactory.getClient(); + + // Then + assertNotNull(client, "GcsClientFactory should handle blank project ID gracefully"); + } finally { + Files.deleteIfExists(tmp); + } + } + + @Test + void resetClient_allowsNewClientCreation() throws IOException { + // Prepare a temporary properties file + Path tmp = Files.createTempFile("gcsclientfactory-reset-test-", ".properties"); + try { + String props = String.join( + "\n", "gcp.storage.endpoint.url=http://localhost:4443", "gcp.storage.project.id=test-project-1"); + Files.writeString(tmp, props); + + // Initialize PropertyUtil and get first client + PropertyUtil.init(tmp.toFile()); + Storage client1 = GcsClientFactory.getClient(); + + // When - reset and create new client with different config + GcsClientFactory.resetClient(); + PropertyUtil.clear(); + + String props2 = String.join( + "\n", "gcp.storage.endpoint.url=http://localhost:4444", "gcp.storage.project.id=test-project-2"); + Files.writeString(tmp, props2); + PropertyUtil.init(tmp.toFile()); + Storage client2 = GcsClientFactory.getClient(); + + // Then - clients should be different instances + assertAll( + "Reset behavior verification", + () -> assertNotNull(client1, "First client should not be null"), + () -> assertNotNull(client2, "Second client should not be null"), + () -> assertNotSame(client1, client2, "After reset, a new instance should be created")); + } finally { + Files.deleteIfExists(tmp); + } + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/S3ClientFactoryTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/S3ClientFactoryTest.java index fd027239..c58ea590 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/S3ClientFactoryTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/client/S3ClientFactoryTest.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import uk.gov.dbt.ndtp.federator.common.utils.PropertyUtil; @@ -19,11 +20,15 @@ */ class S3ClientFactoryTest { + @BeforeEach + void setup() { + PropertyUtil.clear(); + } + @AfterEach void tearDown() { try { S3ClientFactory.resetClient(); - PropertyUtil.clear(); } catch (Exception ignored) { // ignore if not initialized } @@ -102,7 +107,8 @@ void getClient_withProfile_buildsSuccessfully() throws IOException { // Then assertNotNull( client, - "S3ClientFactory should return a non-null S3Client instance even if profile doesn't exist (it falls back)"); + "S3ClientFactory should return a non-null S3Client instance even if profile doesn't exist (it" + + " falls back)"); } finally { Files.deleteIfExists(tmp); } diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProviderTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProviderTest.java new file mode 100644 index 00000000..b04141f2 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/storage/provider/file/impl/GCPFileProviderTest.java @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.federator.common.storage.provider.file.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.google.cloud.ReadChannel; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import uk.gov.dbt.ndtp.federator.common.exception.FileTransferException; +import uk.gov.dbt.ndtp.federator.common.model.FileTransferRequest; +import uk.gov.dbt.ndtp.federator.common.model.SourceType; +import uk.gov.dbt.ndtp.federator.exceptions.FileFetcherException; +import uk.gov.dbt.ndtp.federator.server.processor.file.FileTransferResult; + +@ExtendWith(MockitoExtension.class) +class GCPFileProviderTest { + + @Mock + private Storage storage; + + @Mock + private Blob blob; + + @Mock + private ReadChannel readChannel; + + private GCPFileProvider gcpFileProvider; + + @BeforeEach + void setUp() { + gcpFileProvider = new GCPFileProvider(storage); + } + + @Test + void testGetSuccess() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(blob); + when(blob.exists()).thenReturn(true); + when(blob.getSize()).thenReturn(100L); + when(blob.reader()).thenReturn(readChannel); + + try (FileTransferResult result = gcpFileProvider.get(request)) { + assertNotNull(result); + assertEquals(100L, result.fileSize()); + assertNotNull(result.stream()); + } + + verify(storage).get(any(BlobId.class)); + verify(blob).exists(); + verify(blob).getSize(); + verify(blob).reader(); + } + + @Test + void testGetBlobNotFound_NullBlob() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(null); + + FileFetcherException exception = assertThrows(FileFetcherException.class, () -> gcpFileProvider.get(request)); + assertTrue(exception.getMessage().contains("File not found in GCS")); + } + + @Test + void testGetBlobNotFound_BlobDoesNotExist() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(blob); + when(blob.exists()).thenReturn(false); + + FileFetcherException exception = assertThrows(FileFetcherException.class, () -> gcpFileProvider.get(request)); + assertTrue(exception.getMessage().contains("File not found in GCS")); + } + + @Test + void testGetStorageException404() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + StorageException storageException = new StorageException(404, "Not Found"); + when(storage.get(any(BlobId.class))).thenThrow(storageException); + + FileFetcherException exception = assertThrows(FileFetcherException.class, () -> gcpFileProvider.get(request)); + assertTrue(exception.getMessage().contains("File not found in GCS")); + } + + @Test + void testGetStorageExceptionOther() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + StorageException storageException = new StorageException(500, "Internal Server Error"); + when(storage.get(any(BlobId.class))).thenThrow(storageException); + + FileFetcherException exception = assertThrows(FileFetcherException.class, () -> gcpFileProvider.get(request)); + assertTrue(exception.getMessage().contains("GCS error fetching")); + } + + @Test + void testGetGeneralException() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenThrow(new RuntimeException("Generic error")); + + FileFetcherException exception = assertThrows(FileFetcherException.class, () -> gcpFileProvider.get(request)); + assertTrue(exception.getMessage().contains("Failed to fetch from GCS")); + } + + @Test + void testValidatePathSuccess() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(blob); + when(blob.exists()).thenReturn(true); + + assertDoesNotThrow(() -> gcpFileProvider.validatePath(request)); + verify(storage).get(any(BlobId.class)); + verify(blob).exists(); + } + + @Test + void testValidatePathObjectNotFound_NullBlob() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(null); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS object not found")); + } + + @Test + void testValidatePathObjectNotFound_BlobDoesNotExist() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + when(storage.get(any(BlobId.class))).thenReturn(blob); + when(blob.exists()).thenReturn(false); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS object not found")); + } + + @Test + void testValidatePathStorageException404() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + StorageException storageException = new StorageException(404, "Not Found"); + when(storage.get(any(BlobId.class))).thenThrow(storageException); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS object not found")); + } + + @Test + void testValidatePathStorageExceptionOther() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, "my-bucket", "my-key"); + StorageException storageException = new StorageException(500, "Internal Server Error"); + when(storage.get(any(BlobId.class))).thenThrow(storageException); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS validation error")); + } + + @Test + void testValidatePathMissingBucket() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, null, "my-key"); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS bucket (storageContainer) is required")); + } + + @Test + void testValidatePathBlankBucket() { + FileTransferRequest request = new FileTransferRequest(SourceType.GCP, " ", "my-key"); + + FileTransferException exception = + assertThrows(FileTransferException.class, () -> gcpFileProvider.validatePath(request)); + assertTrue(exception.getMessage().contains("GCS bucket (storageContainer) is required")); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupportTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupportTest.java new file mode 100644 index 00000000..bda1d272 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/federator/common/utils/ResilienceSupportTest.java @@ -0,0 +1,212 @@ +package uk.gov.dbt.ndtp.federator.common.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.util.Properties; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import redis.clients.jedis.exceptions.JedisException; +import uk.gov.dbt.ndtp.federator.exceptions.FederatorTokenException; + +/** + * ResilienceSupportTest + */ +class ResilienceSupportTest { + + private static final String COMPONENT_NAME = "idp"; + private static final String OPERATION = "fetchToken"; + private static final String TARGET_ID = "client"; + private static final String BASE_MESSAGE = + "Failed to fetch token after resilience protections for management node: 1234"; + + private Supplier supplier; + + private static void setupTestFileProperties() { + Properties props = new Properties(); + props.setProperty("management.node.resilience.retry.maxAttempts", "1"); + props.setProperty("management.node.resilience.retry.maxBackOff", "PT0.2S"); + + PropertyUtil propertyUtil = PropertyUtil.getInstance(); + propertyUtil.properties.putAll(props); + PropertyUtil.overrideSystemProperties(propertyUtil.properties); + } + + @BeforeAll + static void initProperties() { + ResilienceSupport.clearForTests(); + PropertyUtil.clear(); + PropertyUtil.init("test.properties"); + setupTestFileProperties(); + } + + @BeforeEach + void setup() { + supplier = Mockito.mock(Supplier.class); + } + + @AfterAll + static void tearDown() { + ResilienceSupport.clearForTests(); + PropertyUtil.clear(); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithHttpTimeoutExceptionAsTheCause() { + HttpTimeoutException originalException = new HttpTimeoutException("Error: request timed out!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals( + BASE_MESSAGE + " (timeout while calling " + COMPONENT_NAME + " for " + TARGET_ID + ")", + thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithSocketTimeoutExceptionAsTheCause() { + SocketTimeoutException originalException = new SocketTimeoutException("Error: request timed out!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals( + BASE_MESSAGE + " (timeout while calling " + COMPONENT_NAME + " for " + TARGET_ID + ")", + thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithInterruptedIOExceptionAsTheCause() { + InterruptedIOException originalException = new InterruptedIOException("Error: file not found!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals(BASE_MESSAGE + " (request was interrupted for " + TARGET_ID + ")", thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithInterruptedExceptionAsTheCause() { + InterruptedException originalException = new InterruptedException("Error: thread interrupted!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals(BASE_MESSAGE + " (request was interrupted for " + TARGET_ID + ")", thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithIOExceptionAsTheCause() { + IOException originalException = new IOException("Error: File not found!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals( + BASE_MESSAGE + " (I/O error while calling " + COMPONENT_NAME + " for " + TARGET_ID + ")", + thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithJedisExceptionAsTheCause() { + JedisException originalException = new JedisException("Error: key not found!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals(BASE_MESSAGE + " (redis cache failure for " + TARGET_ID + ")", thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldEnrichAndRethrowRebuildableExceptionWithUnmatchedCause() { + RuntimeException originalException = new RuntimeException("unknown"); + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals( + BASE_MESSAGE + " (unexpected failure during " + OPERATION + " for " + TARGET_ID + ")", + thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldRethrowNonRebuildableExceptionAsIs() { + CallNotPermittedException callNotPermittedException = + CallNotPermittedException.createCallNotPermittedException(CircuitBreaker.ofDefaults(COMPONENT_NAME)); + + Mockito.when(supplier.get()).thenThrow(callNotPermittedException); + + CallNotPermittedException thrown = assertThrows( + CallNotPermittedException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals(callNotPermittedException, thrown); + } + + @Test + void shouldHandleNullBaseMessageGracefully() { + HttpTimeoutException originalException = new HttpTimeoutException("Error: request timed out!"); + FederatorTokenException federatorTokenException = new FederatorTokenException(null, originalException); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals("" + " (timeout while calling " + COMPONENT_NAME + " for " + TARGET_ID + ")", thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } + + @Test + void shouldHandleNullCauseGracefully() { + FederatorTokenException federatorTokenException = new FederatorTokenException(BASE_MESSAGE); + Mockito.when(supplier.get()).thenThrow(federatorTokenException); + + FederatorTokenException thrown = assertThrows( + FederatorTokenException.class, + () -> ResilienceSupport.decorateAndExecute(COMPONENT_NAME, OPERATION, TARGET_ID, supplier)); + + assertEquals( + BASE_MESSAGE + " (unexpected failure during " + OPERATION + " for " + TARGET_ID + ")", + thrown.getMessage()); + assertEquals(federatorTokenException, thrown.getCause()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractEventMessageConductorTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractEventMessageConductorTest.java index b44294d9..b866f546 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractEventMessageConductorTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractEventMessageConductorTest.java @@ -61,7 +61,7 @@ void testProcessMessage_WithEvent() { @Test void testProcessMessages() throws Exception { - when(mockConsumer.stillAvailable()).thenReturn(true, false); + when(mockConsumer.stillAvailable()).thenReturn(true, false, false); Event mockEvent = mock(Event.class); when(mockEvent.key()).thenReturn("testKey"); when(mockEvent.headers()).thenReturn(Stream.empty()); @@ -70,22 +70,31 @@ void testProcessMessages() throws Exception { conductor.processMessages(); verify(mockProcessor, times(1)).process(mockEvent); - verify(mockConsumer, times(2)).stillAvailable(); + verify(mockConsumer, times(3)).stillAvailable(); } @Test - void testClose() { + void testClose_WithConsumerIsStillAvailable() { + when(mockConsumer.stillAvailable()).thenReturn(true); conductor.close(); verify(mockConsumer).close(); verify(mockProcessor).close(); } + @Test + void testClose_WithConsumerNotStillAvailable() { + when(mockConsumer.stillAvailable()).thenReturn(false); + conductor.close(); + verify(mockProcessor).close(); + } + @Test void testClose_WithExceptions() { doThrow(new RuntimeException("Consumer Close Error")).when(mockConsumer).close(); doThrow(new RuntimeException("Processor Close Error")) .when(mockProcessor) .close(); + when(mockConsumer.stillAvailable()).thenReturn(true); // Should not throw exception assertDoesNotThrow(() -> conductor.close()); diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductorTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductorTest.java index 67c9b1e5..bf213469 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductorTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/AbstractMessageConductorTest.java @@ -57,13 +57,24 @@ void test_processMessages_shouldProcessMessagesCorrectly() throws Exception { } @Test - void test_close_shouldHandleClosingResources() { + void test_close_shouldHandleClosingResources_whenMessageConsumerIsStillAvailable() { + when(messageConsumer.stillAvailable()).thenReturn(true); + conductor.close(); verify(messageConsumer).close(); verify(messageProcessor).close(); } + @Test + void test_close_shouldHandleClosingResources_whenMessageConsumerIsNotStillAvailable() { + when(messageConsumer.stillAvailable()).thenReturn(false); + + conductor.close(); + + verify(messageProcessor).close(); + } + @Test void test_processMessages_shouldThrowMessageProcessingException() { when(messageConsumer.stillAvailable()).thenReturn(true); diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductorTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductorTest.java index 3ea09260..fd956ee1 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductorTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/server/conductor/FileConductorTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import uk.gov.dbt.ndtp.federator.common.model.FileTransferRequest; import uk.gov.dbt.ndtp.federator.common.model.dto.AttributesDTO; +import uk.gov.dbt.ndtp.federator.exceptions.MessageProcessingException; import uk.gov.dbt.ndtp.federator.server.consumer.MessageConsumer; import uk.gov.dbt.ndtp.federator.server.interfaces.StreamObservable; import uk.gov.dbt.ndtp.federator.server.processor.MessageProcessor; @@ -50,9 +51,9 @@ void testContinueProcessing_ObserverCancelled() { when(mockObserver.isCancelled()).thenReturn(true); boolean result = conductor.continueProcessing(); + conductor.processMessages(); assertFalse(result); - verify(mockConsumer).close(); } @Test @@ -70,4 +71,36 @@ void testContinueProcessing_ObserverNotCancelled_ConsumerNotAvailable() { assertFalse(conductor.continueProcessing()); } + + @Test + void testProcessMessages_ConsumerAndProcessorClosed_AfterProcessingAllMessages() { + when(mockConsumer.stillAvailable()).thenReturn(false); + + conductor.processMessages(); + + verify(mockProcessor, times(1)).close(); + } + + @Test + void testProcessMessages_ConsumerAndProcessorClosed_AfterProcessingAllMessages_AndConsumerStillAvailable() { + when(mockConsumer.stillAvailable()).thenReturn(false, true); + + conductor.processMessages(); + + verify(mockConsumer, times(1)).close(); + verify(mockProcessor, times(1)).close(); + } + + @Test + void testProcessMessages_ConsumerAndProcessorClosed_IfExceptionThrownWhileProcessingMessages() { + when(mockConsumer.stillAvailable()).thenReturn(true); + doThrow(new RuntimeException("Error when fetching next message!")) + .when(mockConsumer) + .getNextMessage(); + + assertThrows(MessageProcessingException.class, () -> conductor.processMessages()); + + verify(mockConsumer, times(1)).close(); + verify(mockProcessor, times(1)).close(); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/federator/server/processor/file/FileKafkaEventMessageProcessorTest.java b/src/test/java/uk/gov/dbt/ndtp/federator/server/processor/file/FileKafkaEventMessageProcessorTest.java index e0734b36..6315b71d 100644 --- a/src/test/java/uk/gov/dbt/ndtp/federator/server/processor/file/FileKafkaEventMessageProcessorTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/federator/server/processor/file/FileKafkaEventMessageProcessorTest.java @@ -11,7 +11,6 @@ import java.lang.reflect.Field; import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import uk.gov.dbt.ndtp.federator.common.model.FileTransferRequest; @@ -32,6 +31,7 @@ class FileKafkaEventMessageProcessorTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { + PropertyUtil.clear(); PropertyUtil.init("client.properties"); mockObserver = mock(StreamObservable.class); @@ -50,11 +50,6 @@ void setUp() throws Exception { validatorField.set(processor, mockValidator); } - @AfterEach - void tearDown() { - PropertyUtil.clear(); - } - @Test @SuppressWarnings("unchecked") void testProcessSuccessfully() throws Exception {