From 5d7397a1559e823e76ce4ae708a55b698f17593f Mon Sep 17 00:00:00 2001 From: Sandhya1874 <39799075+Sandhya1874@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:11:42 +0000 Subject: [PATCH] Initial commit --- .github/workflows/pipeline.yml | 109 ++++++++ .../workflows/rename_template_project_pr.yml | 43 +++ .github/workflows/security_owasp.yml | 12 + .github/workflows/security_trivy.yml | 12 + .../security_veracode_pipeline_scan.yml | 12 + .../security_veracode_policy_scan.yml | 12 + .gitignore | 73 +++++ .trivyignore | 12 + Dockerfile | 34 +++ LICENSE | 21 ++ README.md | 136 ++++++++++ applicationinsights.dev.json | 32 +++ applicationinsights.json | 32 +++ build.gradle.kts | 30 +++ docker-compose.yml | 35 +++ gradle.properties | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ helm_deploy/.helmignore | 22 ++ helm_deploy/README.md | 6 + helm_deploy/hmpps-template-kotlin/.helmignore | 21 ++ helm_deploy/hmpps-template-kotlin/Chart.yaml | 12 + helm_deploy/hmpps-template-kotlin/values.yaml | 42 +++ helm_deploy/values-dev.yaml | 20 ++ helm_deploy/values-preprod.yaml | 20 ++ helm_deploy/values-prod.yaml | 17 ++ rename-project.bash | 111 ++++++++ renovate.json | 13 + settings.gradle.kts | 1 + .../HmppsTemplateKotlin.kt | 11 + .../HmppsTemplateKotlinExceptionHandler.kt | 65 +++++ .../config/OpenApiConfiguration.kt | 58 ++++ .../config/WebClientConfiguration.kt | 32 +++ .../health/HealthPingCheck.kt | 15 ++ .../resource/ExampleResource.kt | 77 ++++++ .../service/ExampleApiService.kt | 37 +++ src/main/resources/application-dev.yml | 10 + src/main/resources/application.yml | 68 +++++ src/main/resources/banner.txt | 13 + src/main/resources/logback-spring.xml | 43 +++ .../integration/ExampleResourceIntTest.kt | 129 +++++++++ .../integration/IntegrationTestBase.kt | 37 +++ .../integration/NotFoundTest.kt | 14 + .../integration/OpenApiDocsTest.kt | 105 ++++++++ .../integration/ResourceSecurityTest.kt | 59 ++++ .../integration/health/HealthCheckTest.kt | 68 +++++ .../integration/health/InfoTest.kt | 31 +++ .../wiremock/ExampleApiMockServer.kt | 58 ++++ .../wiremock/HmppsAuthMockServer.kt | 70 +++++ src/test/resources/application-test.yml | 17 ++ 52 files changed, 2268 insertions(+) create mode 100644 .github/workflows/pipeline.yml create mode 100644 .github/workflows/rename_template_project_pr.yml create mode 100644 .github/workflows/security_owasp.yml create mode 100644 .github/workflows/security_trivy.yml create mode 100644 .github/workflows/security_veracode_pipeline_scan.yml create mode 100644 .github/workflows/security_veracode_policy_scan.yml create mode 100644 .gitignore create mode 100644 .trivyignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 applicationinsights.dev.json create mode 100644 applicationinsights.json create mode 100644 build.gradle.kts create mode 100644 docker-compose.yml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 helm_deploy/.helmignore create mode 100644 helm_deploy/README.md create mode 100644 helm_deploy/hmpps-template-kotlin/.helmignore create mode 100644 helm_deploy/hmpps-template-kotlin/Chart.yaml create mode 100644 helm_deploy/hmpps-template-kotlin/values.yaml create mode 100644 helm_deploy/values-dev.yaml create mode 100644 helm_deploy/values-preprod.yaml create mode 100644 helm_deploy/values-prod.yaml create mode 100755 rename-project.bash create mode 100644 renovate.json create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/HmppsTemplateKotlin.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/OpenApiConfiguration.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/WebClientConfiguration.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/health/HealthPingCheck.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/resource/ExampleResource.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/service/ExampleApiService.kt create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/banner.txt create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ExampleResourceIntTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/IntegrationTestBase.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/NotFoundTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/OpenApiDocsTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ResourceSecurityTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/HealthCheckTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/InfoTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/ExampleApiMockServer.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/HmppsAuthMockServer.kt create mode 100644 src/test/resources/application-test.yml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..02c7ac5 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,109 @@ +name: Pipeline [test -> build -> deploy] + +on: + push: + branches: + - '**' + + workflow_dispatch: + inputs: + environment: + description: Environment + type: choice + required: true + options: + - dev + - preprod + - staging + - production + default: 'dev' + docker_registry: + description: Docker registry + required: true + default: 'ghcr.io' + type: choice + options: + - 'ghcr.io' + - 'quay.io' + registry_org: + description: Docker registry organisation + required: true + default: 'ministryofjustice' + type: choice + options: + - 'ministryofjustice' + - 'hmpps' + additional_docker_tag: + description: Additional docker tag that can be used to specify stable tags + required: false + default: '' + type: string + push: + description: Push docker image to registry flag + required: true + default: true + type: boolean + docker_multiplatform: + description: docker multiplatform build or not + required: true + default: true + type: boolean + +permissions: + contents: read + packages: write + +jobs: + helm_lint: + name: helm lint + uses: ministryofjustice/hmpps-github-actions/.github/workflows/test_helm_lint.yml@v1 # WORKFLOW VERSION + secrets: inherit + with: + environment: ${{ inputs.environment || 'dev' }} + kotlin_validate: + name: Validate the kotlin + uses: ministryofjustice/hmpps-github-actions/.github/workflows/kotlin_validate.yml@v1 # WORKFLOW_VERSION + secrets: inherit + build: + name: Build docker image from hmpps-github-actions + if: github.ref == 'refs/heads/main' + uses: ministryofjustice/hmpps-github-actions/.github/workflows/docker_build.yml@v1 # WORKFLOW_VERSION + needs: + - kotlin_validate + with: + docker_registry: ${{ inputs.docker_registry || 'ghcr.io' }} + registry_org: ${{ inputs.registry_org || 'ministryofjustice' }} + additional_docker_tag: ${{ inputs.additional_docker_tag }} + push: ${{ inputs.push || true }} + docker_multiplatform: ${{ inputs.docker_multiplatform || true }} + deploy_dev: + name: Deploy to dev environment + needs: + - build + - helm_lint + uses: ministryofjustice/hmpps-github-actions/.github/workflows/deploy_env.yml@v1 # WORKFLOW_VERSION + secrets: inherit + with: + environment: 'dev' + app_version: '${{ needs.build.outputs.app_version }}' + + # deploy_preprod: + # name: Deploy to pre-production environment + # needs: + # - build + # - deploy_dev + # uses: ministryofjustice/hmpps-github-actions/.github/workflows/deploy_env.yml@v1 # WORKFLOW_VERSION + # secrets: inherit + # with: + # environment: 'preprod' + # app_version: '${{ needs.build.outputs.app_version }}' + # deploy_prod: + # name: Deploy to production environment + # needs: + # - build + # - deploy_preprod + # uses: ministryofjustice/hmpps-github-actions/.github/workflows/deploy_env.yml@v1 # WORKFLOW_VERSION + # secrets: inherit + # with: + # environment: 'prod' + # app_version: '${{ needs.build.outputs.app_version }}' \ No newline at end of file diff --git a/.github/workflows/rename_template_project_pr.yml b/.github/workflows/rename_template_project_pr.yml new file mode 100644 index 0000000..cc85dd2 --- /dev/null +++ b/.github/workflows/rename_template_project_pr.yml @@ -0,0 +1,43 @@ +name: rename-project-create-pr + +on: + workflow_dispatch: + inputs: + product_id: + description: 'Product ID: provide an ID for the product this app/component belongs too. Refer to the developer portal.' + required: true + slack_releases_channel: + description: 'Slack channel for release notifications.' + required: true + security_alerts_slack_channel_id: + description: 'Slack channel for pipeline security notifications.' + required: true + non_prod_alerts_prometheus_severity_label: + description: 'Non-prod kubernetes alerts. The severity label used by prometheus to route alert notifications to slack. See cloud-platform user guide.' + required: true + default: 'digital-prison-service-dev' + prod_alerts_prometheus_severity_label: + description: 'Production kubernetes alerts. The severity label used by prometheus to route alert notifications to slack. See cloud-platform user guide.' + required: true + default: 'digital-prison-service' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run rename-project script + run: ./rename-project.bash ${{ github.event.repository.name }} ${{ inputs.slack_releases_channel }} ${{ inputs.security_alerts_slack_channel_id }} ${{ inputs.non_prod_alerts_prometheus_severity_label }} ${{ inputs.prod_alerts_prometheus_severity_label }} ${{ inputs.product_id }} + + - name: Delete this github actions workflow + run: rm .github/workflows/rename_template_project* + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + commit-message: updating project name after deployment from template repository + title: Update template project name/references + body: Update all references to project name after deploying from template repository + branch: rename_template_project + base: main diff --git a/.github/workflows/security_owasp.yml b/.github/workflows/security_owasp.yml new file mode 100644 index 0000000..6abf635 --- /dev/null +++ b/.github/workflows/security_owasp.yml @@ -0,0 +1,12 @@ +name: Security OWASP dependency check +on: + workflow_dispatch: + schedule: + - cron: "19 6 * * MON-FRI" # Every weekday +jobs: + security-kotlin-owasp-check: + name: Kotlin security OWASP dependency check + uses: ministryofjustice/hmpps-github-actions/.github/workflows/security_owasp.yml@v0.7 # WORKFLOW_VERSION + with: + channel_id: C05J915DX0Q + secrets: inherit diff --git a/.github/workflows/security_trivy.yml b/.github/workflows/security_trivy.yml new file mode 100644 index 0000000..4e80707 --- /dev/null +++ b/.github/workflows/security_trivy.yml @@ -0,0 +1,12 @@ +name: Security trivy dependency check +on: + workflow_dispatch: + schedule: + - cron: "19 6 * * MON-FRI" # Every weekday +jobs: + security-kotlin-trivy-check: + name: Project security trivy dependency check + uses: ministryofjustice/hmpps-github-actions/.github/workflows/security_trivy.yml@v0.7 # WORKFLOW_VERSION + with: + channel_id: C05J915DX0Q + secrets: inherit diff --git a/.github/workflows/security_veracode_pipeline_scan.yml b/.github/workflows/security_veracode_pipeline_scan.yml new file mode 100644 index 0000000..c825bdd --- /dev/null +++ b/.github/workflows/security_veracode_pipeline_scan.yml @@ -0,0 +1,12 @@ +name: Security veracode pipeline scan +on: + workflow_dispatch: + schedule: + - cron: "19 6 * * MON-FRI" # Every weekday +jobs: + security-veracode-pipeline-scan: + name: Project security veracode pipeline scan + uses: ministryofjustice/hmpps-github-actions/.github/workflows/security_veracode_pipeline_scan.yml@v0.7 # WORKFLOW_VERSION + with: + channel_id: C05J915DX0Q + secrets: inherit diff --git a/.github/workflows/security_veracode_policy_scan.yml b/.github/workflows/security_veracode_policy_scan.yml new file mode 100644 index 0000000..f574796 --- /dev/null +++ b/.github/workflows/security_veracode_policy_scan.yml @@ -0,0 +1,12 @@ +name: Security veracode policy scan +on: + workflow_dispatch: + schedule: + - cron: "34 6 * * 1" # Every Monday +jobs: + security-veracode-policy-check: + name: Project security veracode policy scan + uses: ministryofjustice/hmpps-github-actions/.github/workflows/security_veracode_policy_scan.yml@v0.7 # WORKFLOW_VERSION + with: + channel_id: C05J915DX0Q + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bad85f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/ +.gradle/ +.kotlin/ +build/ + +# CMake +cmake-build-debug/ + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ +*.iml + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Java template +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +Dockerrun.aws.json +dist + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/config.yml +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml + +# AWS DOcker build +ecr.repo +dps-gradle-spring-boot-suppressions.xml +.editorconfig +sonar-project.properties + +#Helm +**/Chart.lock diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..1ac1448 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,12 @@ +# WARNING - THIS FILE WAS GENERATED BY THE dps-gradle-spring-boot GRADLE PLUGIN +# AND ANY MANUAL CHANGES WILL BE OVERRIDDEN ON YOUR NEXT BUILD. +# +# To make general changes to the suppressions below, change the gradle plugin dps-gradle-spring-boot, +# publish a new version and update to the new version in your gradle build script +# +# To stop the dps-gradle-spring-boot project from overwriting any project specific customisations here, remove the +# warning at the top of this file. +# +# Suppression for h2 2.1.214 password on command line vulnerability +# can be suppressed as we only run h2 locally and not on build environments +CVE-2022-45868 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ef25a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-jammy AS builder + +ARG BUILD_NUMBER +ENV BUILD_NUMBER ${BUILD_NUMBER:-1_0_0} + +WORKDIR /app +ADD . . +RUN ./gradlew --no-daemon assemble + +FROM eclipse-temurin:21-jre-jammy +LABEL maintainer="HMPPS Digital Studio " + +ARG BUILD_NUMBER +ENV BUILD_NUMBER ${BUILD_NUMBER:-1_0_0} + +RUN apt-get update && \ + apt-get -y upgrade && \ + rm -rf /var/lib/apt/lists/* + +ENV TZ=Europe/London +RUN ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && echo "$TZ" > /etc/timezone + +RUN addgroup --gid 2000 --system appgroup && \ + adduser --uid 2000 --system appuser --gid 2000 + +WORKDIR /app +COPY --from=builder --chown=appuser:appgroup /app/build/libs/hmpps-template-kotlin*.jar /app/app.jar +COPY --from=builder --chown=appuser:appgroup /app/build/libs/applicationinsights-agent*.jar /app/agent.jar +COPY --from=builder --chown=appuser:appgroup /app/applicationinsights.json /app +COPY --from=builder --chown=appuser:appgroup /app/applicationinsights.dev.json /app + +USER 2000 + +ENTRYPOINT ["java", "-XX:+AlwaysActAsServerClassMachine", "-javaagent:/app/agent.jar", "-jar", "/app/app.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d27ec55 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2024 Crown Copyright (Ministry of Justice) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4955a5 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# hmpps-template-kotlin + +[![repo standards badge](https://img.shields.io/badge/endpoint.svg?&style=flat&logo=github&url=https%3A%2F%2Foperations-engineering-reports.cloud-platform.service.justice.gov.uk%2Fapi%2Fv1%2Fcompliant_public_repositories%2Fhmpps-template-kotlin)](https://operations-engineering-reports.cloud-platform.service.justice.gov.uk/public-report/hmpps-template-kotlin "Link to report") +[![Docker Repository on ghcr](https://img.shields.io/badge/ghcr.io-repository-2496ED.svg?logo=docker)](https://ghcr.io/ministryofjustice/hmpps-template-kotlin) +[![API docs](https://img.shields.io/badge/API_docs_-view-85EA2D.svg?logo=swagger)](https://hmpps-template-kotlin-dev.hmpps.service.justice.gov.uk/webjars/swagger-ui/index.html?configUrl=/v3/api-docs) + +Template github repo used for new Kotlin based projects. + +# Instructions + +If this is a HMPPS project then the project will be created as part of bootstrapping - +see [dps-project-bootstrap](https://github.com/ministryofjustice/dps-project-bootstrap). You are able to specify a +template application using the `github_template_repo` attribute to clone without the need to manually do this yourself +within GitHub. + +This project is community managed by the mojdt `#kotlin-dev` slack channel. +Please raise any questions or queries there. Contributions welcome! + +Our security policy is located [here](https://github.com/ministryofjustice/hmpps-template-kotlin/security/policy). + +Documentation to create new service is located [here](https://tech-docs.hmpps.service.justice.gov.uk/applicationplatform/newservice-GHA/). + +## Creating a Cloud Platform namespace + +When deploying to a new namespace, you may wish to use the +[templates project namespace](https://github.com/ministryofjustice/cloud-platform-environments/tree/main/namespaces/live.cloud-platform.service.justice.gov.uk/hmpps-templates-dev) +as the basis for your new namespace. This namespace contains both the kotlin and typescript template projects, +which is the usual way that projects are setup. + +Copy this folder and update all the existing namespace references to correspond to the environment to which you're deploying. + +If you only need the kotlin configuration then remove all typescript references and remove the elasticache configuration. + +To ensure the correct github teams can approve releases, you will need to make changes to the configuration in `resources/service-account-github` where the appropriate team names will need to be added (based on [lines 98-100](https://github.com/ministryofjustice/cloud-platform-environments/blob/main/namespaces/live.cloud-platform.service.justice.gov.uk/hmpps-templates-dev/resources/serviceaccount-github.tf#L98) and the reference appended to the teams list below [line 112](https://github.com/ministryofjustice/cloud-platform-environments/blob/main/namespaces/live.cloud-platform.service.justice.gov.uk/hmpps-templates-dev/resources/serviceaccount-github.tf#L112)). Note: hmpps-sre is in this list to assist with deployment issues. + +Submit a PR to the Cloud Platform team in +#ask-cloud-platform. Further instructions from the Cloud Platform team can be found in +the [Cloud Platform User Guide](https://user-guide.cloud-platform.service.justice.gov.uk/#cloud-platform-user-guide) + +## Renaming from HMPPS Template Kotlin - github Actions + +Once the new repository is deployed. Navigate to the repository in github, and select the `Actions` tab. +Click the link to `Enable Actions on this repository`. + +Find the Action workflow named: `rename-project-create-pr` and click `Run workflow`. This workflow will +execute the `rename-project.bash` and create Pull Request for you to review. Review the PR and merge. + +Note: ideally this workflow would run automatically however due to a recent change github Actions are not +enabled by default on newly created repos. There is no way to enable Actions other then to click the button in the UI. +If this situation changes we will update this project so that the workflow is triggered during the bootstrap project. +Further reading: + +The script takes six arguments: + +### New project name + +This should start with `hmpps-` e.g. `hmpps-prison-visits` so that it can be easily distinguished in github from +other departments projects. Try to avoid using abbreviations so that others can understand easily what your project is. + +### Slack channel for release notifications + +By default, release notifications are only enabled for production. The circleci configuration can be amended to send +release notifications for deployments to other environments if required. Note that if the configuration is amended, +the slack channel should then be amended to your own team's channel as `dps-releases` is strictly for production release +notifications. If the slack channel is set to something other than `dps-releases`, production release notifications +will still automatically go to `dps-releases` as well. This is configured by `releases-slack-channel` in +`.circleci/config.yml`. + +### Slack channel for pipeline security notifications + +Ths channel should be specific to your team and is for daily / weekly security scanning job results. It is your team's +responsibility to keep up-to-date with security issues and update your application so that these jobs pass. You will +only be notified if the jobs fail. The scan results can always be found in circleci for your project. This is +configured by `alerts-slack-channel` in `.circleci/config.yml`. + +### Non production kubernetes alerts + +By default Prometheus alerts are created in the application namespaces to monitor your application e.g. if your +application is crash looping, there are a significant number of errors from the ingress. Since Prometheus runs in +cloud platform AlertManager needs to be setup first with your channel. Please see +[Create your own custom alerts](https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html) +in the Cloud Platform user guide. Once that is setup then the `custom severity label` can be used for +`alertSeverity` in the `helm_deploy/values-*.yaml` configuration. + +Normally it is worth setting up two separate labels and therefore two separate slack channels - one for your production +alerts and one for your non-production alerts. Using the same channel can mean that production alerts are sometimes +lost within non-production issues. + +### Production kubernetes alerts + +This is the severity label for production, determined by the `custom severity label`. See the above +#non-production-kubernetes-alerts for more information. This is configured in `helm_deploy/values-prod.yaml`. + +### Product ID + +This is so that we can link a component to a product and thus provide team and product information in the Developer +Portal. Refer to the developer portal at https://developer-portal.hmpps.service.justice.gov.uk/products to find your +product id. This is configured in `helm_deploy//values.yaml`. + +## Manually branding from template app + +Run the `rename-project.bash` without any arguments. This will prompt for the six required parameters and create a PR. +The script requires a recent version of `bash` to be installed, as well as GNU `sed` in the path. + +## TODOs and Examples + +We have tried to provide some examples of best practice in the application - so there are lots of TODOs in the code +where changes are required to meet your requirements. There is an `ExampleResource` that includes best practice and also +serve as spring security examples. The template typescript project has a demonstration that calls this endpoint as well. + +For the demonstration, rather than introducing a dependency on a different service, this application calls out to +itself. This is only to show a service calling out to another service and is certainly not recommended! + +## Running the application locally + +The application comes with a `dev` spring profile that includes default settings for running locally. This is not +necessary when deploying to kubernetes as these values are included in the helm configuration templates - +e.g. `values-dev.yaml`. + +There is also a `docker-compose.yml` that can be used to run a local instance of the template in docker and also an +instance of HMPPS Auth (required if your service calls out to other services using a token). + +```bash +docker compose pull && docker compose up +``` + +will build the application and run it and HMPPS Auth within a local docker instance. + +### Running the application in Intellij + +```bash +docker compose pull && docker compose up --scale hmpps-template-kotlin=0 +``` + +will just start a docker instance of HMPPS Auth. The application should then be started with a `dev` active profile +in Intellij. diff --git a/applicationinsights.dev.json b/applicationinsights.dev.json new file mode 100644 index 0000000..92b3d80 --- /dev/null +++ b/applicationinsights.dev.json @@ -0,0 +1,32 @@ +{ + "role": { + "name": "hmpps-template-kotlin" + }, + "customDimensions": { + "service.version": "${BUILD_NUMBER}" + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "destination": "console" + }, + "sampling": { + "percentage": 100, + "overrides": [ + { + "telemetryType": "request", + "attributes": [ + { + "key": "http.url", + "value": "https?://[^/]+/health.*", + "matchType": "regexp" + } + ], + "percentage": 100 + } + ] + } +} diff --git a/applicationinsights.json b/applicationinsights.json new file mode 100644 index 0000000..439395d --- /dev/null +++ b/applicationinsights.json @@ -0,0 +1,32 @@ +{ + "role": { + "name": "hmpps-template-kotlin" + }, + "customDimensions": { + "service.version": "${BUILD_NUMBER}" + }, + "instrumentation": { + "logging": { + "level": "DEBUG" + } + }, + "selfDiagnostics": { + "destination": "console" + }, + "sampling": { + "percentage": 100, + "overrides": [ + { + "telemetryType": "request", + "attributes": [ + { + "key": "http.url", + "value": "https?://[^/]+/health.*", + "matchType": "regexp" + } + ], + "percentage": 10 + } + ] + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..f509911 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("uk.gov.justice.hmpps.gradle-spring-boot") version "6.0.6" + kotlin("plugin.spring") version "2.0.20" +} + +configurations { + testImplementation { exclude(group = "org.junit.vintage") } +} + +dependencies { + implementation("uk.gov.justice.service.hmpps:hmpps-kotlin-spring-boot-starter:1.0.7") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") + + testImplementation("uk.gov.justice.service.hmpps:hmpps-kotlin-spring-boot-starter-test:1.0.7") + testImplementation("org.wiremock:wiremock-standalone:3.9.1") + testImplementation("io.swagger.parser.v3:swagger-parser:2.1.22") { + exclude(group = "io.swagger.core.v3") + } +} + +kotlin { + jvmToolchain(21) +} + +tasks { + withType { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86609dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3" +services: + hmpps-template-kotlin: + build: + context: . + networks: + - hmpps + container_name: hmpps-template-kotlin + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/ping"] + environment: + - SERVER_PORT=8080 + - HMPPS_AUTH_URL=http://hmpps-auth:8080/auth + # TODO: Remove this URL and replace with outgoing service URLs + - EXAMPLE_URL=http://hmpps-template-kotlin:8080 + - SPRING_PROFILES_ACTIVE=dev + + hmpps-auth: + image: quay.io/hmpps/hmpps-auth:latest + networks: + - hmpps + container_name: hmpps-auth + ports: + - "8090:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/auth/health"] + environment: + - SERVER_PORT=8080 + - SPRING_PROFILES_ACTIVE=dev + - APPLICATION_AUTHENTICATION_UI_ALLOWLIST=0.0.0.0/0 + +networks: + hmpps: diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..aec96a5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +# WARNING - THIS FILE WAS GENERATED BY THE dps-gradle-spring-boot GRADLE PLUGIN +# AND ANY MANUAL CHANGES WILL BE OVERRIDDEN ON YOUR NEXT BUILD. +# +# To make general changes to the configuration below, change the gradle plugin dps-gradle-spring-boot, +# publish a new version and update to the new version in your gradle build script +# +# To stop the dps-gradle-spring-boot project from overwriting any project specific customisations here, remove the +# warning at the top of this file. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/helm_deploy/.helmignore b/helm_deploy/.helmignore new file mode 100644 index 0000000..50af031 --- /dev/null +++ b/helm_deploy/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm_deploy/README.md b/helm_deploy/README.md new file mode 100644 index 0000000..bb4997d --- /dev/null +++ b/helm_deploy/README.md @@ -0,0 +1,6 @@ +# Deployment Notes + +This helm deployment makes use of the generic-service chart, see chart documentation here: + + + diff --git a/helm_deploy/hmpps-template-kotlin/.helmignore b/helm_deploy/hmpps-template-kotlin/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/helm_deploy/hmpps-template-kotlin/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/helm_deploy/hmpps-template-kotlin/Chart.yaml b/helm_deploy/hmpps-template-kotlin/Chart.yaml new file mode 100644 index 0000000..ab15c41 --- /dev/null +++ b/helm_deploy/hmpps-template-kotlin/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +appVersion: '1.0' +description: A Helm chart for Kubernetes +name: hmpps-template-kotlin +version: 0.2.0 +dependencies: + - name: generic-service + version: "3.6" + repository: https://ministryofjustice.github.io/hmpps-helm-charts + - name: generic-prometheus-alerts + version: "1.10" + repository: https://ministryofjustice.github.io/hmpps-helm-charts diff --git a/helm_deploy/hmpps-template-kotlin/values.yaml b/helm_deploy/hmpps-template-kotlin/values.yaml new file mode 100644 index 0000000..5c87bc2 --- /dev/null +++ b/helm_deploy/hmpps-template-kotlin/values.yaml @@ -0,0 +1,42 @@ +generic-service: + nameOverride: hmpps-template-kotlin + productId: "UNASSIGNED" # productId for the product that this belongs too, i.e. DPS001, see README.md for details + + replicaCount: 4 + + image: + repository: ghcr.io/ministryofjustice/hmpps-template-kotlin + tag: app_version # override at deployment time + port: 8080 + + ingress: + enabled: true + host: app-hostname.local # override per environment + tlsSecretName: hmpps-template-kotlin-cert + + # Environment variables to load into the deployment + env: + JAVA_OPTS: "-Xmx512m" + SERVER_PORT: "8080" + APPLICATIONINSIGHTS_CONNECTION_STRING: "InstrumentationKey=$(APPINSIGHTS_INSTRUMENTATIONKEY)" + APPLICATIONINSIGHTS_CONFIGURATION_FILE: applicationinsights.json + + # Pre-existing kubernetes secrets to load as environment variables in the deployment. + # namespace_secrets: + # [name of kubernetes secret]: + # [name of environment variable as seen by app]: [key of kubernetes secret to load] + + namespace_secrets: + hmpps-template-kotlin: + # Example client registration secrets + EXAMPLE_API_CLIENT_ID: "TEMPLATE_KOTLIN_API_CLIENT_ID" + EXAMPLE_API_CLIENT_SECRET: "TEMPLATE_KOTLIN_API_CLIENT_SECRET" + application-insights: + APPINSIGHTS_INSTRUMENTATIONKEY: "APPINSIGHTS_INSTRUMENTATIONKEY" + + allowlist: + groups: + - internal + +generic-prometheus-alerts: + targetApplication: hmpps-template-kotlin diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml new file mode 100644 index 0000000..c04e87b --- /dev/null +++ b/helm_deploy/values-dev.yaml @@ -0,0 +1,20 @@ +--- +# Per environment values which override defaults in hmpps-template-kotlin/values.yaml + +generic-service: + replicaCount: 2 + + ingress: + host: template-kotlin-dev.hmpps.service.justice.gov.uk + + env: + APPLICATIONINSIGHTS_CONFIGURATION_FILE: "applicationinsights.dev.json" + HMPPS_AUTH_URL: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" + # Template kotlin calls out to itself to provide an example of a service call + # TODO: This should be replaced by a call to a different service, or removed + EXAMPLE_API_URL: "https://template-kotlin-dev.hmpps.service.justice.gov.uk" + +# CloudPlatform AlertManager receiver to route prometheus alerts to slack +# See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts +generic-prometheus-alerts: + alertSeverity: NON_PROD_ALERTS_SEVERITY_LABEL diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml new file mode 100644 index 0000000..18164b9 --- /dev/null +++ b/helm_deploy/values-preprod.yaml @@ -0,0 +1,20 @@ +--- +# Per environment values which override defaults in hmpps-template-kotlin/values.yaml + +generic-service: + replicaCount: 2 + + ingress: + host: template-kotlin-preprod.hmpps.service.justice.gov.uk + + env: + APPLICATIONINSIGHTS_CONFIGURATION_FILE: "applicationinsights.dev.json" + HMPPS_AUTH_URL: "https://sign-in-preprod.hmpps.service.justice.gov.uk/auth" + # Template kotlin calls out to itself to provide an example of a service call + # TODO: This should be replaced by a call to a different service, or removed + EXAMPLE_API_URL: "https://template-kotlin-preprod.hmpps.service.justice.gov.uk" + +# CloudPlatform AlertManager receiver to route prometheus alerts to slack +# See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts +generic-prometheus-alerts: + alertSeverity: NON_PROD_ALERTS_SEVERITY_LABEL diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml new file mode 100644 index 0000000..f76bcf2 --- /dev/null +++ b/helm_deploy/values-prod.yaml @@ -0,0 +1,17 @@ +--- +# Per environment values which override defaults in hmpps-template-kotlin/values.yaml + +generic-service: + ingress: + host: template-kotlin.hmpps.service.justice.gov.uk + + env: + HMPPS_AUTH_URL: "https://sign-in.hmpps.service.justice.gov.uk/auth" + # Template kotlin calls out to itself to provide an example of a service call + # TODO: This should be replaced by a call to a different service, or removed + EXAMPLE_API_URL: "https://template-kotlin.hmpps.service.justice.gov.uk" + +# CloudPlatform AlertManager receiver to route prometheus alerts to slack +# See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts +generic-prometheus-alerts: + alertSeverity: PROD_ALERTS_SEVERITY_LABEL diff --git a/rename-project.bash b/rename-project.bash new file mode 100755 index 0000000..7279c6c --- /dev/null +++ b/rename-project.bash @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +set -e + +if ! echo "$BASH_VERSION" | grep -E "^[45]" &>/dev/null; then + echo "Found bash version: $BASH_VERSION" + echo "Ensure you are using bash version 4 or 5" + exit 1 +fi + +if [[ $# -ge 1 ]]; then + PROJECT_INPUT=$1 + SLACK_RELEASES_CHANNEL=$2 + SECURITY_ALERTS_SLACK_CHANNEL_ID=$3 + NON_PROD_ALERTS_SEVERITY_LABEL=$4 + PROD_ALERTS_SEVERITY_LABEL=$5 + PRODUCT_ID=$6 +else + read -rp "New project name e.g. prison-visits >" PROJECT_INPUT + read -rp "Slack channel for release notifications >" SLACK_RELEASES_CHANNEL + read -rp "Slack channel for pipeline security notifications >" SECURITY_ALERTS_SLACK_CHANNEL_ID + echo "For configurating alert severity labels, please first see https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts" + read -rp "Non-prod kubernetes alerts. The severity label used by prometheus to route alert notifications to slack >" NON_PROD_ALERTS_SEVERITY_LABEL + read -rp "Production kubernetes alerts. The severity label used by prometheus to route alert notifications to slack >" PROD_ALERTS_SEVERITY_LABEL + echo "Refer to the developer portal at https://developer-portal.hmpps.service.justice.gov.uk/products to find your product id." + read -rp "Provide an ID for the product this app/component belongs too >" PRODUCT_ID +fi + +PROJECT_NAME_LOWER=${PROJECT_INPUT,,} # lowercase +PROJECT_NAME_HYPHENS=${PROJECT_NAME_LOWER// /-} # spaces to hyphens + +PROJECT_NAME=${PROJECT_NAME_HYPHENS//[^a-z0-9-]/} # remove all other characters +PROJECT_NAME_WITHOUT_HMPPS=${PROJECT_NAME/hmpps-/} # remove hmpps prefix +PACKAGE_NAME=${PROJECT_NAME_WITHOUT_HMPPS//-/} # remove hyphen + +read -ra PROJECT_NAME_ARRAY <<<"${PROJECT_NAME//-/ }" # convert to array +PROJECT_DESCRIPTION_HMPPS_LOWER=${PROJECT_NAME_ARRAY[*]^} # convert array back to string thus capitalising first character +PROJECT_DESCRIPTION=${PROJECT_DESCRIPTION_HMPPS_LOWER/Hmpps/HMPPS} # ensure that HMPPS is capitalised +CLASS_NAME=${PROJECT_DESCRIPTION_HMPPS_LOWER// /} # then remove spaces + +echo "Found: Project of $PROJECT_DESCRIPTION" +echo " Project name of $PROJECT_NAME" +echo " Package name of $PACKAGE_NAME" +echo " Class name of $CLASS_NAME" + +echo "Performing search and replace" + +# exclude files that get in the way and don't make any difference +EXCLUDES="( -path ./build -o -path ./out -o -path ./.git -o -path ./.gradle -o -path ./gradle -o -path ./.idea -o -path ./rename-project.bash )" +# shellcheck disable=SC2086 +find . $EXCLUDES -prune -o -type f -exec sed -i \ + -e "s/hmpps-template-kotlin/$PROJECT_NAME/g" \ + -e "s/template-kotlin/$PROJECT_NAME_WITHOUT_HMPPS/g" \ + -e "s/HMPPS Template Kotlin/$PROJECT_DESCRIPTION/g" \ + -e "s/HmppsTemplateKotlin/$CLASS_NAME/g" \ + -e "s/templatepackagename/$PACKAGE_NAME/g" {} \; + +echo "Performing directory renames" + +# move package directory to new name +BASE="kotlin/uk/gov/justice/digital/hmpps" +mv "src/test/${BASE}/templatepackagename" "src/test/$BASE/$PACKAGE_NAME" +mv "src/main/${BASE}/templatepackagename" "src/main/$BASE/$PACKAGE_NAME" + +# and move helm stuff to new name +mv "helm_deploy/hmpps-template-kotlin" "helm_deploy/$PROJECT_NAME" + +# Update helm values.yaml with product ID. +sed -i -z -E \ + -e "s/UNASSIGNED/$PRODUCT_ID/" \ + helm_deploy/$PROJECT_NAME/values.yaml + +# Update helm values files with correct slack channels. +sed -i -z -E \ + -e "s/NON_PROD_ALERTS_SEVERITY_LABEL/$NON_PROD_ALERTS_SEVERITY_LABEL/" \ + helm_deploy/values-dev.yaml helm_deploy/values-preprod.yaml + +sed -i -z -E \ + -e "s/PROD_ALERTS_SEVERITY_LABEL/$PROD_ALERTS_SEVERITY_LABEL/" helm_deploy/values-prod.yaml + +# rename kotlin files +mv "src/main/$BASE/$PACKAGE_NAME/HmppsTemplateKotlin.kt" "src/main/$BASE/$PACKAGE_NAME/$CLASS_NAME.kt" +mv "src/main/$BASE/$PACKAGE_NAME/config/HmppsTemplateKotlinExceptionHandler.kt" "src/main/$BASE/$PACKAGE_NAME/config/${CLASS_NAME}ExceptionHandler.kt" + +# change cron job to be random time otherwise we hit rate limiting with veracode +RANDOM_HOUR=$((RANDOM % (9 - 3 + 1) + 3)) +RANDOM_MINUTE=$(($RANDOM%60)) +RANDOM_MINUTE2=$(($RANDOM%60)) +sed -i -z -E \ + -e "s/SLACK_RELEASES_CHANNEL/$SLACK_RELEASES_CHANNEL/" \ + .circleci/config.yml + +echo "NEEDS TO BE SET MANUALLY" +echo "========================" +echo "DAILY CRON: ${RANDOM_MINUTE} ${RANDOM_HOUR}" +echo "WEEKLY CRON: ${RANDOM_MINUTE2} ${RANDOM_HOUR}" +echo "SLACK CHANNEL: ${SECURITY_ALERTS_SLACK_CHANNEL_ID}" + +# TEMPORARILY REMOVED - THIS WILL NEED TO BE DONE MANUALLY UNTIL WE MOVE TO GITHUB ACTIONS BOOTSTRAP +# sed -i -z -E \ +# -e "s/on:\n workflow_dispatch:\n schedule:\n - cron: \"19 6/on:\n workflow_dispatch:\n schedule:\n - cron: \"$RANDOM_MINUTE $RANDOM_HOUR/" \ +# -e "s/on:\n workflow_dispatch:\n schedule:\n - cron: \"34 6/on:\n workflow_dispatch:\n schedule:\n - cron: \"$RANDOM_MINUTE2 $RANDOM_HOUR/" \ +# -e "s/C05J915DX0Q/$SECURITY_ALERTS_SLACK_CHANNEL_ID/" \ +# .github/workflows/* + +# lastly remove ourselves +rm rename-project.bash + +echo "Completed." +echo "Please now review changes and generate a banner for src/main/resources/banner.txt." +echo "There are TODOs in the codebase to guide you through the changes." diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..0cb2e17 --- /dev/null +++ b/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>ministryofjustice/hmpps-renovate-config:jvm"], + "prBodyTemplate": "{{{table}}}{{{notes}}}{{{warnings}}}{{{controls}}}", + "packageRules": [ + { + "matchManagers": ["gradle"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "all non major Gradle dependencies", + "groupSlug": "all-gradle-minor-patch" + } + ] +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ec8ec37 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "hmpps-template-kotlin" diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/HmppsTemplateKotlin.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/HmppsTemplateKotlin.kt new file mode 100644 index 0000000..488b460 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/HmppsTemplateKotlin.kt @@ -0,0 +1,11 @@ +package uk.gov.justice.digital.hmpps.templatepackagename + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class HmppsTemplateKotlin + +fun main(args: Array) { + runApplication(*args) +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt new file mode 100644 index 0000000..acfe35d --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt @@ -0,0 +1,65 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.config + +import jakarta.validation.ValidationException +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.ResponseEntity +import org.springframework.security.access.AccessDeniedException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.resource.NoResourceFoundException +import uk.gov.justice.hmpps.kotlin.common.ErrorResponse + +@RestControllerAdvice +class HmppsTemplateKotlinExceptionHandler { + @ExceptionHandler(ValidationException::class) + fun handleValidationException(e: ValidationException): ResponseEntity = ResponseEntity + .status(BAD_REQUEST) + .body( + ErrorResponse( + status = BAD_REQUEST, + userMessage = "Validation failure: ${e.message}", + developerMessage = e.message, + ), + ).also { log.info("Validation exception: {}", e.message) } + + @ExceptionHandler(NoResourceFoundException::class) + fun handleNoResourceFoundException(e: NoResourceFoundException): ResponseEntity = ResponseEntity + .status(NOT_FOUND) + .body( + ErrorResponse( + status = NOT_FOUND, + userMessage = "No resource found failure: ${e.message}", + developerMessage = e.message, + ), + ).also { log.info("No resource found exception: {}", e.message) } + + @ExceptionHandler(AccessDeniedException::class) + fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity = ResponseEntity + .status(FORBIDDEN) + .body( + ErrorResponse( + status = FORBIDDEN, + userMessage = "Forbidden: ${e.message}", + developerMessage = e.message, + ), + ).also { log.debug("Forbidden (403) returned: {}", e.message) } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity = ResponseEntity + .status(INTERNAL_SERVER_ERROR) + .body( + ErrorResponse( + status = INTERNAL_SERVER_ERROR, + userMessage = "Unexpected error: ${e.message}", + developerMessage = e.message, + ), + ).also { log.error("Unexpected exception", e) } + + private companion object { + private val log = LoggerFactory.getLogger(this::class.java) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/OpenApiConfiguration.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/OpenApiConfiguration.kt new file mode 100644 index 0000000..87e5f69 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/OpenApiConfiguration.kt @@ -0,0 +1,58 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import io.swagger.v3.oas.models.tags.Tag +import org.springframework.boot.info.BuildProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfiguration(buildProperties: BuildProperties) { + private val version: String = buildProperties.version + + @Bean + fun customOpenAPI(): OpenAPI = OpenAPI() + .servers( + listOf( + Server().url("https://template-kotlin-dev.hmpps.service.justice.gov.uk").description("Development"), + Server().url("https://template-kotlin-preprod.hmpps.service.justice.gov.uk").description("Pre-Production"), + Server().url("https://template-kotlin.hmpps.service.justice.gov.uk").description("Production"), + Server().url("http://localhost:8080").description("Local"), + ), + ) + .tags( + listOf( + // TODO: Remove the Popular and Examples tag and start adding your own tags to group your resources + Tag().name("Popular") + .description("The most popular endpoints. Look here first when deciding which endpoint to use."), + Tag().name("Examples").description("Endpoints for searching for a prisoner within a prison"), + ), + ) + .info( + Info().title("HMPPS Template Kotlin").version(version) + .contact(Contact().name("HMPPS Digital Studio").email("feedback@digital.justice.gov.uk")), + ) + // TODO: Remove the default security schema and start adding your own schemas and roles to describe your + // service authorisation requirements + .components( + Components().addSecuritySchemes( + "template-kotlin-ui-role", + SecurityScheme().addBearerJwtRequirement("ROLE_TEMPLATE_KOTLIN__UI"), + ), + ) + .addSecurityItem(SecurityRequirement().addList("template-kotlin-ui-role", listOf("read"))) +} + +private fun SecurityScheme.addBearerJwtRequirement(role: String): SecurityScheme = + type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name("Authorization") + .description("A HMPPS Auth access token with the `$role` role.") diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/WebClientConfiguration.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/WebClientConfiguration.kt new file mode 100644 index 0000000..dac109b --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/config/WebClientConfiguration.kt @@ -0,0 +1,32 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager +import org.springframework.web.reactive.function.client.WebClient +import uk.gov.justice.hmpps.kotlin.auth.authorisedWebClient +import uk.gov.justice.hmpps.kotlin.auth.healthWebClient +import java.time.Duration + +@Configuration +class WebClientConfiguration( + @Value("\${example-api.url}") val exampleApiBaseUri: String, + @Value("\${hmpps-auth.url}") val hmppsAuthBaseUri: String, + @Value("\${api.health-timeout:2s}") val healthTimeout: Duration, + @Value("\${api.timeout:20s}") val timeout: Duration, +) { + // HMPPS Auth health ping is required if your service calls HMPPS Auth to get a token to call other services + // TODO: Remove the health ping if no call outs to other services are made + @Bean + fun hmppsAuthHealthWebClient(builder: WebClient.Builder): WebClient = builder.healthWebClient(hmppsAuthBaseUri, healthTimeout) + + // TODO: This is an example health bean for checking other services and should be removed / replaced + @Bean + fun exampleApiHealthWebClient(builder: WebClient.Builder): WebClient = builder.healthWebClient(exampleApiBaseUri, healthTimeout) + + // TODO: This is an example bean for calling other services and should be removed / replaced + @Bean + fun exampleApiWebClient(authorizedClientManager: OAuth2AuthorizedClientManager, builder: WebClient.Builder): WebClient = + builder.authorisedWebClient(authorizedClientManager, registrationId = "example-api", url = exampleApiBaseUri, timeout) +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/health/HealthPingCheck.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/health/HealthPingCheck.kt new file mode 100644 index 0000000..9f9f1ed --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/health/HealthPingCheck.kt @@ -0,0 +1,15 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.health + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import uk.gov.justice.hmpps.kotlin.health.HealthPingCheck + +// HMPPS Auth health ping is required if your service calls HMPPS Auth to get a token to call other services +// TODO: Remove the health ping if no call outs to other services are made +@Component("hmppsAuth") +class HmppsAuthHealthPing(@Qualifier("hmppsAuthHealthWebClient") webClient: WebClient) : HealthPingCheck(webClient) + +// TODO: Example ping health check calling out to other services +@Component("exampleApi") +class ExampleApiHealthPing(@Qualifier("exampleApiHealthWebClient") webClient: WebClient) : HealthPingCheck(webClient) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/resource/ExampleResource.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/resource/ExampleResource.kt new file mode 100644 index 0000000..8ece331 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/resource/ExampleResource.kt @@ -0,0 +1,77 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.resource + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import uk.gov.justice.digital.hmpps.templatepackagename.service.ExampleApiService +import uk.gov.justice.hmpps.kotlin.common.ErrorResponse +import java.time.LocalDateTime + +// This controller is expected to be called from the UI - so the hmpps-template-typescript project. +// TODO: This is an example and should renamed / replaced +@RestController +// Role here is specific to the UI. +@PreAuthorize("hasRole('ROLE_TEMPLATE_KOTLIN__UI')") +@RequestMapping(value = ["/example"], produces = ["application/json"]) +class ExampleResource(private val exampleApiService: ExampleApiService) { + + @GetMapping("/time") + @Tag(name = "Examples") + @Operation( + summary = "Retrieve today's date and time", + description = "This is an example endpoint that calls a service to return the current date and time. Requires role ROLE_TEMPLATE_KOTLIN__UI", + security = [SecurityRequirement(name = "template-kotlin-ui-role")], + responses = [ + ApiResponse(responseCode = "200", description = "today's date and time"), + ApiResponse( + responseCode = "401", + description = "Unauthorized to access this endpoint", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))], + ), + ApiResponse( + responseCode = "403", + description = "Forbidden to access this endpoint", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))], + ), + ], + ) + fun getTime(): LocalDateTime = exampleApiService.getTime() + + @GetMapping("/message/{parameter}") + @Tag(name = "Popular") + @Operation( + summary = "Example message endpoint to call another API", + description = """This is an example endpoint that calls back to the kotlin template. + It will return a 404 response as the /example-external-api endpoint hasn't been implemented, so we use wiremock + in integration tests to simulate other responses. + Requires role ROLE_TEMPLATE_KOTLIN__UI""", + security = [SecurityRequirement(name = "template-kotlin-ui-role")], + responses = [ + ApiResponse(responseCode = "200", description = "a message with a parameter"), + ApiResponse( + responseCode = "401", + description = "Unauthorized to access this endpoint", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))], + ), + ApiResponse( + responseCode = "403", + description = "Forbidden to access this endpoint", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))], + ), + ApiResponse( + responseCode = "404", + description = "Not found", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ErrorResponse::class))], + ), + ], + ) + fun getMessage(@PathVariable parameter: String) = exampleApiService.exampleGetExternalApiCall(parameter) +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/service/ExampleApiService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/service/ExampleApiService.kt new file mode 100644 index 0000000..d848987 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/service/ExampleApiService.kt @@ -0,0 +1,37 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.service + +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +// This is an example of how to write a service calling out to another service. In this case we have wired up the +// kotlin template with itself so that the template doesn't depend on any other services. +// TODO: This is an example and should be renamed / replaced +@Service +class ExampleApiService( + private val exampleApiWebClient: WebClient, +) { + fun getTime(): LocalDateTime = LocalDateTime.now() + + fun exampleGetExternalApiCall(parameter: String): ExampleMessageDto? = + exampleApiWebClient.get() + // Note that we don't use string interpolation ("/${parameter}"). + // This is important - using string interpolation causes each uri to be added as a separate path in app + // insights and you'll run out of memory in your app. + // Also note that this is just an example and the /example-external-api endpoint doesn't exist in this kotlin + // template project so will return a not found response each time. + .uri("/example-external-api/{parameter}", parameter) + .retrieve() + .bodyToMono(ExampleMessageDto::class.java) + // if the endpoint returns a not found response (404) then treat as empty rather than throwing a server error + // other options would be to re-throw the not found and use the controller advice to return a 404 + .onErrorResume(WebClientResponseException.NotFound::class.java) { Mono.empty() } + .block() +} + +// TODO: This is an example message and should be renamed / replaced +data class ExampleMessageDto( + val message: String, +) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..949a823 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,10 @@ +hmpps-auth: + url: "http://localhost:8090/auth" + +# example client configuration for calling out to other services +# TODO: Remove / replace this configuration +example-api: + url: "http://localhost:8080" + client: + id: "example-api-client" + secret: "example-api-client-secret" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..47522e7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,68 @@ +info.app: + name: HMPPS Template Kotlin + version: 1.0 + +spring: + application: + name: hmpps-template-kotlin + codec: + max-in-memory-size: 10MB + + jackson: + date-format: "yyyy-MM-dd HH:mm:ss" + serialization: + WRITE_DATES_AS_TIMESTAMPS: false + + # TODO: This security section can be removed if your service doesn't call out to other services + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${hmpps-auth.url}/.well-known/jwks.json + + client: + provider: + hmpps-auth: + token-uri: ${hmpps-auth.url}/oauth/token + + registration: + # example client registration for calling out to other services + # TODO: Remove / replace this registration + example-api: + provider: hmpps-auth + client-id: ${example-api.client.id} + client-secret: ${example-api.client.secret} + authorization-grant-type: client_credentials + scope: read + +server: + port: 8080 + servlet: + context-path: / + forward-headers-strategy: native + tomcat: + remoteip: + remote-ip-header: x-forwarded-for + protocol-header: x-forwarded-proto + internal-proxies: 10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.1[6-9]{1}\.\d{1,3}\.\d{1,3}|172\.2[0-9]{1}\.\d{1,3}\.\d{1,3}|172\.3[0-1]{1}\.\d{1,3}\.\d{1,3}|0:0:0:0:0:0:0:1|::1|100\.6[4-9]\.\d{1,3}\.\d{1,3}|100\.[7-9][0-9]{1}\.\d{1,3}\.\d{1,3}|100\.1[0-1][0-9]{1}\.\d{1,3}\.\d{1,3}|100\.12[0-7]\.\d{1,3}\.\d{1,3} + shutdown: graceful + error: + include-message: always + +management: + endpoints: + web: + base-path: / + exposure: + include: 'info, health' + endpoint: + health: + cache: + time-to-live: 2000ms + show-components: always + show-details: always + probes: + enabled: true + info: + cache: + time-to-live: 2000ms diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..fc59263 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,13 @@ +_ _ _ _ ___ ___ ____ +|__| |\/| |__] |__] [__ +| | | | | | ___] + +___ ____ _ _ ___ _ ____ ___ ____ + | |___ |\/| |__] | |__| | |___ + | |___ | | | |___ | | | |___ + +_ _ ____ ___ _ _ _ _ +|_/ | | | | | |\ | +| \_ |__| | |___ | | \| + +TODO: Please change me by generating your own ASCII art and placing in banner.txt diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..b33dba1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,43 @@ + + + + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ExampleResourceIntTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ExampleResourceIntTest.kt new file mode 100644 index 0000000..39af6b2 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ExampleResourceIntTest.kt @@ -0,0 +1,129 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration + +import com.github.tomakehurst.wiremock.client.WireMock +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.ExampleApiExtension.Companion.exampleApi +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.HmppsAuthApiExtension.Companion.hmppsAuth +import java.time.LocalDate + +class ExampleResourceIntTest : IntegrationTestBase() { + + @Nested + @DisplayName("GET /example/time") + inner class TimeEndpoint { + + @Test + fun `should return unauthorized if no token`() { + webTestClient.get() + .uri("/example/time") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `should return forbidden if no role`() { + webTestClient.get() + .uri("/example/time") + .headers(setAuthorisation()) + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `should return forbidden if wrong role`() { + webTestClient.get() + .uri("/example/time") + .headers(setAuthorisation(roles = listOf("ROLE_WRONG"))) + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `should return OK`() { + webTestClient.get() + .uri("/example/time") + .headers(setAuthorisation(roles = listOf("ROLE_TEMPLATE_KOTLIN__UI"))) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$").value { + assertThat(it).startsWith("${LocalDate.now()}") + } + } + } + + @Nested + @DisplayName("GET /example/message/{parameter}") + inner class UserDetailsEndpoint { + + @Test + fun `should return unauthorized if no token`() { + webTestClient.get() + .uri("/example/message/{parameter}", "bob") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `should return forbidden if no role`() { + webTestClient.get() + .uri("/example/message/{parameter}", "bob") + .headers(setAuthorisation(roles = listOf())) + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `should return forbidden if wrong role`() { + webTestClient.get() + .uri("/example/message/{parameter}", "bob") + .headers(setAuthorisation(roles = listOf("ROLE_WRONG"))) + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `should return OK`() { + hmppsAuth.stubGrantToken() + exampleApi.stubExampleExternalApiUserMessage() + webTestClient.get() + .uri("/example/message/{parameter}", "bob") + .headers(setAuthorisation(username = "AUTH_OK", roles = listOf("ROLE_TEMPLATE_KOTLIN__UI"))) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.message").isEqualTo("A stubbed message") + + exampleApi.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/example-external-api/bob"))) + hmppsAuth.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/auth/oauth/token"))) + } + + @Test + fun `should return empty response if user not found`() { + hmppsAuth.stubGrantToken() + exampleApi.stubExampleExternalApiNotFound() + webTestClient.get() + .uri("/example/message/{parameter}", "bob") + .headers(setAuthorisation(username = "AUTH_NOTFOUND", roles = listOf("ROLE_TEMPLATE_KOTLIN__UI"))) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.message").doesNotExist() + + exampleApi.verify(WireMock.getRequestedFor(WireMock.urlEqualTo("/example-external-api/bob"))) + hmppsAuth.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/auth/oauth/token"))) + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/IntegrationTestBase.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/IntegrationTestBase.kt new file mode 100644 index 0000000..21cc096 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/IntegrationTestBase.kt @@ -0,0 +1,37 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration + +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.http.HttpHeaders +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.reactive.server.WebTestClient +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.ExampleApiExtension +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.ExampleApiExtension.Companion.exampleApi +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.HmppsAuthApiExtension +import uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock.HmppsAuthApiExtension.Companion.hmppsAuth +import uk.gov.justice.hmpps.test.kotlin.auth.JwtAuthorisationHelper + +@ExtendWith(HmppsAuthApiExtension::class, ExampleApiExtension::class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("test") +abstract class IntegrationTestBase { + + @Autowired + protected lateinit var webTestClient: WebTestClient + + @Autowired + protected lateinit var jwtAuthHelper: JwtAuthorisationHelper + + internal fun setAuthorisation( + username: String? = "AUTH_ADM", + roles: List = listOf(), + scopes: List = listOf("read"), + ): (HttpHeaders) -> Unit = jwtAuthHelper.setAuthorisationHeader(username = username, scope = scopes, roles = roles) + + protected fun stubPingWithResponse(status: Int) { + hmppsAuth.stubHealthPing(status) + exampleApi.stubHealthPing(status) + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/NotFoundTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/NotFoundTest.kt new file mode 100644 index 0000000..8899f0a --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/NotFoundTest.kt @@ -0,0 +1,14 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration + +import org.junit.jupiter.api.Test + +class NotFoundTest : IntegrationTestBase() { + + @Test + fun `Resources that aren't found should return 404 - test of the exception handler`() { + webTestClient.get().uri("/some-url-not-found") + .headers(setAuthorisation()) + .exchange() + .expectStatus().isNotFound + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/OpenApiDocsTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/OpenApiDocsTest.kt new file mode 100644 index 0000000..1b6792a --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/OpenApiDocsTest.kt @@ -0,0 +1,105 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration + +import io.swagger.v3.parser.OpenAPIV3Parser +import net.minidev.json.JSONArray +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class OpenApiDocsTest : IntegrationTestBase() { + @LocalServerPort + private val port: Int = 0 + + @Test + fun `open api docs are available`() { + webTestClient.get() + .uri("/swagger-ui/index.html?configUrl=/v3/api-docs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk + } + + @Test + fun `open api docs redirect to correct page`() { + webTestClient.get() + .uri("/swagger-ui.html") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is3xxRedirection + .expectHeader().value("Location") { it.contains("/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config") } + } + + @Test + fun `the open api json contains documentation`() { + webTestClient.get() + .uri("/v3/api-docs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .jsonPath("paths").isNotEmpty + } + + @Test + fun `the open api json contains the version number`() { + webTestClient.get() + .uri("/v3/api-docs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody().jsonPath("info.version").isEqualTo(DateTimeFormatter.ISO_DATE.format(LocalDate.now())) + } + + @Test + fun `the open api json is valid and contains documentation`() { + val result = OpenAPIV3Parser().readLocation("http://localhost:$port/v3/api-docs", null, null) + assertThat(result.messages).isEmpty() + assertThat(result.openAPI.paths).isNotEmpty + } + + @Test + fun `the open api json path security requirements are valid`() { + val result = OpenAPIV3Parser().readLocation("http://localhost:$port/v3/api-docs", null, null) + + // The security requirements of each path don't appear to be validated like they are at https://editor.swagger.io/ + // We therefore need to grab all the valid security requirements and check that each path only contains those items + val securityRequirements = result.openAPI.security.flatMap { it.keys } + result.openAPI.paths.forEach { pathItem -> + assertThat(pathItem.value.get.security.flatMap { it.keys }).isSubsetOf(securityRequirements) + } + } + + @ParameterizedTest + @CsvSource(value = ["template-kotlin-ui-role, ROLE_TEMPLATE_KOTLIN__UI"]) + fun `the security scheme is setup for bearer tokens`(key: String, role: String) { + webTestClient.get() + .uri("/v3/api-docs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .jsonPath("$.components.securitySchemes.$key.type").isEqualTo("http") + .jsonPath("$.components.securitySchemes.$key.scheme").isEqualTo("bearer") + .jsonPath("$.components.securitySchemes.$key.description").value { + assertThat(it).contains(role) + } + .jsonPath("$.components.securitySchemes.$key.bearerFormat").isEqualTo("JWT") + .jsonPath("$.security[0].$key").isEqualTo(JSONArray().apply { this.add("read") }) + } + + @Test + fun `all endpoints have a security scheme defined`() { + webTestClient.get() + .uri("/v3/api-docs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk + .expectBody() + .jsonPath("$.paths[*][*][?(!@.security)]").doesNotExist() + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ResourceSecurityTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ResourceSecurityTest.kt new file mode 100644 index 0000000..ed76c3e --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/ResourceSecurityTest.kt @@ -0,0 +1,59 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.servlet.mvc.method.RequestMappingInfo +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import java.io.File + +class ResourceSecurityTest : IntegrationTestBase() { + @Autowired + private lateinit var context: ApplicationContext + + private val unprotectedDefaultMethods = setOf( + "GET /v3/api-docs.yaml", + "GET /swagger-ui.html", + "GET /v3/api-docs", + "GET /v3/api-docs/swagger-config", + " /error", + ) + + @Test + fun `Ensure all endpoints protected with PreAuthorize`() { + // need to exclude any that are forbidden in helm configuration + val exclusions = File("helm_deploy").walk().filter { it.name.equals("values.yaml") }.flatMap { file -> + file.readLines().map { line -> + line.takeIf { it.contains("location") }?.substringAfter("location ")?.substringBefore(" {") + } + }.filterNotNull().flatMap { path -> listOf("GET", "POST", "PUT", "DELETE").map { "$it $path" } } + .toMutableSet().also { + it.addAll(unprotectedDefaultMethods) + } + + val beans = context.getBeansOfType(RequestMappingHandlerMapping::class.java) + beans.forEach { (_, mapping) -> + mapping.handlerMethods.forEach { (mappingInfo, method) -> + val classAnnotation = method.beanType.getAnnotation(PreAuthorize::class.java) + val annotation = method.getMethodAnnotation(PreAuthorize::class.java) + if (classAnnotation == null && annotation == null) { + mappingInfo.getMappings().forEach { + assertThat(exclusions.contains(it)).withFailMessage { + "Found $mappingInfo of type $method with no PreAuthorize annotation" + }.isTrue() + } + } + } + } + } +} + +private fun RequestMappingInfo.getMappings() = + methodsCondition.methods + .map { it.name } + .ifEmpty { listOf("") } // if no methods defined then match all rather than none + .flatMap { method -> + pathPatternsCondition?.patternValues?.map { "$method $it" } ?: emptyList() + } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/HealthCheckTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/HealthCheckTest.kt new file mode 100644 index 0000000..1a9a954 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/HealthCheckTest.kt @@ -0,0 +1,68 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration.health + +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.templatepackagename.integration.IntegrationTestBase + +class HealthCheckTest : IntegrationTestBase() { + + @Test + fun `Health page reports ok`() { + stubPingWithResponse(200) + + webTestClient.get() + .uri("/health") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("status").isEqualTo("UP") + } + + @Test + fun `Health page reports down`() { + stubPingWithResponse(503) + + webTestClient.get() + .uri("/health") + .exchange() + .expectStatus() + .is5xxServerError + .expectBody() + .jsonPath("status").isEqualTo("DOWN") + .jsonPath("components.hmppsAuth.status").isEqualTo("DOWN") + .jsonPath("components.exampleApi.status").isEqualTo("DOWN") + } + + @Test + fun `Health ping page is accessible`() { + webTestClient.get() + .uri("/health/ping") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("status").isEqualTo("UP") + } + + @Test + fun `readiness reports ok`() { + webTestClient.get() + .uri("/health/readiness") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("status").isEqualTo("UP") + } + + @Test + fun `liveness reports ok`() { + webTestClient.get() + .uri("/health/liveness") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("status").isEqualTo("UP") + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/InfoTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/InfoTest.kt new file mode 100644 index 0000000..f7adf23 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/health/InfoTest.kt @@ -0,0 +1,31 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration.health + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.templatepackagename.integration.IntegrationTestBase +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class InfoTest : IntegrationTestBase() { + + @Test + fun `Info page is accessible`() { + webTestClient.get() + .uri("/info") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("build.name").isEqualTo("hmpps-template-kotlin") + } + + @Test + fun `Info page reports version`() { + webTestClient.get().uri("/info") + .exchange() + .expectStatus().isOk + .expectBody().jsonPath("build.version").value { + assertThat(it).startsWith(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE)) + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/ExampleApiMockServer.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/ExampleApiMockServer.kt new file mode 100644 index 0000000..13c52ee --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/ExampleApiMockServer.kt @@ -0,0 +1,58 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +// TODO: Remove / replace this mock server as it currently calls the Example API (itself) +class ExampleApiMockServer : WireMockServer(8091) { + fun stubHealthPing(status: Int) { + stubFor( + get("/health/ping").willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody("""{"status":"${if (status == 200) "UP" else "DOWN"}"}""") + .withStatus(status), + ), + ) + } + + fun stubExampleExternalApiUserMessage() { + stubFor( + get(urlPathMatching("/example-external-api/[a-zA-Z]*")) + .willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody("""{ "message": "A stubbed message" }"""), + ), + ) + } + + fun stubExampleExternalApiNotFound() { + stubFor( + get(urlPathMatching("/example-external-api/[a-zA-Z]*")) + .willReturn( + aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("""{ "userMessage": "A stubbed message" }"""), + ), + ) + } +} + +class ExampleApiExtension : BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + companion object { + @JvmField + val exampleApi = ExampleApiMockServer() + } + + override fun beforeAll(context: ExtensionContext): Unit = exampleApi.start() + override fun beforeEach(context: ExtensionContext): Unit = exampleApi.resetAll() + override fun afterAll(context: ExtensionContext): Unit = exampleApi.stop() +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/HmppsAuthMockServer.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/HmppsAuthMockServer.kt new file mode 100644 index 0000000..d75326b --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/templatepackagename/integration/wiremock/HmppsAuthMockServer.kt @@ -0,0 +1,70 @@ +package uk.gov.justice.digital.hmpps.templatepackagename.integration.wiremock + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.http.HttpHeader +import com.github.tomakehurst.wiremock.http.HttpHeaders +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import java.time.LocalDateTime +import java.time.ZoneOffset + +class HmppsAuthApiExtension : BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + companion object { + @JvmField + val hmppsAuth = HmppsAuthMockServer() + } + + override fun beforeAll(context: ExtensionContext) { + hmppsAuth.start() + } + + override fun beforeEach(context: ExtensionContext) { + hmppsAuth.resetRequests() + } + + override fun afterAll(context: ExtensionContext) { + hmppsAuth.stop() + } +} + +class HmppsAuthMockServer : WireMockServer(WIREMOCK_PORT) { + companion object { + private const val WIREMOCK_PORT = 8090 + } + + fun stubGrantToken() { + stubFor( + post(urlEqualTo("/auth/oauth/token")) + .willReturn( + aResponse() + .withHeaders(HttpHeaders(HttpHeader("Content-Type", "application/json"))) + .withBody( + """ + { + "token_type": "bearer", + "access_token": "ABCDE", + "expires_in": ${LocalDateTime.now().plusHours(2).toEpochSecond(ZoneOffset.UTC)} + } + """.trimIndent(), + ), + ), + ) + } + + fun stubHealthPing(status: Int) { + stubFor( + get("/auth/health/ping").willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody(if (status == 200) """{"status":"UP"}""" else """{"status":"DOWN"}""") + .withStatus(status), + ), + ) + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..ea3dd6f --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,17 @@ +server: + shutdown: immediate + +management.endpoint: + health.cache.time-to-live: 0 + info.cache.time-to-live: 0 + +hmpps-auth: + url: "http://localhost:8090/auth" + +# example client configuration for calling out to other services +# TODO: Remove / replace this configuration +example-api: + url: "http://localhost:8091" + client: + id: "example-api-client" + secret: "example-api-client-secret"