diff --git a/.github/workflows/build-and-push-image-development.yml b/.github/workflows/build-and-push-image-development.yml new file mode 100644 index 000000000..4d4752909 --- /dev/null +++ b/.github/workflows/build-and-push-image-development.yml @@ -0,0 +1,43 @@ +name: Continuous delivery + +on: + push: + branches: + - main +jobs: + build-and-push-image-development: + name: Build and push image development + environment: development + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Azure Container Registry login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DEVELOPMENT_AZURE_ACR_CLIENTID }} + password: ${{ secrets.DEVELOPMENT_AZURE_ACR_SECRET }} + registry: ${{ secrets.DEVELOPMENT_ACR_URL }} + + - name: Prepare tags + id: prepare-tags + run: | + DOCKER_IMAGE=${{ secrets.DEVELOPMENT_ACR_URL }}/academies-academisation-api + VERSION=latest + TAGS="${DOCKER_IMAGE}:${VERSION}" + if [ "${{ github.event_name }}" = "push" ]; then + VERSION=sha-${GITHUB_SHA} + TAGS="$TAGS,${DOCKER_IMAGE}:${VERSION}" + fi + echo ::set-output name=tags::${TAGS} + echo ::set-output name=deploy-version::${VERSION} + + - name: Push image + uses: docker/build-push-action@v3 + with: + context: . + file: ./Dockerfile + build-args: ASPNET_IMAGE_TAG=6.0.9-bullseye-slim-amd64 + push: true + tags: ${{ steps.prepare-tags.outputs.tags }} diff --git a/.github/workflows/continuous-integration-terraform.yml b/.github/workflows/continuous-integration-terraform.yml new file mode 100644 index 000000000..fb222992b --- /dev/null +++ b/.github/workflows/continuous-integration-terraform.yml @@ -0,0 +1,63 @@ +name: Continuous integration + +on: + push: + branches: main + pull_request: + +jobs: + terraform-validate: + name: Terraform Validate + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Check for terraform version mismatch + run: | + DOTFILE_VERSION=$(cat terraform/.terraform-version) + TERRAFORM_IMAGE_REFERENCES=$(grep "uses: docker://hashicorp/terraform" .github/workflows/continuous-integration-terraform.yml | grep -v TERRAFORM_IMAGE_REFERENCES | wc -l | tr -d ' ') + if [ "$(grep "docker://hashicorp/terraform:${DOTFILE_VERSION}" .github/workflows/continuous-integration-terraform.yml | wc -l | tr -d ' ')" != "$TERRAFORM_IMAGE_REFERENCES" ] + then + echo -e "\033[1;31mError: terraform version in .terraform-version file does not match docker://hashicorp/terraform versions in .github/workflows/continuous-integration-terraform.yml" + exit 1 + fi + + - name: Remove azure backend + run: rm ./terraform/backend.tf + + - name: Run a Terraform init + uses: docker://hashicorp/terraform:1.2.9 + with: + entrypoint: terraform + args: -chdir=terraform init + + - name: Run a Terraform validate + uses: docker://hashicorp/terraform:1.2.9 + with: + entrypoint: terraform + args: -chdir=terraform validate + + - name: Run a Terraform format check + uses: docker://hashicorp/terraform:1.2.9 + with: + entrypoint: terraform + args: -chdir=terraform fmt -check=true -diff=true + terraform-docs-validation: + name: Terraform Docs validation + needs: terraform-validate + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Generate Terraform docs + uses: terraform-docs/gh-actions@v1.0.0 + with: + working-dir: terraform + config-file: .terraform-docs.yml + output-file: README.md + output-method: inject + fail-on-diff: true diff --git a/.github/workflows/continuous-integration-tfsec.yml b/.github/workflows/continuous-integration-tfsec.yml new file mode 100644 index 000000000..2c9237304 --- /dev/null +++ b/.github/workflows/continuous-integration-tfsec.yml @@ -0,0 +1,14 @@ +name: Continuous integration +on: + pull_request: +jobs: + tfsec-pr-commenter: + name: tfsec PR commenter + runs-on: ubuntu-latest + steps: + - name: Clone repo + uses: actions/checkout@v3 + - name: tfsec + uses: aquasecurity/tfsec-pr-commenter-action@v1.2.0 + with: + github_token: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 32b5134ca..a48d585a4 100644 --- a/.gitignore +++ b/.gitignore @@ -372,3 +372,15 @@ FodyWeavers.xsd .env.*.local !.env.development.local.example !.env.database.example + +# Homebrew +Brewfile.lock.json + +### Terraform +.terraformrc* +terraform.rc* +*.tfstate* +*.tfvars* +!terraform.tfvars.example +.terraform/ +backend.vars diff --git a/Dockerfile b/Dockerfile index 857c33aff..09afdf1a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ # Stage 1 +ARG ASPNET_IMAGE_TAG=6.0.9-bullseye-slim FROM mcr.microsoft.com/dotnet/sdk:6.0 as build WORKDIR /build @@ -27,7 +28,8 @@ RUN dotnet publish Dfe.Academies.Academisation.WebApi -c Release -o /app COPY ./script/webapi-docker-entrypoint.sh /app/docker-entrypoint.sh -FROM mcr.microsoft.com/dotnet/aspnet:6.0.9-bullseye-slim AS final +ARG ASPNET_IMAGE_TAG +FROM "mcr.microsoft.com/dotnet/aspnet:${ASPNET_IMAGE_TAG}" AS final RUN apt-get update RUN apt-get install unixodbc curl gnupg -y diff --git a/terraform/.terraform-docs.yml b/terraform/.terraform-docs.yml new file mode 100644 index 000000000..a6917808f --- /dev/null +++ b/terraform/.terraform-docs.yml @@ -0,0 +1,26 @@ +--- +formatter: "markdown table" +version: "~> 0.16" +settings: + anchor: true + default: true + description: false + escape: true + hide-empty: false + html: true + indent: 2 + lockfile: true + read-comments: true + required: true + sensitive: true + type: true +sort: + enabled: true + by: name +output: + file: README.md + mode: inject + template: |- + + {{ .Content }} + diff --git a/terraform/.terraform-version b/terraform/.terraform-version new file mode 100644 index 000000000..9d4f8239d --- /dev/null +++ b/terraform/.terraform-version @@ -0,0 +1 @@ +1.2.9 diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..c0c526814 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/azure/azapi" { + version = "1.0.0" + constraints = ">= 0.5.0, >= 1.0.0" + hashes = [ + "h1:UEarnWmn8kdWSjcPdFj/bl+wxxpjwxRhM0FjK4/IyIE=", + "zh:01a33aaefe4d185e70d926103eeb0ac9fefeadf750f69c5977ead2ae02e0b038", + "zh:1ce767851be07e432b4cdde91b40beef84f030432bb7b431ffda85b89305414d", + "zh:1cf15bc8430377091c06373c74a68ce61a9f36dd1455929a64e8083332f2c291", + "zh:4372f59b2761b3ae4b59d59f978af547cd8fae44d2b2e5baa91735b0ea3b16e2", + "zh:6602e2aae7937456418f53372d7139d2f56aea5e46dfd46634f9b202988178c0", + "zh:6f0945ee6ae05cbd708c10ee7b0f8c987032e35122a01d661188538f7548e59f", + "zh:6fc5e5017b8f87aff48732cc619f1295175913e3c1c039a170e8f0100a8233a2", + "zh:740f6c339f28406988204af6fadc9e58c754a22f234902b34c1f6d54421476c2", + "zh:7f003da3b64cb5129627b96a5eb0a03113853a0b17fd4cb77bd505fd27a8ca0b", + "zh:a1ed7aa209cdee91b013691ddb61d77eb3d840f9cba2f4c8b923ba80823c5912", + "zh:d6dad27af147a127027a8aa08a259f6dc418b09f842620e56e5db85547b1b090", + "zh:e67ddb150ff40cf9453fd56f47c2ac657ede1c1861b4d2f9009e98bddfc345b2", + ] +} + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "3.25.0" + constraints = ">= 3.20.0, >= 3.25.0" + hashes = [ + "h1:y0PfEHDqCTSkv3bQpwM+RPEDB8+76PqwuRLF0lB7vhw=", + "zh:35ae77914a6280592a199a56c75a5f953cb5553f4416e50f4d4f47732eeb8d67", + "zh:3e2562a26b047e314a9fd28ee16ab460ce412fe00ed17583f3abfcc9c6998e0f", + "zh:431bc3cac313ee1e8f053cdf85f22d497a41d20497af83dfe531c9f5ecdee290", + "zh:48aa1304cbd9e7124ad3bf68be8260f2d38ca41d98f893d9e2652c2ed5c88e3d", + "zh:66eb8e90443aef5fc2bf96d12d70129cd961b4a93c2571c54b50f8aace5bf83f", + "zh:947d7301de9adbf669c64c341e2832b20da7712396b82b2abb71e34b231265bd", + "zh:afba5c2d0138946dcf71a43983b0d36383efef437edc594fd18d14348646b1fa", + "zh:b6296deeb3c119edc457b9ccaff3725d523702b9be4d7e4e15359e7ba4b37a3d", + "zh:e04d05092a42e1101fab92161403bc4a4baefcfd5325eacf881c0e06b29ef69b", + "zh:ed5d72359a36c712b6e1c58da00210c1becb2b23b33cf112545476020976f259", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fbb17dc9e6213392873e9a6f32d90376a8d1a019e5db315b8a40c12fc01cec18", + ] +} diff --git a/terraform/Brewfile b/terraform/Brewfile new file mode 100644 index 000000000..e862e7336 --- /dev/null +++ b/terraform/Brewfile @@ -0,0 +1,4 @@ +brew "tfenv" +brew "terraform-docs" +brew "tfsec" +brew "az" diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 000000000..fcad51b71 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,170 @@ +This documentation covers the deployment of the infrastructure to host the app. + +## Azure infrastructure + +The infrastructure is managed using [Terraform](https://www.terraform.io/).
+The state is stored remotely in encrypted Azure storage.
+[Terraform workspaces](https://www.terraform.io/docs/state/workspaces.html) are used to separate environments. + +#### Configuring the storage backend + +The Terraform state is stored remotely in Azure, this allows multiple team members to +make changes and means the state file is backed up. The state file contains +sensitive information so access to it should be restricted, and it should be stored +encrypted at rest. + +##### Create a new storage backend + +This step only needs to be done once per project (eg. not per environment). +If it has already been created, obtain the storage backend attributes and skip to the next step. + +The [Azure tutorial](https://docs.microsoft.com/en-us/azure/developer/terraform/store-state-in-azure-storage) outlines the steps to create a storage account and container for the state file. You will need: + +- resource_group_name: The name of the resource group used for the Azure Storage account. +- storage_account_name: The name of the Azure Storage account. +- container_name: The name of the blob container. +- key: The name of the state store file to be created. + +##### Create a backend configuration file + +Create a new file named `backend.vars` with the following content: + +``` +resource_group_name = [the name of the Azure resource group] +storage_account_name = [the name of the Azure Storage account] +container_name = [the name of the blob container] +key = "terraform.tstate" +``` + +##### Install dependencies + +We can use [Homebrew](https://brew.sh) to install the dependecies we need to deploy the infrastructure (eg. tfenv, Azure cli). +These are listed in the `Brewfile` + +to install, run: + +``` +$ brew bundle +``` + +##### Log into azure with the Azure CLI + +Log in to your account: + +``` +$ az login +``` + +Confirm which account you are currently using: + +``` +$ az account show +``` + +To list the available subscriptions, run: + +``` +$ az account list +``` + +Then if needed, switch to it using the 'id': + +``` +$ az account set --subscription +``` + +##### Initialise Terraform + +Install the required terraform version with the Terraform version manager `tfenv`: + +``` +$ tfenv install +``` + +Initialize Terraform to download the required Terraform modules and configure the remote state backend +to use the settings you specified in the previous step. + +`$ terraform init -backend-config=backend.vars` + +##### Create a Terraform variables file + +Each environment will need it's own `tfvars` file. + +Copy the `terraform.tfvars.example` to `environment-name.tfvars` and modify the contents as required + +##### Create the infrastructure + +Now Terraform has been initialised you can create a workspace if needed: + +`$ terraform workspace new staging` + +Or to check what workspaces already exist: + +`$ terraform workspace list` + +Switch to the new or existing workspace: + +`$ terraform workspace select staging` + +Plan the changes: + +`$ terraform plan -var-file=staging.tfvars` + +Terraform will ask you to provide any variables not specified in an `*.auto.tfvars` file. +Now you can run: + +`$ terraform apply -var-file=staging.tfvars` + +If everything looks good, answer `yes` and wait for the new infrastructure to be created. + +##### Azure resources + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.2.9 | +| [azapi](#requirement\_azapi) | >= 1.0.0 | +| [azurerm](#requirement\_azurerm) | >= 3.25.0 | + +## Providers + +| Name | Version | +|------|---------| +| [azurerm](#provider\_azurerm) | 3.25.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [azure\_container\_apps\_hosting](#module\_azure\_container\_apps\_hosting) | github.com/DFE-Digital/terraform-azurerm-container-apps-hosting | 0.5.2 | + +## Resources + +| Name | Type | +|------|------| +| [azurerm_key_vault.tfvars](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault) | resource | +| [azurerm_key_vault_secret.tfvars](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_secret) | resource | +| [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [azure\_location](#input\_azure\_location) | Azure location in which to launch resources. | `string` | n/a | yes | +| [container\_command](#input\_container\_command) | Container command | `list(any)` | n/a | yes | +| [container\_secret\_environment\_variables](#input\_container\_secret\_environment\_variables) | Container secret environment variables | `map(string)` | n/a | yes | +| [enable\_container\_registry](#input\_enable\_container\_registry) | Set to true to create a container registry | `bool` | n/a | yes | +| [enable\_mssql\_database](#input\_enable\_mssql\_database) | Set to true to create an Azure SQL server/database, with aprivate endpoint within the virtual network | `bool` | n/a | yes | +| [environment](#input\_environment) | Environment name. Will be used along with `project_name` as a prefix for all resources. | `string` | n/a | yes | +| [image\_name](#input\_image\_name) | Image name | `string` | n/a | yes | +| [project\_name](#input\_project\_name) | Project name. Will be used along with `environment` as a prefix for all resources. | `string` | n/a | yes | +| [tags](#input\_tags) | Tags to be applied to all resources | `map(string)` | n/a | yes | +| [tfvars\_filename](#input\_tfvars\_filename) | tfvars filename. This ensures that tfvars are kept up to date in Key Vault. | `string` | n/a | yes | +| [virtual\_network\_address\_space](#input\_virtual\_network\_address\_space) | Virtual network address space CIDR | `string` | n/a | yes | + +## Outputs + +No outputs. + diff --git a/terraform/backend.tf b/terraform/backend.tf new file mode 100644 index 000000000..6602f2060 --- /dev/null +++ b/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "azurerm" {} +} diff --git a/terraform/backend.vars.example b/terraform/backend.vars.example new file mode 100644 index 000000000..589eb974c --- /dev/null +++ b/terraform/backend.vars.example @@ -0,0 +1,4 @@ +resource_group_name = "" +storage_account_name = "" +container_name = "" +key = "terraform.tstate" diff --git a/terraform/container-apps-hosting.tf b/terraform/container-apps-hosting.tf new file mode 100644 index 000000000..84bd85eb3 --- /dev/null +++ b/terraform/container-apps-hosting.tf @@ -0,0 +1,18 @@ +module "azure_container_apps_hosting" { + source = "github.com/DFE-Digital/terraform-azurerm-container-apps-hosting?ref=0.5.2" + + environment = local.environment + project_name = local.project_name + azure_location = local.azure_location + tags = local.tags + + virtual_network_address_space = local.virtual_network_address_space + + enable_container_registry = local.enable_container_registry + + image_name = local.image_name + container_command = local.container_command + container_secret_environment_variables = local.container_secret_environment_variables + + enable_mssql_database = local.enable_mssql_database +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 000000000..cee07df25 --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1 @@ +data "azurerm_client_config" "current" {} diff --git a/terraform/key-vault-tfvars-secrets.tf b/terraform/key-vault-tfvars-secrets.tf new file mode 100644 index 000000000..36da6624b --- /dev/null +++ b/terraform/key-vault-tfvars-secrets.tf @@ -0,0 +1,48 @@ +resource "azurerm_key_vault" "tfvars" { + name = "${local.environment}${local.project_name}-tfvars" + location = module.azure_container_apps_hosting.azurerm_resource_group_default.location + resource_group_name = module.azure_container_apps_hosting.azurerm_resource_group_default.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Get", + ] + + secret_permissions = [ + "Set", + "Get", + "Delete", + "Purge", + "Recover" + ] + } + + # It won't be possible to add/manage a network acl for this + # vault, as it will need to be accessable for multiple people. + # tfsec:ignore:azure-keyvault-specify-network-acl + network_acls { + bypass = "None" + default_action = "Allow" + } + + purge_protection_enabled = true + + tags = local.tags +} + +# Expiry doesn't need to be set, as this is just used as a way to +# store and share the tfvars +# tfsec:ignore:azure-keyvault-ensure-secret-expiry +resource "azurerm_key_vault_secret" "tfvars" { + name = "${local.environment}${local.project_name}-tfvars" + value = base64encode(file(local.tfvars_filename)) + key_vault_id = azurerm_key_vault.tfvars.id + content_type = "text/plain+base64" +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 000000000..93f4ecd29 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,13 @@ +locals { + environment = var.environment + project_name = var.project_name + azure_location = var.azure_location + tags = var.tags + virtual_network_address_space = var.virtual_network_address_space + enable_container_registry = var.enable_container_registry + image_name = var.image_name + container_command = var.container_command + container_secret_environment_variables = var.container_secret_environment_variables + enable_mssql_database = var.enable_mssql_database + tfvars_filename = var.tfvars_filename +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 000000000..12bf2de93 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,6 @@ +provider "azurerm" { + features {} + skip_provider_registration = true +} + +provider "azapi" {} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 000000000..4762044a5 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,12 @@ +environment = "development" +project_name = "myproject" +azure_location = "uksouth" +enable_container_registry = true +image_name = "myimage" +enable_mssql_database = true +mssql_server_admin_password = "S3crEt" +mssql_database_name = "mydatabase" +container_command = ["/bin/bash", "-c", "echo hello && sleep 86400"] +container_environment_variables = { + "ASPNETCORE_ENVIRONMENT" = "production" +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 000000000..f5a21875a --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,55 @@ +variable "environment" { + description = "Environment name. Will be used along with `project_name` as a prefix for all resources." + type = string +} + +variable "tfvars_filename" { + description = "tfvars filename. This ensures that tfvars are kept up to date in Key Vault." + type = string +} + +variable "project_name" { + description = "Project name. Will be used along with `environment` as a prefix for all resources." + type = string +} + +variable "azure_location" { + description = "Azure location in which to launch resources." + type = string +} + +variable "tags" { + description = "Tags to be applied to all resources" + type = map(string) +} + +variable "virtual_network_address_space" { + description = "Virtual network address space CIDR" + type = string +} + +variable "enable_container_registry" { + description = "Set to true to create a container registry" + type = bool +} + +variable "image_name" { + description = "Image name" + type = string +} + +variable "container_command" { + description = "Container command" + type = list(any) +} + +variable "container_secret_environment_variables" { + description = "Container secret environment variables" + type = map(string) + sensitive = true +} + +variable "enable_mssql_database" { + description = "Set to true to create an Azure SQL server/database, with aprivate endpoint within the virtual network" + type = bool +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 000000000..516868c8e --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.2.9" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.25.0" + } + azapi = { + source = "Azure/azapi" + version = ">= 1.0.0" + } + } +}