diff --git a/.gitignore b/.gitignore index 003ed1c..730779b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ local.settings.json node_modules/ globalConfig.json +terraform/.terraform* +dist/ diff --git a/AzureResourceGroup/template.json b/AzureResourceGroup/template.json deleted file mode 100644 index de829c2..0000000 --- a/AzureResourceGroup/template.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "location": { - "type": "String", - "allowedValues": [ - "uksouth", - "ukwest" - ] - }, - "environment": { - "type": "String" - }, - "functionAppName": { - "type": "String" - }, - "hostingPlanSkuTier": { - "type": "String" - }, - "hostingPlanSkuName": { - "type": "String" - }, - "enableCORS": { - "type": "Bool" - }, - "buildTag": { - "type": "String" - }, - "expiresTag": { - "type": "String", - "defaultValue": "Never" - }, - "ownerTag": { - "type": "String" - } - }, - "variables": { - "locationAbbreviation": "[substring(parameters('location'), 0, 3)]", - "storageAccountName": "[concat('nhsukfeedback', parameters('environment'), variables('locationAbbreviation'))]", - "hostingPlanName": "[concat('nhsuk-user-feedback-plan-', parameters('environment'), '-', variables('locationAbbreviation'))]", - "appInsightsName": "[parameters('functionAppName')]", - "databaseName": "[concat('nhsuk-user-feedback-db-', parameters('environment'))]", - "tags": { - "Owner": "[parameters('ownerTag')]", - "Product": "User Feedback", - "Build": "[parameters('buildTag')]", - "Expires": "[parameters('expiresTag')]", - "Environment": "[parameters('environment')]" - } - }, - "resources": [ - { - "type": "Microsoft.Web/sites", - "apiVersion": "2019-08-01", - "name": "[parameters('functionAppName')]", - "kind": "functionapp,linux", - "location": "[parameters('location')]", - "dependsOn": [ - "[concat('microsoft.insights/components/', variables('appInsightsName'))]", - "[concat('Microsoft.Web/serverfarms/', variables('hostingPlanName'))]", - "[concat('Microsoft.Storage/storageAccounts/', variables('storageAccountName'))]", - "[concat('Microsoft.DocumentDB/databaseAccounts/', variables('databaseName'))]" - ], - "tags": "[variables('tags')]", - "properties": { - "name": "[parameters('functionAppName')]", - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", - "siteConfig": { - "appSettings": [ - { - "name": "FUNCTIONS_EXTENSION_VERSION", - "value": "~3" - }, - { - "name": "FUNCTIONS_WORKER_RUNTIME", - "value": "node" - }, - { - "name": "APPINSIGHTS_INSTRUMENTATIONKEY", - "value": "[reference(concat('microsoft.insights/components/', variables('appInsightsName')), '2015-05-01').InstrumentationKey]" - }, - { - "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", - "value": "[reference(concat('microsoft.insights/components/', variables('appInsightsName')), '2015-05-01').ConnectionString]" - }, - { - "name": "WEBSITE_NODE_DEFAULT_VERSION", - "value": "~12" - }, - { - "name": "AzureWebJobsStorage", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]" - }, - { - "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]" - }, - { - "name": "WEBSITE_CONTENTSHARE", - "value": "[toLower(parameters('functionAppName'))]" - }, - { - "name": "MONGO_CONNECTION_STRING", - "value": "[concat(listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('databaseName')), '2019-12-12').connectionStrings[0].connectionString, '&retrywrites=false&maxIdleTimeMS=120000')]" - } - ], - "cors": { - "allowedOrigins": [ - "[if(parameters('enableCORS'), '*', 'https://www.nhs.uk')]" - ] - } - }, - "reserved": true - } - }, - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2019-06-01", - "name": "[variables('storageAccountName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "name": "Standard_LRS" - }, - "properties": { - "supportsHttpsTrafficOnly": true - } - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2019-08-01", - "name": "[variables('hostingPlanName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "sku": { - "Tier": "[parameters('hostingPlanSkuTier')]", - "Name": "[parameters('hostingPlanSkuName')]" - }, - "kind": "linux", - "properties": { - "reserved": true - } - }, - { - "type": "microsoft.insights/components", - "apiVersion": "2015-05-01", - "name": "[variables('appInsightsName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "web", - "properties": { - "Request_Source": "rest", - "Application_Type": "web" - } - }, - { - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2020-03-01", - "name": "[variables('databaseName')]", - "location": "[parameters('location')]", - "tags": "[variables('tags')]", - "kind": "MongoDB", - "properties": { - "locations": [{ - "locationName": "[parameters('location')]" - }], - "databaseAccountOfferType": "Standard", - "capabilities": [ - { - "name": "EnableMongo" - } - ] - } - } - ], - "outputs": { - "functionAppName": { - "type": "string", - "value": "[parameters('functionAppName')]" - }, - "storageAccountName": { - "type": "string", - "value": "[variables('storageAccountName')]" - } - } -} diff --git a/azure-pipeline-templates/deploy.yaml b/azure-pipeline-templates/deploy.yaml new file mode 100644 index 0000000..caf4d7f --- /dev/null +++ b/azure-pipeline-templates/deploy.yaml @@ -0,0 +1,31 @@ +steps: + +# Since this is a deployment stage, we need to checkout the code. Non-deployment stages do this by default +- checkout: self + +- task: AzureCLI@2 + inputs: + azureSubscription: 'nhsuk-user-feedback-${{parameters.environment}}' + scriptType: 'bash' + scriptLocation: 'inlineScript' + addSpnToEnvironment: true # adds $servicePrincipalId, $servicePrincipalKey and $tenantId to the env vars + inlineScript: | + set -e + # Set variables that terraform uses for azure authentication. + # Make these variables available to future jobs. + echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query 'id' --output tsv)" + echo "##vso[task.setvariable variable=ARM_TENANT_ID]$tenantId" + echo "##vso[task.setvariable variable=ARM_CLIENT_ID]$servicePrincipalId" + echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]$servicePrincipalKey" + displayName: 'Get Azure auth variables' + +- task: AzureCLI@2 + inputs: + azureSubscription: 'nhsuk-user-feedback-${{parameters.environment}}' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + ./scripts/deploy.sh \ + --env=${{parameters.environment}} \ + --region=${{parameters.region}} + displayName: 'Run deploy script' diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a26d048..c4866e9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -3,16 +3,17 @@ trigger: branches: include: - - master - - refs/tags/* + - master + - refs/tags/* + pr: - master - pool: - vmImage: 'ubuntu-latest' + vmImage: ubuntu-latest stages: + - stage: Test jobs: - job: Test @@ -24,33 +25,57 @@ stages: displayName: 'Install Node.js' - script: | - npm install + npm ci npm test displayName: 'npm test' -- stage: Build - displayName: Build stage +- stage: DevDeployment + displayName: 'Dev Deployment' + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) + dependsOn: + - Test jobs: - - job: Build + - deployment: Deployment + environment: 'dev' + strategy: + runOnce: + deploy: + steps: + - template: azure-pipeline-templates/deploy.yaml + parameters: + environment: 'dev' + region: 'uks' - steps: - - task: NodeTool@0 - inputs: - versionSpec: '10.x' - displayName: 'Install Node.js' - - - script: | - npm install --production - displayName: 'npm install' +- stage: StagDeployment + displayName: 'Stag Deployment' + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) + dependsOn: + - Test + jobs: + - deployment: Deployment + environment: 'staging' + strategy: + runOnce: + deploy: + steps: + - template: azure-pipeline-templates/deploy.yaml + parameters: + environment: 'stag' + region: 'uks' - - task: ArchiveFiles@2 - displayName: 'Archive files' - inputs: - rootFolderOrFile: '$(System.DefaultWorkingDirectory)' - includeRootFolder: false - archiveType: zip - archiveFile: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip - replaceExistingArchive: true - - - upload: $(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip - artifact: drop +- stage: ProdDeployment + displayName: 'Prod Deployment' + dependsOn: + - StagDeployment + - Test + jobs: + - deployment: Deployment + environment: 'production' + strategy: + runOnce: + deploy: + steps: + - template: azure-pipeline-templates/deploy.yaml + parameters: + environment: 'prod' + region: 'uks' diff --git a/scripts/create_terraform_state.sh b/scripts/create_terraform_state.sh new file mode 100755 index 0000000..f8cdc0e --- /dev/null +++ b/scripts/create_terraform_state.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +for i in "$@" +do +case $i in + --env=*) + ENV="${i#*=}" + shift # past argument=value + ;; + --region=*) + REGION="${i#*=}" + shift # past argument=value + ;; + *) + # unknown option + ;; +esac +done + +if [[ -z "$ENV" ]]; then + echo "--env option must be provided" + exit 1 +fi +if [[ -z "$REGION" ]]; then + echo "--region option must be provided" + exit 1 +fi + +echo "ENV = $ENV" +echo "REGION = $REGION" + +set -e + + +RESOURCE_GROUP_NAME="nhsuk-user-feedback-rg-tfstate-$ENV-$REGION" +STORAGE_ACCOUNT_NAME="nhsukfeedbacktstate$ENV" +CONTAINER_NAME="tstate" + +# This resource group should have been created for you by the infrastructure team +# az group create --name $RESOURCE_GROUP_NAME --location $REGION + +# Create storage account +az storage account create --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ACCOUNT_NAME --sku Standard_LRS --encryption-services blob + +# Get storage account key +ACCOUNT_KEY=$(az storage account keys list --resource-group $RESOURCE_GROUP_NAME --account-name $STORAGE_ACCOUNT_NAME --query '[0].value' -o tsv) + +# Create blob container +az storage container create --name $CONTAINER_NAME --account-name $STORAGE_ACCOUNT_NAME --account-key $ACCOUNT_KEY + +echo "storage_account_name: $STORAGE_ACCOUNT_NAME" +echo "container_name: $CONTAINER_NAME" +echo "access_key: $ACCOUNT_KEY" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..cd1f242 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +for i in "$@" +do +case $i in + --env=*) + ENV="${i#*=}" + shift # past argument=value + ;; + --region=*) + REGION="${i#*=}" + shift # past argument=value + ;; + *) + # unknown option + ;; +esac +done + +if [[ -z "$ENV" ]]; then + echo "--env option must be provided" + exit 1 +fi +if [[ -z "$REGION" ]]; then + echo "--region option must be provided" + exit 1 +fi + +echo "ENV = $ENV" +echo "REGION = $REGION" + +set -e + +mkdir -p "dist" +ZIP_FILE_PATH="dist/build.zip" + +# Create zip file of entire project, excluding some directories. +zip -r "$ZIP_FILE_PATH" . -x ".git/*" -x "dist/*" -x "terraform/*" -x "node_modules/*" -x "tests/*" + + +# Initialize terraform with Azure backend configs +terraform -chdir=./terraform init \ + -backend-config="resource_group_name=nhsuk-user-feedback-rg-tfstate-$ENV-$REGION" \ + -backend-config="storage_account_name=nhsukfeedbacktstate$ENV" + +# Apply terraform changes +terraform -chdir=./terraform apply \ + -auto-approve \ + -var-file="vars/$ENV.tfvars" + +# Get terraform outputs required for the functionapp deployment +RESOURCE_GROUP=$(terraform -chdir=./terraform output -raw resource_group_name) +FUNCTION_APP_NAME=$(terraform -chdir=./terraform output -raw function_app_name) + +# Deploy to the functionapp +az functionapp deployment source config-zip \ + -g $RESOURCE_GROUP \ + -n $FUNCTION_APP_NAME \ + --src $ZIP_FILE_PATH \ + --build-remote true diff --git a/terraform-state-pipeline.yaml b/terraform-state-pipeline.yaml new file mode 100644 index 0000000..d0c18cb --- /dev/null +++ b/terraform-state-pipeline.yaml @@ -0,0 +1,27 @@ +# Only allow this pipeline to be run manually +trigger: none + +pool: + vmImage: ubuntu-latest + +parameters: +- name: env + displayName: Environment (dev, stag, prod, etc.) + type: string +- name: region + displayName: Region (uks, ukw, etc.) + type: string + +steps: + +- task: AzureCLI@2 + inputs: + azureSubscription: 'nhsuk-user-feedback-${{ parameters.env }}' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + ./scripts/create_terraform_state.sh \ + --env=${{ parameters.env }} \ + --region=${{ parameters.region }} + displayName: 'Create terraform state resources' + diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..2d85b72 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,12 @@ +locals { + region_short = substr(var.region, 0, 3) +} + +locals { + common_tags = { + product = "User Feedback" + owner = "mike.monteith@nhs.net" + expires = "Never" + environment = var.env + } +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..cc89ad6 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,114 @@ +# Configure the Azure provider +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 2.26" + } + } + + backend "azurerm" { + # We need to configure a different backend for different environments, so we use the following environment + # variables when running terraform init instead of putting the resource group and storage account + # config here as you normally would. + # Use these variables: + # -backend-config="resource_group_name=nhsuk-user-feedback-rg-tfstate-${ENV}-${REGION_SHORT}" + # -backend-config="storage_account_name=nhsukfeedbacktstate${ENV}" + container_name = "tstate" + key = "terraform.tfstate" + } +} + +provider "azurerm" { + features {} +} + +data "azurerm_resource_group" "rg" { + name = "nhsuk-user-feedback-rg-${var.env}-${local.region_short}" +} + +resource "azurerm_storage_account" "storage_account" { + name = "nhsukfeedback${var.env}${local.region_short}" + resource_group_name = data.azurerm_resource_group.rg.name + location = data.azurerm_resource_group.rg.location + account_tier = "Standard" + account_replication_type = "LRS" + + tags = local.common_tags +} + +resource "azurerm_app_service_plan" "service_plan" { + name = "nhsuk-user-feedback-plan-${var.env}-${local.region_short}" + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name + kind = "functionapp" + reserved = true + tags = local.common_tags + + sku { + tier = "Dynamic" + size = "Y1" + } +} + +resource "azurerm_application_insights" "application_insights" { + name = "nhsuk-user-feedback-func-${var.env}-${local.region_short}" + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name + tags = local.common_tags + application_type = "web" +} + +resource "azurerm_cosmosdb_account" "db" { + name = "nhsuk-user-feedback-db-${var.env}" + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name + tags = local.common_tags + offer_type = "Standard" + kind = "MongoDB" + + capabilities { + name = "EnableMongo" + } + + consistency_policy { + consistency_level = "Session" + max_interval_in_seconds = 5 + max_staleness_prefix = 100 + } + + geo_location { + location = data.azurerm_resource_group.rg.location + failover_priority = 0 + } +} + +resource "azurerm_function_app" "function_app" { + name = "nhsuk-user-feedback-func-${var.env}-${local.region_short}" + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name + app_service_plan_id = azurerm_app_service_plan.service_plan.id + storage_account_name = azurerm_storage_account.storage_account.name + storage_account_access_key = azurerm_storage_account.storage_account.primary_access_key + tags = local.common_tags + + os_type = "linux" + version = "~3" + + site_config { + cors { + allowed_origins = [var.enable_cors ? "*" : "https://www.nhs.uk"] + } + } + + app_settings = { + FUNCTIONS_WORKER_RUNTIME = "node" + APPINSIGHTS_INSTRUMENTATIONKEY = azurerm_application_insights.application_insights.instrumentation_key + APPLICATIONINSIGHTS_CONNECTION_STRING = azurerm_application_insights.application_insights.connection_string + WEBSITE_NODE_DEFAULT_VERSION: "~12" + WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: azurerm_storage_account.storage_account.primary_connection_string + WEBSITE_CONTENTSHARE: "nhsuk-user-feedback-func-${var.env}-${local.region_short}" + MONGO_CONNECTION_STRING: "${azurerm_cosmosdb_account.db.connection_strings.0}&retrywrites=false&maxIdleTimeMS=120000" + SCM_DO_BUILD_DURING_DEPLOYMENT: "true" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..ffd3939 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,8 @@ +output "resource_group_name" { + value = data.azurerm_resource_group.rg.name + description = "Resource Group name" +} + +output "function_app_name" { + value = azurerm_function_app.function_app.name +} diff --git a/terraform/plan b/terraform/plan new file mode 100644 index 0000000..3123aa9 Binary files /dev/null and b/terraform/plan differ diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..98c5570 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,15 @@ +variable "env" { + description = "Environment short name e.g dev, stag, prod" + type = string +} + +variable "region" { + description = "Azure region" + type = string +} + +variable "enable_cors" { + description = "Allow all domains for cross-origin requests" + type = bool + default = false +} diff --git a/terraform/vars/dev.tfvars b/terraform/vars/dev.tfvars new file mode 100644 index 0000000..4616efa --- /dev/null +++ b/terraform/vars/dev.tfvars @@ -0,0 +1,3 @@ +env = "dev" +region = "uksouth" +enable_cors = true