diff --git a/.github/workflows/_reusable_deploy.yml b/.github/workflows/_reusable_deploy.yml index fd3de0dc1fa..17252d0443b 100644 --- a/.github/workflows/_reusable_deploy.yml +++ b/.github/workflows/_reusable_deploy.yml @@ -53,16 +53,26 @@ jobs: # INSTALL DEPENDENCIES - name: Install NodeJS dependencies run: npm ci --ignore-scripts + working-directory: "./infra" + + - name: Build + run: npm run build + working-directory: "./infra" + + - name: Test + run: npm run test + working-directory: "./infra" # CDK DIFF - name: Diff CDK stack uses: metriport/deploy-with-cdk@master with: cdk_action: "diff" - cdk_version: "2.49.0" + cdk_version: "2.122.0" cdk_stack: "FHIRServerStack" cdk_env: "${{ inputs.deploy_env }}" env: + INPUT_PATH: "infra" AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} @@ -72,10 +82,11 @@ jobs: uses: metriport/deploy-with-cdk@master with: cdk_action: "deploy --verbose --require-approval never" - cdk_version: "2.49.0" + cdk_version: "2.122.0" cdk_stack: "FHIRServerStack" cdk_env: "${{ inputs.deploy_env }}" env: + INPUT_PATH: "infra" AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index d290846c1a4..5e8c5d86c53 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -1,13 +1,13 @@ -name: Deploy - Production +name: Deploy - PRODUCTION on: - # push: # a commit to the specified branches, if any - # branches: - # - master + push: # a commit to the specified branches, if any + branches: + - master workflow_dispatch: # manually executed by a user jobs: - deploy: + deploy-prod: uses: ./.github/workflows/_reusable_deploy.yml with: deploy_env: "production" @@ -16,3 +16,14 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ secrets.REGION_PRODUCTION }} INFRA_CONFIG: ${{ secrets.INFRA_CONFIG_PRODUCTION }} + + deploy-sandbox: + uses: ./.github/workflows/_reusable_deploy.yml + needs: [deploy-prod] + with: + deploy_env: "sandbox" + secrets: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.REGION_SANDBOX }} + INFRA_CONFIG: ${{ secrets.INFRA_CONFIG_SANDBOX }} diff --git a/.github/workflows/deploy-sandbox.yml b/.github/workflows/deploy-sandbox.yml deleted file mode 100644 index 676d2d7ad5d..00000000000 --- a/.github/workflows/deploy-sandbox.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Deploy - Sandbox - -on: - # push: # a commit to the specified branches, if any - # branches: - # - master - workflow_dispatch: # manually executed by a user - -jobs: - deploy: - uses: ./.github/workflows/_reusable_deploy.yml - with: - deploy_env: "sandbox" - secrets: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: ${{ secrets.REGION_STAGING }} - INFRA_CONFIG: ${{ secrets.INFRA_CONFIG_STAGING }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 42f555a19c7..91f023f2ced 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -1,9 +1,9 @@ name: Deploy - Staging on: - # push: # a commit to the specified branches, if any - # branches: - # - develop + push: # a commit to the specified branches, if any + branches: + - develop workflow_dispatch: # manually executed by a user jobs: diff --git a/Dockerfile b/Dockerfile index 5947974db25..accdf60a3c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=linux/arm64/v8 maven:3.9-eclipse-temurin-17 as build-fhir +FROM --platform=linux/amd64 maven:3.9-eclipse-temurin-17 as build-fhir WORKDIR /tmp/hapi-fhir-jpaserver-starter ARG OPENTELEMETRY_JAVA_AGENT_VERSION=1.17.0 diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000000..5947974db25 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,29 @@ +FROM --platform=linux/arm64/v8 maven:3.9-eclipse-temurin-17 as build-fhir +WORKDIR /tmp/hapi-fhir-jpaserver-starter + +ARG OPENTELEMETRY_JAVA_AGENT_VERSION=1.17.0 +RUN curl -LSsO https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OPENTELEMETRY_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar + +COPY pom.xml . +COPY server.xml . +RUN mvn -ntp dependency:go-offline + +COPY src/ ./src/ +RUN mvn clean install -DskipTests -Djdk.lang.Process.launchMechanism=vfork + +FROM build-fhir AS build-distroless +RUN mvn package spring-boot:repackage -Pboot +RUN mkdir /app && cp ./target/ROOT.war /app/main.war + +########### distroless brings focus on security and runs on plain spring boot - this is the default image +FROM gcr.io/distroless/java17-debian11:nonroot as default +# 65532 is the nonroot user's uid +# used here instead of the name to allow Kubernetes to easily detect that the container +# is running as a non-root (uid != 0) user. +USER 65532:65532 +WORKDIR /app + +COPY --chown=nonroot:nonroot --from=build-distroless /app /app +COPY --chown=nonroot:nonroot --from=build-fhir /tmp/hapi-fhir-jpaserver-starter/opentelemetry-javaagent.jar /app + +ENTRYPOINT ["java", "--class-path", "/app/main.war", "-Dloader.path=main.war!/WEB-INF/classes/,main.war!/WEB-INF/,/app/extra-classes", "org.springframework.boot.loader.PropertiesLauncher", "app/main.war"] diff --git a/docker-compose-fhir-converter.yml b/docker-compose-fhir-converter.yml index 4e77e037d33..f32487eb18f 100644 --- a/docker-compose-fhir-converter.yml +++ b/docker-compose-fhir-converter.yml @@ -1,7 +1,9 @@ version: "3" services: fhir-server-fhir-converter: - build: . + build: + context: ./ + dockerfile: ./Dockerfile.dev container_name: fhir-server-fhir-converter depends_on: fhir-postgres-fhir-converter: diff --git a/docker-compose.yml b/docker-compose.yml index 7c4a5dce538..5b4943f5006 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ version: "3" services: fhir-server: - build: . + build: + context: ./ + dockerfile: ./Dockerfile.dev container_name: fhir-server depends_on: fhir-postgres: diff --git a/infra/README.md b/infra/README.md index 323054a41e4..bc2052e9253 100644 --- a/infra/README.md +++ b/infra/README.md @@ -14,3 +14,18 @@ Run these commands on the terminal from the `./infra` folder of this repository: $ cdk bootstrap -c env= # only needs to be run once $ ./deploy.sh ``` + +### Updating the configuration + +Currently, the configuration is Base64 encoded and stored on GH secrets. + +```shell +$ base64 -i infra/config/staging.ts +$ base64 -i infra/config/production.ts +$ base64 -i infra/config/sandbox.ts +``` + +Copy the resulting strings and update the respective secrets: +- `INFRA_CONFIG_STAGING` +- `INFRA_CONFIG_PRODUCTION` +- `INFRA_CONFIG_SANDBOX` \ No newline at end of file diff --git a/infra/lib/fhir-server-stack.ts b/infra/lib/fhir-server-stack.ts index c215afb97c2..adebf133bfe 100644 --- a/infra/lib/fhir-server-stack.ts +++ b/infra/lib/fhir-server-stack.ts @@ -1,4 +1,11 @@ -import { Aspects, CfnOutput, Duration, Stack, StackProps } from "aws-cdk-lib"; +import { + Aspects, + CfnOutput, + Duration, + RemovalPolicy, + Stack, + StackProps, +} from "aws-cdk-lib"; import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import * as ec2 from "aws-cdk-lib/aws-ec2"; @@ -19,6 +26,7 @@ import { Construct } from "constructs"; import { EnvConfig } from "./env-config"; import { getConfig } from "./shared/config"; import { vCPU } from "./shared/fargate"; +import { addDefaultMetricsToTargetGroup } from "./shared/target-group"; import { isProd, isSandbox, mbToBytes } from "./util"; export function settings() { @@ -146,6 +154,8 @@ export class FHIRServerStack extends Stack { storageEncrypted: true, parameterGroup, cloudwatchLogsExports: ["postgresql"], + deletionProtection: true, + removalPolicy: RemovalPolicy.RETAIN, }); Aspects.of(dbCluster).add({ @@ -227,7 +237,7 @@ export class FHIRServerStack extends Stack { publicLoadBalancer: false, idleTimeout: maxExecutionTimeout, runtimePlatform: { - cpuArchitecture: ecs.CpuArchitecture.ARM64, + cpuArchitecture: ecs.CpuArchitecture.X86_64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, }, } @@ -293,6 +303,14 @@ export class FHIRServerStack extends Stack { scaleOutCooldown: Duration.seconds(30), }); + const targetGroup = fargateService.targetGroup; + addDefaultMetricsToTargetGroup({ + targetGroup, + scope: this, + id: "FhirServer", + alarmAction, + }); + // allow the NLB to talk to fargate fargateService.service.connections.allowFrom( ec2.Peer.ipv4(this.vpc.vpcCidrBlock), @@ -385,6 +403,21 @@ export class FHIRServerStack extends Stack { evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); + + /** + * For Aurora Serverless, this alarm is not important as it auto-scales. However, we always + * create this alarm because of compliance controls (SOC2). + * @see: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.StorageReliability.html#aurora-storage-growth + */ + createAlarm({ + metric: dbCluster.metricFreeLocalStorage(), + name: "FreeLocalStorageAlarm", + threshold: mbToBytes(10_000), + evaluationPeriods: 1, + comparisonOperator: + cloudwatch.ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); } } diff --git a/infra/lib/shared/cloudwatch-metric.ts b/infra/lib/shared/cloudwatch-metric.ts new file mode 100644 index 00000000000..501fa0753f1 --- /dev/null +++ b/infra/lib/shared/cloudwatch-metric.ts @@ -0,0 +1,35 @@ +import { Alarm, ComparisonOperator, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch"; +import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; +import { Construct } from "constructs"; + +export function addAlarmToMetric({ + scope, + metric, + alarmName, + threshold, + evaluationPeriods, + comparisonOperator, + treatMissingData, + alarmAction, + includeOkAction = true, +}: { + scope: Construct; + metric: Metric; + alarmName: string; + threshold: number; + evaluationPeriods: number; + comparisonOperator?: ComparisonOperator; + treatMissingData?: TreatMissingData; + alarmAction?: SnsAction; + includeOkAction?: boolean; +}): Alarm { + const alarm = metric.createAlarm(scope, alarmName, { + threshold, + evaluationPeriods, + comparisonOperator, + treatMissingData, + }); + alarmAction && alarm.addAlarmAction(alarmAction); + alarmAction && includeOkAction && alarm.addOkAction(alarmAction); + return alarm; +} diff --git a/infra/lib/shared/target-group.ts b/infra/lib/shared/target-group.ts new file mode 100644 index 00000000000..1c8f2fbb017 --- /dev/null +++ b/infra/lib/shared/target-group.ts @@ -0,0 +1,52 @@ +import { Duration } from "aws-cdk-lib"; +import { ComparisonOperator, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch"; +import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; +import { ApplicationTargetGroup, HttpCodeTarget } from "aws-cdk-lib/aws-elasticloadbalancingv2"; +import { Construct } from "constructs"; +import { addAlarmToMetric } from "../shared/cloudwatch-metric"; + +export function addDefaultMetricsToTargetGroup({ + targetGroup, + scope, + id, + idx = 0, + alarmAction, +}: { + targetGroup: ApplicationTargetGroup; + scope: Construct; + id: string; + idx?: number; + alarmAction?: SnsAction; +}) { + const name = `${id}_TargetGroup${idx}`; + addAlarmToMetric({ + scope, + metric: targetGroup.metrics.unhealthyHostCount(), + alarmName: `${name}_UnhealthyRequestCount`, + threshold: 1, + evaluationPeriods: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: TreatMissingData.NOT_BREACHING, + alarmAction, + }); + addAlarmToMetric({ + scope, + metric: targetGroup.metrics.targetResponseTime(), + alarmName: `${name}_TargetResponseTime`, + threshold: Duration.seconds(29).toSeconds(), + evaluationPeriods: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: TreatMissingData.NOT_BREACHING, + alarmAction, + }); + addAlarmToMetric({ + scope, + metric: targetGroup.metrics.httpCodeTarget(HttpCodeTarget.TARGET_5XX_COUNT), + alarmName: `${name}_Target5xxCount`, + threshold: 5, + evaluationPeriods: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: TreatMissingData.NOT_BREACHING, + alarmAction, + }); +} diff --git a/infra/package-lock.json b/infra/package-lock.json index 0a155510515..58e16ec7ea0 100644 --- a/infra/package-lock.json +++ b/infra/package-lock.json @@ -8,8 +8,8 @@ "name": "infrastructure", "version": "0.1.0", "dependencies": { - "aws-cdk-lib": "2.87.0", - "constructs": "^10.1.144", + "aws-cdk-lib": "2.122.0", + "constructs": "^10.2.69", "source-map-support": "^0.5.21" }, "bin": { @@ -22,7 +22,7 @@ "@types/prettier": "2.7.1", "@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/parser": "^5.48.2", - "aws-cdk": "2.87.0", + "aws-cdk": "2.122.0", "esbuild": "^0.15.12", "eslint": "^8.32.0", "eslint-config-prettier": "^8.6.0", @@ -56,10 +56,10 @@ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-kubectl-v20/-/asset-kubectl-v20-2.1.2.tgz", "integrity": "sha512-3M2tELJOxQv0apCIiuKQ4pAbncz9GuLwnKFqxifWfe77wuMxyTRPmxssYHs42ePqzap1LT6GDcPygGs+hHstLg==" }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v5": { - "version": "2.0.166", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v5/-/asset-node-proxy-agent-v5-2.0.166.tgz", - "integrity": "sha512-j0xnccpUQHXJKPgCwQcGGNu4lRiC1PptYfdxBIH1L4dRK91iBxtSQHESRQX+yB47oGLaF/WfNN/aF3WXwlhikg==" + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.0.3.tgz", + "integrity": "sha512-twhuEG+JPOYCYPx/xy5uH2+VUsIEhPTzDY0F1KuB+ocjWWB/KEDiOVL19nHvbPCB6fhWnkykXEMJ4HHcKvjtvg==" }, "node_modules/@babel/code-frame": { "version": "7.18.6", @@ -1740,9 +1740,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.87.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.87.0.tgz", - "integrity": "sha512-dBm74nl3dMUxoAzgjcfKnzJyoVNIV//B1sqDN11cC3LXEflYapcBxPxZHAyGcRXg5dW3m14dMdKVQfmt4N970g==", + "version": "2.122.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.122.0.tgz", + "integrity": "sha512-WqiVTedcuW4LjH4WqtQncliUdeDa9j9xgu3II8Qd1HmCZotbzBorYIHDvOJ+m3ovIzd9DL+hNq9PPUqxtBe0VQ==", "dev": true, "bin": { "cdk": "bin/cdk" @@ -1755,9 +1755,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.87.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.87.0.tgz", - "integrity": "sha512-9kirXX7L7OP/yGmCbaYlkt5OAtowGiGw0AYFIQvSwvx/UU3aJO5XuDwAgDsvToDkRpBi0yX0bNwqa0DItu+C6A==", + "version": "2.122.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.122.0.tgz", + "integrity": "sha512-NBUfYk/SialvKFsvBG/Ucd7lM+BID3Uy3EEOnIBbioDpnMotm5SDaU/RUm4APS4sxzQZX1DjduD5ZUFNHnEWhQ==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -1771,17 +1771,17 @@ "yaml" ], "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.177", - "@aws-cdk/asset-kubectl-v20": "^2.1.1", - "@aws-cdk/asset-node-proxy-agent-v5": "^2.0.148", + "@aws-cdk/asset-awscli-v1": "^2.2.201", + "@aws-cdk/asset-kubectl-v20": "^2.1.2", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.0.1", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", - "fs-extra": "^11.1.1", - "ignore": "^5.2.4", + "fs-extra": "^11.2.0", + "ignore": "^5.3.0", "jsonschema": "^1.4.1", "minimatch": "^3.1.2", - "punycode": "^2.3.0", - "semver": "^7.5.1", + "punycode": "^2.3.1", + "semver": "^7.5.4", "table": "^6.8.1", "yaml": "1.10.2" }, @@ -1896,7 +1896,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.1.1", + "version": "11.2.0", "inBundle": true, "license": "MIT", "dependencies": { @@ -1914,7 +1914,7 @@ "license": "ISC" }, "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.2.4", + "version": "5.3.0", "inBundle": true, "license": "MIT", "engines": { @@ -1981,7 +1981,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.0", + "version": "2.3.1", "inBundle": true, "license": "MIT", "engines": { @@ -1997,7 +1997,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.5.2", + "version": "7.5.4", "inBundle": true, "license": "ISC", "dependencies": { @@ -2066,7 +2066,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.0", + "version": "2.0.1", "inBundle": true, "license": "MIT", "engines": { @@ -2402,11 +2402,11 @@ "dev": true }, "node_modules/constructs": { - "version": "10.1.255", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.1.255.tgz", - "integrity": "sha512-m7ddP9mF92096r4/2oqUg8sUYNdt7x2tjRSltnTtgvqiasBr0gSjzLkHqlGR0IrnvQWemv1kA1GESWxYAK20vw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.3.0.tgz", + "integrity": "sha512-vbK8i3rIb/xwZxSpTjz3SagHn1qq9BChLEfy5Hf6fB3/2eFbrwt2n9kHwQcS0CPTRBesreeAcsJfMq2229FnbQ==", "engines": { - "node": ">= 14.17.0" + "node": ">= 16.14.0" } }, "node_modules/convert-source-map": { @@ -4911,6 +4911,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } diff --git a/infra/package.json b/infra/package.json index 4ab458e50ef..869b565e8b3 100644 --- a/infra/package.json +++ b/infra/package.json @@ -19,7 +19,7 @@ "@types/prettier": "2.7.1", "@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/parser": "^5.48.2", - "aws-cdk": "2.87.0", + "aws-cdk": "2.122.0", "esbuild": "^0.15.12", "eslint": "^8.32.0", "eslint-config-prettier": "^8.6.0", @@ -30,8 +30,8 @@ "typescript": "~4.8.4" }, "dependencies": { - "aws-cdk-lib": "2.87.0", - "constructs": "^10.1.144", + "aws-cdk-lib": "2.122.0", + "constructs": "^10.2.69", "source-map-support": "^0.5.21" } }