diff --git a/.github/actions/cloud-platform-auth/action.yml b/.github/actions/cloud-platform-auth/action.yml new file mode 100644 index 0000000..78f3483 --- /dev/null +++ b/.github/actions/cloud-platform-auth/action.yml @@ -0,0 +1,31 @@ +name: Cloud Platform Auth +description: Authenticate with MOJ Cloud Platform + +inputs: + api: + description: The KUBE_ENV_API + required: true + cert: + description: The KUBE_CERT + required: true + cluster: + description: The KUBE_CLUSTER + required: true + namespace: + description: The KUBE_NAMESPACE + required: true + token: + description: The KUBE_TOKEN + required: true + +runs: + using: composite + steps: + - name: Authenticate + shell: bash + run: | + echo "${{ inputs.cert }}" > ca.crt + kubectl config set-cluster ${{ inputs.cluster }} --certificate-authority=./ca.crt --server=${{ inputs.api }} + kubectl config set-credentials cd-serviceaccount --token=${{ inputs.token }} + kubectl config set-context ${{ inputs.cluster }} --cluster=${{ inputs.cluster }} --user=cd-serviceaccount --namespace=${{ inputs.namespace }} + kubectl config use-context ${{ inputs.cluster }} diff --git a/.github/actions/cloud-platform-deploy/action.yml b/.github/actions/cloud-platform-deploy/action.yml new file mode 100644 index 0000000..fa25dd6 --- /dev/null +++ b/.github/actions/cloud-platform-deploy/action.yml @@ -0,0 +1,59 @@ +name: Cloud Platform Deploy +description: Deploy to Cloud Platform using Helm + +inputs: + environment: + description: The environment to deploy to (dev/preprod/prod) + required: true + version: + description: The version of the service to deploy + required: true + api: + description: The KUBE_ENV_API + required: true + cert: + description: The KUBE_CERT + required: true + cluster: + description: The KUBE_CLUSTER + required: true + namespace: + description: The KUBE_NAMESPACE + required: true + token: + description: The KUBE_TOKEN + required: true + +runs: + using: composite + steps: + - uses: actions/checkout@v3 + + - name: Authenticate + uses: ./.github/actions/cloud-platform-auth + with: + api: ${{ inputs.api }} + cert: ${{ inputs.cert }} + cluster: ${{ inputs.cluster }} + namespace: ${{ inputs.namespace }} + token: ${{ inputs.token }} + + - name: Deploy + shell: bash + run: | + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install helm + cd helm_deploy/${{ github.event.repository.name }} + yq -i ".appVersion = \"${{ inputs.version }}\"" "Chart.yaml" + helm dependency update . + exec helm upgrade '${{ github.event.repository.name }}' . \ + --atomic \ + --history-max 10 \ + --force \ + --install \ + --reset-values \ + --set 'image.tag=${{ inputs.version }}' \ + --set 'version=${{ inputs.version }}' \ + --timeout 10m \ + --values '${{ steps.env.outputs.values-file }}' \ + --wait diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml new file mode 100644 index 0000000..3e4ea14 --- /dev/null +++ b/.github/actions/docker-build/action.yml @@ -0,0 +1,38 @@ +name: Build Docker image +description: Build, and optionally push, a Docker image + +inputs: + project: + description: Project name + push: + description: Whether to push images to the registry + default: 'false' + version: + description: Version + +runs: + using: "composite" + steps: + - uses: docker/setup-qemu-action@v2 + - uses: docker/setup-buildx-action@v2 + - uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build Docker images + uses: docker/build-push-action@v4 + with: + cache-from: type=gha + cache-to: type=gha,mode=max + context: . + push: ${{ inputs.push }} + provenance: false + tags: | + ghcr.io/ministryofjustice/${{ inputs.project }}:latest + ghcr.io/ministryofjustice/${{ inputs.project }}:${{ inputs.version }} + build-args: | + GIT_BRANCH=${{ github.head_ref || github.ref_name }} + GIT_REF=${{ github.sha }} + BUILD_NUMBER=${{ inputs.version }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6b4b9c5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: Build + +on: + workflow_call: + inputs: + push: + type: boolean + default: false + force-deploy: + type: boolean + default: false + outputs: + version: + value: ${{ jobs.build-docker.outputs.version }} + workflow_dispatch: + inputs: + push: + description: Push images + type: boolean + default: false + +env: + push: ${{ inputs.push }} + +jobs: + build-docker: + name: Docker build + runs-on: ubuntu-latest + strategy: + matrix: + project: + - hmpps-component-dependencies + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v3 + + - name: Set version + id: version + run: | + version=$(date '+%Y-%m-%d').${{ github.run_number }}.$(echo ${{ github.sha }} | cut -c1-7) + echo "version=$version" | tee -a "$GITHUB_OUTPUT" + + - name: Build Docker images + uses: ./.github/actions/docker-build + id: build + with: + project: ${{ matrix.project }} + push: ${{ env.push }} + version: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f0c47b3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,72 @@ +name: Deploy + +on: + workflow_call: + inputs: + github_environment: + description: The name of the github environment for deployment secrets + type: string + required: true + environment: + description: The name of the environment to deploy to + type: string + required: true + version: + description: The image version to deploy + type: string + required: true + + workflow_dispatch: + inputs: + github_environment: + description: The name of the github environment for deployment secrets + type: choice + required: true + options: + - development + - production + environment: + description: Environment + type: choice + required: true + options: + - dev + - prod + version: + description: Image version + type: string + required: true + +jobs: + deploy: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + environment: [development, production] + environment: + name: ${{ inputs.github_environment }} + steps: + - uses: actions/checkout@v3 + + - name: Deploy to Dev + uses: ./.github/actions/cloud-platform-deploy + with: + environment: ${{ inputs.environment }} + version: ${{ inputs.version }} + api: https://${{ secrets.DEVELOPMENT_KUBE_CLUSTER }} + cert: ${{ secrets.DEVELOPMENT_KUBE_CERT }} + cluster: ${{ secrets.DEVELOPMENT_KUBE_CLUSTER }} + namespace: ${{ secrets.DEVELOPMENT_KUBE_NAMESPACE }} + token: ${{ secrets.DEVELOPMENT_KUBE_TOKEN }} + + - name: Deploy to Prod + uses: ./.github/actions/cloud-platform-deploy + with: + environment: ${{ inputs.environment }} + version: ${{ inputs.version }} + api: https://${{ secrets.PRODUCTION_KUBE_CLUSTER }} + cert: ${{ secrets.PRODUCTION_KUBE_CERT }} + cluster: ${{ secrets.PRODUCTION_KUBE_CLUSTER }} + namespace: ${{ secrets.PRODUCTION_KUBE_NAMESPACE }} + token: ${{ secrets.PRODUCTION_KUBE_TOKEN }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..dfa1567 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,46 @@ +name: Pipeline + +on: + push: + branches: + - main + workflow_dispatch: # Can be triggered manually from a branch + inputs: + environment: + description: 'Deployment Environment (valid values: "development", "production")' + required: true + default: 'development' + version: + description: 'Application version to deploy' + required: true + +jobs: + build: + name: Build + uses: ./.github/workflows/build.yml + with: + push: true + secrets: inherit + + + deploy_to_dev: + name: Deploy to dev + uses: ./.github/workflows/deploy.yml + needs: build + with: + github_environment: development + environment: dev + version: ${{ needs.build.outputs.version }} + secrets: inherit + + + deploy_to_prod: + name: Deploy to prod + uses: ./.github/workflows/deploy.yml + needs: + - deploy_to_dev # wait for the deploy_to_dev job to complete + with: + github_environment: production + environment: prod + version: ${{ github.event.inputs.version }} + secrets: inherit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be49b1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# Stage: base image +FROM node:20.11-bookworm-slim as base + +ARG BUILD_NUMBER +ARG GIT_REF +ARG GIT_BRANCH + +LABEL maintainer="HMPPS Digital Studio " + +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 + +# Cache breaking and ensure required build / git args defined +RUN test -n "$BUILD_NUMBER" || (echo "BUILD_NUMBER not set" && false) +RUN test -n "$GIT_REF" || (echo "GIT_REF not set" && false) +RUN test -n "$GIT_BRANCH" || (echo "GIT_BRANCH not set" && false) + +# Define env variables for runtime health / info +ENV BUILD_NUMBER=${BUILD_NUMBER} +ENV GIT_REF=${GIT_REF} +ENV GIT_BRANCH=${GIT_BRANCH} + +RUN apt-get update && \ + apt-get upgrade -y && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +# Stage: build assets +FROM base as build + +ARG BUILD_NUMBER +ARG GIT_REF +ARG GIT_BRANCH + +COPY package*.json ./ +RUN npm ci --no-audit + +COPY . . +RUN npm run build + +RUN npm prune --no-audit --omit=dev + +# Stage: copy production assets and dependencies +FROM base + +COPY --from=build --chown=appuser:appgroup \ + /app/package.json \ + /app/package-lock.json \ + ./ + +COPY --from=build --chown=appuser:appgroup \ + /app/dist ./dist + +COPY --from=build --chown=appuser:appgroup \ + /app/node_modules ./node_modules + +EXPOSE 3000 3001 +ENV NODE_ENV='production' +USER 2000 + +CMD [ "npm", "start" ] 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..7ff7517 --- /dev/null +++ b/helm_deploy/README.md @@ -0,0 +1,55 @@ +# Deployment Notes + +## Prerequisites + +- Ensure you have helm v3 client installed. + +```sh +$ helm version +version.BuildInfo{Version:"v3.0.1", GitCommit:"7c22ef9ce89e0ebeb7125ba2ebf7d421f3e82ffa", GitTreeState:"clean", GoVersion:"go1.13.4"} +``` + +- Ensure a TLS cert for your intended hostname is configured and ready, see section below. + +### Useful helm (v3) commands: + +__Test chart template rendering:__ + +This will out the fully rendered kubernetes resources in raw yaml. + +```sh +helm template [path to chart] --values=values-dev.yaml +``` + +__List releases:__ + +```sh +helm --namespace [namespace] list +``` + +__List current and previously installed application versions:__ + +```sh +helm --namespace [namespace] history [release name] +``` + +__Rollback to previous version:__ + +```sh +helm --namespace [namespace] rollback [release name] [revision number] --wait +``` + +Note: replace _revision number_ with one from listed in the `history` command) + +__Example deploy command:__ + +The following example is `--dry-run` mode - which will allow for testing. + +```sh +helm upgrade [release name] [path to chart]. \ + --install --wait --force --reset-values --timeout 5m --history-max 10 \ + --dry-run \ + --namespace [namespace] \ + --values values-dev.yaml \ + --values example-secrets.yaml +``` diff --git a/helm_deploy/hmpps-component-dependencies/.helmignore b/helm_deploy/hmpps-component-dependencies/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/.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-component-dependencies/Chart.yaml b/helm_deploy/hmpps-component-dependencies/Chart.yaml new file mode 100644 index 0000000..972ad08 --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +appVersion: '1.0' +description: A Helm chart for Kubernetes +name: hmpps-component-dependencies +version: 0.2.0 diff --git a/helm_deploy/hmpps-component-dependencies/templates/_envs.tpl b/helm_deploy/hmpps-component-dependencies/templates/_envs.tpl new file mode 100644 index 0000000..95022c8 --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/templates/_envs.tpl @@ -0,0 +1,43 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Environment variables for web and worker containers +*/}} +{{- define "deployment.envs" -}} +env: + - name: APPINSIGHTS_INSTRUMENTATIONKEY + valueFrom: + secretKeyRef: + name: {{ template "app.name" . }} + key: APPINSIGHTS_INSTRUMENTATIONKEY + +{{range .Values.appinsightEnvs }} + - name: {{ . }}_APPINSIGHTS_ID + valueFrom: + secretKeyRef: + name: {{ template "app.name" $ }} + key: {{ . }}_APPINSIGHTS_ID + + - name: {{ . }}_APPINSIGHTS_KEY + valueFrom: + secretKeyRef: + name: {{ template "app.name" $ }} + key: {{ . }}_APPINSIGHTS_KEY +{{ end }} + - name: SERVICE_CATALOGUE_URL + value: {{ .Values.apis.serviceCatalogue.url | quote }} + + - name: REDIS_HOST + valueFrom: + secretKeyRef: + name: {{ .Values.redis.secretName}} + key: primary_endpoint_address + + - name: REDIS_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.redis.secretName}} + key: auth_token + + - name: REDIS_TLS_ENABLED + value: "true" +{{end -}} diff --git a/helm_deploy/hmpps-component-dependencies/templates/_helpers.tpl b/helm_deploy/hmpps-component-dependencies/templates/_helpers.tpl new file mode 100644 index 0000000..be0a584 --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "app.labels" -}} +helm.sh/chart: {{ include "app.chart" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/name: {{ include "app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/helm_deploy/hmpps-component-dependencies/templates/update-dependency-info.yaml b/helm_deploy/hmpps-component-dependencies/templates/update-dependency-info.yaml new file mode 100644 index 0000000..05103f8 --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/templates/update-dependency-info.yaml @@ -0,0 +1,25 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: update-dependency-info + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.cronjobs.updateDependencyInfo }} + concurrencyPolicy: Replace + failedJobsHistoryLimit: 5 + startingDeadlineSeconds: 43200 + successfulJobsHistoryLimit: 5 + jobTemplate: + spec: + template: + spec: + containers: + - name: hmpps-component-dependencies + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + args: + - node + - dist/run +{{ include "deployment.envs" . | nindent 12 }} + restartPolicy: Never + activeDeadlineSeconds: 3600 diff --git a/helm_deploy/hmpps-component-dependencies/values.yaml b/helm_deploy/hmpps-component-dependencies/values.yaml new file mode 100644 index 0000000..e3140bf --- /dev/null +++ b/helm_deploy/hmpps-component-dependencies/values.yaml @@ -0,0 +1,9 @@ + +image: + repository: ghcr.io/ministryofjustice/hmpps-component-dependencies + tag: latest + +appinsightEnvs: ["DEV", "PREPROD", "PROD"] + +cronjobs: + updateDependencyInfo: 0 */6 * * * diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml new file mode 100644 index 0000000..df4c095 --- /dev/null +++ b/helm_deploy/values-dev.yaml @@ -0,0 +1,9 @@ +--- +# Per environment values which override defaults in hmpps-component-dependencies/values.yaml + +redis: + secretName: elasticache-redis + +apis: + serviceCatalogue: + url: https://service-catalogue-dev.hmpps.service.justice.gov.uk diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml new file mode 100644 index 0000000..052601c --- /dev/null +++ b/helm_deploy/values-prod.yaml @@ -0,0 +1,9 @@ +--- +# Per environment values which override defaults in hmpps-component-dependencies/values.yaml + +redis: + secretName: elasticache-redis-dev + +apis: + serviceCatalogue: + url: https://service-catalogue.hmpps.service.justice.gov.uk diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index b734e64..0000000 --- a/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['/src*/*.test.ts'], - testPathIgnorePatterns: ['/node_modules/', 'templates'], -}