diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..41a127f393 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +**/target/ +node_modules +.vscode +.claude diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..423cb83dd3 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,120 @@ +name: Publish Docker Image + +on: + workflow_dispatch: {} + # One workflow, shared by every environment branch: a merge into any of + # these triggers a build tagged latest-. No per-branch copies + # or separate workflow files to keep in sync. + push: + branches: + - development + - qa + - staging + - production + +# Publishes a floating latest- tag - without this, two runs for the +# same branch could race and let an older commit's build finish last, +# overwriting latest- with a stale image. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + # Mirrors the "Run API Unit Tests" job in the Bamboo CHPL Development + # Deployment plan (chpl-build/chpl-build-dev): `mvn clean test` against + # chpl/pom.xml on JDK 21. Gates the image build so a broken build never + # gets published. + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Run unit tests + env: + MAVEN_OPTS: -Xms512m -Xmx1024m + run: mvn -B clean test --file chpl/pom.xml + + # Mirrors the "Build API" job (CompileApiTask) in the same Bamboo plan, and + # runs the exact `mvn clean package -DskipTests` command that docker/Dockerfile's + # build stage runs - so a compile/packaging failure shows up here, fast, + # instead of partway through the (slower) Docker build. + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Compile and package + env: + MAVEN_OPTS: -Xms512m -Xmx1024m + run: mvn -B clean package -DskipTests --file chpl/pom.xml + + build-and-push: + needs: [unit-tests, compile] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image metadata + id: meta + run: | + echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "branch=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> "$GITHUB_OUTPUT" + + # This image contains no secrets - see docker/Dockerfile. Every environment + # (development/qa/staging/production) runs the exact same image; secrets are + # supplied as container environment variables at `docker run` time, not baked + # in here. + # + # Tags: + # build- - immutable, sequential build number (github.run_number + # never repeats or goes backwards for this workflow) + # sha- - immutable, traces back to the exact commit + # latest- - floating pointer to the most recent build from that + # branch (latest-development, latest-qa, latest-staging, + # latest-production once triggered from those branches) + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + push: true + # build-push-action v6 enables these by default, which each push a + # separate untagged provenance/SBOM manifest to GHCR alongside the + # real image - not needed here, so turned off to keep the package + # version list clean. + provenance: false + sbom: false + tags: | + ${{ steps.meta.outputs.image }}:build-${{ github.run_number }} + ${{ steps.meta.outputs.image }}:sha-${{ steps.meta.outputs.sha_short }} + ${{ steps.meta.outputs.image }}:latest-${{ steps.meta.outputs.branch }} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..b9cc717c53 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,45 @@ +# Stage 1: Build the WAR file using Maven +FROM maven:3.9-eclipse-temurin-21-alpine AS build +WORKDIR /app + +# Copy the pom.xml files first to leverage Docker layer caching +# This ensures that if only source code changes, dependencies are not re-downloaded +COPY chpl/pom.xml . +COPY chpl/chpl-api/pom.xml chpl-api/ +COPY chpl/chpl-resources/pom.xml chpl-resources/ +COPY chpl/chpl-service/pom.xml chpl-service/ + +# Copy the rest of the project files +COPY chpl/chpl-api/lombok.config chpl-api/lombok.config +COPY chpl/chpl-api/src chpl-api/src +COPY chpl/chpl-resources/src chpl-resources/src +COPY chpl/chpl-service/lombok.config chpl-service/lombok.config +COPY chpl/chpl-service/src chpl-service/src + +RUN mvn clean package -DskipTests + +# Stage 2: Deploy to Tomcat +# +# This image contains no secrets and is identical across every environment +# (dev/qa/stage/production). Tomcat config below only has ${ENV_VAR} +# placeholders (resolved at container startup - see catalina.properties' +# org.apache.tomcat.util.digester.PROPERTY_SOURCE) and the checked-in +# environment.properties/email.properties already mark every sensitive key +# as SECRET, relying on Spring's Environment to prefer OS environment +# variables over those classpath files. +# +# Required environment variables at `docker run` time: +# DB_URL, DB_USERNAME, DB_PASSWORD - jdbc/openchpl datasource +# plus every property marked SECRET in +# chpl/chpl-resources/src/main/resources/environment.properties and email.properties +# (e.g. SPRING_REDIS_PASSWORD, COGNITO_SECRETKEY, AZURE_CLIENTSECRET_ONC, JIRA_PASSWORD, ...) +FROM tomcat:11.0.23-jdk21 + +COPY docker/tomcat-conf/server.xml /usr/local/tomcat/conf/server.xml +COPY docker/tomcat-conf/context.xml /usr/local/tomcat/conf/context.xml +COPY docker/tomcat-conf/catalina.properties /usr/local/tomcat/conf/catalina.properties + +COPY --from=build /app/chpl-api/target/chpl-service.war /usr/local/tomcat/webapps/chpl-service.war + +# Expose our custom Tomcat port +EXPOSE 8181 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..fb863b9c96 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,147 @@ +# Running the chpl-api Docker image + +`docker/Dockerfile` builds a **secret-free** image: the same image is used for +every environment (development/qa/staging/production). Nothing sensitive is +baked in at build time - all of it is supplied as container environment +variables when the image is actually run. See `docker/Dockerfile` and +`docker/tomcat-conf/` for how that works mechanically (Tomcat's +`EnvironmentPropertySource` for `server.xml`, Spring's `Environment` for +everything else). + +This doc lists every environment variable the image needs or accepts. It does +**not** contain real values for any environment - only what each variable is +for and what kind of value it expects. Real values for existing environments +currently live in [AudaciousInquiry/chpl-build](https://github.com/AudaciousInquiry/chpl-build) +(`chpl-build-{dev,qa,stg,prod}/src/main/resources/override-api-properties*.sh`) +- that repo should stay private, and its values should never be copied into +this one. + +## Quick start + +```sh +docker run -d --name chpl-api \ + -p 8181:8181 \ + -e DB_URL="jdbc:postgresql://:5432/openchpl" \ + -e DB_USERNAME="" \ + -e DB_PASSWORD="" \ + -e chplUrlBegin="https://chpl-dev.healthit.gov" \ + -e SPRING_REDIS_HOST="" \ + -e SPRING_REDIS_PORT="6379" \ + -e SPRING_REDIS_PASSWORD="" \ + ghcr.io//chpl-api:latest-development +``` + +Add `-e` flags for whichever of the feature-area variables below apply to +your environment - omitted ones just leave that feature unconfigured. + +Note: property keys that contain dots (like `spring.redis.host`) are **not** +valid POSIX environment variable names on every shell/platform - Spring will +also match the all-caps/underscore form (`SPRING_REDIS_HOST`). Use whichever +form your deployment tooling handles more easily; both resolve to the same +property. + +## Required to start at all + +| Env var | Used for | +|---|---| +| `DB_URL` | JDBC URL for the `jdbc/openchpl` datasource (`server.xml`) | +| `DB_USERNAME` | DB username (`server.xml`) | +| `DB_PASSWORD` | DB password (`server.xml`) | + +## Filesystem paths (need a mounted volume, not just an env var) + +These properties are file/directory paths the app reads or writes at runtime. +Mount a volume at the path you set, or the app will fail writing to a +read-only container filesystem. + +| Env var | Property | Purpose | +|---|---|---| +| `downloadFolderPath` | `downloadFolderPath` | Where generated downloadable files are written | +| `auditDataFilePath` | `auditDataFilePath` | Where audit data backups are written | + +## Environment-dependent (not secret, but should differ per environment) + +| Env var | Property | Purpose | Example shape | +|---|---|---|---| +| `chplUrlBegin` | `chplUrlBegin` | Public base URL for this environment | `https://chpl-qa.healthit.gov` | +| `EMAILBUILDER_CONFIG_EMAILSUBJECTSUFFIX` | `emailBuilder.config.emailSubjectSuffix` | Tag appended to outgoing email subjects | `[QA]` | +| `SERVER_ENVIRONMENT` | `server.environment` | `non-production` or `production` | | +| `REPORT_ENVIRONMENT` | `report.environment` | Label used on generated reports | `DEV`, `QA`, `STAGE`, `PROD` | + +## Feature-area secrets + +Every property below is marked `SECRET` in +[`environment.properties`](../chpl/chpl-resources/src/main/resources/environment.properties) +or [`email.properties`](../chpl/chpl-resources/src/main/resources/email.properties) +- meaning the app has no usable default and one of these env vars must be set +for that feature to work. If a feature isn't used in a given environment, its +variables can be omitted. + +**Database / cache** +| Env var | Property | +|---|---| +| `SPRING_REDIS_HOST` | `spring.redis.host` | +| `SPRING_REDIS_PORT` | `spring.redis.port` | +| `SPRING_REDIS_PASSWORD` | `spring.redis.password` | + +**AWS Cognito (authentication)** +| Env var | Property | +|---|---| +| `COGNITO_ACCESSKEY` | `cognito.accessKey` | +| `COGNITO_SECRETKEY` | `cognito.secretKey` | +| `COGNITO_REGION` | `cognito.region` | +| `COGNITO_USERPOOLID` | `cognito.userPoolId` | +| `COGNITO_USERPOOLCLIENTSECRET` | `cognito.userPoolClientSecret` | +| `COGNITO_CLIENTID` | `cognito.clientId` | +| `COGNITO_ENVIRONMENT_GROUPNAME` | `cognito.environment.groupName` | +| `COGNITO_SYSTEMUSERUUID` | `cognito.systemUserUuid` | +| `COGNITO_ANONYMOUSUSERUUID` | `cognito.anonymousUserUuid` | + +**ONC Azure AD** +| Env var | Property | +|---|---| +| `AZURE_USER_ONC` | `azure.user.onc` | +| `AZURE_CLIENTID_ONC` | `azure.clientId.onc` | +| `AZURE_CLIENTSECRET_ONC` | `azure.clientSecret.onc` | +| `AZURE_TENANTID_ONC` | `azure.tenantId.onc` | + +**JIRA** +| Env var | Property | +|---|---| +| `JIRA_USERNAME` | `jira.username` | +| `JIRA_PASSWORD` | `jira.password` | + +**Datadog** +| Env var | Property | +|---|---| +| `DATADOG_APIKEY` | `datadog.apiKey` | +| `DATADOG_APPKEY` | `datadog.appKey` | + +**AIA (Real World Testing validation)** +| Env var | Property | +|---|---| +| `AIA_AUTHENTICATE_CLIENTSECRET` | `aia.authenticate.clientSecret` | +| `AIA_AUTHENTICATE_CLIENTID` | `aia.authenticate.clientId` | + +**FF4J admin console** +| Env var | Property | +|---|---| +| `FF4J_WEBCONSOLE_USERNAME` | `ff4j.webconsole.username` | +| `FF4J_WEBCONSOLE_PASSWORD` | `ff4j.webconsole.password` | + +**Email / notifications** +| Env var | Property | +|---|---| +| `internalErrorEmailRecipients` | `internalErrorEmailRecipients` | +| `internalFutureCertificationStatusEmailRecipients` | `internalFutureCertificationStatusEmailRecipients` | +| `emailBuilder_config_forwardAddress` | `emailBuilder_config_forwardAddress` | +| `DIRECTREVIEW_CHPLCHANGES_EMAIL` | `directReview.chplChanges.email` | +| `DIRECTREVIEW_UNKNOWNCHANGES_EMAIL` | `directReview.unknownChanges.email` | + +## Anything not listed here + +`environment.properties`, `email.properties`, `lookup.properties`, and +`errors.properties` (all in `chpl/chpl-resources/src/main/resources/`) have +sensible non-secret defaults for everything else, and can still be overridden +the same way (env var name = property name, dots -> underscores, upper-cased) +if a particular deployment needs to tune something not listed above. diff --git a/docker/tomcat-conf/catalina.properties b/docker/tomcat-conf/catalina.properties new file mode 100644 index 0000000000..91ba487f86 --- /dev/null +++ b/docker/tomcat-conf/catalina.properties @@ -0,0 +1,202 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# +# +# List of comma-separated paths defining the contents of the "common" +# classloader. Prefixes should be used to define what is the repository type. +# Path may be relative to the CATALINA_HOME or CATALINA_BASE path or absolute. +# If left as blank,the JVM system loader will be used as Catalina's "common" +# loader. +# Examples: +# "foo": Add this folder as a class repository +# "foo/*.jar": Add all the JARs of the specified folder as class +# repositories +# "foo/bar.jar": Add bar.jar as a class repository +# +# Note: Values are enclosed in double quotes ("...") in case either the +# ${catalina.base} path or the ${catalina.home} path contains a comma. +# Because double quotes are used for quoting, the double quote character +# may not appear in a path. +common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar","${catalina.home}/lib","${catalina.home}/lib/*.jar" + +# +# List of comma-separated paths defining the contents of the "server" +# classloader. Prefixes should be used to define what is the repository type. +# Path may be relative to the CATALINA_HOME or CATALINA_BASE path or absolute. +# If left as blank, the "common" loader will be used as Catalina's "server" +# loader. +# Examples: +# "foo": Add this folder as a class repository +# "foo/*.jar": Add all the JARs of the specified folder as class +# repositories +# "foo/bar.jar": Add bar.jar as a class repository +# +# Note: Values may be enclosed in double quotes ("...") in case either the +# ${catalina.base} path or the ${catalina.home} path contains a comma. +# Because double quotes are used for quoting, the double quote character +# may not appear in a path. +server.loader= + +# +# List of comma-separated paths defining the contents of the "shared" +# classloader. Prefixes should be used to define what is the repository type. +# Path may be relative to the CATALINA_BASE path or absolute. If left as blank, +# the "common" loader will be used as Catalina's "shared" loader. +# Examples: +# "foo": Add this folder as a class repository +# "foo/*.jar": Add all the JARs of the specified folder as class +# repositories +# "foo/bar.jar": Add bar.jar as a class repository +# Please note that for single jars, e.g. bar.jar, you need the URL form +# starting with file:. +# +# Note: Values may be enclosed in double quotes ("...") in case either the +# ${catalina.base} path or the ${catalina.home} path contains a comma. +# Because double quotes are used for quoting, the double quote character +# may not appear in a path. +shared.loader= + +# Default list of JAR files that should not be scanned using the JarScanner +# functionality. This is typically used to scan JARs for configuration +# information. JARs that do not contain such information may be excluded from +# the scan to speed up the scanning process. This is the default list. JARs on +# this list are excluded from all scans. The list must be a comma separated list +# of JAR file names. +# The list of JARs to skip may be over-ridden at a Context level for individual +# scan types by configuring a JarScanner with a nested JarScanFilter. +# The JARs listed below include: +# - Tomcat Bootstrap JARs +# - Tomcat API JARs +# - Catalina JARs +# - Jasper JARs +# - Tomcat JARs +# - Common non-Tomcat JARs +# - Test JARs (JUnit, Cobertura and dependencies) +tomcat.util.scan.StandardJarScanFilter.jarsToSkip=\ +annotations-api.jar,\ +ant-junit*.jar,\ +ant-launcher*.jar,\ +ant*.jar,\ +asm-*.jar,\ +aspectj*.jar,\ +bcel*.jar,\ +biz.aQute.bnd*.jar,\ +bootstrap.jar,\ +catalina-ant.jar,\ +catalina-ha.jar,\ +catalina-ssi.jar,\ +catalina-storeconfig.jar,\ +catalina-tribes.jar,\ +catalina.jar,\ +cglib-*.jar,\ +cobertura-*.jar,\ +commons-beanutils*.jar,\ +commons-codec*.jar,\ +commons-collections*.jar,\ +commons-compress*.jar,\ +commons-daemon.jar,\ +commons-dbcp*.jar,\ +commons-digester*.jar,\ +commons-fileupload*.jar,\ +commons-httpclient*.jar,\ +commons-io*.jar,\ +commons-lang*.jar,\ +commons-logging*.jar,\ +commons-math*.jar,\ +commons-pool*.jar,\ +derby-*.jar,\ +dom4j-*.jar,\ +easymock-*.jar,\ +ecj-*.jar,\ +el-api.jar,\ +geronimo-spec-jaxrpc*.jar,\ +h2*.jar,\ +ha-api-*.jar,\ +hamcrest-*.jar,\ +hibernate*.jar,\ +httpclient*.jar,\ +icu4j-*.jar,\ +jakartaee-migration-*.jar,\ +jasper-el.jar,\ +jasper.jar,\ +jaspic-api.jar,\ +jaxb-*.jar,\ +jaxen-*.jar,\ +jaxws-rt-*.jar,\ +jdom-*.jar,\ +jetty-*.jar,\ +jmx-tools.jar,\ +jmx.jar,\ +jsp-api.jar,\ +jstl.jar,\ +jta*.jar,\ +junit-*.jar,\ +junit.jar,\ +log4j*.jar,\ +mail*.jar,\ +objenesis-*.jar,\ +oraclepki.jar,\ +org.hamcrest.core_*.jar,\ +org.junit_*.jar,\ +oro-*.jar,\ +servlet-api-*.jar,\ +servlet-api.jar,\ +slf4j*.jar,\ +taglibs-standard-spec-*.jar,\ +tagsoup-*.jar,\ +tomcat-api.jar,\ +tomcat-coyote.jar,\ +tomcat-coyote-ffm.jar,\ +tomcat-dbcp.jar,\ +tomcat-i18n-*.jar,\ +tomcat-jdbc.jar,\ +tomcat-jni.jar,\ +tomcat-juli-adapters.jar,\ +tomcat-juli.jar,\ +tomcat-util-scan.jar,\ +tomcat-util.jar,\ +tomcat-websocket.jar,\ +tools.jar,\ +unboundid-ldapsdk-*.jar,\ +websocket-api.jar,\ +websocket-client-api.jar,\ +wsdl4j*.jar,\ +xercesImpl.jar,\ +xml-apis.jar,\ +xmlParserAPIs-*.jar,\ +xmlParserAPIs.jar,\ +xom-*.jar + +# Default list of JAR files that should be scanned that overrides the default +# jarsToSkip list above. This is typically used to include a specific JAR that +# has been excluded by a broad file name pattern in the jarsToSkip list. +# The list of JARs to scan may be over-ridden at a Context level for individual +# scan types by configuring a JarScanner with a nested JarScanFilter. +tomcat.util.scan.StandardJarScanFilter.jarsToScan=\ +log4j-taglib*.jar,\ +log4j-jakarta-web*.jar,\ +log4javascript*.jar,\ +slf4j-taglib*.jar + +# String cache configuration. +tomcat.util.buf.StringCache.byte.enabled=true +#tomcat.util.buf.StringCache.char.enabled=true +#tomcat.util.buf.StringCache.trainThreshold=500000 +#tomcat.util.buf.StringCache.cacheSize=5000 + +# Enables ${ENV_VAR} substitution in server.xml/context.xml, resolved from +# container environment variables at startup (used for jdbc/openchpl above). +org.apache.tomcat.util.digester.PROPERTY_SOURCE=org.apache.tomcat.util.digester.EnvironmentPropertySource diff --git a/docker/tomcat-conf/context.xml b/docker/tomcat-conf/context.xml new file mode 100644 index 0000000000..8f002a1d2a --- /dev/null +++ b/docker/tomcat-conf/context.xml @@ -0,0 +1,35 @@ + + + + + + + + WEB-INF/web.xml + WEB-INF/tomcat-web.xml + ${catalina.base}/conf/web.xml + + + + + + + diff --git a/docker/tomcat-conf/server.xml b/docker/tomcat-conf/server.xml new file mode 100644 index 0000000000..9b78900dc6 --- /dev/null +++ b/docker/tomcat-conf/server.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +