diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..164cbad --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax +* @sschnabe @rpahli @fabian-schlegel @jschwarze @wistefan @monotek +.github/workflows/* @kokuwaio-bot +pom.xml @kokuwaio-bot diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index b95b40f..0000000 --- a/.github/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Keycloak Metrics - -Provides metrics for Keycloak user/admin events and user/client/session count. Tested on Keycloak [22-26](.woodpecker/verify.yaml#L7-L11). - -[![maven](https://img.shields.io/maven-central/v/io.kokuwa.keycloak/keycloak-event-metrics.svg?label=maven)](https://central.sonatype.com/artifact/io.kokuwa.keycloak/keycloak-event-metrics) -[![pulls](https://img.shields.io/docker/pulls/kokuwaio/keycloak-event-metrics)](https://hub.docker.com/r/kokuwaio/keycloak-event-metrics) -[![size](https://img.shields.io/docker/image-size/kokuwaio/keycloak-event-metrics)](https://hub.docker.com/r/kokuwaio/keycloak-event-metrics) -[![dockerfile](https://img.shields.io/badge/source-Dockerfile%20-blue)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/src/branch/main/Dockerfile) -[![license](https://img.shields.io/badge/license-EUPL%201.2-blue)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/src/branch/main/LICENSE) -[![issues](https://img.shields.io/gitea/issues/open/kokuwaio/keycloak-event-metrics?gitea_url=https%3A%2F%2Fgit.kokuwa.io)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/issues) -[![prs](https://img.shields.io/gitea/pull-requests/open/kokuwaio/keycloak-event-metrics?gitea_url=https%3A%2F%2Fgit.kokuwa.io)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/pulls) -[![build](https://ci.kokuwa.io/api/badges/kokuwaio/keycloak-event-metrics/status.svg)](https://ci.kokuwa.io/repos/kokuwaio/keycloak-event-metrics/) - -For more documention see: [git.kokuwa.io/kokuwaio/keycloak-event-metrics](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9984b24 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: monthly + day: monday + # github parses time without quotes to int + # yamllint disable-line rule:quoted-strings + time: "09:00" + timezone: Europe/Berlin + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + day: monday + # github parses time without quotes to int + # yamllint disable-line rule:quoted-strings + time: "09:00" + timezone: Europe/Berlin diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f0a851e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + pull_request: {} + +jobs: + + yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ibiqlik/action-yamllint@v3 + with: + format: colored + strict: true + + markdown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: avto-dev/markdown-lint@v1 + with: + args: /github/workspace + + javadoc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp javadoc:javadoc-no-fork -Ddoclint=all + + checkstyle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp checkstyle:check + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + server-id: sonatype-nexus + server-username: SERVER_USERNAME + server-password: SERVER_PASSWORD + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp verify -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false + if: ${{ github.ref != 'refs/heads/main' }} + - run: mvn -B -ntp deploy -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false + if: ${{ github.ref == 'refs/heads/main' }} + env: + SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml new file mode 100644 index 0000000..ed63eca --- /dev/null +++ b/.github/workflows/dependabot.yaml @@ -0,0 +1,17 @@ +name: Dependabot + +on: pull_request_target + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }} + - run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..653e10d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GIT_ACTION_TOKEN }} + - uses: crazy-max/ghaction-import-gpg@v5 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + server-id: sonatype-nexus + server-username: SERVER_USERNAME + server-password: SERVER_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: GPG_PASSPHRASE + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp release:prepare + - run: mvn -B -ntp release:perform + env: + SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 1939404..0000000 --- a/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# do not include developer stuff here, use `git config --global core.excludesFile ~/.gitignore` for your setup - -target -pom.xml.releaseBackup -release.properties diff --git a/.justfile b/.justfile deleted file mode 100644 index c751e60..0000000 --- a/.justfile +++ /dev/null @@ -1,21 +0,0 @@ -# https://just.systems/man/en/ - -[private] -@default: - just --list --unsorted - -# Run linter. -@lint: - docker run --rm --read-only --volume=$(pwd):$(pwd):ro --workdir=$(pwd) kokuwaio/hadolint - docker run --rm --read-only --volume=$(pwd):$(pwd):ro --workdir=$(pwd) kokuwaio/yamllint - docker run --rm --read-only --volume=$(pwd):$(pwd):rw --workdir=$(pwd) kokuwaio/markdownlint --fix - docker run --rm --read-only --volume=$(pwd):$(pwd):ro --workdir=$(pwd) kokuwaio/renovate - docker run --rm --read-only --volume=$(pwd):$(pwd):ro --workdir=$(pwd) woodpeckerci/woodpecker-cli lint - -# Build image with local docker daemon. -@build: - docker build . --tag=kokuwaio/keycloak-event-metrics:dev - -# Inspect image layers with `dive`. -@dive: build - dive build . diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml deleted file mode 100644 index 7916e19..0000000 --- a/.woodpecker/deploy.yaml +++ /dev/null @@ -1,61 +0,0 @@ -when: - instance: ci.kokuwa.io - repo: kokuwaio/keycloak-event-metrics - event: [manual, push] - branch: main - path: [.woodpecker/deploy.yaml, README.md, Dockerfile, pom.xml, src/main/**] - -services: - - name: dockerd - image: kokuwaio/dockerd - privileged: true - ports: [2375, 8080] - -steps: - - maven: - image: maven:3.9.10-eclipse-temurin-17 - commands: mvn deploy - environment: - MAVEN_ARGS: --batch-mode --color=always --no-transfer-progress --settings=.woodpecker/maven/settings.xml - MAVEN_GPG_KEY: {from_secret: woodpecker_gpg_key} - SONATYPE_ORG_USERNAME: {from_secret: sonatype_org_username} - SONATYPE_ORG_PASSWORD: {from_secret: sonatype_org_password} - - image: - image: kokuwaio/buildctl - settings: - name: - - docker.io/kokuwaio/keycloak-event-metrics:snapshot - - ghcr.io/kokuwaio/keycloak-event-metrics:snapshot - build-args: {MAVEN_MIRROR_CENTRAL: "${MAVEN_MIRROR_CENTRAL}"} - platform: [linux/amd64, linux/arm64] - auth: - "https://index.docker.io/v1/": - username: {from_secret: docker_io_username} - password: {from_secret: docker_io_password} - ghcr.io: - username: {from_secret: ghcr_io_username} - password: {from_secret: ghcr_io_password} - annotation: - org.opencontainers.image.title: Keycloak Metrics - org.opencontainers.image.description: Provides metrics for Keycloak user/admin events and user/client/session count. - org.opencontainers.image.url: $CI_REPO_URL - org.opencontainers.image.documentation: $CI_REPO_URL/README.md - org.opencontainers.image.source: $CI_REPO_CLONE_URL - org.opencontainers.image.revision: $CI_COMMIT_SHA - org.opencontainers.image.vendor: kokuwa.io - org.opencontainers.image.licenses: EUPL-1.2 - org.opencontainers.image.ref.name: kokuwaio/keycloak-event-metrics - org.opencontainers.image.version: snapshot - - dockerhub: - image: kokuwaio/dockerhub-metadata - settings: - repository: kokuwaio/keycloak-event-metrics - description-short: Provides metrics for Keycloak user/admin events and user/client/session count. - categories: monitoring-and-observability - username: {from_secret: dockerhub_username} - password: {from_secret: dockerhub_password} - when: - path: [README.md] diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml deleted file mode 100644 index 7de51db..0000000 --- a/.woodpecker/lint.yaml +++ /dev/null @@ -1,26 +0,0 @@ -when: - event: [manual, pull_request, push] - branch: main - path: [.woodpecker/lint.yaml, renovate.json, Dockerfile, "**/*.y*ml", "**/*.md"] - -steps: - - renovate: - image: kokuwaio/renovate-config-validator - depends_on: [] - when: [path: [.woodpecker/lint.yaml, renovate.json]] - - yaml: - image: kokuwaio/yamllint - depends_on: [] - when: [path: [.woodpecker/lint.yaml, .yamllint.yaml, "**/*.y*ml"]] - - markdown: - image: kokuwaio/markdownlint - depends_on: [] - when: [path: [.woodpecker/lint.yaml, .markdownlint.yaml, "**/*.md"]] - - dockerfile: - image: kokuwaio/hadolint - depends_on: [] - when: [path: [.woodpecker/lint.yaml, Dockerfile]] diff --git a/.woodpecker/maven/settings.xml b/.woodpecker/maven/settings.xml deleted file mode 100644 index 4542205..0000000 --- a/.woodpecker/maven/settings.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - false - /woodpecker/.m2 - - - git.kokuwa.io - ${env.FORGEJO_USERNAME} - ${env.FORGEJO_PASSWORD} - - - sonatype.org - ${env.SONATYPE_ORG_USERNAME} - ${env.SONATYPE_ORG_PASSWORD} - - - docker.io - ${env.DOCKER_IO_USERNAME} - ${env.DOCKER_IO_PASSWORD} - - - ghcr.io - ${env.GHCR_IO_USERNAME} - ${env.GHCR_IO_PASSWORD} - - - - - http://mirror.woodpecker.svc.cluster.local/maven2 - central - - - diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml deleted file mode 100644 index 5d560ab..0000000 --- a/.woodpecker/release.yaml +++ /dev/null @@ -1,61 +0,0 @@ -when: - instance: ci.kokuwa.io - repo: kokuwaio/keycloak-event-metrics - event: deployment - branch: main - -steps: - - maven: - image: maven:3.9.10-eclipse-temurin-17 - commands: - # setup git with ssk key signing - - git config user.email "$GIT_USER_EMAIL" - - git config user.name "$GIT_USER_NAME" - - git config commit.gpgsign true - - git config gpg.format ssh - - git config user.signingkey /run/secrets/sign.pub - - install -m 400 /dev/null /run/secrets/sign && echo "$GIT_SIGN_KEY" > /run/secrets/sign - - install -m 444 /dev/null /run/secrets/sign.pub && echo "$GIT_SIGN_PUB" > /run/secrets/sign.pub - # release & write version to env file for image - - mvn release:prepare release:perform - - echo "VERSION=$(mvn help:evaluate --quiet --file=target/checkout/pom.xml -Dexpression=project.version -DforceStdout)" > maven.env - environment: - MAVEN_ARGS: --batch-mode --color=always --no-transfer-progress --settings=.woodpecker/maven/settings.xml - MAVEN_GPG_KEY: {from_secret: woodpecker_gpg_key} - GIT_SIGN_KEY: {from_secret: woodpecker_sign_key} - GIT_SIGN_PUB: {from_secret: woodpecker_sign_pub} - FORGEJO_USERNAME: {from_secret: woodpecker_username} - FORGEJO_PASSWORD: {from_secret: woodpecker_password} - SONATYPE_ORG_USERNAME: {from_secret: sonatype_org_username} - SONATYPE_ORG_PASSWORD: {from_secret: sonatype_org_password} - - image: - image: kokuwaio/buildctl - settings: - env-file: maven.env - name: - - docker.io/kokuwaio/keycloak-event-metrics:latest - - docker.io/kokuwaio/keycloak-event-metrics:$VERSION - - ghcr.io/kokuwaio/keycloak-event-metrics:latest - - ghcr.io/kokuwaio/keycloak-event-metrics:$VERSION - build-args: {MAVEN_MIRROR_CENTRAL: "${MAVEN_MIRROR_CENTRAL}"} - platform: [linux/amd64, linux/arm64] - auth: - "https://index.docker.io/v1/": - username: {from_secret: docker_io_username} - password: {from_secret: docker_io_password} - ghcr.io: - username: {from_secret: ghcr_io_username} - password: {from_secret: ghcr_io_password} - annotation: - org.opencontainers.image.title: Keycloak Metrics - org.opencontainers.image.description: Provides metrics for Keycloak user/admin events and user/client/session count. - org.opencontainers.image.url: $CI_REPO_URL - org.opencontainers.image.documentation: $CI_REPO_URL/README.md - org.opencontainers.image.source: $CI_REPO_CLONE_URL - org.opencontainers.image.revision: $CI_COMMIT_SHA - org.opencontainers.image.vendor: kokuwa.io - org.opencontainers.image.licenses: EUPL-1.2 - org.opencontainers.image.ref.name: kokuwaio/keycloak-event-metrics - org.opencontainers.image.version: $VERSION diff --git a/.woodpecker/verify.yaml b/.woodpecker/verify.yaml deleted file mode 100644 index 5d9002b..0000000 --- a/.woodpecker/verify.yaml +++ /dev/null @@ -1,24 +0,0 @@ -when: - event: [manual, pull_request] - path: [.woodpecker/verify.yaml, pom.xml, src/**] - -services: - - name: dockerd - image: kokuwaio/dockerd - privileged: true - ports: [2375, 8080] - -steps: - - test: - image: maven:3.9.10-eclipse-temurin-17 - commands: mvn verify -P-deploy - environment: - MAVEN_ARGS: --batch-mode --color=always --no-transfer-progress --settings=.woodpecker/maven/settings.xml - - image: - image: kokuwaio/buildctl - settings: - platform: [linux/amd64, linux/arm64] - when: - instance: ci.kokuwa.io diff --git a/.woodpecker/versions.yaml b/.woodpecker/versions.yaml deleted file mode 100644 index 47a286e..0000000 --- a/.woodpecker/versions.yaml +++ /dev/null @@ -1,26 +0,0 @@ -when: - event: [manual, pull_request] - path: [.woodpecker/versions.yaml, pom.xml, src/**] - -depends_on: [verify] -matrix: - KEYCLOAK_VERSION: - - 22.0.5 - - 23.0.7 - - 24.0.5 - - 25.0.6 - - 26.2.5 - -services: - - name: dockerd - image: kokuwaio/dockerd - privileged: true - ports: [2375, 8080] - -steps: - - test: - image: maven:3.9.10-eclipse-temurin-17 - commands: mvn verify -Dversion.org.keycloak.test="$KEYCLOAK_VERSION" -P-deploy,-check - environment: - MAVEN_ARGS: --batch-mode --color=always --no-transfer-progress --settings=.woodpecker/maven/settings.xml diff --git a/.yamllint.yaml b/.yamllint similarity index 83% rename from .yamllint.yaml rename to .yamllint index 21966f2..8011808 100644 --- a/.yamllint.yaml +++ b/.yamllint @@ -13,3 +13,7 @@ rules: quoted-strings: quote-type: double required: only-when-needed + + # allow everything on keys + truthy: + check-keys: false diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 6d8ad09..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM maven:3.9.10-eclipse-temurin-17 AS build -SHELL ["/usr/bin/bash", "-e", "-u", "-c"] -WORKDIR /build -ARG MAVEN_ARGS="--batch-mode --color=always --no-transfer-progress" -ARG MAVEN_MIRROR_CENTRAL -RUN mkdir "$HOME/.m2" && printf "\n\ -\n\ - /tmp/mvn-repo\n\ - %scentral\n\ -" "${MAVEN_MIRROR_CENTRAL:-https://repo.maven.apache.org/maven2}" > "$HOME/.m2/settings.xml" -COPY . . -RUN --mount=type=cache,target=/tmp/mvn-repo mvn package -DskipTests -P=-dev - -FROM busybox:1.37.0-uclibc -COPY --from=build --chmod=444 /build/target/keycloak-event-metrics.jar /opt/keycloak/providers/keycloak-event-metrics.jar diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore deleted file mode 100644 index 744a668..0000000 --- a/Dockerfile.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -* -.* - -!pom.xml -!src/main/** diff --git a/LICENSE b/LICENSE index dacd3ae..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,288 +1,201 @@ - - EUROPEAN UNION PUBLIC LICENCE v. 1.2 - EUPL © the European Union 2007, 2016 - -This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined -below) which is provided under the terms of this Licence. Any use of the Work, -other than as authorised under this Licence is prohibited (to the extent such -use is covered by a right of the copyright holder of the Work). - -The Work is provided under the terms of this Licence when the Licensor (as -defined below) has placed the following notice immediately following the -copyright notice for the Work: - - Licensed under the EUPL - -or has expressed by any other means his willingness to license under the EUPL. - -1. Definitions - -In this Licence, the following terms have the following meaning: - -- ‘The Licence’: this Licence. - -- ‘The Original Work’: the work or software distributed or communicated by the - Licensor under this Licence, available as Source Code and also as Executable - Code as the case may be. - -- ‘Derivative Works’: the works or software that could be created by the - Licensee, based upon the Original Work or modifications thereof. This Licence - does not define the extent of modification or dependence on the Original Work - required in order to classify a work as a Derivative Work; this extent is - determined by copyright law applicable in the country mentioned in Article 15. - -- ‘The Work’: the Original Work or its Derivative Works. - -- ‘The Source Code’: the human-readable form of the Work which is the most - convenient for people to study and modify. - -- ‘The Executable Code’: any code which has generally been compiled and which is - meant to be interpreted by a computer as a program. - -- ‘The Licensor’: the natural or legal person that distributes or communicates - the Work under the Licence. - -- ‘Contributor(s)’: any natural or legal person who modifies the Work under the - Licence, or otherwise contributes to the creation of a Derivative Work. - -- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of - the Work under the terms of the Licence. - -- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, - renting, distributing, communicating, transmitting, or otherwise making - available, online or offline, copies of the Work or providing access to its - essential functionalities at the disposal of any other natural or legal - person. - -2. Scope of the rights granted by the Licence - -The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, -sublicensable licence to do the following, for the duration of copyright vested -in the Original Work: - -- use the Work in any circumstance and for all usage, -- reproduce the Work, -- modify the Work, and make Derivative Works based upon the Work, -- communicate to the public, including the right to make available or display - the Work or copies thereof to the public and perform publicly, as the case may - be, the Work, -- distribute the Work or copies thereof, -- lend and rent the Work or copies thereof, -- sublicense rights in the Work or copies thereof. - -Those rights can be exercised on any media, supports and formats, whether now -known or later invented, as far as the applicable law permits so. - -In the countries where moral rights apply, the Licensor waives his right to -exercise his moral right to the extent allowed by law in order to make effective -the licence of the economic rights here above listed. - -The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to -any patents held by the Licensor, to the extent necessary to make use of the -rights granted on the Work under this Licence. - -3. Communication of the Source Code - -The Licensor may provide the Work either in its Source Code form, or as -Executable Code. If the Work is provided as Executable Code, the Licensor -provides in addition a machine-readable copy of the Source Code of the Work -along with each copy of the Work that the Licensor distributes or indicates, in -a notice following the copyright notice attached to the Work, a repository where -the Source Code is easily and freely accessible for as long as the Licensor -continues to distribute or communicate the Work. - -4. Limitations on copyright - -Nothing in this Licence is intended to deprive the Licensee of the benefits from -any exception or limitation to the exclusive rights of the rights owners in the -Work, of the exhaustion of those rights or of other applicable limitations -thereto. - -5. Obligations of the Licensee - -The grant of the rights mentioned above is subject to some restrictions and -obligations imposed on the Licensee. Those obligations are the following: - -Attribution right: The Licensee shall keep intact all copyright, patent or -trademarks notices and all notices that refer to the Licence and to the -disclaimer of warranties. The Licensee must include a copy of such notices and a -copy of the Licence with every copy of the Work he/she distributes or -communicates. The Licensee must cause any Derivative Work to carry prominent -notices stating that the Work has been modified and the date of modification. - -Copyleft clause: If the Licensee distributes or communicates copies of the -Original Works or Derivative Works, this Distribution or Communication will be -done under the terms of this Licence or of a later version of this Licence -unless the Original Work is expressly distributed only under this version of the -Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee -(becoming Licensor) cannot offer or impose any additional terms or conditions on -the Work or Derivative Work that alter or restrict the terms of the Licence. - -Compatibility clause: If the Licensee Distributes or Communicates Derivative -Works or copies thereof based upon both the Work and another work licensed under -a Compatible Licence, this Distribution or Communication can be done under the -terms of this Compatible Licence. For the sake of this clause, ‘Compatible -Licence’ refers to the licences listed in the appendix attached to this Licence. -Should the Licensee's obligations under the Compatible Licence conflict with -his/her obligations under this Licence, the obligations of the Compatible -Licence shall prevail. - -Provision of Source Code: When distributing or communicating copies of the Work, -the Licensee will provide a machine-readable copy of the Source Code or indicate -a repository where this Source will be easily and freely available for as long -as the Licensee continues to distribute or communicate the Work. - -Legal Protection: This Licence does not grant permission to use the trade names, -trademarks, service marks, or 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 copyright notice. - -6. Chain of Authorship - -The original Licensor warrants that the copyright in the Original Work granted -hereunder is owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each Contributor warrants that the copyright in the modifications he/she brings -to the Work are owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each time You accept the Licence, the original Licensor and subsequent -Contributors grant You a licence to their contributions to the Work, under the -terms of this Licence. - -7. Disclaimer of Warranty - -The Work is a work in progress, which is continuously improved by numerous -Contributors. It is not a finished work and may therefore contain defects or -‘bugs’ inherent to this type of development. - -For the above reason, the Work is provided under the Licence on an ‘as is’ basis -and without warranties of any kind concerning the Work, including without -limitation merchantability, fitness for a particular purpose, absence of defects -or errors, accuracy, non-infringement of intellectual property rights other than -copyright as stated in Article 6 of this Licence. - -This disclaimer of warranty is an essential part of the Licence and a condition -for the grant of any rights to the Work. - -8. Disclaimer of Liability - -Except in the cases of wilful misconduct or damages directly caused to natural -persons, the Licensor will in no event be liable for any direct or indirect, -material or moral, damages of any kind, arising out of the Licence or of the use -of the Work, including without limitation, damages for loss of goodwill, work -stoppage, computer failure or malfunction, loss of data or any commercial -damage, even if the Licensor has been advised of the possibility of such damage. -However, the Licensor will be liable under statutory product liability laws as -far such laws apply to the Work. - -9. Additional agreements - -While distributing the Work, You may choose to conclude an additional agreement, -defining obligations or services consistent with this Licence. However, if -accepting obligations, You may act only on your own behalf and on your sole -responsibility, not on behalf of the original Licensor or 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 -the fact You have accepted any warranty or additional liability. - -10. Acceptance of the Licence - -The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ -placed under the bottom of a window displaying the text of this Licence or by -affirming consent in any other similar way, in accordance with the rules of -applicable law. Clicking on that icon indicates your clear and irrevocable -acceptance of this Licence and all of its terms and conditions. - -Similarly, you irrevocably accept this Licence and all of its terms and -conditions by exercising any rights granted to You by Article 2 of this Licence, -such as the use of the Work, the creation by You of a Derivative Work or the -Distribution or Communication by You of the Work or copies thereof. - -11. Information to the public - -In case of any Distribution or Communication of the Work by means of electronic -communication by You (for example, by offering to download the Work from a -remote location) the distribution channel or media (for example, a website) must -at least provide to the public the information requested by the applicable law -regarding the Licensor, the Licence and the way it may be accessible, concluded, -stored and reproduced by the Licensee. - -12. Termination of the Licence - -The Licence and the rights granted hereunder will terminate automatically upon -any breach by the Licensee of the terms of the Licence. - -Such a termination will not terminate the licences of any person who has -received the Work from the Licensee under the Licence, provided such persons -remain in full compliance with the Licence. - -13. Miscellaneous - -Without prejudice of Article 9 above, the Licence represents the complete -agreement between the Parties as to the Work. - -If any provision of the Licence is invalid or unenforceable under applicable -law, this will not affect the validity or enforceability of the Licence as a -whole. Such provision will be construed or reformed so as necessary to make it -valid and enforceable. - -The European Commission may publish other linguistic versions or new versions of -this Licence or updated versions of the Appendix, so far this is required and -reasonable, without reducing the scope of the rights granted by the Licence. New -versions of the Licence will be published with a unique version number. - -All linguistic versions of this Licence, approved by the European Commission, -have identical value. Parties can take advantage of the linguistic version of -their choice. - -14. Jurisdiction - -Without prejudice to specific agreement between parties, - -- any litigation resulting from the interpretation of this License, arising - between the European Union institutions, bodies, offices or agencies, as a - Licensor, and any Licensee, will be subject to the jurisdiction of the Court - of Justice of the European Union, as laid down in article 272 of the Treaty on - the Functioning of the European Union, - -- any litigation arising between other parties and resulting from the - interpretation of this License, will be subject to the exclusive jurisdiction - of the competent court where the Licensor resides or conducts its primary - business. - -15. Applicable Law - -Without prejudice to specific agreement between parties, - -- this Licence shall be governed by the law of the European Union Member State - where the Licensor has his seat, resides or has his registered office, - -- this licence shall be governed by Belgian law if the Licensor has no seat, - residence or registered office inside a European Union Member State. - -Appendix - -‘Compatible Licences’ according to Article 5 EUPL are: - -- GNU General Public License (GPL) v. 2, v. 3 -- GNU Affero General Public License (AGPL) v. 3 -- Open Software License (OSL) v. 2.1, v. 3.0 -- Eclipse Public License (EPL) v. 1.0 -- CeCILL v. 2.0, v. 2.1 -- Mozilla Public Licence (MPL) v. 2 -- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 -- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for - works other than software -- European Union Public Licence (EUPL) v. 1.1, v. 1.2 -- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong - Reciprocity (LiLiQ-R+). - -The European Commission may update this Appendix to later versions of the above -licences without producing a new version of the EUPL, as long as they provide -the rights granted in Article 2 of this Licence and protect the covered Source -Code from exclusive appropriation. - -All other changes or additions to this Appendix require the production of a new -EUPL version. + 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 [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index b7d0bb0..3451ad6 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,10 @@ -# Keycloak Metrics +# Keycloak Event Metrics -Provides metrics for Keycloak user/admin events and user/client/session count. Tested on Keycloak [22-26](.woodpecker/verify.yaml#L7-L11). +Provides metrics for Keycloak user/admin events. -[![maven](https://img.shields.io/maven-central/v/io.kokuwa.keycloak/keycloak-event-metrics.svg?label=maven)](https://central.sonatype.com/artifact/io.kokuwa.keycloak/keycloak-event-metrics) -[![pulls](https://img.shields.io/docker/pulls/kokuwaio/keycloak-event-metrics)](https://hub.docker.com/r/kokuwaio/keycloak-event-metrics) -[![size](https://img.shields.io/docker/image-size/kokuwaio/keycloak-event-metrics)](https://hub.docker.com/r/kokuwaio/keycloak-event-metrics) -[![dockerfile](https://img.shields.io/badge/source-Dockerfile%20-blue)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/src/branch/main/Dockerfile) -[![license](https://img.shields.io/badge/license-EUPL%201.2-blue)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/src/branch/main/LICENSE) -[![issues](https://img.shields.io/gitea/issues/open/kokuwaio/keycloak-event-metrics?gitea_url=https%3A%2F%2Fgit.kokuwa.io)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/issues) -[![prs](https://img.shields.io/gitea/pull-requests/open/kokuwaio/keycloak-event-metrics?gitea_url=https%3A%2F%2Fgit.kokuwa.io)](https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/pulls) -[![build](https://ci.kokuwa.io/api/badges/kokuwaio/keycloak-event-metrics/status.svg)](https://ci.kokuwa.io/repos/kokuwaio/keycloak-event-metrics/) - -## Why? - -[aerogear/keycloak-metrics-spi](https://github.com/aerogear/keycloak-metrics-spi) is an alternative to this plugin but is not well maintained. This implementation is different: - -* no Prometheus push (event listener only adds counter to Micrometer) -* no realm specific Prometheus endpoint, only `/metrics` (from Quarkus) -* no jvm/http metrics, this is [already](https://www.keycloak.org/server/configuration-metrics#_available_metrics) included in Keycloak -* different metric names, can relace model ids with name (see [configuration](#kc_metrics_event_replace_ids)) -* deployed to maven central and very small (15 kb vs. 151 KB [aerogear/keycloak-metrics-spi](https://github.com/aerogear/keycloak-metrics-spi)) -* gauge for active/offline sessions and user/client count +[![Apache License, Version 2.0, January 2004](https://img.shields.io/github/license/kokuwaio/keycloak-event-metrics.svg?label=License)](http://www.apache.org/licenses/) +[![Maven Central](https://img.shields.io/maven-central/v/io.kokuwa.keycloak/keycloak-event-metrics.svg?label=Maven%20Central)](https://central.sonatype.com/search?namespace=io.kokuwa.keycloak&q=keycloak-event-metrics) +[![CI](https://img.shields.io/github/actions/workflow/status/kokuwaio/keycloak-event-metrics/ci.yaml?branch=main&label=CI)](https://github.com/kokuwaio/keycloak-event-metrics/actions/workflows/ci.yaml?query=branch%3Amain) ## What? @@ -32,7 +16,7 @@ User events are added with key `keycloak_event_user_total` and tags: * `type`: [EventType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/EventType.java#L27) from [Event#type](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L44) * `realm`: realm id from [Event#realmId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L46) -* `client`: client id from [Event#clientId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L48), unknown client_ids are grouped into UNKOWN +* `client`: client id from [Event#clientId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L48) * `error`: error from [Event#error](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L56), only present for error types Examples: @@ -41,7 +25,6 @@ Examples: keycloak_event_user_total{client="test",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",type="LOGIN",error="",} 2.0 keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN",error="",} 1.0 keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN_ERROR",error="invalid_user_credentials",} 1.0 -keycloak_event_user_total{client="UNKNOWN",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN_ERROR",error="invalid_user_credentials",} 1.0 ``` ### Admin Events @@ -60,118 +43,26 @@ keycloak_event_admin_total{error="",operation="CREATE",realm="1fdb3465-1675-49e8 keycloak_event_admin_total{error="",operation="CREATE",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",resource="USER",} 1.0 ``` -## Configuration - -### `KC_METRICS_EVENT_REPLACE_IDS` - -Set to `true` (the default value) than replace model ids from events with names: - -* [RealmModel#getId()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/RealmModel.java#L82) with [RealmModel#getName()](https://github.com/keycloak/keycloak/blob/main/server-spi/src/main/java/org/keycloak/models/RealmModel.java#L84) - -Metrics: - -```txt -keycloak_event_user_total{client="test-client",error="",realm="test-realm",type="LOGIN",} 2.0 -keycloak_event_user_total{client="other-client",error="",realm="other-realm",type="LOGIN",} 1.0 -keycloak_event_user_total{client="other-client",error="invalid_user_credentials",realm="other-realm",type="LOGIN_ERROR",} 1.0 -``` - -### `KC_METRICS_STATS_ENABLED` - -Set to `true` (default is `false`) to provide metrics for user/client count per realm and session count per client. Metrics: - -```txt -# HELP keycloak_users -# TYPE keycloak_users gauge -keycloak_users{realm="master",} 1.0 -keycloak_users{realm="my-realm",} 2.0 -keycloak_users{realm="other-realm",} 1.0# HELP keycloak_active_user_sessions -# TYPE keycloak_active_user_sessions gauge -keycloak_active_user_sessions{client="admin-cli",realm="userCount_1",} 0.0 -keycloak_active_user_sessions{client="admin-cli",realm="userCount_2",} 0.0 -keycloak_active_user_sessions{client="admin-cli",realm="master",} 1.0 -# TYPE keycloak_active_client_sessions gauge -keycloak_active_client_sessions{client="admin-cli",realm="userCount_1",} 0.0 -keycloak_active_client_sessions{client="admin-cli",realm="userCount_2",} 0.0 -keycloak_active_client_sessions{client="admin-cli",realm="master",} 0.0 -# TYPE keycloak_offline_sessions gauge -keycloak_offline_sessions{client="admin-cli",realm="userCount_1",} 0.0 -keycloak_offline_sessions{client="admin-cli",realm="userCount_2",} 0.0 -keycloak_offline_sessions{client="admin-cli",realm="master",} 0.0 -``` - -### `KC_METRICS_STATS_INTERVAL` - -If `KC_METRICS_STATS_ENABLED` is `true` this will define the interval for scraping. If not configured `PT60s` will be used. - -### `KC_METRICS_STATS_INFO_THRESHOLD` and `KC_METRICS_STATS_WARN_THRESHOLD` - -If `KC_METRICS_STATS_ENABLED` is `true` this envs will define logging if scraping takes to long. Both envs are parsed as `java.lang.Duration`. - -Default values: - -* `KC_METRICS_STATS_INFO_THRESHOLD`: 50% of `KC_METRICS_STATS_INTERVAL` = 30s -* `KC_METRICS_STATS_WARN_THRESHOLD`: 75% of `KC_METRICS_STATS_INTERVAL` = 45s - -If scrapping takes less than `KC_METRICS_STATS_INFO_THRESHOLD` duration will be logged on debug level. - ## Installation -### Grafana Dashboard - -Can be found here: [keycloak-metrics.json](https://git.kokuwa.io/keycloak/keycloak/blob/main/src/test/k3s/dev/grafana/files/dashboards/keycloak-metrics.json) - ### Testcontainers For usage in [Testcontainers](https://www.testcontainers.org/) see [KeycloakExtension.java](src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java#L57-L68) -### Container Image - -Registries: - -* [ghcr.io/kokuwaio/keycloak-event-metrics](https://github.com/kokuwaio/keycloak-event-metrics/pkgs/container/keycloak-event-metrics) -* [docker.io/kokuwaio/keycloak-event-metrics](https://hub.docker.com/r/kokuwaio/keycloak-event-metrics) - -This images are based on busybox, so you can use cp to copy the jar into your keycloak. - ### Docker -Check: [kowaio/keycloak](https://git.kokuwa.io/keycloak/keycloak) - Dockerfile: ```Dockerfile -### -### download keycloak event metrics -### - -FROM debian:stable-slim AS metrics - -RUN apt-get -qq update -RUN apt-get -qq install --yes --no-install-recommends ca-certificates wget - -ARG METRICS_VERSION=2.0.0 -ARG METRICS_FILE=keycloak-event-metrics-${METRICS_VERSION}.jar -ARG METRICS_URL=https://repo1.maven.org/maven2/io/kokuwa/keycloak/keycloak-event-metrics/${METRICS_VERSION} - -RUN wget --quiet --no-hsts ${METRICS_URL}/${METRICS_FILE} -RUN wget --quiet --no-hsts ${METRICS_URL}/${METRICS_FILE}.sha1 -RUN echo "$(cat ${METRICS_FILE}.sha1) ${METRICS_FILE}" sha1sum --quiet --check --strict - -RUN mkdir -p /opt/keycloak/providers -RUN mv ${METRICS_FILE} /opt/keycloak/providers - -### -### build keycloak with metrics -### - -FROM quay.io/keycloak/keycloak:25.2.5 +FROM quay.io/keycloak/keycloak:21.0.1 ENV KEYCLOAK_ADMIN=admin ENV KEYCLOAK_ADMIN_PASSWORD=password ENV KC_HEALTH_ENABLED=true ENV KC_METRICS_ENABLED=true +ENV KC_LOG_CONSOLE_COLOR=true -COPY --from=metrics /opt/keycloak/providers /opt/keycloak/providers +ADD target/keycloak-event-metrics-0.0.1-SNAPSHOT.jar /opt/keycloak/providers RUN /opt/keycloak/bin/kc.sh build ``` diff --git a/pom.xml b/pom.xml index ea2dea8..7ea6e96 100644 --- a/pom.xml +++ b/pom.xml @@ -4,11 +4,11 @@ io.kokuwa.keycloak keycloak-event-metrics - 2.0.1-SNAPSHOT + 0.1.0 Keycloak Metrics Provides metrics for Keycloak user/admin events - https://git.kokuwa.io/kokuwaio/keycloak-event-metrics + https://github.com/kokuwaio/keycloak-event-metrics 2023 Kokuwa.io @@ -16,41 +16,45 @@ - EUPL-1.2 - https://eupl.eu/1.2/en - repo + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 - stephan.schnabel + stephanschnabel Stephan Schnabel - https://schnabel.org - stephan@schnabel.org - Europe/Berlin + https://github.com/sschnabe + stephan@grayc.de + GrayC GmbH + https://grayc.de - https://git.kokuwa.io/kokuwaio/keycloak-event-metrics - scm:git:https://git.kokuwa.io/kokuwaio/keycloak-event-metrics.git - scm:git:https://git.kokuwa.io/kokuwaio/keycloak-event-metrics.git - HEAD + https://github.com/kokuwaio/keycloak-event-metrics + scm:git:https://github.com/kokuwaio/keycloak-event-metrics.git + scm:git:https://github.com/kokuwaio/keycloak-event-metrics.git + 0.1.0 - forgejo - https://git.kokuwa.io/kokuwaio/keycloak-event-metrics/issues + github + https://github.com/kokuwaio/keycloak-event-metrics/issues - woodpecker - https://ci.kokuwa.io/repos/kokuwaio/keycloak-event-metrics + github + https://github.com/kokuwaio/keycloak-event-metrics/actions - sonatype.org - https://central.sonatype.com/repository/maven-snapshots/ + sonatype-nexus + https://oss.sonatype.org/content/repositories/snapshots + + sonatype-nexus + https://oss.sonatype.org/service/local/staging/deploy/maven2 + @@ -59,40 +63,67 @@ - 2025-06-25T14:15:39Z UTF-8 - ISO-8859-1 - 17 - ${maven.compiler.release} - ${maven.compiler.release} - -Xlint:all - true - true + 17 + 17 true - - true - java.,javax.,jakarta.,org. - ${project.basedir}/src/eclipse/formatter.xml + true + true + true - + - 26.2.5 - ${version.org.keycloak} + + + 3.2.1 + 3.2.0 + 3.11.0 + 3.5.0 + 3.1.0 + 3.0.1 + 3.1.0 + 3.3.0 + 1.0.0 + 3.0.0-M7 + 3.3.0 + 3.2.1 + 3.0.0-M9 + 1.2.0 + 1.6.13 + 10.8.0 + 0.5.6 + + + + 21.0.1 + 1.17.6 + + org.keycloak - keycloak-quarkus-server + keycloak-parent ${version.org.keycloak} pom import + + + + org.testcontainers + testcontainers-bom + ${version.org.testcontainers} + pom + import + + @@ -101,110 +132,51 @@ org.keycloak keycloak-core - ${version.org.keycloak.test} provided org.keycloak keycloak-server-spi - ${version.org.keycloak.test} provided org.keycloak keycloak-server-spi-private - ${version.org.keycloak.test} provided + + org.keycloak + keycloak-quarkus-server + provided + + + com.openshift + openshift-restclient-java + + + org.keycloak keycloak-admin-client test - - - org.glassfish.jaxb - jaxb-runtime - - - org.jboss.resteasy - resteasy-multipart-provider - - - - com.sun.istack - istack-commons-tools - - - com.sun.istack - istack-commons-runtime - - - - - - - org.jboss.logging - jboss-logging - provided - - - org.jboss.logging - commons-logging-jboss-logging - test - - - org.hibernate.orm - hibernate-core - provided - - - javax.xml.bind - jaxb-api - - - org.glassfish.jaxb - jaxb-runtime - - - org.hibernate.common - hibernate-commons-annotations - - - org.jboss - jandex - - - antlr - antlr - - - com.fasterxml - classmate - - - - - io.micrometer - micrometer-core - provided - org.mockito - mockito-junit-jupiter + org.testcontainers + junit-jupiter test - - org.testcontainers - testcontainers + + org.wildfly.client + wildfly-client-config + 1.0.1.Final test - ${project.artifactId} ${project.basedir}/src/test/resources @@ -215,106 +187,147 @@ org.apache.maven.plugins - maven-compiler-plugin - 3.14.0 + maven-checkstyle-plugin + ${version.org.apache.maven.plugins.checkstyle} - ${maven.compiler.compilerArgument} + checkstyle.xml + checkstyle-suppression.xml + true + + + com.puppycrawl.tools + checkstyle + ${version.com.puppycrawl.tools.checkstyle} + + + io.kokuwa + maven-parent + ${version.io.kokuwa.checkstyle} + zip + checkstyle + + + + + org.apache.maven.plugins + maven-clean-plugin + ${version.org.apache.maven.plugins.clean} + + + org.apache.maven.plugins + maven-compiler-plugin + ${version.org.apache.maven.plugins.compiler} + + + org.apache.maven.plugins + maven-dependency-plugin + ${version.org.apache.maven.plugins.dependency} org.apache.maven.plugins maven-deploy-plugin - 3.1.4 + ${version.org.apache.maven.plugins.deploy} org.apache.maven.plugins maven-failsafe-plugin - 3.5.3 + ${version.org.apache.maven.plugins.surefire} + + true + ${maven.test.redirectTestOutputToFile} + org.apache.maven.plugins maven-gpg-plugin - 3.2.7 + ${version.org.apache.maven.plugins.gpg} org.apache.maven.plugins maven-install-plugin - 3.1.4 + ${version.org.apache.maven.plugins.install} org.apache.maven.plugins maven-jar-plugin - 3.4.2 + ${version.org.apache.maven.plugins.jar} org.apache.maven.plugins maven-javadoc-plugin - 3.11.2 + ${version.org.apache.maven.plugins.jar} org.apache.maven.plugins maven-release-plugin - 3.1.1 + ${version.org.apache.maven.plugins.release} - test - check - deploy -DskipITs - deploy,release - true - @{prefix} prepare release @{releaseLabel} [CI SKIP] @{project.version} + release + true + true + @{prefix} prepare release @{releaseLabel} [no ci] - - org.apache.maven.plugins - maven-resources-plugin - 3.3.1 - - ${project.build.propertiesEncoding} - - - - org.apache.maven.plugins - maven-site-plugin - 3.21.0 - org.apache.maven.plugins maven-source-plugin - 3.3.1 + ${version.org.apache.maven.plugins.source} + + + org.apache.maven.plugins + maven-resources-plugin + ${version.org.apache.maven.plugins.resources} + + UTF-8 + org.apache.maven.plugins maven-surefire-plugin - 3.5.3 + ${version.org.apache.maven.plugins.surefire} org.codehaus.mojo tidy-maven-plugin - 1.4.0 + ${version.org.codehaus.mojo.tidy} - org.sonatype.central - central-publishing-maven-plugin - 0.8.0 - - - net.revelc.code.formatter - formatter-maven-plugin - 2.27.0 - - ${formatter.configFile} - - - - net.revelc.code - impsort-maven-plugin - 1.12.0 + org.sonatype.plugins + nexus-staging-maven-plugin + ${version.org.sonatype.plugins.nexus-staging} + + + org.codehaus.mojo + tidy-maven-plugin + + + + check + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + check + + + + + org.apache.maven.plugins @@ -329,143 +342,29 @@ - - - org.apache.maven.plugins - maven-install-plugin - - - default-install - - - - - - dev - - - !env.CI - - - - true - - - - - org.codehaus.mojo - tidy-maven-plugin - - - validate - - pom - - - - - - net.revelc.code - impsort-maven-plugin - - - validate - - sort - - - - - - net.revelc.code.formatter - formatter-maven-plugin - - - validate - - format - - - - - - - - - check - - - env.CI - - - - - - org.codehaus.mojo - tidy-maven-plugin - - - validate - - check - - - - - - net.revelc.code - impsort-maven-plugin - - - validate - - check - - - - - - net.revelc.code.formatter - formatter-maven-plugin - - - validate - - validate - - - - - - - - - deploy - - - env.CI - - + release - + org.apache.maven.plugins maven-source-plugin - jar + jar-no-fork + + org.apache.maven.plugins maven-javadoc-plugin @@ -478,7 +377,7 @@ - + org.apache.maven.plugins maven-gpg-plugin @@ -487,32 +386,24 @@ sign - - bc - + + + org.sonatype.plugins + nexus-staging-maven-plugin + true + + sonatype-nexus + https://oss.sonatype.org/ + true + + + - - release - - - - org.sonatype.central - central-publishing-maven-plugin - true - - sonatype.org - true - published - - - - - diff --git a/renovate.json b/renovate.json deleted file mode 100644 index c59fb01..0000000 --- a/renovate.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>kokuwaio/renovate-config", ":reviewer(stephan.schnabel)"], - "pinDigests": false -} diff --git a/src/eclipse/formatter.xml b/src/eclipse/formatter.xml deleted file mode 100644 index 61186a2..0000000 --- a/src/eclipse/formatter.xml +++ /dev/null @@ -1,404 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java new file mode 100644 index 0000000..d630739 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java @@ -0,0 +1,32 @@ +package io.kokuwa.keycloak.metrics; + +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.admin.AdminEvent; + +/** + * Listener for {@link Event} and {@link AdminEvent}. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListener implements EventListenerProvider, AutoCloseable { + + private final MicrometerEventRecorder recorder; + + MicrometerEventListener(MicrometerEventRecorder recorder) { + this.recorder = recorder; + } + + @Override + public void onEvent(Event event) { + recorder.userEvent(event); + } + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + recorder.adminEvent(event); + } + + @Override + public void close() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java new file mode 100644 index 0000000..9e25a21 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java @@ -0,0 +1,42 @@ +package io.kokuwa.keycloak.metrics; + +import javax.enterprise.inject.spi.CDI; + +import org.keycloak.Config.Scope; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Factory for {@link MicrometerEventListener}, uses {@link MeterRegistry} from CDI. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListenerFactory implements EventListenerProviderFactory { + + private MicrometerEventRecorder recorder; + + @Override + public String getId() { + return "metrics-listener"; + } + + @Override + public void init(Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) { + recorder = new MicrometerEventRecorder(CDI.current().select(MeterRegistry.class).get()); + } + + @Override + public EventListenerProvider create(KeycloakSession session) { + return new MicrometerEventListener(recorder); + } + + @Override + public void close() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java new file mode 100644 index 0000000..49ec789 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java @@ -0,0 +1,32 @@ +package io.kokuwa.keycloak.metrics; + +import org.keycloak.events.EventListenerSpi; +import org.keycloak.provider.Provider; + +/** + * Factory for {@link MicrometerEventListener}. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListenerSpi extends EventListenerSpi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "Micrometer Metrics Provider"; + } + + @Override + public Class getProviderClass() { + return MicrometerEventListener.class; + } + + @Override + public Class getProviderFactoryClass() { + return MicrometerEventListenerFactory.class; + } +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java new file mode 100644 index 0000000..99320c6 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java @@ -0,0 +1,51 @@ +package io.kokuwa.keycloak.metrics; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.keycloak.events.Event; +import org.keycloak.events.admin.AdminEvent; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Micrometer based recorder for events. + * + * @author Stephan Schnabel + */ +public class MicrometerEventRecorder { + + private final Map counters = new HashMap<>(); + private final MeterRegistry registry; + + MicrometerEventRecorder(MeterRegistry registry) { + this.registry = registry; + } + + void adminEvent(AdminEvent event) { + counter("keycloak_event_admin", + "realm", toBlankIfNull(event.getRealmId()), + "resource", toBlankIfNull(event.getResourceType()), + "operation", toBlankIfNull(event.getOperationType()), + "error", toBlankIfNull(event.getError())); + } + + void userEvent(Event event) { + counter("keycloak_event_user", + "realm", toBlankIfNull(event.getRealmId()), + "type", toBlankIfNull(event.getType()), + "client", toBlankIfNull(event.getClientId()), + "error", toBlankIfNull(event.getError())); + } + + private void counter(String counter, String... tags) { + counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags)) + .increment(); + } + + private String toBlankIfNull(Object value) { + return value == null ? "" : value.toString(); + } +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListener.java b/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListener.java deleted file mode 100644 index 0bed727..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListener.java +++ /dev/null @@ -1,84 +0,0 @@ -package io.kokuwa.keycloak.metrics.event; - -import java.util.Objects; -import java.util.Optional; - -import org.jboss.logging.Logger; -import org.keycloak.events.Event; -import org.keycloak.events.EventListenerProvider; -import org.keycloak.events.admin.AdminEvent; -import org.keycloak.models.ClientModel; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; - -import io.micrometer.core.instrument.Metrics; - -/** - * Listener for {@link Event} and {@link AdminEvent}. - * - * @author Stephan Schnabel - */ -public class MetricsEventListener implements EventListenerProvider, AutoCloseable { - - private static final Logger log = Logger.getLogger(MetricsEventListener.class); - private final boolean replaceIds; - private final KeycloakSession session; - - MetricsEventListener(boolean replaceIds, KeycloakSession session) { - this.replaceIds = replaceIds; - this.session = session; - } - - @Override - public void onEvent(Event event) { - Metrics.counter("keycloak_event_user", - "realm", toBlank(replaceIds ? getRealmName(event.getRealmId()) : event.getRealmId()), - "type", toBlank(event.getType()), - "client", getClientId(event.getClientId()), - "error", toBlank(event.getError())) - .increment(); - } - - @Override - public void onEvent(AdminEvent event, boolean includeRepresentation) { - Metrics.counter("keycloak_event_admin", - "realm", toBlank(replaceIds ? getRealmName(event.getRealmId()) : event.getRealmId()), - "resource", toBlank(event.getResourceType()), - "operation", toBlank(event.getOperationType()), - "error", toBlank(event.getError())) - .increment(); - } - - @Override - public void close() {} - - private String getRealmName(String id) { - return Optional.ofNullable(session.getContext()).map(KeycloakContext::getRealm) - .filter(realm -> id == null || id.equals(realm.getId())) - .or(() -> { - log.tracev("Context realm was empty with id {0}", id); - return Optional.ofNullable(id).map(session.realms()::getRealm); - }) - .map(RealmModel::getName) - .orElseGet(() -> { - log.warnv("Failed to find realm with id {0}", id); - return id; - }); - } - - private String getClientId(String clientId) { - return Optional.ofNullable(session.getContext()) - .map(KeycloakContext::getClient) - .filter(model -> Objects.equals(model.getClientId(), clientId)) - .map(ClientModel::getClientId) - .orElseGet(() -> { - log.tracev("Client for id {0} is unknown", clientId); - return "UNKNOWN"; - }); - } - - private String toBlank(Object value) { - return value == null ? "" : value.toString(); - } -} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerFactory.java deleted file mode 100644 index 1580be7..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerFactory.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.kokuwa.keycloak.metrics.event; - -import org.jboss.logging.Logger; -import org.keycloak.Config.Scope; -import org.keycloak.events.EventListenerProvider; -import org.keycloak.events.EventListenerProviderFactory; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; - -/** - * Factory for {@link MetricsEventListener}. - * - * @author Stephan Schnabel - */ -public class MetricsEventListenerFactory implements EventListenerProviderFactory { - - private static final Logger log = Logger.getLogger(MetricsEventListenerFactory.class); - private boolean replaceIds; - - @Override - public String getId() { - return "metrics-listener"; - } - - @Override - public void init(Scope config) { - replaceIds = "true".equals(System.getenv().getOrDefault("KC_METRICS_EVENT_REPLACE_IDS", "true")); - log.info(replaceIds ? "Configured with model names." : "Configured with model ids."); - } - - @Override - public void postInit(KeycloakSessionFactory factory) {} - - @Override - public EventListenerProvider create(KeycloakSession session) { - return new MetricsEventListener(replaceIds, session); - } - - @Override - public void close() {} -} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactory.java deleted file mode 100644 index 13b626e..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactory.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import org.keycloak.provider.ProviderFactory; - -/** - * Factory for Keycloak metrics. - * - * @author Stephan Schnabel - */ -public interface MetricsStatsFactory extends ProviderFactory {} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryImpl.java b/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryImpl.java deleted file mode 100644 index 16aeb25..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryImpl.java +++ /dev/null @@ -1,71 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import java.time.Duration; -import java.util.Optional; - -import org.jboss.logging.Logger; -import org.keycloak.Config.Scope; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.timer.TimerProvider; - -/** - * Implementation of {@link MetricsStatsFactory}. - * - * @author Stephan Schnabel - */ -public class MetricsStatsFactoryImpl implements MetricsStatsFactory { - - private static final Logger log = Logger.getLogger(MetricsStatsFactory.class); - - @Override - public String getId() { - return "default"; - } - - @Override - public void init(Scope config) {} - - @Override - public void postInit(KeycloakSessionFactory factory) { - - if (!"true".equals(getenv("KC_METRICS_STATS_ENABLED"))) { - log.infov("Keycloak stats not enabled."); - return; - } - - var intervalDuration = Optional - .ofNullable(getenv("KC_METRICS_STATS_INTERVAL")) - .map(Duration::parse) - .orElse(Duration.ofSeconds(60)); - var infoThreshold = Optional - .ofNullable(getenv("KC_METRICS_STATS_INFO_THRESHOLD")) - .map(Duration::parse) - .orElse(Duration.ofMillis(Double.valueOf(intervalDuration.toMillis() * 0.5).longValue())); - var warnThreshold = Optional - .ofNullable(getenv("KC_METRICS_STATS_WARN_THRESHOLD")) - .map(Duration::parse) - .orElse(Duration.ofMillis(Double.valueOf(intervalDuration.toMillis() * 0.75).longValue())); - log.infov("Keycloak stats enabled with interval of {0} and info/warn after {1}/{2}.", - intervalDuration, infoThreshold, warnThreshold); - - var interval = intervalDuration.toMillis(); - var task = new MetricsStatsTask(intervalDuration, infoThreshold, warnThreshold); - KeycloakModelUtils.runJobInTransaction(factory, session -> session - .getProvider(TimerProvider.class) - .scheduleTask(task, interval, "metrics")); - } - - @Override - public MetricsStatsTask create(KeycloakSession session) { - return null; - } - - @Override - public void close() {} - - String getenv(String key) { - return System.getenv().get(key); - } -} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpi.java b/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpi.java deleted file mode 100644 index 35db096..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpi.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import org.keycloak.provider.Provider; -import org.keycloak.provider.ProviderFactory; -import org.keycloak.provider.Spi; - -/** - * SPI for Keycloak metrics. - * - * @author Stephan Schnabel - */ -public class MetricsStatsSpi implements Spi { - - @Override - public boolean isInternal() { - return false; - } - - @Override - public String getName() { - return "metrics"; - } - - @Override - public Class getProviderClass() { - return MetricsStatsTask.class; - } - - @Override - public Class> getProviderFactoryClass() { - // this must be an interface, otherwise spi will be silenty ignored - return MetricsStatsFactory.class; - } -} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTask.java b/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTask.java deleted file mode 100644 index e16188d..0000000 --- a/src/main/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTask.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import java.time.Duration; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; - -import org.jboss.logging.Logger; -import org.keycloak.models.KeycloakSession; -import org.keycloak.provider.Provider; -import org.keycloak.timer.ScheduledTask; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; - -/** - * Keycloak metrics. - * - * @author Stephan Schnabel - */ -public class MetricsStatsTask implements Provider, ScheduledTask { - - private static final Logger log = Logger.getLogger(MetricsStatsTask.class); - private static final Map values = new HashMap<>(); - private final Duration interval; - private final Duration infoThreshold; - private final Duration warnThreshold; - - MetricsStatsTask(Duration interval, Duration infoThreshold, Duration warnThreshold) { - this.interval = interval; - this.infoThreshold = infoThreshold; - this.warnThreshold = warnThreshold; - } - - @Override - public void run(KeycloakSession session) { - log.tracev("Triggered metrics stats task."); - var start = Instant.now(); - - try { - scrape(session); - } catch (org.hibernate.exception.SQLGrammarException e) { - log.infov("Metrics status task skipped, database not ready."); - return; - } catch (Exception e) { - log.errorv(e, "Failed to scrape stats."); - return; - } - - var duration = Duration.between(start, Instant.now()); - if (duration.compareTo(interval) > 0) { - log.errorv("Finished scrapping keycloak stats in {0}, consider to increase interval.", duration); - } else if (duration.compareTo(warnThreshold) > 0) { - log.warnv("Finished scrapping keycloak stats in {0}, consider to increase interval.", duration); - } else if (duration.compareTo(infoThreshold) > 0) { - log.infov("Finished scrapping keycloak stats in {0}.", duration); - } else { - log.debugv("Finished scrapping keycloak stats in {0}.", duration); - } - } - - @Override - public void close() {} - - private void scrape(KeycloakSession session) { - session.realms().getRealmsStream().forEach(realm -> { - session.getContext().setRealm(realm); - log.tracev("Scrape for realm {0}.", realm.getName()); - var tagRealm = Tag.of("realm", realm.getName()); - gauge("keycloak_users", Set.of(tagRealm), session.users().getUsersCount(realm), true); - gauge("keycloak_clients", Set.of(tagRealm), session.clients().getClientsCount(realm), true); - var sessions = session.sessions(); - var activeSessions = sessions.getActiveClientSessionStats(realm, false); - realm.getClientsStream().forEach(client -> { - var tags = Set.of(tagRealm, Tag.of("client", client.getClientId())); - gauge("keycloak_offline_sessions", tags, sessions.getOfflineSessionsCount(realm, client), false); - gauge("keycloak_active_user_sessions", tags, sessions.getActiveUserSessions(realm, client), false); - gauge("keycloak_active_client_sessions", tags, activeSessions.getOrDefault(client.getId(), 0L), false); - }); - }); - } - - private void gauge(String name, Set tags, long value, boolean force) { - var key = name + tags; - if (!force && value == 0 && !values.containsKey(key)) { - return; - } - values.computeIfAbsent(key, s -> Metrics.gauge(name, tags, new AtomicLong())).set(value); - } -} diff --git a/src/main/resources/META-INF/services/io.kokuwa.keycloak.metrics.stats.MetricsStatsFactory b/src/main/resources/META-INF/services/io.kokuwa.keycloak.metrics.stats.MetricsStatsFactory deleted file mode 100644 index 45c8b40..0000000 --- a/src/main/resources/META-INF/services/io.kokuwa.keycloak.metrics.stats.MetricsStatsFactory +++ /dev/null @@ -1 +0,0 @@ -io.kokuwa.keycloak.metrics.stats.MetricsStatsFactoryImpl diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory index 9984e54..a54f10d 100644 --- a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -1 +1 @@ -io.kokuwa.keycloak.metrics.event.MetricsEventListenerFactory +io.kokuwa.keycloak.metrics.MicrometerEventListenerFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi new file mode 100644 index 0000000..d2b799f --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi @@ -0,0 +1 @@ +io.kokuwa.keycloak.metrics.MicrometerEventListenerSpi diff --git a/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/src/main/resources/META-INF/services/org.keycloak.provider.Spi deleted file mode 100644 index f80dc90..0000000 --- a/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ /dev/null @@ -1 +0,0 @@ -io.kokuwa.keycloak.metrics.stats.MetricsStatsSpi \ No newline at end of file diff --git a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java index a9ffeaa..c3115fe 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java @@ -1,16 +1,11 @@ package io.kokuwa.keycloak.metrics; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.time.Instant; import java.util.UUID; -import java.util.function.Supplier; - -import jakarta.ws.rs.NotAuthorizedException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,13 +14,8 @@ import org.keycloak.events.EventType; import io.kokuwa.keycloak.metrics.junit.KeycloakClient; import io.kokuwa.keycloak.metrics.junit.KeycloakExtension; -import io.kokuwa.keycloak.metrics.junit.Prometheus; +import io.kokuwa.keycloak.metrics.prometheus.Prometheus; -/** - * Integration tests with Keycloak. - * - * @author Stephan Schnabel - */ @ExtendWith(KeycloakExtension.class) public class KeycloakIT { @@ -33,98 +23,44 @@ public class KeycloakIT { @Test void loginAndAttempts(KeycloakClient keycloak, Prometheus prometheus) { - var realmName1 = "loginAndAttempts_1"; - var clientId1 = realmName1 + "_client_1"; + var realmName1 = UUID.randomUUID().toString(); var username1 = UUID.randomUUID().toString(); var password1 = UUID.randomUUID().toString(); - keycloak.createRealm(realmName1); - keycloak.createClient(realmName1, clientId1); - keycloak.createUser(realmName1, username1, password1); - - var realmName2 = "loginAndAttempts_2"; - var clientId2 = realmName2 + "_client_2"; + var realmName2 = UUID.randomUUID().toString(); var username2 = UUID.randomUUID().toString(); var password2 = UUID.randomUUID().toString(); - keycloak.createRealm(realmName2); - keycloak.createClient(realmName2, clientId2); + var realmId1 = keycloak.createRealm(realmName1); + var realmId2 = keycloak.createRealm(realmName2); + keycloak.createUser(realmName1, username1, password1); keycloak.createUser(realmName2, username2, password2); - var clientId3 = realmName2 + "_" + UUID.randomUUID(); - var clientId4 = realmName2 + "_" + UUID.randomUUID(); - prometheus.scrap(); var loginBefore = prometheus.userEvent(EventType.LOGIN); - var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmName1, clientId1); - var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmName2, clientId2); + var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmId2); var loginErrorBefore = prometheus.userEvent(EventType.LOGIN_ERROR); - var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName1, clientId1); - var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId2); - var loginErrorBeforeUNKNOWN = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, "UNKNOWN"); + var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); - assertDoesNotThrow(() -> keycloak.login(clientId1, realmName1, username1, password1)); - assertDoesNotThrow(() -> keycloak.login(clientId1, realmName1, username1, password1)); - assertDoesNotThrow(() -> keycloak.login(clientId2, realmName2, username2, password2)); - assertThrows(NotAuthorizedException.class, () -> keycloak.login(clientId3, realmName2, "nope", "nö")); - assertThrows(NotAuthorizedException.class, () -> keycloak.login(clientId4, realmName2, "foo", "bar")); - assertThrows(NotAuthorizedException.class, () -> keycloak.login(clientId2, realmName2, username2, "nope")); + assertTrue(keycloak.login(realmName1, username1, password1)); + assertTrue(keycloak.login(realmName1, username1, password1)); + assertTrue(keycloak.login(realmName2, username2, password2)); + assertFalse(keycloak.login(realmName2, username2, "nope")); prometheus.scrap(); var loginAfter = prometheus.userEvent(EventType.LOGIN); - var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmName1, clientId1); - var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmName2, clientId2); + var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmId2); var loginErrorAfter = prometheus.userEvent(EventType.LOGIN_ERROR); - var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName1, clientId1); - var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId2); - var loginErrorAfter3 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId3); - var loginErrorAfter4 = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, clientId4); - var loginErrorAfterUNKNOWN = prometheus.userEvent(EventType.LOGIN_ERROR, realmName2, "UNKNOWN"); + var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); assertAll("prometheus", () -> assertEquals(loginBefore + 3, loginAfter, "login success total"), () -> assertEquals(loginBefore1 + 2, loginAfter1, "login success #1"), () -> assertEquals(loginBefore2 + 1, loginAfter2, "login success #2"), - () -> assertEquals(loginErrorBefore + 3, loginErrorAfter, "login failure total"), + () -> assertEquals(loginErrorBefore + 1, loginErrorAfter, "login failure total"), () -> assertEquals(loginErrorBefore1 + 0, loginErrorAfter1, "login failure #1"), - () -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2"), - () -> assertEquals(0, loginErrorAfter3, "login failure #3"), - () -> assertEquals(0, loginErrorAfter4, "login failure #4"), - () -> assertEquals(loginErrorBeforeUNKNOWN + 2, loginErrorAfterUNKNOWN, "login failure UNKNOWN")); - } - - @DisplayName("user count") - @Test - void userCount(KeycloakClient keycloak, Prometheus prometheus) { - - var realmName1 = "userCount_1"; - var realmName2 = "userCount_2"; - var username = UUID.randomUUID().toString(); - - keycloak.createRealm(realmName1); - keycloak.createRealm(realmName2); - - await(() -> prometheus.userCount(realmName1) == 0, prometheus, "realm 1 not found"); - await(() -> prometheus.userCount(realmName2) == 0, prometheus, "realm 2 not found"); - - keycloak.createUser(realmName1, username, UUID.randomUUID().toString()); - keycloak.createUser(realmName1, UUID.randomUUID().toString(), UUID.randomUUID().toString()); - keycloak.createUser(realmName1, UUID.randomUUID().toString(), UUID.randomUUID().toString()); - keycloak.createUser(realmName2, UUID.randomUUID().toString(), UUID.randomUUID().toString()); - - await(() -> prometheus.userCount(realmName1) == 3, prometheus, "realm 1 shoud have 3 users"); - await(() -> prometheus.userCount(realmName2) == 1, prometheus, "realm 2 shoud have 1 users"); - - keycloak.deleteUser(realmName1, username); - - await(() -> prometheus.userCount(realmName1) == 2, prometheus, "realm 1 shoud have 2 users after deletion"); - await(() -> prometheus.userCount(realmName2) == 1, prometheus, "realm 2 shoud have 1 users"); - } - - void await(Supplier check, Prometheus prometheus, String message) { - var end = Instant.now().plusSeconds(10); - while (Instant.now().isBefore(end) && !check.get()) { - assertDoesNotThrow(() -> Thread.sleep(1000)); - prometheus.scrap(); - } - assertTrue(check.get(), message); + () -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2")); } } diff --git a/src/test/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerTest.java b/src/test/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerTest.java deleted file mode 100644 index d54fd36..0000000 --- a/src/test/java/io/kokuwa/keycloak/metrics/event/MetricsEventListenerTest.java +++ /dev/null @@ -1,378 +0,0 @@ -package io.kokuwa.keycloak.metrics.event; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - -import java.util.UUID; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.keycloak.events.Event; -import org.keycloak.events.EventType; -import org.keycloak.events.admin.AdminEvent; -import org.keycloak.events.admin.OperationType; -import org.keycloak.events.admin.ResourceType; -import org.keycloak.models.ClientModel; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RealmProvider; -import org.mockito.Mock; - -import io.kokuwa.keycloak.metrics.junit.AbstractMockitoTest; -import io.micrometer.core.instrument.Metrics; - -/** - * Test for {@link MetricsEventListener} with Mockito. - * - * @author Stephan Schnabel - */ -@DisplayName("events: listener") -public class MetricsEventListenerTest extends AbstractMockitoTest { - - @Mock - KeycloakSession session; - @Mock - RealmModel realmModel; - @Mock - RealmProvider realmProvider; - @Mock - ClientModel clientModel; - @Mock - KeycloakContext context; - - @DisplayName("onEvent(true)") - @Nested - class onEvent { - - @DisplayName("replace(true) - without error") - @Test - void replaceWithoutError() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN; - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(context.getClient()).thenReturn(clientModel); - when(realmModel.getId()).thenReturn(realmId); - when(realmModel.getName()).thenReturn(realmName); - when(clientModel.getClientId()).thenReturn(clientId); - - listener(true).onEvent(toEvent(realmId, clientId, type, null)); - assertEvent(realmName, clientId, type.toString(), ""); - } - - @DisplayName("replace(true) - with error") - @Test - void replaceWithError() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN_ERROR; - var error = UUID.randomUUID().toString(); - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(context.getClient()).thenReturn(clientModel); - when(realmModel.getId()).thenReturn(realmId); - when(realmModel.getName()).thenReturn(realmName); - when(clientModel.getClientId()).thenReturn(clientId); - - listener(true).onEvent(toEvent(realmId, clientId, type, error)); - assertEvent(realmName, clientId, type.toString(), error); - } - - @DisplayName("replace(true) - all fields empty") - @Test - void replaceFieldsEmpty() { - - var realmName = UUID.randomUUID().toString(); - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toEvent(null, null, null, null)); - assertEvent(realmName, "UNKNOWN", "", ""); - } - - @DisplayName("replace(true) - context is null") - @Test - void replaceFieldsContextNull() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN_ERROR; - - when(session.realms()).thenReturn(realmProvider); - when(realmProvider.getRealm(realmId)).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toEvent(realmId, clientId, type, null)); - assertEvent(realmName, "UNKNOWN", type.toString(), ""); - } - - @DisplayName("replace(true) - context is empty") - @Test - void replaceFieldsContextEmpty() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN_ERROR; - - when(session.getContext()).thenReturn(context); - when(session.realms()).thenReturn(realmProvider); - when(realmProvider.getRealm(realmId)).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toEvent(realmId, clientId, type, null)); - assertEvent(realmName, "UNKNOWN", type.toString(), ""); - } - - @DisplayName("replace(true) - realmId is unknown") - @Test - void replaceFieldsRealmIdUnknown() { - - var realmId = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN_ERROR; - - when(session.getContext()).thenReturn(context); - when(session.realms()).thenReturn(realmProvider); - when(context.getRealm()).thenReturn(realmModel); - when(context.getClient()).thenReturn(clientModel); - when(realmModel.getId()).thenReturn(UUID.randomUUID().toString()); - when(clientModel.getClientId()).thenReturn(clientId); - - listener(true).onEvent(toEvent(realmId, clientId, type, null)); - assertEvent(realmId, clientId, type.toString(), ""); - } - - @DisplayName("replace(false) - without error") - @Test - void notReplaceWithoutError() { - - var realmId = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN; - - listener(false).onEvent(toEvent(realmId, clientId, type, null)); - assertEvent(realmId, "UNKNOWN", type.toString(), ""); - } - - @DisplayName("replace(false) - with error") - @Test - void notReplaceWithError() { - - var realmId = UUID.randomUUID().toString(); - var clientId = UUID.randomUUID().toString(); - var type = EventType.LOGIN_ERROR; - var error = UUID.randomUUID().toString(); - - listener(false).onEvent(toEvent(realmId, clientId, type, error)); - assertEvent(realmId, "UNKNOWN", type.toString(), error); - } - - @DisplayName("replace(false) - all fields empty") - @Test - void notReplaceFieldsEmpty() { - listener(false).onEvent(toEvent(null, null, null, null)); - assertEvent("", "UNKNOWN", "", ""); - } - - private Event toEvent(String realmId, String clientId, EventType type, String error) { - var event = new Event(); - event.setRealmId(realmId); - event.setClientId(clientId); - event.setType(type); - event.setError(error); - return event; - } - - private void assertEvent(String realm, String client, String type, String error) { - assertCounter("keycloak_event_user", - "realm", realm, - "client", client, - "type", type, - "error", error); - } - } - - @DisplayName("onEvent(AdminEvent,boolean)") - @Nested - class onAdminEvent { - - @DisplayName("replace(true) - without error") - @Test - void replaceWithoutError() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(realmModel.getId()).thenReturn(realmId); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toAdminEvent(realmId, resource, operation, null), false); - assertAdminEvent(realmName, resource.toString(), operation.toString(), ""); - } - - @DisplayName("replace(true) - with error") - @Test - void replaceWithError() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - var error = UUID.randomUUID().toString(); - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(realmModel.getId()).thenReturn(realmId); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toAdminEvent(realmId, resource, operation, error), false); - assertAdminEvent(realmName, resource.toString(), operation.toString(), error); - } - - @DisplayName("replace(true) - all fields empty") - @Test - void replaceFieldsEmpty() { - - var realmName = UUID.randomUUID().toString(); - - when(session.getContext()).thenReturn(context); - when(context.getRealm()).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toAdminEvent(null, null, null, null), false); - assertAdminEvent(realmName, "", "", ""); - } - - @DisplayName("replace(true) - context is null") - @Test - void replaceFieldsContextNull() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - - when(session.realms()).thenReturn(realmProvider); - when(realmProvider.getRealm(realmId)).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toAdminEvent(realmId, resource, operation, null), false); - assertAdminEvent(realmName, resource.toString(), operation.toString(), ""); - } - - @DisplayName("replace(true) - context is empty") - @Test - void replaceFieldsContextEmpty() { - - var realmId = UUID.randomUUID().toString(); - var realmName = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - - when(session.getContext()).thenReturn(context); - when(session.realms()).thenReturn(realmProvider); - when(realmProvider.getRealm(realmId)).thenReturn(realmModel); - when(realmModel.getName()).thenReturn(realmName); - - listener(true).onEvent(toAdminEvent(realmId, resource, operation, null), false); - assertAdminEvent(realmName, resource.toString(), operation.toString(), ""); - } - - @DisplayName("replace(true) - realmId is unknown") - @Test - void replaceFieldsRealmIdUnknown() { - - var realmId = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - - when(session.getContext()).thenReturn(context); - when(session.realms()).thenReturn(realmProvider); - when(context.getRealm()).thenReturn(realmModel); - when(realmModel.getId()).thenReturn(UUID.randomUUID().toString()); - - listener(true).onEvent(toAdminEvent(realmId, resource, operation, null), false); - assertAdminEvent(realmId, resource.toString(), operation.toString(), ""); - } - - @DisplayName("replace(false) - without error") - @Test - void noReplaceWithoutError() { - - var realmId = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - - listener(false).onEvent(toAdminEvent(realmId, resource, operation, null), false); - assertAdminEvent(realmId, resource.toString(), operation.toString(), ""); - } - - @DisplayName("replace(false) - with error") - @Test - void noReplaceWithError() { - - var realmId = UUID.randomUUID().toString(); - var resource = ResourceType.USER; - var operation = OperationType.CREATE; - var error = UUID.randomUUID().toString(); - - listener(false).onEvent(toAdminEvent(realmId, resource, operation, error), false); - assertAdminEvent(realmId, resource.toString(), operation.toString(), error); - } - - @DisplayName("replace(false) - all fields empty") - @Test - void noReplaceFieldsEmpty() { - listener(false).onEvent(toAdminEvent(null, null, null, null), false); - assertAdminEvent("", "", "", ""); - } - - private AdminEvent toAdminEvent(String realmId, ResourceType resource, OperationType operation, String error) { - var event = new AdminEvent(); - event.setRealmId(realmId); - event.setResourceType(resource); - event.setOperationType(operation); - event.setError(error); - return event; - } - - private void assertAdminEvent(String realm, String resource, String operation, String error) { - assertCounter("keycloak_event_admin", - "realm", realm, - "resource", resource, - "operation", operation, - "error", error); - } - } - - private MetricsEventListener listener(boolean replace) { - return new MetricsEventListener(replace, session); - } - - private static void assertCounter(String metric, String... tags) { - var counter = Metrics.globalRegistry.counter(metric, tags); - assertEquals(1D, counter.count(), "micrometer.counter.count"); - assertEquals(0, Metrics.globalRegistry - .getMeters().stream() - .filter(meter -> meter != counter) - .count(), - "other meter found"); - } -} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/AbstractMockitoTest.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/AbstractMockitoTest.java deleted file mode 100644 index 1a3d597..0000000 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/AbstractMockitoTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package io.kokuwa.keycloak.metrics.junit; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.ClassOrderer; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.TestClassOrder; -import org.junit.jupiter.api.TestMethodOrder; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -/** - * Mockito base class with configured logging. - * - * @author Stephan Schnabel - */ -@ExtendWith(MockitoExtension.class) -@TestClassOrder(ClassOrderer.DisplayName.class) -@TestMethodOrder(MethodOrderer.DisplayName.class) -public abstract class AbstractMockitoTest { - - private static final List LOGS = new ArrayList<>(); - - static { - - System.setProperty("org.jboss.logging.provider", "jdk"); - System.setProperty("java.util.logging.SimpleFormatter.format", "%1$tT %4$-5s %2$s %5$s%6$s%n"); - - Logger.getLogger("org.junit").setLevel(Level.INFO); - Logger.getLogger("").setLevel(Level.ALL); - Logger.getLogger("").addHandler(new Handler() { - - @Override - public void publish(LogRecord log) { - LOGS.add(log); - } - - @Override - public void flush() {} - - @Override - public void close() {} - }); - } - - @BeforeEach - void reset() { - Metrics.globalRegistry.clear(); - Metrics.addRegistry(new SimpleMeterRegistry()); - LOGS.clear(); - } - - public static void assertLog(Level level, String message) { - assertTrue(LOGS.stream() - .filter(l -> l.getLevel().equals(level)) - .filter(l -> l.getMessage().equals(message)) - .findAny().isPresent(), - "log with level " + level + " and message " + message + " not found"); - } -} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java index b79ca14..6507440 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java @@ -1,33 +1,19 @@ package io.kokuwa.keycloak.metrics.junit; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpRequest.BodyPublishers; -import java.net.http.HttpResponse.BodyHandlers; import java.util.List; import java.util.Map; -import java.util.UUID; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.core.MultivaluedHashMap; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.token.TokenService; -import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; -import com.fasterxml.jackson.databind.ObjectMapper; - /** * Client for keycloak. * @@ -36,75 +22,51 @@ import com.fasterxml.jackson.databind.ObjectMapper; public class KeycloakClient { private final Keycloak keycloak; - private final TokenService tokenService; + private final TokenService token; - private final ObjectMapper mapper = new ObjectMapper(); - private final HttpClient client = HttpClient.newHttpClient(); - private final String url; - private final String adminToken; - - KeycloakClient(String url, Keycloak keycloak, TokenService tokenService) { + KeycloakClient(Keycloak keycloak, TokenService token) { this.keycloak = keycloak; - this.tokenService = tokenService; - this.url = url; - this.adminToken = login("admin-cli", "master", "admin", "password").getToken(); + this.token = token; } - public void createRealm(String realmName) { + public String createRealm(String realmName) { + var client = new ClientRepresentation(); + client.setClientId("test"); + client.setPublicClient(true); + client.setDirectAccessGrantsEnabled(true); var realm = new RealmRepresentation(); - realm.setId(UUID.randomUUID().toString()); realm.setEnabled(true); realm.setRealm(realmName); realm.setEventsListeners(List.of("metrics-listener")); + realm.setClients(List.of(client)); keycloak.realms().create(realm); - } - - public void createClient(String realmName, String clientId) { - var client = new ClientRepresentation(); - client.setId(UUID.randomUUID().toString()); - client.setClientId(clientId); - client.setPublicClient(true); - client.setDirectAccessGrantsEnabled(true); - var response = keycloak.realms().realm(realmName).clients().create(client); - assertEquals(201, response.getStatus()); + return keycloak.realms().realm(realmName).toRepresentation().getId(); } public void createUser(String realmName, String username, String password) { + var credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(false); + var user = new UserRepresentation(); + user.setEnabled(true); + user.setEmail(username + "@example.org"); + user.setEmailVerified(true); + user.setUsername(username); + user.setCredentials(List.of(credential)); + keycloak.realms().realm(realmName).users().create(user); + } + + public boolean login(String realmName, String username, String password) { try { - var response = client.send(HttpRequest.newBuilder() - .uri(URI.create(url + "/admin/realms/" + realmName + "/users")) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) - .POST(BodyPublishers.ofString(mapper.writeValueAsString(Map.of( - "enabled", true, - "emailVerified", true, - "email", username + "@example.org", - "username", username, - "firstName", username, - "lastName", username, - "credentials", List.of(Map.of( - "type", CredentialRepresentation.PASSWORD, - "value", password, - "temporary", false)))))) - .build(), BodyHandlers.ofString()); - assertEquals(201, response.statusCode(), "Body: " + response.body()); - } catch (IOException | InterruptedException e) { - fail("Failed to create user", e); + token.grantToken(realmName, new MultivaluedHashMap<>(Map.of( + OAuth2Constants.CLIENT_ID, "test", + OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD, + OAuth2Constants.USERNAME, username, + OAuth2Constants.PASSWORD, password))); + return true; + } catch (NotAuthorizedException e) { + return false; } } - - public void deleteUser(String realmName, String username) { - keycloak.realms().realm(realmName).users() - .searchByUsername(username, true).stream() - .map(UserRepresentation::getId) - .forEach(keycloak.realms().realm(realmName).users()::delete); - } - - public AccessTokenResponse login(String clientId, String realmName, String username, String password) { - return tokenService.grantToken(realmName, new MultivaluedHashMap<>(Map.of( - OAuth2Constants.CLIENT_ID, clientId, - OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD, - OAuth2Constants.USERNAME, username, - OAuth2Constants.PASSWORD, password))); - } } diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java index aa179f7..31ffe6c 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java @@ -7,7 +7,7 @@ import java.time.Duration; import java.util.Properties; import java.util.Set; -import jakarta.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.ClientBuilder; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -15,11 +15,13 @@ import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolver; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.token.TokenService; -import org.testcontainers.containers.FixedHostPortGenericContainer; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.MountableFile; +import io.kokuwa.keycloak.metrics.prometheus.Prometheus; +import io.kokuwa.keycloak.metrics.prometheus.PrometheusClient; + /** * JUnit extension to start keycloak. * @@ -46,33 +48,26 @@ public class KeycloakExtension implements BeforeAllCallback, ParameterResolver { throw new Exception("Failed to read properties", e); } var version = properties.getProperty("version"); - var image = "quay.io/keycloak/keycloak:" + version; var jar = properties.getProperty("jar"); var timeout = properties.getProperty("timeout"); - // create and start container - use fixed port in ci + // create and start container - @SuppressWarnings({ "resource", "deprecation" }) - var container = (System.getenv("CI") == null - ? new GenericContainer<>(image).withExposedPorts(8080) - : new FixedHostPortGenericContainer<>(image).withFixedExposedPort(8080, 8080)); + @SuppressWarnings("resource") + var container = new GenericContainer<>("quay.io/keycloak/keycloak:" + version) + .withEnv("KEYCLOAK_ADMIN", "admin") + .withEnv("KEYCLOAK_ADMIN_PASSWORD", "password") + .withEnv("KC_LOG_CONSOLE_COLOR", "true") + .withEnv("KC_HEALTH_ENABLED", "true") + .withEnv("KC_METRICS_ENABLED", "true") + .withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar") + .withLogConsumer(out -> System.out.print(out.getUtf8String())) + .withExposedPorts(8080) + .withStartupTimeout(Duration.parse(timeout)) + .waitingFor(Wait.forHttp("/health").forPort(8080)) + .withCommand("start-dev"); try { - container - .withEnv("KEYCLOAK_ADMIN", "admin") - .withEnv("KEYCLOAK_ADMIN_PASSWORD", "password") - .withEnv("KC_LOG_LEVEL", "io.kokuwa:trace") - // otherwise port 9000 will be used, with this config we can test different keycloak versions - .withEnv("KC_LEGACY_OBSERVABILITY_INTERFACE", "true") - .withEnv("KC_HEALTH_ENABLED", "true") - .withEnv("KC_METRICS_ENABLED", "true") - .withEnv("KC_METRICS_STATS_ENABLED", "true") - .withEnv("KC_METRICS_STATS_INTERVAL", "PT1s") - .withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar") - .withLogConsumer(out -> System.out.print(out.getUtf8String())) - .withStartupTimeout(Duration.parse(timeout)) - .waitingFor(Wait.forHttp("/health").forPort(8080).withStartupTimeout(Duration.ofMinutes(10))) - .withCommand("start-dev") - .start(); + container.start(); } catch (RuntimeException e) { throw new Exception("Failed to start keycloak", e); } @@ -85,7 +80,7 @@ public class KeycloakExtension implements BeforeAllCallback, ParameterResolver { var target = ClientBuilder.newClient().target(url); var token = Keycloak.getClientProvider().targetProxy(target, TokenService.class); prometheus = new Prometheus(Keycloak.getClientProvider().targetProxy(target, PrometheusClient.class)); - client = new KeycloakClient(url, keycloak, token); + client = new KeycloakClient(keycloak, token); } @Override diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/Prometheus.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java similarity index 80% rename from src/test/java/io/kokuwa/keycloak/metrics/junit/Prometheus.java rename to src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java index 74a0cb9..d249edd 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/Prometheus.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java @@ -1,4 +1,4 @@ -package io.kokuwa.keycloak.metrics.junit; +package io.kokuwa.keycloak.metrics.prometheus; import java.util.HashSet; import java.util.Map; @@ -31,20 +31,11 @@ public class Prometheus { .sum(); } - public int userEvent(EventType type, String realmName, String clientId) { + public int userEvent(EventType type, String realmName) { return state.stream() .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total")) .filter(metric -> Objects.equals(metric.tags().get("type"), type.toString())) .filter(metric -> Objects.equals(metric.tags().get("realm"), realmName)) - .filter(metric -> Objects.equals(metric.tags().get("client"), clientId)) - .mapToInt(metric -> metric.value().intValue()) - .sum(); - } - - public int userCount(String realm) { - return state.stream() - .filter(metric -> Objects.equals(metric.name(), "keycloak_users")) - .filter(metric -> Objects.equals(metric.tags().get("realm"), realm)) .mapToInt(metric -> metric.value().intValue()) .sum(); } diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusClient.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java similarity index 54% rename from src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusClient.java rename to src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java index 94f17e8..2355f3f 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusClient.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java @@ -1,9 +1,9 @@ -package io.kokuwa.keycloak.metrics.junit; +package io.kokuwa.keycloak.metrics.prometheus; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.MediaType; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.MediaType; /** * JAX-RS client for prometheus endpoint. diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusMetric.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java similarity index 86% rename from src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusMetric.java rename to src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java index 9996372..a79f6c8 100644 --- a/src/test/java/io/kokuwa/keycloak/metrics/junit/PrometheusMetric.java +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java @@ -1,4 +1,4 @@ -package io.kokuwa.keycloak.metrics.junit; +package io.kokuwa.keycloak.metrics.prometheus; import java.util.Map; diff --git a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryTest.java b/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryTest.java deleted file mode 100644 index 1a8a794..0000000 --- a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsFactoryTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Duration; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.platform.commons.util.ReflectionUtils; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.KeycloakTransactionManager; -import org.keycloak.timer.TimerProvider; -import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; -import org.mockito.Mock; -import org.mockito.Spy; - -import io.kokuwa.keycloak.metrics.junit.AbstractMockitoTest; - -/** - * Test for {@link MetricsStatsFactory} with Mockito. - * - * @author Stephan Schnabel - */ -@DisplayName("metrics: factory") -public class MetricsStatsFactoryTest extends AbstractMockitoTest { - - @Spy - MetricsStatsFactoryImpl factory; - @Mock - KeycloakSessionFactory sessionFactory; - @Mock - KeycloakSession session; - - @DisplayName("disabled") - @Test - void disabled() { - factory.init(null); - factory.postInit(sessionFactory); - assertNull(factory.create(session)); - factory.close(); - } - - @DisplayName("enabled - with default values") - @Test - void enabledDefault() { - when(factory.getenv("KC_METRICS_STATS_ENABLED")).thenReturn("true"); - when(factory.getenv("KC_METRICS_STATS_INTERVAL")).thenReturn(null); - when(factory.getenv("KC_METRICS_STATS_INFO_THRESHOLD")).thenReturn(null); - when(factory.getenv("KC_METRICS_STATS_WARN_THRESHOLD")).thenReturn(null); - assertTask(Duration.ofSeconds(60), Duration.ofSeconds(30), Duration.ofSeconds(45)); - } - - @DisplayName("enabled - with custom interval") - @Test - void enabledCustomInterval() { - when(factory.getenv("KC_METRICS_STATS_ENABLED")).thenReturn("true"); - when(factory.getenv("KC_METRICS_STATS_INTERVAL")).thenReturn("PT300s"); - when(factory.getenv("KC_METRICS_STATS_INFO_THRESHOLD")).thenReturn(null); - when(factory.getenv("KC_METRICS_STATS_WARN_THRESHOLD")).thenReturn(null); - assertTask(Duration.ofSeconds(300), Duration.ofSeconds(150), Duration.ofSeconds(225)); - } - - @DisplayName("enabled - with custom thresholds") - @Test - void enabledCustomThresholds() { - when(factory.getenv("KC_METRICS_STATS_ENABLED")).thenReturn("true"); - when(factory.getenv("KC_METRICS_STATS_INTERVAL")).thenReturn(null); - when(factory.getenv("KC_METRICS_STATS_INFO_THRESHOLD")).thenReturn("PT40s"); - when(factory.getenv("KC_METRICS_STATS_WARN_THRESHOLD")).thenReturn("PT50s"); - assertTask(Duration.ofSeconds(60), Duration.ofSeconds(40), Duration.ofSeconds(50)); - } - - private void assertTask(Duration interval, Duration infoThreshold, Duration warnThreshold) { - - var timerProvider = mock(TimerProvider.class); - when(sessionFactory.create()).thenReturn(session); - when(session.getProvider(TimerProvider.class)).thenReturn(timerProvider); - when(session.getTransactionManager()).thenReturn(mock(KeycloakTransactionManager.class)); - - factory.postInit(sessionFactory); - - var taskCaptor = ArgumentCaptor.forClass(MetricsStatsTask.class); - verify(timerProvider).scheduleTask( - taskCaptor.capture(), - ArgumentMatchers.eq(interval.toMillis()), - ArgumentMatchers.eq("metrics")); - assertNotNull(taskCaptor.getValue(), "task"); - assertField(interval, taskCaptor.getValue(), "interval"); - assertField(infoThreshold, taskCaptor.getValue(), "infoThreshold"); - assertField(warnThreshold, taskCaptor.getValue(), "warnThreshold"); - } - - private void assertField(Duration expected, MetricsStatsTask task, String name) { - assertEquals( - expected, - assertDoesNotThrow(() -> ReflectionUtils.tryToReadFieldValue(MetricsStatsTask.class, name, task).get()), - "field " + name + " invalid"); - } -} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpiTest.java b/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpiTest.java deleted file mode 100644 index e318262..0000000 --- a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsSpiTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ServiceLoader; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import io.kokuwa.keycloak.metrics.junit.AbstractMockitoTest; - -/** - * Test for {@link MetricsStatsSpi} with Mockito. - * - * @author Stephan Schnabel - */ -@DisplayName("metrics: spi") -public class MetricsStatsSpiTest extends AbstractMockitoTest { - - @Test - void test() { - - var spi = new MetricsStatsSpi(); - assertEquals("metrics", spi.getName(), "getName()"); - assertFalse(spi.isInternal(), "isInternal()"); - assertNotNull(spi.getProviderClass(), "getProviderClass()"); - assertTrue(spi.getProviderFactoryClass().isInterface(), "getProviderFactoryClass() - should be an interface"); - - var factory = ServiceLoader.load(spi.getProviderFactoryClass()).findFirst().orElse(null); - assertNotNull(factory, "failed to read factory with service loader"); - assertEquals(MetricsStatsFactoryImpl.class, factory.getClass(), "factory.class"); - assertEquals("default", factory.getId(), "factory.id"); - } -} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTaskTest.java b/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTaskTest.java deleted file mode 100644 index 9d696e7..0000000 --- a/src/test/java/io/kokuwa/keycloak/metrics/stats/MetricsStatsTaskTest.java +++ /dev/null @@ -1,231 +0,0 @@ -package io.kokuwa.keycloak.metrics.stats; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import java.util.Map; -import java.util.UUID; -import java.util.logging.Level; -import java.util.stream.Stream; - -import org.hibernate.exception.SQLGrammarException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.keycloak.models.ClientModel; -import org.keycloak.models.ClientProvider; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RealmProvider; -import org.keycloak.models.UserProvider; -import org.keycloak.models.UserSessionProvider; -import org.mockito.Mock; - -import io.kokuwa.keycloak.metrics.junit.AbstractMockitoTest; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.Metrics; - -/** - * Test for {@link MetricsStatsTask} with Mockito. - * - * @author Stephan Schnabel - */ -@DisplayName("metrics: task") -public class MetricsStatsTaskTest extends AbstractMockitoTest { - - @Mock - KeycloakSession session; - @Mock - RealmProvider realmProvider; - @Mock - UserProvider userProvider; - @Mock - UserSessionProvider sessionProvider; - @Mock - ClientProvider clientProvider; - - @BeforeEach - void setup() { - when(session.realms()).thenReturn(realmProvider); - } - - @DisplayName("catch - nullpointer") - @Test - void catchNPE() { - when(session.realms()).thenThrow(NullPointerException.class); - task().run(session); - assertLog(Level.SEVERE, "Failed to scrape stats."); - } - - @DisplayName("catch - database") - @Test - void catchDatabase() { - when(session.realms()).thenThrow(SQLGrammarException.class); - task().run(session); - assertLog(Level.INFO, "Metrics status task skipped, database not ready."); - } - - @DisplayName("log - debug") - @Test - void logDebug() { - when(realmProvider.getRealmsStream()).thenReturn(Stream.of()); - task(Duration.ofMillis(300), Duration.ofMillis(100), Duration.ofMillis(200)).run(session); - assertLog(Level.FINE, "Finished scrapping keycloak stats in {0}."); - } - - @DisplayName("log - info") - @Test - void logInfo() { - when(realmProvider.getRealmsStream()).thenReturn(Stream.of()); - task(Duration.ofMillis(300), Duration.ZERO, Duration.ofMillis(200)).run(session); - assertLog(Level.INFO, "Finished scrapping keycloak stats in {0}."); - } - - @DisplayName("log - warn") - @Test - void logWarn() { - when(realmProvider.getRealmsStream()).thenReturn(Stream.of()); - task(Duration.ofMillis(300), Duration.ofMillis(100), Duration.ZERO).run(session); - assertLog(Level.WARNING, "Finished scrapping keycloak stats in {0}, consider to increase interval."); - } - - @DisplayName("log - error") - @Test - void logError() { - when(realmProvider.getRealmsStream()).thenReturn(Stream.of()); - task(Duration.ZERO, Duration.ofMillis(100), Duration.ofMillis(200)).run(session); - assertLog(Level.SEVERE, "Finished scrapping keycloak stats in {0}, consider to increase interval."); - } - - @DisplayName("scrape") - @Test - void scrape() { - - var realm = UUID.randomUUID().toString(); - var realmModel = mock(RealmModel.class); - var client1 = UUID.randomUUID().toString(); - var client1Id = UUID.randomUUID().toString(); - var client1Model = mock(ClientModel.class); - var client2 = UUID.randomUUID().toString(); - var client2Id = UUID.randomUUID().toString(); - var client2Model = mock(ClientModel.class); - when(realmModel.getName()).thenReturn(realm); - when(realmModel.getClientsStream()).then(i -> Stream.of(client1Model, client2Model)); - when(client1Model.getId()).thenReturn(client1Id); - when(client1Model.getClientId()).thenReturn(client1); - when(client2Model.getId()).thenReturn(client2Id); - when(client2Model.getClientId()).thenReturn(client2); - - when(session.clients()).thenReturn(clientProvider); - when(session.users()).thenReturn(userProvider); - when(session.sessions()).thenReturn(sessionProvider); - when(session.getContext()).thenReturn(mock(KeycloakContext.class)); - when(realmProvider.getRealmsStream()).then(i -> Stream.of(realmModel)); - - // empty realm - - when(userProvider.getUsersCount(realmModel)).thenReturn(0); - when(clientProvider.getClientsCount(realmModel)).thenReturn(0L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client1Model)).thenReturn(0L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client2Model)).thenReturn(0L); - when(sessionProvider.getActiveUserSessions(realmModel, client1Model)).thenReturn(0L); - when(sessionProvider.getActiveUserSessions(realmModel, client2Model)).thenReturn(0L); - when(sessionProvider.getActiveClientSessionStats(realmModel, false)).thenReturn(Map.of()); - task().run(session); - assertUsersCount(realmModel, 0); - assertClientsCount(realmModel, 0); - assertOfflineSessions(realmModel, client1Model, null); - assertOfflineSessions(realmModel, client2Model, null); - assertActiveUserSessions(realmModel, client1Model, null); - assertActiveUserSessions(realmModel, client2Model, null); - assertActiveClientSessions(realmModel, client1Model, null); - assertActiveClientSessions(realmModel, client2Model, null); - - // initial values - - when(userProvider.getUsersCount(realmModel)).thenReturn(10); - when(clientProvider.getClientsCount(realmModel)).thenReturn(20L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client1Model)).thenReturn(0L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client2Model)).thenReturn(1L); - when(sessionProvider.getActiveUserSessions(realmModel, client1Model)).thenReturn(2L); - when(sessionProvider.getActiveUserSessions(realmModel, client2Model)).thenReturn(3L); - when(sessionProvider.getActiveClientSessionStats(realmModel, false)) - .thenReturn(Map.of(client1Id, 5L, client2Id, 0L)); - task().run(session); - assertUsersCount(realmModel, 10); - assertClientsCount(realmModel, 20); - assertOfflineSessions(realmModel, client1Model, null); - assertOfflineSessions(realmModel, client2Model, 1); - assertActiveUserSessions(realmModel, client1Model, 2); - assertActiveUserSessions(realmModel, client2Model, 3); - assertActiveClientSessions(realmModel, client1Model, 5); - assertActiveClientSessions(realmModel, client2Model, null); - - // updated values - - when(userProvider.getUsersCount(realmModel)).thenReturn(11); - when(clientProvider.getClientsCount(realmModel)).thenReturn(19L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client1Model)).thenReturn(3L); - when(sessionProvider.getOfflineSessionsCount(realmModel, client2Model)).thenReturn(2L); - when(sessionProvider.getActiveUserSessions(realmModel, client1Model)).thenReturn(1L); - when(sessionProvider.getActiveUserSessions(realmModel, client2Model)).thenReturn(0L); - when(sessionProvider.getActiveClientSessionStats(realmModel, false)) - .thenReturn(Map.of(client1Id, 4L, client2Id, 3L)); - task().run(session); - assertUsersCount(realmModel, 11); - assertClientsCount(realmModel, 19); - assertOfflineSessions(realmModel, client1Model, 3); - assertOfflineSessions(realmModel, client2Model, 2); - assertActiveUserSessions(realmModel, client1Model, 1); - assertActiveUserSessions(realmModel, client2Model, 0); - assertActiveClientSessions(realmModel, client1Model, 4); - assertActiveClientSessions(realmModel, client2Model, 3); - } - - private MetricsStatsTask task() { - return task(Duration.ofMillis(300), Duration.ofMillis(100), Duration.ofMillis(200)); - } - - private MetricsStatsTask task(Duration interval, Duration infoThreshold, Duration warnThreshold) { - return new MetricsStatsTask(interval, infoThreshold, warnThreshold); - } - - private static void assertUsersCount(RealmModel realm, int count) { - assertGauge("keycloak_users", realm, null, count); - } - - private static void assertClientsCount(RealmModel realm, int count) { - assertGauge("keycloak_clients", realm, null, count); - } - - private static void assertActiveClientSessions(RealmModel realm, ClientModel client, Integer count) { - assertGauge("keycloak_active_client_sessions", realm, client, count); - } - - private static void assertActiveUserSessions(RealmModel realm, ClientModel client, Integer count) { - assertGauge("keycloak_active_user_sessions", realm, client, count); - } - - private static void assertOfflineSessions(RealmModel realm, ClientModel client, Integer count) { - assertGauge("keycloak_offline_sessions", realm, client, count); - } - - private static void assertGauge(String name, RealmModel realm, ClientModel client, Integer count) { - var gauges = Metrics.globalRegistry.getMeters().stream() - .filter(Gauge.class::isInstance) - .filter(gauge -> gauge.getId().getName().equals(name)) - .filter(gauge -> gauge.getId().getTag("realm").equals(realm.getName())) - .filter(gauge -> client == null || gauge.getId().getTag("client").equals(client.getClientId())) - .map(Gauge.class::cast) - .toList(); - if (count == null) { - assertEquals(0, gauges.size()); - } else { - assertEquals(1, gauges.size()); - assertEquals(count.doubleValue(), gauges.get(0).value()); - } - } -} diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties index 9d19498..d66d19f 100644 --- a/src/test/resources/test.properties +++ b/src/test/resources/test.properties @@ -1,3 +1,3 @@ -version=${version.org.keycloak.test} +version=${version.org.keycloak} timeout=PT5m jar=${project.build.directory}/${project.build.finalName}.jar