diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a371588 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: maven + registries: "*" + directory: "/" + schedule: + interval: monthly + commit-message: + prefix: "NA:" + groups: + maven-non-breaking-changes: + update-types: + - 'minor' + - 'patch' + - package-ecosystem: docker + registries: "*" + directory: "/" + schedule: + interval: monthly + commit-message: + prefix: "NA:" + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + registries: '*' + commit-message: + prefix: 'NA:' diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml new file mode 100644 index 0000000..26ef919 --- /dev/null +++ b/.github/workflows/latest.yml @@ -0,0 +1,38 @@ +name: Build and Publish the latest and greatest Application Image + +on: + push: + branches: + - main + +env: + REGISTRY: ghcr.io + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'maven' + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - id: buildx + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Image + run: | + make dist RELEASE_TAG=latest + permissions: + contents: read + packages: write \ No newline at end of file diff --git a/.github/workflows/notify-github-issues.yml b/.github/workflows/notify-github-issues.yml new file mode 100644 index 0000000..5255acb --- /dev/null +++ b/.github/workflows/notify-github-issues.yml @@ -0,0 +1,19 @@ +name: Send Google Chat notification when an issue is opened + +on: + issues: + types: [opened, reopened] + +jobs: + notify-google-chat: + runs-on: ubuntu-latest + permissions: + contents: 'read' + id-token: 'write' + steps: + - id: 'notify_google_chat' + uses: 'google-github-actions/send-google-chat-webhook@v0.0.2' + with: + webhook_url: '${{ secrets.GOOGLE_CHAT_GAIA_SUPPORT_CHANNEL }}' + mention: "" + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..47ab1bb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Build and Publish a tagged Application Image + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+-[a-z]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+' + +env: + REGISTRY: ghcr.io + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'maven' + - name: Get version + id: version + run: | + echo "parsing version from ref '$GITHUB_REF'" + VERSION=$(echo "$GITHUB_REF" | sed -e 's|.*/v\(.*\)|\1|g') + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - id: buildx + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Update Version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + echo "updating version to '$VERSION'" + make update-version "VERSION=$VERSION" + - name: Build and push Image + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + make dist RELEASE_TAG=v$VERSION + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + name: ${{ steps.version.outputs.version }} + body: | + Docker image: `ghcr.io/oviva-ag/epa-fm-watchdog:v${{ steps.version.outputs.version }}` + files: | + target/epa-fm-watchdog-jar-with-dependencies.jar \ No newline at end of file diff --git a/.github/workflows/spotless.yml b/.github/workflows/spotless.yml new file mode 100644 index 0000000..7f19229 --- /dev/null +++ b/.github/workflows/spotless.yml @@ -0,0 +1,31 @@ +name: Spotless Linter + +on: + push: + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + checks: write + steps: + - name: Checkout repo + uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'maven' + - name: run spotless + id: spotless + run: ./mvnw -B spotless:check + - name: check spotless + if: always() + uses: oviva-ag/checks-action@v2.0.0 + with: + name: Check spotless output + token: ${{ secrets.GITHUB_TOKEN }} + conclusion: ${{ steps.spotless.outcome }} \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..9768feb --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,41 @@ +name: Unit Tests + +on: + push: + branches: + - 'main' + pull_request: + types: [ opened, synchronize, reopened ] + paths-ignore: + - '.github/**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'maven' + - name: Run unit tests + run: ./mvnw -B verify +# - name: Cache SonarCloud packages +# if: ${{ github.event.pull_request == null || github.event.pull_request.head.repo.full_name == 'oviva-ag/epa-fm-watchdog' }} +# uses: actions/cache@v4 +# with: +# path: ~/.sonar/cache +# key: ${{ runner.os }}-sonar +# restore-keys: ${{ runner.os }}-sonar +# - name: Run Sonar +# if: ${{ github.event.pull_request == null || github.event.pull_request.head.repo.full_name == 'oviva-ag/epa-fm-watchdog' }} +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# run: ./mvnw -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94b1464 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea/ +.vscode/ +*.iml +target/ +.flattened-pom.xml +*_jwks.json +env.properties +*.pem +*.p12 +dependency-reduced-pom.xml +.DS_Store diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..aabe6ec --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..f95f1ee --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.8/apache-maven-3.9.8-bin.zip diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..0fff468 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* thomas.richner@oviva.com franco.grbac@oviva.com diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2339a63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4 + +LABEL org.opencontainers.image.source="https://github.com/oviva-ag/epa-fm-watchdog" + +ARG JAVA_PACKAGE=java-21-openjdk-headless +ARG RUN_JAVA_VERSION=1.3.8 +ARG OTEL_AGENT_VERSION=v2.6.0 + +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' + +# Install java and the run-java script +# Also set up permissions for user `1001` +RUN <> /etc/alternatives/jre/conf/security/java.security +echo "securerandom.strongAlgorithms=NativePRNGNonBlocking:SUN,DRBG:SUN" >> /etc/alternatives/jre/conf/security/java.security +EOF + +# Configure the JAVA_OPTIONS, you can add -XshowSettings:vm to also display the heap size. +ENV JAVA_OPTIONS="-javaagent:/opentelemetry-javaagent.jar" + +# Configure OpenTelemetry +ENV OTEL_JAVAAGENT_DEBUG=false +ENV OTEL_JAVAAGENT_ENABLED=false +ENV OTEL_METRICS_EXPORTER=none +ENV OTEL_LOGS_EXPORTER=none +ENV OTEL_TRACES_EXPORTER=otlp +ENV OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=grpc + +## Allowlist instrumented components for faster startup +ENV OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED=false +ENV OTEL_INSTRUMENTATION_JAVA_HTTP_CLIENT_ENABLED=true +ENV OTEL_INSTRUMENTATION_JAXRS_ENABLED=false +ENV OTEL_INSTRUMENTATION_UNDERTOW_ENABLED=true + +COPY --chown=1001 target/epa-fm-watchdog-jar-with-dependencies.jar /deployments/ + +USER 1001 + +EXPOSE 8080 + +ENTRYPOINT [ "/deployments/run-java.sh" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8b4af6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Oviva AG + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..37a99ec --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ + +MVN=./mvnw + +VERSION?=$(shell $(MVN) -q -Dexec.executable=echo -Dexec.args='$${project.version}' --non-recursive exec:exec) + +DOCKER_REPO=ghcr.io/oviva-ag/ +IMAGE_NAME=epa-fm-watchdog + +GIT_COMMIT=`git rev-parse HEAD` + +.PHONY: update-version test unit-test integration-test setup dist build clean install docker + +build: + @$(MVN) -T 8 $(MAVEN_CLI_OPTS) -am package + +clean: + @$(MVN) -T 8 $(MAVEN_CLI_OPTS) -am clean + +test: + @$(MVN) -B verify + +update-version: + @$(MVN) -B versions:set "-DnewVersion=$(VERSION)" + +docker: build + @docker build -t $(IMAGE_NAME):v$(VERSION) . + +dist: build +ifndef RELEASE_TAG + $(error RELEASE_TAG is not set) +endif + docker buildx build --push --platform linux/amd64,linux/arm64 --label git-commit=$(GIT_COMMIT) --tag "$(DOCKER_REPO)$(IMAGE_NAME):$(RELEASE_TAG)" . diff --git a/README.md b/README.md new file mode 100644 index 0000000..0153626 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# ePA Fachmodul Konnektor Watchdog + +This is a simple application to expose Prometheus metrics for an ePA Fachmodul Konnektor. +Most notably: + +- whether it is reachable and serving the service-discovery document (SDS) +- which SMC-B cards are plugged and whether they are verified or not + +## Configuration + +The application is configured by environment variables, here the available options and their defaults: + +| name | description | default | +|-------------------------------------|-----------------------------------------------------------------------------------------|---------------------| +| `EPA_FM_WATCHDOG_LOG_LEVEL`* | Log level for the entire application. | `INFO` | +| `EPA_FM_WATCHDOG_ADDRESS`* | Address to bind the Prometheus server to. | `0.0.0.0` | +| `EPA_FM_WATCHDOG_PORT`* | Port to bind the Prometheus server to. | `8080` | +| `EPA_FM_WATCHDOG_KONNEKTOR_URI`* | URI of the Konnektor to watch, e.g. `https://10.0.0.1:443`. | | +| `EPA_FM_WATCHDOG_PROXY_ADDRESS`* | Address of the forward proxy infront of the Konnektor, e.g. `127.0.0.1`. | `3128` | +| `EPA_FM_WATCHDOG_CREDENTIALS_PATH`* | The PKCS#12 keystore containing the TLS client certificate to connect to the Konnektor. | `./credentials.p12` | +| `EPA_FM_WATCHDOG_WORKPLACE_ID`* | The workplace ID configured in the Konnektor. | `a` | +| `EPA_FM_WATCHDOG_CLIENT_SYSTEM_ID`* | The client system ID configured in the Konnektor. | `c` | +| `EPA_FM_WATCHDOG_MANDANT_ID`* | The mandant ID configured in the Konnektor. | `m` | +| `EPA_FM_WATCHDOG_USER_ID`* | The user ID configured in the Konnektor. | `admin` | diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7e5f30e --- /dev/null +++ b/pom.xml @@ -0,0 +1,516 @@ + + + 4.0.0 + + com.oviva.epa.watchdog + epa-fm-watchdog + 0.0.1-SNAPSHOT + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + + scm:git:https://github.com/oviva-ag/epa-fm-watchdog.git + https://github.com/oviva-ag/epa-fm-watchdog + + + + 3.8.6 + + UTF-8 + UTF-8 + + 21 + ${maven.compiler.source} + ${maven.compiler.source} + + 3.6.0.Final + 2.0.13 + + 1.13.2 + + 1.5.6 + + 2.43.0 + 0.8.12 + + 3.6.0 + + oviva-ag + https://sonarcloud.io + ${project.artifactId} + oviva-ag_epa-fm-watchdog + ${project.groupId}_${project.artifactId} + + + ${maven.multiModuleProjectDirectory}/reports/target/site/jacoco-aggregate/jacoco.xml + + + + + + com.github.spotbugs + spotbugs-annotations + 4.8.6 + + + com.google.code.findbugs + jsr305 + + + + + org.slf4j + slf4j-bom + ${version.slf4j} + pom + import + + + io.undertow + undertow-core + 2.3.15.Final + + + io.micrometer + micrometer-core + ${version.micrometer} + + + io.opentelemetry + opentelemetry-bom + 1.40.0 + pom + import + + + io.micrometer + micrometer-registry-prometheus + ${version.micrometer} + + + org.jboss.logging + jboss-logging + ${version.jboss-logging} + + + com.fasterxml.jackson + jackson-bom + 2.17.2 + pom + import + + + ch.qos.logback + logback-classic + ${version.logback.classic} + + + + + jakarta.platform + jakarta.jakartaee-bom + 10.0.0 + pom + import + + + org.eclipse.angus + angus-activation + 2.0.2 + + + + + org.junit + junit-bom + 5.10.3 + pom + import + + + org.hamcrest + hamcrest + 2.2 + + + org.mockito + mockito-bom + 5.12.0 + pom + import + + + org.testcontainers + testcontainers-bom + 1.20.1 + pom + import + + + io.rest-assured + rest-assured + 5.5.0 + + + + + org.apache.commons + commons-lang3 + 3.15.0 + + + com.github.jknack + handlebars + ${version.handlebars} + + + com.github.jknack + handlebars-helpers + ${version.handlebars} + + + com.jayway.jsonpath + json-path + 2.9.0 + + + org.ow2.asm + asm + + + + + org.wiremock + wiremock + 3.9.1 + + + com.github.jknack + * + + + commons-fileupload + commons-fileupload + + + org.apache.commons + commons-lang3 + + + com.jayway.jsonpath + json-path + + + + + + + + + + ch.qos.logback + logback-classic + + + com.fasterxml.jackson.core + jackson-databind + + + io.opentelemetry + opentelemetry-api + + + io.undertow + undertow-core + + + io.micrometer + micrometer-registry-prometheus + + + com.oviva.epa + diga-epa-client + 1.0.0-rc.0 + + + com.google.auto.service + auto-service + 1.0-rc5 + true + + + org.junit.jupiter + junit-jupiter + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.mockito + mockito-junit-jupiter + test + + + + + ${project.artifactId} + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-clean-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-install-plugin + 3.1.2 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + maven-assembly-plugin + 3.7.1 + + + maven-site-plugin + 3.12.1 + + + maven-dependency-plugin + 3.7.1 + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + org.codehaus.mojo + extra-enforcer-rules + 1.8.0 + + + + + enforce + + enforce + + validate + + + + ${maven.version} + + + The reactor is not valid + + + + + + + + com.google.code.findbugs:jsr305 + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + + + + + + + + + + + + + + + spotless-check + + check + + verify + + + + spotless-apply + + apply + + compile + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + ${project.build.directory}/jacoco.exec + + + + report + + report + + test + + ${project.build.directory}/jacoco.exec + ${project.reporting.outputDirectory}/jacoco + + + + + + org.codehaus.mojo + flatten-maven-plugin + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + verify + + + + + maven-assembly-plugin + + + + + com.oviva.epa.watchdog.Main + true + true + + + + jar-with-dependencies + + + + + + make-assembly + + single + + package + + + + + + + + diff --git a/src/main/java/com/oviva/epa/watchdog/CardCheckGauges.java b/src/main/java/com/oviva/epa/watchdog/CardCheckGauges.java new file mode 100644 index 0000000..ae926be --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/CardCheckGauges.java @@ -0,0 +1,98 @@ +package com.oviva.epa.watchdog; + +import com.oviva.epa.client.KonnektorService; +import com.oviva.epa.client.KonnektorServiceBuilder; +import com.oviva.epa.client.konn.KonnektorConnection; +import com.oviva.epa.client.konn.KonnektorConnectionFactory; +import com.oviva.epa.client.model.Card; +import com.oviva.epa.client.model.KonnektorException; +import com.oviva.epa.client.model.PinStatus; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Locale; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class CardCheckGauges implements Iterable> { + + private Logger logger = LoggerFactory.getLogger(CardCheckGauges.class); + + private final Main.KonnektorConfig config; + private final KonnektorConnectionFactory konnektorFactory; + + CardCheckGauges(Main.KonnektorConfig config, KonnektorConnectionFactory konnektorFactory) { + this.config = config; + this.konnektorFactory = konnektorFactory; + } + + @Override + public Iterator> iterator() { + logger.atDebug().log("connecting to {}", config.konnektorUri()); + var conn = konnektorFactory.connect(); + + var konnektorService = buildService(config, conn); + + logger.atDebug().log("fetching cards from {}", config.konnektorUri()); + var cards = konnektorService.getCardsInfo(); + + var l = + cards.stream() + .filter(c -> c.type() == Card.CardType.SMC_B) + .map(c -> checkCard(konnektorService, c)) + // explicit .collect(...) to make Java generics work + .collect(ArrayList>::new, ArrayList::add, ArrayList::addAll); + return l.iterator(); + } + + private KonnektorService buildService(Main.KonnektorConfig cfg, KonnektorConnection conn) { + + var userAgent = userAgent(); + logger.atDebug().log("client using user-agent: {}", userAgent); + + return KonnektorServiceBuilder.newBuilder() + .connection(conn) + .workplaceId(cfg.workplaceId()) + .clientSystemId(cfg.clientSystemId()) + .mandantId(cfg.mandantId()) + .userId(cfg.userId()) + .userAgent(userAgent) + .build(); + } + + private String userAgent() { + var agent = + Optional.ofNullable(Main.class.getPackage().getImplementationTitle()) + .orElse("epa-fm-watchdog") + .toUpperCase(Locale.ROOT) + .replaceAll("[^A-Z0-9]", "_"); + + var version = + Optional.ofNullable(Main.class.getPackage().getImplementationVersion()).orElse("0.0.1"); + return "%s/%s".formatted(agent, version); + } + + private MultiGauge.Row checkCard(KonnektorService service, Card card) { + var tags = Tags.of("holder", card.holderName(), "card_handle", card.handle()); + try { + return MultiGauge.Row.of( + tags, + card, + (Card c) -> { + try { + var status = service.verifySmcPin(card.handle()); + if (status == PinStatus.VERIFIED) { + return 1.0; + } + return 0.0; + } catch (KonnektorException e) { + return 0.0; + } + }); + } catch (Exception e) { + return MultiGauge.Row.of(tags, (Card) null, s -> 0.0); + } + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/Main.java b/src/main/java/com/oviva/epa/watchdog/Main.java new file mode 100644 index 0000000..f56223c --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/Main.java @@ -0,0 +1,227 @@ +package com.oviva.epa.watchdog; + +import com.oviva.epa.client.konn.KonnektorConnectionFactory; +import com.oviva.epa.client.konn.KonnektorConnectionFactoryBuilder; +import com.oviva.epa.watchdog.cfg.ConfigProvider; +import com.oviva.epa.watchdog.cfg.EnvConfigProvider; +import com.oviva.epa.watchdog.handlers.MetricsHandler; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import io.undertow.Handlers; +import io.undertow.Undertow; +import io.undertow.server.HttpHandler; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.*; +import java.util.function.Supplier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Main implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(Main.class); + + private final ConfigProvider configProvider; + private Undertow server; + + public Main(ConfigProvider configProvider) { + this.configProvider = configProvider; + } + + public static void main(String[] args) { + logger.atDebug().log("initialising application"); + try (var app = new Main(new EnvConfigProvider("EPA_FM_WATCHDOG", System::getenv))) { + app.run(); + } + } + + public void run() { + logger.atDebug().log("running application"); + + var config = loadConfig(configProvider); + logger.atInfo().log("config loaded: {}", config); + + var registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + registerGauges(registry, config); + logger.atDebug().log("gauges registered"); + + var host = config.watchdogAddress(); + var port = config.watchdogPort(); + + logger.atDebug().log("booting server at http://{}:{}/", host, port); + + server = buildServer(host, port, new MetricsHandler(registry::scrape)); + server.start(); + + logger.atInfo().log("server ready at http://{}:{}/", host, port); + } + + private Undertow buildServer(String host, int port, HttpHandler metricsHandler) { + + return Undertow.builder() + .addHttpListener(port, host) + .setHandler( + Handlers.path() + .addExactPath( + "/health", + ex -> { + ex.setStatusCode(200); + ex.endExchange(); + }) + .addExactPath("/metrics", metricsHandler)) + .build(); + } + + private void registerGauges(MeterRegistry registry, KonnektorConfig config) { + + var konnektorFactory = buildFactory(config); + + Gauge.builder( + "konnektor_status", + konnektorFactory, + k -> { + try { + k.connect(); + return 1; + } catch (Exception e) { + return 0; + } + }) + .tag("konnektor", config.konnektorUri().toString()) + .register(registry); + logger.atInfo().log("registered 'up' gauge for konnektor {}", config.konnektorUri().toString()); + + var gauges = + MultiGauge.builder("card_status") + .tag("konnektor", config.konnektorUri().toString()) + .description("the status of all plugged in cards") + .register(registry); + + logger.atInfo().log( + "registered gauges for all cards in konnektor {}", config.konnektorUri().toString()); + + gauges.register(new CardCheckGauges(config, konnektorFactory), true); + } + + @Override + public void close() { + if (server != null) { + server.stop(); + } + } + + record KonnektorConfig( + URI konnektorUri, + String proxyAddress, + int proxyPort, + List clientKeys, + String workplaceId, + String mandantId, + String clientSystemId, + String userId, + String watchdogAddress, + int watchdogPort) {} + + private KonnektorConfig loadConfig(ConfigProvider configProvider) { + + var address = configProvider.get("address").orElse("0.0.0.0"); + var port = configProvider.get("port").map(Integer::parseInt).orElse(8080); + + var uri = mustLoad("konnektor.uri").map(URI::create).orElseThrow(); + + var proxyAddress = mustLoad("proxy.address").orElseThrow(); + + var proxyPort = configProvider.get("proxy.port").map(Integer::parseInt).orElse(3128); + + var pw = configProvider.get("credentials.password").orElse("0000"); + + var keys = + configProvider + .get("credentials.path") + .map(Path::of) + .or(() -> Optional.of(Path.of("./credentials.p12"))) + .map(p -> loadKeys(p, pw)) + .orElseThrow(configNotValid("credentials.path")); + + var workplace = configProvider.get("workplace.id").orElse("a"); + + var clientSystem = configProvider.get("client_system.id").orElse("c"); + + var mandant = configProvider.get("mandant.id").orElse("m"); + + var user = configProvider.get("user.id").orElse("admin"); + + return new KonnektorConfig( + uri, proxyAddress, proxyPort, keys, workplace, mandant, clientSystem, user, address, port); + } + + private Optional mustLoad(String key) { + + var v = configProvider.get(key); + if (v.isEmpty()) { + + throw configNotFound(key).get(); + } + + return v; + } + + private Supplier configNotFound(String key) { + return () -> (T) new IllegalStateException("configuration for '%s' not found".formatted(key)); + } + + private Supplier configNotValid(String key) { + return () -> (T) new IllegalStateException("configuration for '%s' not valid".formatted(key)); + } + + private KonnektorConnectionFactory buildFactory(KonnektorConfig cfg) { + return KonnektorConnectionFactoryBuilder.newBuilder() + .clientKeys(cfg.clientKeys()) + .konnektorUri(cfg.konnektorUri()) + .proxyServer(cfg.proxyAddress(), cfg.proxyPort()) + .trustAllServers() // currently we don't validate the server's certificate + .build(); + } + + private List loadKeys(Path keystorePath, String password) { + + try { + var ks = loadKeyStore(keystorePath, password); + + final KeyManagerFactory keyFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyFactory.init(ks, password.toCharArray()); + return Arrays.asList(keyFactory.getKeyManagers()); + } catch (UnrecoverableKeyException + | CertificateException + | IOException + | KeyStoreException + | NoSuchAlgorithmException e) { + throw new IllegalStateException( + "failed to load keystore from: %s".formatted(keystorePath), e); + } + } + + private KeyStore loadKeyStore(Path p, String password) + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + + try (var fis = Files.newInputStream(p)) { + var keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(fis, password.toCharArray()); + + return keyStore; + } + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/cfg/ConfigProvider.java b/src/main/java/com/oviva/epa/watchdog/cfg/ConfigProvider.java new file mode 100644 index 0000000..d2be327 --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/cfg/ConfigProvider.java @@ -0,0 +1,7 @@ +package com.oviva.epa.watchdog.cfg; + +import java.util.Optional; + +public interface ConfigProvider { + Optional get(String name); +} diff --git a/src/main/java/com/oviva/epa/watchdog/cfg/EnvConfigProvider.java b/src/main/java/com/oviva/epa/watchdog/cfg/EnvConfigProvider.java new file mode 100644 index 0000000..ccfedc3 --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/cfg/EnvConfigProvider.java @@ -0,0 +1,27 @@ +package com.oviva.epa.watchdog.cfg; + +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +public class EnvConfigProvider implements ConfigProvider { + + private final String prefix; + private final Function getenv; + + public EnvConfigProvider(String prefix, UnaryOperator getenv) { + this.prefix = prefix; + this.getenv = getenv; + } + + @Override + public Optional get(String name) { + + var mangled = prefix + "_" + name; + mangled = mangled.toUpperCase(Locale.ROOT); + mangled = mangled.replaceAll("[^A-Z0-9]", "_"); + + return Optional.ofNullable(getenv.apply(mangled)); + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/handlers/HealthHandler.java b/src/main/java/com/oviva/epa/watchdog/handlers/HealthHandler.java new file mode 100644 index 0000000..152f32a --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/handlers/HealthHandler.java @@ -0,0 +1,28 @@ +package com.oviva.epa.watchdog.handlers; + +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; + +public class HealthHandler implements HttpHandler { + + private IsUp isUp; + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + + var up = false; + try { + up = isUp.isUp(); + } catch (Exception e) { + // all fine, its down + } + + var status = up ? 200 : 503; + exchange.setStatusCode(status); + exchange.endExchange(); + } + + interface IsUp { + boolean isUp(); + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/handlers/MetricsHandler.java b/src/main/java/com/oviva/epa/watchdog/handlers/MetricsHandler.java new file mode 100644 index 0000000..5bd28c9 --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/handlers/MetricsHandler.java @@ -0,0 +1,32 @@ +package com.oviva.epa.watchdog.handlers; + +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HttpString; +import java.nio.charset.StandardCharsets; + +public class MetricsHandler implements HttpHandler { + + private final PrometheusMetrics metrics; + + public MetricsHandler(PrometheusMetrics metrics) { + this.metrics = metrics; + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + + var res = metrics.scrape(); + + exchange.setStatusCode(200); + exchange + .getResponseHeaders() + .put(HttpString.tryFromString("content-type"), "text/plain; version=0.0.4"); + exchange.getResponseSender().send(res, StandardCharsets.UTF_8); + exchange.endExchange(); + } + + public interface PrometheusMetrics { + String scrape(); + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/logging/JsonEncoder.java b/src/main/java/com/oviva/epa/watchdog/logging/JsonEncoder.java new file mode 100644 index 0000000..b1cc5fd --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/logging/JsonEncoder.java @@ -0,0 +1,280 @@ +package com.oviva.epa.watchdog.logging; + +import ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter; +import ch.qos.logback.classic.pattern.ThrowableHandlingConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.encoder.EncoderBase; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.io.JsonStringEncoder; +import io.opentelemetry.api.trace.Span; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import org.slf4j.event.KeyValuePair; + +/** GCP flavoured JSON logging */ +public class JsonEncoder extends EncoderBase { + + // https://cloud.google.com/error-reporting/docs/formatting-error-messages + private static final String REPORTED_ERROR_TYPE = + "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent"; + + private static final String SOURCE_LOCATION_FIELD = "logging.googleapis.com/sourceLocation"; + + private final JsonFactory jsonFactory = new JsonFactory(); + private final DateTimeFormatter timeFormatter = DateTimeFormatter.ISO_INSTANT; + private final ThrowableHandlingConverter throwableConverter = + new ExtendedThrowableProxyConverter(); + + private final String serviceName; + private final String serviceVersion; + + public JsonEncoder() { + var pkg = JsonEncoder.class.getPackage(); + serviceName = pkg.getImplementationTitle(); + serviceVersion = pkg.getImplementationVersion(); + } + + @Override + public byte[] encode(ILoggingEvent event) { + var baos = new ByteArrayOutputStream(); + try (var generator = jsonFactory.createGenerator(baos)) { + generator.writeStartObject(); + + // https://cloud.google.com/logging/docs/structured-logging#structured_logging_special_fields + // https://github.com/googleapis/java-logging-logback/blob/main/src/main/java/com/google/cloud/logging/logback/LoggingAppender.java + + writeTimestamp(generator, event); + writeSeverity(generator, event); + writeLogger(generator, event); + writeMessage(generator, event); + writeThread(generator, event); + + writeServiceContext(generator); + writeTraceContext(generator); + + var mdc = event.getMDCPropertyMap(); + writeMdc(generator, mdc); + + writeKeyValue(generator, event); + + if ("ERROR".equals(event.getLevel().toString())) { + writeError(generator, event, mdc); + } + writeStackTrace(generator, event); + + generator.writeEndObject(); + generator.writeRaw('\n'); + generator.flush(); + } catch (NullPointerException | IOException e) { + return logFallbackError(event, e); + } + return baos.toByteArray(); + } + + private byte[] logFallbackError(ILoggingEvent event, Throwable t) { + // skipping JSON encoding and falling back to a very basic message + var escapedMessage = + escapeJsonOrDefault( + t.getMessage(), "error serializing log record: " + event.getFormattedMessage()); + + var sn = escapeJsonOrDefault(this.serviceName, ""); + var sv = escapeJsonOrDefault(this.serviceVersion, ""); + + var stackTrace = escapeJson(stringifyStackTrace(t)); + + return """ + {"time":"%s","severity":"ERROR","message":"%s","stack_trace":"%s","serviceContext":{"service":"%s","version":"%s"}} + """ + .formatted(timeFormatter.format(event.getInstant()), escapedMessage, stackTrace, sn, sv) + .getBytes(StandardCharsets.UTF_8); + } + + private void writeServiceContext(JsonGenerator generator) throws IOException { + + var name = this.serviceName; + var version = this.serviceVersion; + + if (name == null && version == null) { + return; + } + + generator.writeObjectFieldStart("serviceContext"); + if (name != null && !name.isEmpty()) { + generator.writeStringField("service", this.serviceName); + } + if (version != null && !version.isEmpty()) { + generator.writeStringField("version", this.serviceVersion); + } + + generator.writeEndObject(); + } + + private void writeLogger(JsonGenerator generator, ILoggingEvent logRecord) throws IOException { + generator.writeStringField("logger", logRecord.getLoggerName()); + } + + private void writeMessage(JsonGenerator generator, ILoggingEvent logRecord) throws IOException { + generator.writeStringField("message", logRecord.getFormattedMessage()); + } + + private void writeSeverity(JsonGenerator generator, ILoggingEvent logRecord) throws IOException { + // should we map that to the GCP levels? + generator.writeStringField("severity", logRecord.getLevel().toString()); + } + + private void writeTimestamp(JsonGenerator generator, ILoggingEvent logRecord) throws IOException { + generator.writeStringField("time", timeFormatter.format(logRecord.getInstant())); + } + + private void writeThread(JsonGenerator generator, ILoggingEvent logRecord) throws IOException { + generator.writeStringField("thread_name", logRecord.getThreadName()); + } + + private void writeError(JsonGenerator generator, ILoggingEvent logRecord, Map mdc) + throws IOException { + + // https://cloud.google.com/error-reporting/docs/formatting-error-messages + + generator.writeStringField("@type", REPORTED_ERROR_TYPE); + writeSourceLocation(generator, logRecord); + } + + private static void writeSourceLocation(JsonGenerator generator, ILoggingEvent logRecord) + throws IOException { + var stack = logRecord.getCallerData(); + if (stack == null || stack.length == 0) { + return; + } + + var topFrame = stack[0]; + + generator.writeObjectFieldStart(SOURCE_LOCATION_FIELD); + generator.writeStringField("file", topFrame.getFileName()); + generator.writeStringField("line", Integer.toString(topFrame.getLineNumber())); + + var className = topFrame.getClassName(); + var methodName = topFrame.getMethodName(); + + generator.writeStringField("function", "%s.%s".formatted(className, methodName)); + generator.writeEndObject(); + } + + private void writeMdc(JsonGenerator generator, Map mdc) throws IOException { + if (mdc.isEmpty()) { + return; + } + + generator.writeObjectFieldStart("mdc"); + + for (Map.Entry entry : mdc.entrySet()) { + if (entry.getValue() == null) { + continue; + } + generator.writeStringField(entry.getKey(), entry.getValue()); + } + + generator.writeEndObject(); + } + + private void writeKeyValue(JsonGenerator generator, ILoggingEvent event) throws IOException { + var kvPairs = event.getKeyValuePairs(); + if (kvPairs == null || kvPairs.isEmpty()) { + return; + } + + for (KeyValuePair pair : kvPairs) { + if (pair.key == null || pair.value == null) { + continue; + } + if (pair.value instanceof Map m) { + + generator.writeObjectFieldStart(pair.key); + writeValue(generator, m); + generator.writeEndObject(); + } else { + generator.writeStringField(pair.key, pair.value.toString()); + } + } + } + + private void writeValue(JsonGenerator generator, Map m) throws IOException { + for (Map.Entry e : m.entrySet()) { + if (e.getKey() == null || e.getValue() == null) { + continue; + } + if (e.getValue() instanceof Map m2) { + generator.writeObjectFieldStart(e.getKey()); + writeValue(generator, m2); + generator.writeEndObject(); + } else { + generator.writeStringField(e.getKey(), e.getValue().toString()); + } + } + } + + private static void writeTraceContext(JsonGenerator generator) throws IOException { + var span = Span.current(); + if (span == null) { + return; + } + var spanContext = span.getSpanContext(); + if (spanContext == null || !spanContext.isValid()) { + return; + } + + generator.writeStringField("logging.googleapis.com/spanId", spanContext.getSpanId()); + generator.writeStringField("logging.googleapis.com/trace", spanContext.getTraceId()); + generator.writeBooleanField("logging.googleapis.com/trace_sampled", spanContext.isSampled()); + } + + private void writeStackTrace(JsonGenerator generator, ILoggingEvent event) throws IOException { + var t = event.getThrowableProxy(); + if (t == null) { + return; + } + var stackTrace = throwableConverter.convert(event); + generator.writeStringField("stack_trace", stackTrace); + } + + @Override + public byte[] headerBytes() { + throwableConverter.start(); + return null; + } + + @Override + public byte[] footerBytes() { + throwableConverter.stop(); + return null; + } + + private static String escapeJsonOrDefault(String s, String defaultValue) { + + if (s == null) { + return defaultValue; + } + s = s.strip(); + if (s.isEmpty()) { + return defaultValue; + } + + return escapeJson(s); + } + + private static String escapeJson(String s) { + return new String(JsonStringEncoder.getInstance().quoteAsString(s)); + } + + private static String stringifyStackTrace(Throwable t) { + var baos = new ByteArrayOutputStream(); + var pw = new PrintWriter(baos); + t.printStackTrace(pw); + pw.flush(); + return baos.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/oviva/epa/watchdog/logging/LogConfigurator.java b/src/main/java/com/oviva/epa/watchdog/logging/LogConfigurator.java new file mode 100644 index 0000000..e871dbd --- /dev/null +++ b/src/main/java/com/oviva/epa/watchdog/logging/LogConfigurator.java @@ -0,0 +1,42 @@ +package com.oviva.epa.watchdog.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.Configurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.spi.ContextAwareBase; +import com.google.auto.service.AutoService; +import java.util.Optional; + +@AutoService(Configurator.class) +public class LogConfigurator extends ContextAwareBase implements Configurator { + @Override + public ExecutionStatus configure(LoggerContext context) { + addInfo("Setting up default configuration."); + + var ca = new ConsoleAppender(); + ca.setContext(context); + ca.setName("console"); + + var encoder = new JsonEncoder(); + encoder.setContext(context); + + ca.setEncoder(encoder); + ca.start(); + + var rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME); + rootLogger.addAppender(ca); + + rootLogger.setLevel(getLevel()); + + return ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY; + } + + private Level getLevel() { + return Optional.ofNullable(System.getenv("EPA_FM_WATCHDOG_LOG_LEVEL")) + .map(Level::valueOf) + .orElse(Level.INFO); + } +} diff --git a/src/main/resources/META-INF/cxf/org.apache.cxf.Logger/org.apache.cxf.common.logging.Slf4jLogger b/src/main/resources/META-INF/cxf/org.apache.cxf.Logger/org.apache.cxf.common.logging.Slf4jLogger new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/com/oviva/epa/watchdog/MainTest.java b/src/test/java/com/oviva/epa/watchdog/MainTest.java new file mode 100644 index 0000000..0a7b5fc --- /dev/null +++ b/src/test/java/com/oviva/epa/watchdog/MainTest.java @@ -0,0 +1,109 @@ +package com.oviva.epa.watchdog; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import org.junit.jupiter.api.*; + +@Disabled("needs a running TI Konnektor") +class MainTest { + + private static final String CONFIG_PROPERTIES = + """ + konnektor.uri=https://10.156.145.103:443 + proxy.address=127.0.0.1 + """; + + private static final CountDownLatch exit = new CountDownLatch(1); + private final Pattern cardStatusPattern = + Pattern.compile( + """ + card_status\\{card_handle="[a-f0-9-]+",holder="[^"]+",konnektor="https://10.156.145.103:443"} ([.0-9]+)"""); + + private final Pattern konnektorStatusUpPattern = + Pattern.compile( + """ + konnektor_status\\{konnektor="https://10.156.145.103:443"} 1.0"""); + + @BeforeAll + static void beforeEach() throws IOException { + bootApp(); + } + + @AfterAll + static void afterAll() { + exit.countDown(); + } + + @Test + void metrics_cardStatus() throws Exception { + + var metrics = fetchMetrics(); + assertContains(cardStatusPattern, metrics); + } + + @Test + void metrics_konnekturStatusUp() throws Exception { + + var metrics = fetchMetrics(); + assertContains(konnektorStatusUpPattern, metrics); + } + + private String fetchMetrics() throws IOException, InterruptedException { + + var client = HttpClient.newHttpClient(); + + var req = HttpRequest.newBuilder(URI.create("http://localhost:8080/metrics")).build(); + + var res = client.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, res.statusCode()); + return res.body(); + } + + private void assertContains(Pattern pattern, String contents) { + assertTrue( + pattern.matcher(contents).find(), + "expected '%s' to match '%s'".formatted(contents, pattern.pattern())); + } + + private static void bootApp() throws IOException { + + var config = new Properties(); + config.load(new StringReader(CONFIG_PROPERTIES)); + + var executor = Executors.newFixedThreadPool(1); + + var started = new CountDownLatch(1); + executor.execute( + () -> { + try (var m = new Main(k -> Optional.ofNullable(config.getProperty(k)))) { + m.run(); + started.countDown(); + exit.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + boolean ok = false; + try { + ok = started.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (!ok) { + fail("server failed to boot within timeout"); + } + } +} diff --git a/src/test/java/com/oviva/epa/watchdog/cfg/EnvConfigProviderTest.java b/src/test/java/com/oviva/epa/watchdog/cfg/EnvConfigProviderTest.java new file mode 100644 index 0000000..35924a6 --- /dev/null +++ b/src/test/java/com/oviva/epa/watchdog/cfg/EnvConfigProviderTest.java @@ -0,0 +1,36 @@ +package com.oviva.epa.watchdog.cfg; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.function.UnaryOperator; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class EnvConfigProviderTest { + + private static final String PREFIX = "SOME_PREFIX"; + + static Stream mangleTestCases() { + return Stream.of( + new TC("my.config", PREFIX + "_MY_CONFIG"), new TC("a.no..ther", PREFIX + "_A_NO__THER")); + } + + @ParameterizedTest + @MethodSource("mangleTestCases") + void getMangleName(TC t) { + + var getenv = (UnaryOperator) mock(UnaryOperator.class); + + var sut = new EnvConfigProvider(PREFIX, getenv); + + // when + sut.get(t.key()); + + // then + verify(getenv).apply(t.expected()); + } + + record TC(String key, String expected) {} +} diff --git a/src/test/java/com/oviva/epa/watchdog/logging/JsonEncoderTest.java b/src/test/java/com/oviva/epa/watchdog/logging/JsonEncoderTest.java new file mode 100644 index 0000000..6744fe8 --- /dev/null +++ b/src/test/java/com/oviva/epa/watchdog/logging/JsonEncoderTest.java @@ -0,0 +1,190 @@ +package com.oviva.epa.watchdog.logging; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.event.KeyValuePair; + +class JsonEncoderTest { + + private static final Instant TIMESTAMP = Instant.parse("2024-08-09T14:13:33Z"); + private static final String LOGGER_NAME = "com.example.MyLogger"; + private static final String THREAD_NAME = "main"; + private final JsonEncoder encoder = new JsonEncoder(); + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + @BeforeEach + void beforeEach() { + encoder.headerBytes(); + } + + @AfterEach + void afterEach() { + encoder.footerBytes(); + } + + @Test + void encode_simple() { + + var e = mockEvent(); + when(e.getFormattedMessage()).thenReturn("Hello World!"); + + var msg = encoder.encode(e); + + assertMatchesJson( + """ + {"logger":"com.example.MyLogger","message":"Hello World!","severity":"INFO","thread_name":"main","time":"2024-08-09T14:13:33Z"} + """, + msg); + } + + @Test + void encode_fallback() { + + var e = mockEvent(); + doThrow(NullPointerException.class).when(e).getKeyValuePairs(); + + var msg = encoder.encode(e); + + assertMatchesJson( + """ + {"message":"error serializing log record: null","serviceContext":{"service":"","version":""},"severity":"ERROR","stack_trace":"java.lang.NullPointerException\\n","time":"2024-08-09T14:13:33Z"} + """, + msg); + } + + @Test + void encode_error() { + + var e = mockEvent(); + when(e.getLevel()).thenReturn(Level.ERROR); + when(e.getFormattedMessage()).thenReturn("what a terrible failure"); + + var msg = encoder.encode(e); + + assertMatchesJson( + """ + {"@type":"type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent","logger":"com.example.MyLogger","message":"what a terrible failure","severity":"ERROR","thread_name":"main","time":"2024-08-09T14:13:33Z"} + """, + msg); + } + + @Test + void encode_mdc() { + + var e = mockEvent(); + when(e.getLevel()).thenReturn(Level.DEBUG); + when(e.getFormattedMessage()).thenReturn("oha, sup?"); + + when(e.getMDCPropertyMap()) + .thenReturn( + Map.of( + "traceId", "k398cidkekk", + "spanId", "499910")); + + var msg = encoder.encode(e); + + assertMatchesJson( + """ + {"logger":"com.example.MyLogger","mdc":{"spanId":"499910","traceId":"k398cidkekk"},"message":"oha, sup?","severity":"DEBUG","thread_name":"main","time":"2024-08-09T14:13:33Z"} + """, + msg); + } + + @Test + void encode_kv() { + + var e = mockEvent(); + when(e.getLevel()).thenReturn(Level.DEBUG); + when(e.getFormattedMessage()).thenReturn("oha, sup?"); + + when(e.getKeyValuePairs()) + .thenReturn( + List.of( + new KeyValuePair("req", Map.of("url", "https://example.com", "method", "GET")), + new KeyValuePair("a", Map.of("b", Map.of("c", "d"))), + new KeyValuePair("status", 500))); + + var msg = encoder.encode(e); + + assertMatchesJson( + """ + {"a":{"b":{"c":"d"}},"logger":"com.example.MyLogger","message":"oha, sup?","req":{"method":"GET","url":"https://example.com"},"severity":"DEBUG","status":"500","thread_name":"main","time":"2024-08-09T14:13:33Z"} + """, + msg); + } + + @Test + void encode_otel() { + + var tracer = otelTesting.getOpenTelemetry().getTracer("test"); + + var span = tracer.spanBuilder("log").startSpan(); + byte[] msg = null; + try (var scope = span.makeCurrent()) { + var e = mockEvent(); + when(e.getLevel()).thenReturn(Level.DEBUG); + when(e.getFormattedMessage()).thenReturn("oha, sup?"); + + msg = encoder.encode(e); + } + + var traceId = span.getSpanContext().getTraceId(); + var spanId = span.getSpanContext().getSpanId(); + + assertMatchesJson( + """ + {"logger":"com.example.MyLogger","logging.googleapis.com/spanId":"%s","logging.googleapis.com/trace":"%s","logging.googleapis.com/trace_sampled":true,"message":"oha, sup?","severity":"DEBUG","thread_name":"main","time":"2024-08-09T14:13:33Z"} + """ + .formatted(spanId, traceId), + msg); + } + + private ILoggingEvent mockEvent() { + + var e = mock(ILoggingEvent.class); + when(e.getInstant()).thenReturn(TIMESTAMP); + when(e.getLevel()).thenReturn(Level.INFO); + when(e.getLoggerName()).thenReturn(LOGGER_NAME); + when(e.getThreadName()).thenReturn(THREAD_NAME); + return e; + } + + void assertMatchesJson(String expected, byte[] actual) { + var mapper = JsonMapper.builder().nodeFactory(new SortingNodeFactory()).build(); + + try { + var expectedTree = mapper.readTree(expected); + var actualTree = mapper.readTree(actual); + + assertEquals(mapper.writeValueAsString(expectedTree), mapper.writeValueAsString(actualTree)); + } catch (IOException e) { + fail(e); + } + } + + static class SortingNodeFactory extends JsonNodeFactory { + @Override + public ObjectNode objectNode() { + return new ObjectNode(this, new TreeMap()); + } + } +}