diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 54969108..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: LAMP Server Build Test -on: [push, pull_request] -jobs: - build: - name: Build - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [12.x] - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: npm install, build, and test - env: - SECRETS: ${{ secrets.LAMP_SECRETS }} - run: | - npm install - echo $SECRETS > src/secrets.ts - npm run build diff --git a/.github/workflows/callable-build-docker.yml b/.github/workflows/callable-build-docker.yml new file mode 100644 index 00000000..12fd1731 --- /dev/null +++ b/.github/workflows/callable-build-docker.yml @@ -0,0 +1,107 @@ +name: "Task: Build" +on: + workflow_call: + inputs: + override_sha: + description: 'Optionally force checkout of a specific sha' + default: '' + type: string + push: + description: 'To push or not to push' + required: true + type: boolean + outputs: + container_image_digest: + description: "The sha256 digest of the built container image" + value: ${{ jobs.docker.outputs.digest }} + +permissions: + packages: write + contents: read + attestations: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-24.04 + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - uses: actions/checkout@v4 + if: ${{ inputs.override_sha != '' }} + with: + fetch-depth: 1 + ref: ${{ inputs.override_sha }} + + - uses: actions/checkout@v4 + if: ${{ inputs.override_sha == '' }} + with: + fetch-depth: 1 + + - name: Configuration + id: config + run: | + REPOSITORY_OWNER=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.repository_owner }}") + echo "REPOSITORY_OWNER=${REPOSITORY_OWNER}" >> "$GITHUB_OUTPUT" + + REPOSITORY_NAME=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.event.repository.name }}") + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> "$GITHUB_OUTPUT" + + TARGET_IMAGE="ghcr.io/${REPOSITORY_OWNER}/${REPOSITORY_NAME}" + echo "TARGET_IMAGE=${TARGET_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ steps.config.outputs.REPOSITORY_OWNER }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + context: workflow + images: ${{ steps.config.outputs.TARGET_IMAGE }} + tags: |- + ${{ inputs.override_sha == '' && 'type=ref,event=branch' || '' }} + ${{ inputs.override_sha == '' && 'type=ref,event=tag' || '' }} + ${{ inputs.override_sha == '' && 'type=ref,event=pr' || '' }} + ${{ inputs.override_sha == '' && '# skip raw sha' || format('type=raw,value=sha-{0}', inputs.override_sha) }} + labels: |- + ${{ inputs.override_sha != '' && 'org.opencontainers.image.version=unknown' }} + ${{ inputs.override_sha != '' && format('org.opencontainers.image.revision={0}', inputs.override_sha) }} + flavor: | + latest=false + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ inputs.push }} + platforms: "linux/amd64" + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + if: ${{ inputs.push }} + with: + subject-name: ${{ steps.config.outputs.TARGET_IMAGE }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: ${{ inputs.push }} + + - name: Diff containers + run: | + wget https://github.com/reproducible-containers/diffoci/releases/download/v0.1.7/diffoci-v0.1.7.linux-amd64 -O diffoci + chmod +x diffoci + ./diffoci diff --semantic ghcr.io/bracketsoftware/lamp-server:pr-3 ghcr.io/bidmcdigitalpsychiatry/lamp-server:2023.7.27 diff --git a/.github/workflows/callable-deploy-ecs.yml b/.github/workflows/callable-deploy-ecs.yml new file mode 100644 index 00000000..1dc7c0b7 --- /dev/null +++ b/.github/workflows/callable-deploy-ecs.yml @@ -0,0 +1,98 @@ +name: "Task: Deploy" +on: + workflow_call: + inputs: + env: + description: Target environment of deployment. (dev, stg, prod) + required: true + type: string + container_digest: + description: 'The container sha256 digest to deploy' + required: true + type: string + +permissions: + contents: 'read' + id-token: 'write' + +concurrency: + group: ${{ inputs.env }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-24.04 + environment: ${{ inputs.env }} + steps: + - uses: actions/checkout@v4 + + - name: Configuration + id: config + run: | + REPOSITORY_OWNER=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.repository_owner }}") + echo "REPOSITORY_OWNER=${REPOSITORY_OWNER}" >> "$GITHUB_OUTPUT" + + REPOSITORY_NAME=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.event.repository.name }}") + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> "$GITHUB_OUTPUT" + + TARGET_IMAGE="ghcr.io/${REPOSITORY_OWNER}/${REPOSITORY_NAME}" + echo "TARGET_IMAGE=${TARGET_IMAGE}" >> "$GITHUB_OUTPUT" + + TARGET_IMAGE_W_DIGEST="${TARGET_IMAGE}@${{ inputs.container_digest }}" + echo "TARGET_IMAGE_W_DIGEST=${TARGET_IMAGE_W_DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.IAM_ROLE_ARN }} + aws-region: us-east-2 + + - name: Download task definition + run: | + aws ecs describe-task-definition \ + --task-definition ${{ vars.ECS_TASK_DEF_FAMILY }} \ + --query taskDefinition \ + > task.json + + # Remove Ignored Properties + + echo "$( jq 'del(.compatibilities)' task.json )" > task.json + echo "$( jq 'del(.taskDefinitionArn)' task.json )" > task.json + echo "$( jq 'del(.requiresAttributes)' task.json )" > task.json + echo "$( jq 'del(.revision)' task.json )" > task.json + echo "$( jq 'del(.status)' task.json )" > task.json + echo "$( jq 'del(.registeredAt)' task.json )" > task.json + echo "$( jq 'del(.registeredBy)' task.json )" > task.json + + # Update Image + echo "$( jq --arg image "${{ steps.config.outputs.TARGET_IMAGE_W_DIGEST }}" '.containerDefinitions |= map((select(.name == "server") | .image) |= $image)' task.json )" > task.json + cat task.json + + - name: Deploy Amazon ECS task definition + id: ecs-deploy + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + with: + task-definition: task.json + service: ${{ vars.ECS_SERVICE }} + cluster: ${{ vars.ECS_CLUSTER }} + wait-for-service-stability: true + propagate-tags: SERVICE + + - name: Verify deploy + id: check-deployment + run: | + TASK_DEF_EXPECTED=${{ steps.ecs-deploy.outputs.task-definition-arn }} + TASK_DEF_CURRENT=$( + aws ecs describe-services \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --services ${{ vars.ECS_SERVICE }} \ + --query services[0].deployments[0].taskDefinition \ + | jq -r "." + ) + echo "Task Arn - Current: $TASK_DEF_CURRENT" + echo "Task Arn - Expected: $TASK_DEF_EXPECTED" + if [ "$TASK_DEF_CURRENT" != "$TASK_DEF_EXPECTED" ]; then + echo "Current task arn does not match the expected task arn." + echo "The deployment may have been rolled back or been deposed by a more recent deployment attempt" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/manual-deploy-sha.yml b/.github/workflows/manual-deploy-sha.yml new file mode 100644 index 00000000..0a5cb038 --- /dev/null +++ b/.github/workflows/manual-deploy-sha.yml @@ -0,0 +1,90 @@ +name: 'Manual Deploy: Git Sha' +on: + workflow_dispatch: + inputs: + sha: + type: string + required: true + description: Git sha (long format) to build and deploy + env: + type: environment + default: dev + +concurrency: + group: ${{ github.workflow }}-${{ inputs.sha }} + cancel-in-progress: false + +jobs: + # This job builds a new container and deploys it to the target environment. It + # is unsafe to deploy such arbitrary builds directly to production without + # first trialing them in lower environments. + + validate: + name: "Validate Inputs" + runs-on: ubuntu-24.04 + steps: + - name: Verify not production + if: ${{ inputs.env == 'prod' }} + run: | + cat << EOF + #--------------------------------------------------------------------------------- + # ERROR: Cannot deploy arbitrary sha to production + #--------------------------------------------------------------------------------- + # + # DETAILS: + # + # This job builds a new container and deploys it to the target environment. It + # is unsafe to deploy such arbitrary builds directly to production without + # first trialing them in lower environments. + # + # WORKAROUND: + # + # If you truely must release a specific sha, + # + # 1) Use this workflow to build and deploy the sha to staging + # 2) Deploy the assigned tag from that deployment using the + # "Manual Deploy: Docker Tag" workflow + # Note: You must cite a DOCKER tag + # Which should look like "sha-9e14d6f3da3c3c3f7ea73b74dec8c931365745e4" + # + #--------------------------------------------------------------------------------- + EOF + exit 1 + - name: Verify sha is hex + run: | + if [[ ! "${{ inputs.sha }}" =~ ^[0-9A-Fa-f]+$ ]]; then + echo "sha must be hexidecimal"; exit 1 + fi + + length=$(expr length "${{ inputs.sha }}") + if [ "$length" != "40"]; then + echo "sha must be all 40 characters"; exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + + - name: Verify commit exists + run: | + git cat-file commit ${{ inputs.sha }} + + build-docker: + name: "Build" + uses: ./.github/workflows/callable-build-docker.yml + secrets: inherit + with: + override_sha: ${{ inputs.sha }} + push: true + needs: + - validate + + deploy-ecs: + name: "Deploy container to ECS" + uses: ./.github/workflows/callable-deploy-ecs.yml + secrets: inherit + with: + env: ${{ inputs.env }} + container_digest: ${{ needs.build-docker.outputs.container_image_digest }} + needs: + - build-docker \ No newline at end of file diff --git a/.github/workflows/manual-deploy-tag.yml b/.github/workflows/manual-deploy-tag.yml new file mode 100644 index 00000000..bbef99c7 --- /dev/null +++ b/.github/workflows/manual-deploy-tag.yml @@ -0,0 +1,76 @@ +name: 'Manual Deploy: Docker Tag' +on: + workflow_dispatch: + inputs: + tag: + type: string + required: true + description: Tag of the DOCKER image to deploy + env: + type: environment + default: stg + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + + locate: + name: Find Target Image + runs-on: ubuntu-24.04 + outputs: + digest: ${{ steps.inspect.outputs.digest }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Configuration + id: config + run: | + REPOSITORY_OWNER=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.repository_owner }}") + echo "REPOSITORY_OWNER=${REPOSITORY_OWNER}" >> "$GITHUB_OUTPUT" + + REPOSITORY_NAME=$(tr "[:upper:]" "[:lower:]" <<< "${{ github.event.repository.name }}") + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> "$GITHUB_OUTPUT" + + TARGET_IMAGE="ghcr.io/${REPOSITORY_OWNER}/${REPOSITORY_NAME}" + echo "TARGET_IMAGE=${TARGET_IMAGE}" >> "$GITHUB_OUTPUT" + + TARGET_IMAGE_W_TAG="${TARGET_IMAGE}:${{ inputs.tag }}" + echo "TARGET_IMAGE_W_TAG=${TARGET_IMAGE_W_TAG}" >> "$GITHUB_OUTPUT" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Pull target tag & inspect + id: inspect + run: | + set -e + + docker pull ${{ steps.config.outputs.TARGET_IMAGE_W_TAG }} + checksum=$( + docker inspect ${{ steps.config.outputs.TARGET_IMAGE_W_TAG }} \ + | jq '.[].RepoDigests.[]' \ + | tr -d '"' \ + | sed 's/^.*@//' + ) + + echo "digest=${checksum}" >> "$GITHUB_OUTPUT" + deploy: + name: "Deploy" + uses: ./.github/workflows/callable-deploy-ecs.yml + secrets: inherit + with: + env: ${{ inputs.env }} + container_digest: ${{ needs.locate.outputs.digest }} + needs: + - locate \ No newline at end of file diff --git a/.github/workflows/on-merge-to-master.yml b/.github/workflows/on-merge-to-master.yml new file mode 100644 index 00000000..b1e49931 --- /dev/null +++ b/.github/workflows/on-merge-to-master.yml @@ -0,0 +1,29 @@ +name: On Branch Update (master) +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + + build-docker: + name: "Build" + uses: ./.github/workflows/callable-build-docker.yml + secrets: inherit + with: + push: true + + deploy-env-dev: + name: "Deploy Env - Dev" + uses: ./.github/workflows/callable-deploy-ecs.yml + secrets: inherit + with: + env: dev + container_digest: ${{ needs.build-docker.outputs.container_image_digest }} + needs: + - build-docker \ No newline at end of file diff --git a/.github/workflows/on-pr-update.yml b/.github/workflows/on-pr-update.yml new file mode 100644 index 00000000..71ea6195 --- /dev/null +++ b/.github/workflows/on-pr-update.yml @@ -0,0 +1,20 @@ +name: On Pull Request Update +on: + pull_request: + types: + - opened + - reopened + - edited + - synchronize + +concurrency: + group: ${{ github.head_ref }} + cancel-in-progress: true + +jobs: + build-docker: + name: "Build" + uses: ./.github/workflows/callable-build-docker.yml + secrets: inherit + with: + push: true diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml new file mode 100644 index 00000000..80b84c3e --- /dev/null +++ b/.github/workflows/on-tag.yml @@ -0,0 +1,28 @@ +name: On Tag +on: + push: + tags: + - "*" + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + + build-docker: + name: "Build" + uses: ./.github/workflows/callable-build-docker.yml + secrets: inherit + with: + push: true + + deploy-stg: + name: "Deploy Staging" + uses: ./.github/workflows/callable-deploy-ecs.yml + secrets: inherit + with: + env: stg + container_digest: ${{ needs.build-docker.outputs.container_image_digest }} + needs: + - build-docker diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml deleted file mode 100644 index 3db42e54..00000000 --- a/.github/workflows/production.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Build Image -on: - push: - branches: [master] - tags: '*' -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: jerray/publish-docker-action@v1.0.3 - with: - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - registry: ghcr.io - repository: 'bidmcdigitalpsychiatry/lamp-server' - auto_tag: true diff --git a/Dockerfile b/Dockerfile index 4e4c2b23..bb5291da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ -FROM node:18.12.1-alpine3.16 -WORKDIR /usr/src/app -COPY package*.json ./ -RUN npm install -COPY . . -RUN npm run build -EXPOSE 3000 -CMD ["node", "-r", "source-map-support/register", "./build/src/index.js"] +FROM ghcr.io/bidmcdigitalpsychiatry/lamp-server:2023.7.27 + +RUN npm install @sentry/node --save && npm cache clean --force + +ADD tsconfig.json tsconfig.json + +ADD src/app.ts src/app.ts +ADD src/applySentry.ts src/applySentry.ts +ADD src/utils/instrument.ts src/utils/instrument.ts +ADD src/index.ts src/index.ts + +RUN npx tsc --skipLibCheck + +CMD [ "node", "-r", "source-map-support/register", "./build/src/index.js"] + diff --git a/diff.sh b/diff.sh new file mode 100755 index 00000000..120a2405 --- /dev/null +++ b/diff.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +TEMP_DIR=`mktemp -d` + +docker run -it $1 sh -c 'find /usr/src/app -type f | sort | xargs -I{} sha512sum {}' > $TEMP_DIR/dockerfiles.first.txt +docker run -it $2 sh -c 'find /usr/src/app -type f | sort | xargs -I{} sha512sum {}' > $TEMP_DIR/dockerfiles.second.txt + +meld $TEMP_DIR/dockerfiles* diff --git a/src/app.ts b/src/app.ts index b5b63c21..f7177d91 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import express, { Application } from "express" import cors from "cors" import morgan from "morgan" import API from "./service" +import { applySentry } from "./applySentry" const app: Application = express() app.set("json spaces", 2) @@ -14,6 +15,11 @@ app.use(express.urlencoded({ extended: true })) // Establish the API router, as well as a few individual utility routes. app.use("/", API) app.get(["/favicon.ico", "/service-worker.js"], (req, res) => res.status(204)) +app.get("/debug-sentry-77be9c00-79f8-11f0-9595-1f183f5fde2a", function mainHandler(req: any, res: any) { + throw new Error("Demo Sentry Error!"); +}); app.all("*", (req, res) => res.status(404).json({ message: "404.api-endpoint-unimplemented" })) -export default app +applySentry(app) + +export default app \ No newline at end of file diff --git a/src/applySentry.ts b/src/applySentry.ts new file mode 100644 index 00000000..b8be88f1 --- /dev/null +++ b/src/applySentry.ts @@ -0,0 +1,16 @@ +import * as Sentry from "@sentry/node"; +import { Application } from "express"; + +export function applySentry(app: Application) { + if (process.env.SENTRY_DSN != "") { + Sentry.setupExpressErrorHandler(app); + + // Optional fallthrough error handler + app.use(function onError(err: any, req: any, res: any, next: any) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + "\n"); + }); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b8557ca2..e574c081 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,11 @@ +//----------------------------------------------------------------------------- +// Important! +// +// Sentry instrumentation must be the first thing to execute so it is able to +// capture any errors further into launch. +require("./utils/instrument") +//----------------------------------------------------------------------------- + //require("dotenv").config() import https from "https" import { HTTPS_CERT } from "./utils" @@ -5,7 +13,7 @@ import { Bootstrap } from "./repository/Bootstrap" import app from './app' // NodeJS v15+ do not log unhandled promise rejections anymore. -process.on('unhandledRejection', error => { console.dir(error) }) +// process.on('unhandledRejection', error => { console.dir(error) }) // Initialize and configure the application. async function main(): Promise { diff --git a/src/utils/instrument.ts b/src/utils/instrument.ts new file mode 100644 index 00000000..374e202c --- /dev/null +++ b/src/utils/instrument.ts @@ -0,0 +1,10 @@ +import * as Sentry from "@sentry/node"; + +if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENV, + release: "2023.7.27-w-sentry", + sendDefaultPii: true, + }); +} diff --git a/tsconfig.json b/tsconfig.json index d3eb8503..35c7807d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,11 +8,17 @@ "./src", "./test" ], - "outDir": "./build", + "outDir": "./build/src", "esModuleInterop": true, "strict": true, "resolveJsonModule": true, "inlineSourceMap": true, "allowSyntheticDefaultImports": true, - } -} + }, + "files": [ + "src/app.ts", + "src/applySentry.ts", + "src/index.ts", + "src/utils/instrument.ts" + ] +} \ No newline at end of file