From ccd1caaac914c2f27f98396043efcb64fe5b39fe Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:30:38 +0100 Subject: [PATCH 1/7] DSTA-669: Bring in code changes from previous repo work. --- .gitattributes | 4 + Makefile | 85 +++--- infrastructure/README.md | 250 ++++++++++++++++++ infrastructure/bootstrap/dns.bicep | 14 + infrastructure/bootstrap/main.bicep | 72 +++++ .../bootstrap/privateEndpoint.bicep | 73 +++++ .../bootstrap/terraformStorage.bicep | 80 ++++++ infrastructure/environments/dev/variables.sh | 10 + infrastructure/environments/prod/variables.sh | 11 + infrastructure/terraform/main.tf | 0 infrastructure/terraform/variables.tf | 0 scripts/bash/run_bootstrap.sh | 142 ++++++++++ scripts/make/azure.mk | 33 +++ scripts/make/bootstrap.mk | 8 + scripts/make/environment.mk | 16 ++ scripts/make/shared.mk | 86 ++++++ scripts/make/terraform.mk | 62 +++++ 17 files changed, 913 insertions(+), 33 deletions(-) create mode 100644 infrastructure/README.md create mode 100644 infrastructure/bootstrap/dns.bicep create mode 100644 infrastructure/bootstrap/main.bicep create mode 100644 infrastructure/bootstrap/privateEndpoint.bicep create mode 100644 infrastructure/bootstrap/terraformStorage.bicep create mode 100644 infrastructure/environments/dev/variables.sh create mode 100644 infrastructure/environments/prod/variables.sh create mode 100644 infrastructure/terraform/main.tf create mode 100644 infrastructure/terraform/variables.tf create mode 100644 scripts/bash/run_bootstrap.sh create mode 100644 scripts/make/azure.mk create mode 100644 scripts/make/bootstrap.mk create mode 100644 scripts/make/environment.mk create mode 100644 scripts/make/shared.mk create mode 100644 scripts/make/terraform.mk diff --git a/.gitattributes b/.gitattributes index 1f16008..068c0c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ scripts/terraform/** linguist-vendored scripts/tests/test.mk linguist-vendored scripts/init.mk linguist-vendored scripts/shellscript-linter.sh linguist-vendored + +# Normalize shell scripts to LF to avoid shebang CRLF issues +*.sh text eol=lf +scripts/** text eol=lf diff --git a/Makefile b/Makefile index d56e163..a463800 100644 --- a/Makefile +++ b/Makefile @@ -1,36 +1,55 @@ -# This file is for you! Edit it to implement your own hooks (make targets) into -# the project as automated steps to be executed on locally and in the CD pipeline. - -include scripts/init.mk - -# ============================================================================== - -# Example CI/CD targets are: dependencies, build, publish, deploy, clean, etc. +.DEFAULT_GOAL := help +.PHONY: help workflow config dependencies githooks-config githooks-run +.SILENT: help workflow +.NOTPARALLEL: # this is because make -j could cause race conditions + +ifeq (,$(filter oneshell,$(.FEATURES))) +$(error .ONESHELL not supported (GNU Make 3.82+ required, found $(MAKE_VERSION))) +endif + +include scripts/make/shared.mk +include scripts/make/environment.mk +include scripts/make/bootstrap.mk +include scripts/make/azure.mk +include scripts/make/terraform.mk + +# --------------------------------------------------------------------------- +# Help & Meta +# --------------------------------------------------------------------------- +help: # Print help @Others + printf "\nUsage: \033[3m\033[93m[arg1=val1] [arg2=val2] \033[0m\033[0m\033[32mmake\033[0m\033[34m \033[0m\n\n" + perl -e '$(HELP_SCRIPT)' $(MAKEFILE_LIST) + +# --------------------------------------------------------------------------- +# Bootstrap & Environment +# --------------------------------------------------------------------------- +# Configure development environment (main) @Configuration +config: + _install-tools + _install-uv + githooks-config + dependencies dependencies: # Install dependencies needed to build and test the project @Pipeline - uv sync --directory nbss - -build: # Build the project artefact @Pipeline - # TODO: Implement the artefact build step - -publish: # Publish the project artefact @Pipeline - # TODO: Implement the artefact publishing step - -deploy: # Deploy the project artefact to the target environment @Pipeline - # TODO: Implement the artefact deployment step - -clean:: # Clean-up project resources (main) @Operations - # TODO: Implement project resources clean-up step - -config:: # Configure development environment (main) @Configuration - # TODO: Use only 'make' targets that are specific to this project, e.g. you may not need to install Node.js - make _install-dependencies - -# ============================================================================== + @if [ -f nbss/pyproject.toml ]; then \ + uv sync --no-build --directory nbss; \ + else \ + echo "Skipping uv sync: nbss/pyproject.toml not found"; \ + fi + @if [ -f package.json ]; then \ + npm install; \ + else \ + echo "Skipping npm install: package.json not found"; \ + fi + +githooks-config: + if ! command -v pre-commit >/dev/null 2>&1; then \ + pip install pre-commit; \ + fi + pre-commit install + +githooks-run: # Run git hooks configured in this repository @Operations + pre-commit run \ + --config scripts/config/pre-commit.yaml \ + --all-files -${VERBOSE}.SILENT: \ - build \ - clean \ - config \ - dependencies \ - deploy \ diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..9e7c2e5 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,250 @@ +# Infrastructure Guide + +## What this contains + +The infrastructure folder contains the IaC definitions and environment configuration used to bootstrap Azure prerequisites and then run Terraform safely. + +## General IaC process flow + +At a high level, the delivery flow is: + +```mermaid +--- +title: Boostrap process flow +config: + look: handDrawn +--- +flowchart LR + A("`1. Tools`") --> B("`2. Terraform state`") + B --> C("`3. **Initialise** Terraform`") + + subgraph TF[Terraform] + direction LR + C --> D("`4. Plan changes`") + D --> E("`5. Apply changes`") + end +``` + +| Step | Description | +| --- | --- | +| 1. Setup local environment | Ensure required tools are installed and authenticated (make, bash, Azure CLI, Terraform, git). | +| 2. Setup Terraform state | Create state backend resources (storage and private connectivity) before standard Terraform operations. | +| 3. Initialise Terraform | Run terraform init to configure backend and download providers and modules. | +| 4. Generate a resource plan | Run terraform plan to see the proposed delta between current and desired state. | +| 5. Apply the resource plan | Run terraform apply to execute approved changes. | + +Terraform requires remote state backend resources. In this repository, bootstrap creates those resources first so later Terraform commands can run consistently. + +## Using `make` + +The repository uses make targets to provide a single, repeatable command interface for developers and pipelines. + +The main make file depends on other make files to create relevant targets: + +- [Makefile](../Makefile) - the main entry file +- [scripts/make/environment.mk](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment +- [scripts/make/azure.mk](../scripts/make/azure.mk) - targets for Azure cloud commands +- [scripts/make/bootstrap.mk](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources +- [scripts/make/terraform.mk](../scripts/make/terraform.mk) - targets for all Terraform commands +- [scripts/bash/run_bootstrap.sh](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process + +## The bootstrap process + +We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. + +> This process is necessary before running any of the Terraform-related make targets + +The following diagram shows an overview of what the bootstrap process does: + +```mermaid +--- +title: Bootstrap process flow +config: + look: handDrawn +--- +flowchart LR + A("`fa:fa-spinner Run **make dev bootstrap**`") --> BS + +subgraph BS[Bootstrap - First] + direction LR + C(Resolve subscription IDs) + C e2@==> BSS +end + +subgraph BSS["Bootstrap - Second"] + direction LR + E("Validate prerequisites") + E --> F("fa:fa-spinner Create what-if deployment") + F --> G{Proceed?} + G -->|No| H(Exit with no changes) + G -->|Yes| I("`fa:fa-spinner Run **az deployment sub create**`") + I --> J(Deploy Bicep modules) + J --> K(Return outputs) +end + +BS --> BSS + +``` + +| Process | What it does | +| --- | --- | +| Run `make dev bootstrap` | Starts bootstrap process using the `dev` environment context and environment variables. | +| Set Azure account | Selects the target subscription for all Azure CLI context. | +| Resolve subscription IDs | Resolves HUB_SUBSCRIPTION_ID and ARM_SUBSCRIPTION_ID used by deployment steps. | +| Run bootstrap orchestrator script | Executes the Bash script that performs validation, what-if, and deployment. | +| Validate prerequisites | Confirms required Entra group and hub subscription metadata are available. | +| Create what-if deployment | Shows previewed subscription-scope changes before any live update. | +| Exit with no changes | Stops execution safely without infrastructure changes. | +| Run az deployment sub create | Executes the subscription-scoped Bicep deployment. | +| Deploy bootstrap Bicep modules | Creates and wires storage, private DNS, private endpoint, and infra resource group resources. | +| Return bootstrap outputs | Provides IDs for verification and downstream automation. | + +## Bootstrap prerequisites + +Before running bootstrap, several tools and other requirements must be in place: + +- Install Azure CLI and authenticate with `az login`. +- Ensure access to both subscriptions specified in the target environment's variables script. +- Ensure the required Entra group exists: `screening__`. +- Install GNU Make and Bash. +- On Windows with WSL, you might run into CRLF issues so please ensure the shell and make files use LF endings. + +## Bootstrap inputs + +Many bootstrap inputs have default values which are defined in separate environment files. The environment makefile loads the environment variables per environment target specified (`dev`, `prod`) + +Environment target definitions: + +- [scripts/make/environment.mk](../scripts/make/environment.mk) + +Environment-specific variables: + +- [infrastructure/environments/dev/variables.sh](environments/dev/variables.sh) +- [infrastructure/environments/prod/variables.sh](environments/prod/variables.sh) + +Common variables: + +| Variable | Purpose | +| --- | --- | +| REGION | Azure region for deployment. Default is UK South. | +| APP_SHORT_NAME | Application short code to identify the deployments and resources. Default is 'nbsse'. | +| STORAGE_ACCOUNT_RG | Resource group that hosts Terraform state storage. Default is 'rg-dtos-state-files'. | +| ENABLE_SOFT_DELETE | Enables or disables blob delete retention policies. | +| AZURE_SUBSCRIPTION | Full display name of the application subscription used for 'az account set'. | +| HUB_SUBSCRIPTION | Full display name of the hub subscription used to resolve hub subscription ID. | + + +## Bicep modules + +Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing inital Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. + +Each Bicep module covers a single concern, and the top-level `main.bicep` coordinates its dependencies via explicit module outputs rather than implied assumptions. + +The Bicep bootstrap modules are found in [infrastructure/bootstrap](bootstrap). + +| Bicep file | Creates or configures | Outputs | +| --- | --- | --- | +| [main.bicep](bootstrap/main.bicep) | | storageAccountId, storagePrivateDNSZoneId, storagePrivateEndpointId, infraResourceGroupId | +| [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, terraform-state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | +| [dns.bicep](bootstrap/dns.bicep) | Private DNS zone lookup | | privateDNSZoneID | +| [privateEndpoint.bicep](bootstrap/privateEndpoint.bicep) | Private endpoint wiring | | Existing hub VNet and subnet, resourceID, privateDNSZoneID | privateEndpointID | + +### Bicep deployment parameters + +The bootstrap template [infrastructure/bootstrap/main.bicep](bootstrap/main.bicep) accepts: + +- enableSoftDelete +- envConfig +- region +- storageAccountRGName +- storageAccountName +- appShortName +- userGroupPrincipalID +- infraResourceGroupName (optional, defaults internally) + +## Outputs from bootstrap + +Human-readable output from script execution includes: + +- Resolved hub subscription name and ID. +- Resolved Entra group display name and principal ID. +- What-if preview. +- Final deployment output from Azure CLI. + +Bicep deployment outputs include: + +- storageAccountId +- storagePrivateDNSZoneId +- storagePrivateEndpointId +- infraResourceGroupId + +## How to run bootstrap + +From the repository root containing the main `Makefile`, inside a bash terminal enter: + +```bash +make dev bootstrap +``` + +You can also specify any optional overrides as environment variables passed to the script, like: + +```bash +make dev bootstrap REGION="UK South" APP_SHORT_NAME="nbsse" +``` + +To target a production environment, please use the following: + +```bash +make prod bootstrap +``` + +> Note: production environment values currently include placeholders in [infrastructure/environments/prod/variables.sh](environments/prod/variables.sh), so update those first. + +## Common next commands + +After bootstrap succeeds, typical Terraform workflow is: + +```bash +make dev terraform-init +make dev terraform-plan +make dev terraform-apply +``` + +--- + +## Troubleshooting + +- ### WSL error: `env: bash\r not found` + Cause: shell files are saved with CRLF. + Fix: + - Convert to LF endings. + - - Keep .gitattributes enforcing LF for shell scripts. + +- ### set: `invalid option pipefail` + Cause: usually CRLF line ending symptom. + Fix: + - Convert affected shell files to LF. + +- ### Unable to resolve hub subscription + Cause: HUB_SUBSCRIPTION value does not match a known subscription display name. + Fix: + - Verify values in environment variable files. + - Validate account access with Azure CLI. + +- ### Required Entra group not found + Cause: missing group or permission issue when querying Entra. + Fix: + - Verify naming pattern `screening__`. + - Confirm your account can query Entra groups. + - use `az login --tenant xxxx` to log into the specified tenant + +- ### Failed to clone `dtos-devops-templates` during `terraform-init` + Cause: network access to GitHub blocked, or invalid TERRAFORM_MODULES_REF. + Fix: + - Check connectivity and credentials to github.com. + - Verify TERRAFORM_MODULES_REF in environment variables. + + + + + diff --git a/infrastructure/bootstrap/dns.bicep b/infrastructure/bootstrap/dns.bicep new file mode 100644 index 0000000..3189660 --- /dev/null +++ b/infrastructure/bootstrap/dns.bicep @@ -0,0 +1,14 @@ +param resourceServiceType string + +var dnsZoneName = { + storage: 'privatelink.blob.${environment().suffixes.storage}' + // Cannot read vault URL from environment() because of https://github.com/Azure/bicep/issues/9839 + keyVault: 'privatelink.vaultcore.azure.net' +} + +// Retrieve the private DNS zone for storage accounts +resource privateDNSZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: dnsZoneName[resourceServiceType] +} + +output privateDNSZoneID string = privateDNSZone.id diff --git a/infrastructure/bootstrap/main.bicep b/infrastructure/bootstrap/main.bicep new file mode 100644 index 0000000..305d67b --- /dev/null +++ b/infrastructure/bootstrap/main.bicep @@ -0,0 +1,72 @@ +targetScope = 'subscription' + +param enableSoftDelete bool +param envConfig string +param region string +param storageAccountRGName string +param storageAccountName string +param appShortName string +param userGroupPrincipalID string +param infraResourceGroupName string = 'rg-nbsse-${envConfig}-infra' + +var hubMap = { + dev: 'dev' + prod: 'prod' +} + +var hub = hubMap[envConfig] +var privateEndpointRGName = 'rg-hub-${hub}-uks-hub-private-endpoints' +var privateDNSZoneRGName = 'rg-hub-${hub}-uks-private-dns-zones' +var userGroupName = 'screening_${appShortName}_${envConfig}' + +resource storageAccountRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: storageAccountRGName +} + +resource privateEndpointResourceGroup 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: privateEndpointRGName +} + +resource privateDNSZoneRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: privateDNSZoneRGName +} + +module terraformStateStorageAccount 'terraformStorage.bicep' = { + scope: storageAccountRG + params: { + storageLocation: region + storageName: storageAccountName + enableSoftDelete: enableSoftDelete + userGroupPrincipalID: userGroupPrincipalID + userGroupName: userGroupName + } +} + +module terraformStoragePrivateDnsZone 'dns.bicep' = { + scope: privateDNSZoneRG + params: { + resourceServiceType: 'storage' + } +} + +module terraformStoragePrivateEndpoint 'privateEndpoint.bicep' = { + scope: privateEndpointResourceGroup + params: { + hub: hub + region: region + name: storageAccountName + resourceServiceType: 'storage' + resourceID: terraformStateStorageAccount.outputs.storageAccountID + privateDNSZoneID: terraformStoragePrivateDnsZone.outputs.privateDNSZoneID + } +} + +resource infraRG 'Microsoft.Resources/resourceGroups@2024-11-01' = { + name: infraResourceGroupName + location: region +} + +output storageAccountId string = terraformStateStorageAccount.outputs.storageAccountID +output storagePrivateDNSZoneId string = terraformStoragePrivateDnsZone.outputs.privateDNSZoneID +output storagePrivateEndpointId string = terraformStoragePrivateEndpoint.outputs.privateEndpointID +output infraResourceGroupId string = infraRG.id diff --git a/infrastructure/bootstrap/privateEndpoint.bicep b/infrastructure/bootstrap/privateEndpoint.bicep new file mode 100644 index 0000000..8a08b10 --- /dev/null +++ b/infrastructure/bootstrap/privateEndpoint.bicep @@ -0,0 +1,73 @@ +param hub string +param region string +param privateDNSZoneID string +param name string +param resourceID string +param resourceServiceType string + +var hubRGName = 'rg-hub-${hub}-uks-hub-networking' +var hubVnetName = 'VNET-${toUpper(hub)}-UKS-HUB' +var hubSubnetName = 'SN-${toUpper(hub)}-UKS-HUB-pep' + +var groupID = { + storage: 'blob' + keyVault: 'vault' +} + +// Retrieve the existing vnet resource group +resource vnetRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: hubRGName + scope: subscription() +} + +// Retrieve the existing vnet +resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' existing = { + name: hubVnetName + scope: vnetRG +} + +// Retrieve the existing Subnet within the vnet +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-01-01' existing = { + parent: vnet + name: hubSubnetName +} + +// Create the private endpoint for the storage account +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = { + name: '${name}-pep' + location: region + properties: { + subnet: { + id: subnet.id + } + privateLinkServiceConnections: [ + { + name: '${name}-connection' + properties: { + privateLinkServiceId: resourceID + groupIds: [ + groupID[resourceServiceType] + ] + } + } + ] + } +} + +// Register the private endpoint in the private DNS zone +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: privateEndpoint + name: '${name}-dns' + properties: { + privateDnsZoneConfigs: [ + { + name: '${name}-dns-zone-config' + properties: { + privateDnsZoneId: privateDNSZoneID + } + } + ] + } +} + +output privateEndpointID string = privateEndpoint.id diff --git a/infrastructure/bootstrap/terraformStorage.bicep b/infrastructure/bootstrap/terraformStorage.bicep new file mode 100644 index 0000000..8fb55ec --- /dev/null +++ b/infrastructure/bootstrap/terraformStorage.bicep @@ -0,0 +1,80 @@ +param storageLocation string +param storageName string +param enableSoftDelete bool +param userGroupPrincipalID string +param userGroupName string + +// Create storage account without public access +resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: storageName + location: storageLocation + sku: { + name: 'Standard_RAGRS' + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + allowSharedKeyAccess: false + encryption: { + requireInfrastructureEncryption: true + } + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Disabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Deny' + } + } +} + +// Create the blob service +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + parent: storageAccount + name: 'default' + properties: { + containerDeleteRetentionPolicy: { + days: enableSoftDelete ? 15 : null + enabled: enableSoftDelete + } + deleteRetentionPolicy: { + days: enableSoftDelete ? 15 : null + enabled: enableSoftDelete + } + isVersioningEnabled: true + } +} + +// Create the blob container +resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + parent: blobService + name: 'terraform-state' + properties: { + publicAccess: 'None' + defaultEncryptionScope: '$account-encryption-key' + denyEncryptionScopeOverride: false + } +} + +// Define role assignments array +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleAssignments = [ + { + roleName: 'blobContributor' + roleId: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + description: 'Blob Contributor access to the Terraform state resource group' + } +] + +// Entra ID Group RBAC assignments using loop +resource groupRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in roleAssignments:{ + name: guid(subscription().subscriptionId, userGroupPrincipalID, role.roleId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} ${role.description}' + } +}] + +// Output the storage account ID so it can be used to create the private endpoint +output storageAccountID string = storageAccount.id diff --git a/infrastructure/environments/dev/variables.sh b/infrastructure/environments/dev/variables.sh new file mode 100644 index 0000000..c6e2317 --- /dev/null +++ b/infrastructure/environments/dev/variables.sh @@ -0,0 +1,10 @@ +ENV_CONFIG=dev +ENVIRONMENT=dev +AZURE_SUBSCRIPTION="Digital Screening DToS - DevOps" +HUB_SUBSCRIPTION="Digital Screening DToS - DevOps" +HUB=dev +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-dev-uks +# To reference a tag use full syntax: DEVOPS_TEMPLATES_BRANCH=refs/tags/v0.1 +DEVOPS_TEMPLATES_BRANCH=main diff --git a/infrastructure/environments/prod/variables.sh b/infrastructure/environments/prod/variables.sh new file mode 100644 index 0000000..fbc5d9c --- /dev/null +++ b/infrastructure/environments/prod/variables.sh @@ -0,0 +1,11 @@ +# TODO update subscription names to actual production names +ENV_CONFIG=prod +ENVIRONMENT=prod +AZURE_SUBSCRIPTION="FIXME" +HUB_SUBSCRIPTION="FIXME" +HUB=prod +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-prod-uks +# To reference a tag use full syntax: DEVOPS_TEMPLATES_BRANCH=refs/tags/v0.1 +DEVOPS_TEMPLATES_BRANCH=main diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/scripts/bash/run_bootstrap.sh b/scripts/bash/run_bootstrap.sh new file mode 100644 index 0000000..bcaef30 --- /dev/null +++ b/scripts/bash/run_bootstrap.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'Usage: %s \n' "$0" >&2 +} + +if (( $# < 8 || $# > 9 )); then + printf 'ERROR: expected 8 or 9 arguments, received %d.\n' "$#" >&2 + usage + exit 2 +fi + +if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then + ANSI_ESC=$'\033' + ANSI_RESET="${ANSI_ESC}[0m" + ANSI_BOLD="${ANSI_ESC}[1m" + ANSI_DIM="${ANSI_ESC}[2m" + ANSI_BLUE="${ANSI_ESC}[34m" + ANSI_CYAN="${ANSI_ESC}[36m" + ANSI_GREEN="${ANSI_ESC}[32m" + ANSI_YELLOW="${ANSI_ESC}[33m" +else + ANSI_ESC='' + ANSI_RESET='' + ANSI_BOLD='' + ANSI_DIM='' + ANSI_BLUE='' + ANSI_CYAN='' + ANSI_GREEN='' + ANSI_YELLOW='' +fi + +format_log_message() { + local msg="$1" + + # Highlight words wrapped in [[...]] as bold yellow text. + msg=$(printf '%s' "$msg" | sed -E "s/\\[\\[([^]]+)\\]\\]/${ANSI_BOLD}${ANSI_YELLOW}\\1${ANSI_RESET}/g") + printf '%s' "$msg" +} + +log_info() { + local msg + msg="$(format_log_message "$1")" + printf '%b\n' "${ANSI_BLUE}${ANSI_BOLD}INFO${ANSI_RESET} ${msg}" +} + +log_step() { + local msg + msg="$(format_log_message "$1")" + printf '%b\n' "${ANSI_CYAN}${ANSI_BOLD}STEP${ANSI_RESET} ${msg}" +} + +log_ok() { + local msg + msg="$(format_log_message "$1")" + printf '%b\n' "${ANSI_GREEN}${ANSI_BOLD}OK${ANSI_RESET} ${msg}" +} + +log_warn() { + local msg + msg="$(format_log_message "$1")" + printf '%b\n' "${ANSI_YELLOW}${ANSI_BOLD}WARN${ANSI_RESET} ${msg}" +} + +REGION="$1" +HUB_SUBSCRIPTION_ID="$2" +ENABLE_SOFT_DELETE="$3" +ENV_CONFIG="$4" +STORAGE_ACCOUNT_RG="$5" +STORAGE_ACCOUNT_NAME="$6" +APP_SHORT_NAME="$7" +ARM_SUBSCRIPTION_ID="$8" + +MAIN_DEPLOYMENT_NAME="bootstrap-${APP_SHORT_NAME}-${ENV_CONFIG}-main" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +MAIN_TEMPLATE="${REPO_ROOT}/infrastructure/bootstrap/main.bicep" +userGroupName="screening_${APP_SHORT_NAME}_${ENV_CONFIG}" + +check_prerequisites() { + local command_name template subscription_id + + userGroupPrincipalID=$(az ad group show --group "$userGroupName" --query id -o tsv 2>/dev/null || true) + if [ -z "$userGroupPrincipalID" ]; then + log_warn "Required Entra group '[[$userGroupName]]' was not found or cannot be read" + return 1 + fi + + userGroupDisplayName=$(az ad group show --group "$userGroupName" --query displayName -o tsv 2>/dev/null || true) + if [ -z "$userGroupDisplayName" ]; then + userGroupDisplayName="$userGroupName" + fi + + hubSubscriptionName=$(az account list --query "[?id=='${HUB_SUBSCRIPTION_ID}'].name | [0]" -o tsv 2>/dev/null || true) + if [ -z "$hubSubscriptionName" ]; then + hubSubscriptionName="$HUB_SUBSCRIPTION_ID" + fi + + log_ok "Prerequisite checks passed" + log_ok "Hub subscription: [[$hubSubscriptionName]] ([[$HUB_SUBSCRIPTION_ID]])" + log_ok "User group to grant access: [[$userGroupDisplayName]] ([[$userGroupPrincipalID]])" +} + +check_prerequisites + +mainBicepParams=( + enableSoftDelete="$ENABLE_SOFT_DELETE" + envConfig="$ENV_CONFIG" + region="$REGION" + storageAccountRGName="$STORAGE_ACCOUNT_RG" + storageAccountName="$STORAGE_ACCOUNT_NAME" + appShortName="$APP_SHORT_NAME" + userGroupPrincipalID="$userGroupPrincipalID" +) + +echo +log_step "Pre-test deploying bootstrap resources into hub subscription [[$hubSubscriptionName]]" +az deployment sub create \ + --location "$REGION" \ + --template-file "$MAIN_TEMPLATE" \ + --name "$MAIN_DEPLOYMENT_NAME" \ + --subscription "$HUB_SUBSCRIPTION_ID" \ + --parameters "${mainBicepParams[@]}" \ + --what-if + +echo +read -r -p "Proceed with deployment? (y/n): " confirm +[[ "$confirm" != "y" ]] && exit 0 + +echo +log_step "Deploying bootstrap resources into hub subscription [[$hubSubscriptionName]]..." +output=$(az deployment sub create \ + --location "$REGION" \ + --template-file "$MAIN_TEMPLATE" \ + --name "$MAIN_DEPLOYMENT_NAME" \ + --subscription "$HUB_SUBSCRIPTION_ID" \ + --parameters "${mainBicepParams[@]}") + +log_info "Deployment output:" +echo "$output" diff --git a/scripts/make/azure.mk b/scripts/make/azure.mk new file mode 100644 index 0000000..89a8113 --- /dev/null +++ b/scripts/make/azure.mk @@ -0,0 +1,33 @@ +.SILENT: set-az-account get-subscription-ids +.PHONY: set-az-account get-subscription-ids + +set-az-account: # Set the Azure account for the environment - make set-az-account @Azure + $(eval AZURE_SUBSCRIPTION_NAME=$(subst ",,$(AZURE_SUBSCRIPTION))) + if [ "${SKIP_AZURE_LOGIN}" = "true" ]; then + echo "Skipping Azure account selection (SKIP_AZURE_LOGIN=true)" + exit 0 + fi + echo "Setting Azure account to subscription: ${AZURE_SUBSCRIPTION_NAME}" + az account show >/dev/null + az account set --subscription "${AZURE_SUBSCRIPTION_NAME}" + + +get-subscription-ids: # Retrieve the hub subscription ID based on the subscription name in ${HUB_SUBSCRIPTION} - make get-subscription-ids @Azure + $(eval HUB_SUBSCRIPTION_NAME=$(subst ",,$(HUB_SUBSCRIPTION))) + $(eval HUB_SUBSCRIPTION_ID=$(shell az account show --query id --output tsv --subscription "${HUB_SUBSCRIPTION_NAME}")) + $(if ${ARM_SUBSCRIPTION_ID},,$(eval export ARM_SUBSCRIPTION_ID=$(shell az account show --query id --output tsv))) + if [ -z "$(HUB_SUBSCRIPTION_ID)" ]; then + echo "Unable to resolve hub subscription: $(HUB_SUBSCRIPTION_NAME)" + exit 1 + fi + if [ -z "$(ARM_SUBSCRIPTION_ID)" ]; then + echo "Unable to resolve application subscription" + exit 1 + fi + echo + echo "Working with subscription IDs" + echo "===============================" + echo HUB_SUBSCRIPTION_ID=${HUB_SUBSCRIPTION_ID} + echo ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} + echo + diff --git a/scripts/make/bootstrap.mk b/scripts/make/bootstrap.mk new file mode 100644 index 0000000..82edc11 --- /dev/null +++ b/scripts/make/bootstrap.mk @@ -0,0 +1,8 @@ +.PHONY: bootstrap +.SILENT: bootstrap + +bootstrap: set-az-account get-subscription-ids # Initialise Terraform resources - make bootstrap @Bootstrap + @echo STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate + $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) + @bash scripts/bash/run_bootstrap.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" + diff --git a/scripts/make/environment.mk b/scripts/make/environment.mk new file mode 100644 index 0000000..b617727 --- /dev/null +++ b/scripts/make/environment.mk @@ -0,0 +1,16 @@ +# specifying environments like this avoids order-dependent commands like 'make dev terraform-plan' + +.SILENT: dev prod +.PHONY: dev prod + +REGION ?= UK South +APP_SHORT_NAME ?= nbsse +STORAGE_ACCOUNT_RG ?= rg-dtos-state-files + +dev: # Provide a shortcut for dev environment - make dev @Environment + $(eval export ENV_CONFIG=dev) + $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) + +prod: # Provide a shortcut for production environment - make prod @Environment + $(eval export ENV_CONFIG=prod) + $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) \ No newline at end of file diff --git a/scripts/make/shared.mk b/scripts/make/shared.mk new file mode 100644 index 0000000..6762d76 --- /dev/null +++ b/scripts/make/shared.mk @@ -0,0 +1,86 @@ + +.PHONY: _install-tool _install-tools _install-uv shellscript-lint-all +.ONESHELL: +.SHELLFLAGS := -ce + +MAKEFLAGS += --no-print-directory # '+=' preserves caller-supplied flags +SHELL := /bin/bash + +_install-tool: # Install asdf dependency - mandatory: name=[listed in the '.tool-versions' file]; optional: version=[if not listed] + echo ${name} + asdf plugin add ${name} ||: + asdf install ${name} $(or ${version},) + +_install-tools: # Install all the tools listed in .tool-versions + for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do \ + $(MAKE) _install-tool name="$${plugin}" ; \ + done + +_install-uv: # Install uv toolset if not present + if command -v uv >/dev/null 2>&1; then + echo "uv already installed: $$(uv --version)" + exit 0 + fi + + curl -LsSf https://astral.sh/uv/install.sh | sh + + if ! command -v uv >/dev/null 2>&1 && [ -x "$$HOME/.local/bin/uv" ]; then + export PATH="$$HOME/.local/bin:$$PATH" + fi + + uv --version + +# This script parses all the make target descriptions and renders the help output. +HELP_SCRIPT = \ + \ + use Text::Wrap; \ + %help_info; \ + my $$max_command_length = 0; \ + my $$terminal_width = `tput cols` || 120; chomp($$terminal_width); \ + \ + while(<>){ \ + next if /^_/; \ + \ + if (/^([\w-_]+)\s*:.*\#(.*?)(@(\w+))?\s*$$/) { \ + my $$command = $$1; \ + my $$description = $$2; \ + $$description =~ s/@\w+//; \ + my $$category_key = $$4 // 'Others'; \ + (my $$category_name = $$category_key) =~ s/(?<=[a-z])([A-Z])/\ $$1/g; \ + $$category_name = lc($$category_name); \ + $$category_name =~ s/^(.)/\U$$1/; \ + \ + push @{$$help_info{$$category_name}}, [$$command, $$description]; \ + $$max_command_length = (length($$command) > 37) ? 40 : $$max_command_length; \ + } \ + } \ + \ + my $$description_width = $$terminal_width - $$max_command_length - 4; \ + $$Text::Wrap::columns = $$description_width; \ + \ + for my $$category (sort { $$a eq 'Others' ? 1 : $$b eq 'Others' ? -1 : $$a cmp $$b } keys %help_info) { \ + print "\033[1m$$category\033[0m:\n\n"; \ + for my $$item (sort { $$a->[0] cmp $$b->[0] } @{$$help_info{$$category}}) { \ + my $$description = $$item->[1]; \ + my @desc_lines = split("\n", wrap("", "", $$description)); \ + my $$first_line_description = shift @desc_lines; \ + \ + $$first_line_description =~ s/(\w+)(\|\w+)?=/\033[3m\033[93m$$1$$2\033[0m=/g; \ + \ + my $$formatted_command = $$item->[0]; \ + $$formatted_command = substr($$formatted_command, 0, 37) . "..." if length($$formatted_command) > 37; \ + \ + print sprintf(" \033[0m\033[34m%-$${max_command_length}s\033[0m%s %s\n", $$formatted_command, $$first_line_description); \ + for my $$line (@desc_lines) { \ + $$line =~ s/(\w+)(\|\w+)?=/\033[3m\033[93m$$1$$2\033[0m=/g; \ + print sprintf(" %-$${max_command_length}s %s\n", " ", $$line); \ + } \ + print "\n"; \ + } \ + } + +shellscript-lint-all: # Lint all shell scripts in the scripts directory, do not fail on error, just print the error messages @Quality + for file in $$(find scripts -type f -name "*.sh"); do \ + file=$${file} scripts/shellscript-linter.sh ||: ; \ + done + diff --git a/scripts/make/terraform.mk b/scripts/make/terraform.mk new file mode 100644 index 0000000..b40e168 --- /dev/null +++ b/scripts/make/terraform.mk @@ -0,0 +1,62 @@ +.PHONY: terraform-init terraform-validate terraform-plan terraform-apply terraform-destroy terraform-fetch-modules _check-paths +.SILENT: terraform-validate terraform-init terraform-fetch-modules + +TF_DIR ?= infrastructure/terraform +TF_VARS ?= infrastructure/environments/${ENV_CONFIG}/variables.tfvars +TF_MODULES_DIR := infrastructure/modules/dtos-devops-templates + +_check-paths: + @echo "TF_DIR: $(TF_DIR)" + @echo "TF_VARS: $(TF_VARS)" + @echo "TF_MODULES_DIR: $(TF_MODULES_DIR)" + + @if [ ! -d "$(TF_DIR)" ]; then \ + echo "ERROR: TF_DIR does not exist: $(TF_DIR)"; \ + exit 1; \ + fi + @if ! find "$(TF_DIR)" -maxdepth 1 -type f -name "*.tf" | grep -q .; then \ + echo "ERROR: TF_DIR contains no Terraform *.tf files: $(TF_DIR)"; \ + exit 1; \ + fi + @if [ ! -f "$(TF_VARS)" ]; then \ + echo "ERROR: TF_VARS does not exist: $(TF_VARS)"; \ + exit 1; \ + fi + + @echo "✅ Terraform paths are valid" + +terraform-init: _check-paths terraform-fetch-modules set-az-account get-subscription-ids # Initialise Terraform and backend storage - make terraform-init @Terraform + $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) + $(eval export ARM_USE_AZUREAD=true) + + # Don't specify '-upgrade' because plan/apply must honour the lock file. \ + terraform -chdir="$(TF_DIR)" init \ + -reconfigure \ + -backend-config="subscription_id=${HUB_SUBSCRIPTION_ID}" \ + -backend-config="resource_group_name=${STORAGE_ACCOUNT_RG}" \ + -backend-config="storage_account_name=${STORAGE_ACCOUNT_NAME}" \ + -backend-config="key=${ENVIRONMENT}.tfstate"; \ + + $(eval export TF_VAR_app_short_name=${APP_SHORT_NAME}) + $(eval export TF_VAR_environment=${ENVIRONMENT}) + $(eval export TF_VAR_env_config=${ENV_CONFIG}) + $(eval export TF_VAR_hub=${HUB}) + $(eval export TF_VAR_hub_subscription_id=${HUB_SUBSCRIPTION_ID}) + +terraform-plan: terraform-init # Plan Terraform changes - make terraform-plan @Terraform + terraform -chdir="$(TF_DIR)" plan -var-file "$(TF_VARS)" + +terraform-apply: terraform-init # Apply Terraform plan changes - make terraform-apply @Terraform + terraform -chdir="$(TF_DIR)" apply -var-file "$(TF_VARS)" ${AUTO_APPROVE} + +terraform-destroy: terraform-init # Destroy Terraform resources - make terraform-destroy @Terraform + terraform -chdir="$(TF_DIR)" destroy -var-file "$(TF_VARS)" ${AUTO_APPROVE} + +terraform-validate: terraform-init # Validate Terraform changes - make terraform-validate @Terraform + terraform -chdir="$(TF_DIR)" validate + +terraform-fetch-modules: # Git clone the DevOps Templates repo if it doesn't exist on disk. @Terraform + @if [ ! -d "$(TF_MODULES_DIR)/.git" ]; then \ + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_REF} \ + https://github.com/NHSDigital/dtos-devops-templates.git "$(TF_MODULES_DIR)"; \ + fi From f7e21a7ab88de0012da6f233f8e15c3b5d1eaef8 Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:39:52 +0100 Subject: [PATCH 2/7] DSTA-669: makefile reference issue with init.mk --- .github/workflows/cicd-1-pull-request.yaml | 4 +- infrastructure/README.md | 64 ++++++++-------------- scripts/bash/run_bootstrap.sh | 2 +- scripts/make/environment.mk | 2 +- scripts/make/shared.mk | 2 +- scripts/make/terraform.mk | 5 +- 6 files changed, 31 insertions(+), 48 deletions(-) diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 5ef2bf4..1a94b43 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -31,7 +31,7 @@ jobs: id: variables run: | datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - BUILD_DATETIME=$datetime make version-create-effective-file + BUILD_DATETIME=$datetime make -f scripts/init.mk version-create-effective-file echo "build_datetime_london=$(TZ=Europe/London date --date=$datetime +'%Y-%m-%dT%H:%M:%S%z')" >> $GITHUB_OUTPUT echo "build_datetime=$datetime" >> $GITHUB_OUTPUT echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT @@ -65,7 +65,7 @@ jobs: export TERRAFORM_VERSION="${{ steps.variables.outputs.terraform_version }}" export VERSION="${{ steps.variables.outputs.version }}" export DOES_PULL_REQUEST_EXIST="${{ steps.pr_exists.outputs.does_pull_request_exist }}" - make list-variables + make -f scripts/init.mk list-variables commit-stage: # Recommended maximum execution time is 2 minutes name: "Commit stage" needs: [metadata] diff --git a/infrastructure/README.md b/infrastructure/README.md index 9e7c2e5..806d046 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,11 +1,9 @@ # Infrastructure Guide ## What this contains - The infrastructure folder contains the IaC definitions and environment configuration used to bootstrap Azure prerequisites and then run Terraform safely. ## General IaC process flow - At a high level, the delivery flow is: ```mermaid @@ -29,19 +27,18 @@ flowchart LR | --- | --- | | 1. Setup local environment | Ensure required tools are installed and authenticated (make, bash, Azure CLI, Terraform, git). | | 2. Setup Terraform state | Create state backend resources (storage and private connectivity) before standard Terraform operations. | -| 3. Initialise Terraform | Run terraform init to configure backend and download providers and modules. | -| 4. Generate a resource plan | Run terraform plan to see the proposed delta between current and desired state. | -| 5. Apply the resource plan | Run terraform apply to execute approved changes. | +| 3. Initialise Terraform | Run `terraform init` to configure backend and download providers and modules. | +| 4. Generate a resource plan | Run `terraform plan` to see the proposed delta between current and desired state. | +| 5. Apply the resource plan | Run `terraform apply` to execute approved changes. | Terraform requires remote state backend resources. In this repository, bootstrap creates those resources first so later Terraform commands can run consistently. ## Using `make` - The repository uses make targets to provide a single, repeatable command interface for developers and pipelines. The main make file depends on other make files to create relevant targets: -- [Makefile](../Makefile) - the main entry file +- [Main make file](../Makefile) - the main entry file - [scripts/make/environment.mk](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment - [scripts/make/azure.mk](../scripts/make/azure.mk) - targets for Azure cloud commands - [scripts/make/bootstrap.mk](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources @@ -49,7 +46,6 @@ The main make file depends on other make files to create relevant targets: - [scripts/bash/run_bootstrap.sh](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process ## The bootstrap process - We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. > This process is necessary before running any of the Terraform-related make targets @@ -100,7 +96,6 @@ BS --> BSS | Return bootstrap outputs | Provides IDs for verification and downstream automation. | ## Bootstrap prerequisites - Before running bootstrap, several tools and other requirements must be in place: - Install Azure CLI and authenticate with `az login`. @@ -110,8 +105,7 @@ Before running bootstrap, several tools and other requirements must be in place: - On Windows with WSL, you might run into CRLF issues so please ensure the shell and make files use LF endings. ## Bootstrap inputs - -Many bootstrap inputs have default values which are defined in separate environment files. The environment makefile loads the environment variables per environment target specified (`dev`, `prod`) +Many bootstrap inputs have default values which are defined in separate environment files. The environment make file loads the environment variables per environment target specified (`dev`, `prod`) Environment target definitions: @@ -133,10 +127,8 @@ Common variables: | AZURE_SUBSCRIPTION | Full display name of the application subscription used for 'az account set'. | | HUB_SUBSCRIPTION | Full display name of the hub subscription used to resolve hub subscription ID. | - ## Bicep modules - -Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing inital Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. +Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing initial Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. Each Bicep module covers a single concern, and the top-level `main.bicep` coordinates its dependencies via explicit module outputs rather than implied assumptions. @@ -145,25 +137,22 @@ The Bicep bootstrap modules are found in [infrastructure/bootstrap](bootstrap). | Bicep file | Creates or configures | Outputs | | --- | --- | --- | | [main.bicep](bootstrap/main.bicep) | | storageAccountId, storagePrivateDNSZoneId, storagePrivateEndpointId, infraResourceGroupId | -| [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, terraform-state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | +| [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, Terraform state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | | [dns.bicep](bootstrap/dns.bicep) | Private DNS zone lookup | | privateDNSZoneID | | [privateEndpoint.bicep](bootstrap/privateEndpoint.bicep) | Private endpoint wiring | | Existing hub VNet and subnet, resourceID, privateDNSZoneID | privateEndpointID | ### Bicep deployment parameters - The bootstrap template [infrastructure/bootstrap/main.bicep](bootstrap/main.bicep) accepts: -- enableSoftDelete -- envConfig -- region -- storageAccountRGName -- storageAccountName -- appShortName -- userGroupPrincipalID -- infraResourceGroupName (optional, defaults internally) +- `enableSoftDelete` +- `envConfig` +- `region` +- `storageAccountRGName` +- `storageAccountName` +- `appShortName` +- `userGroupPrincipalID` ## Outputs from bootstrap - Human-readable output from script execution includes: - Resolved hub subscription name and ID. @@ -179,8 +168,7 @@ Bicep deployment outputs include: - infraResourceGroupId ## How to run bootstrap - -From the repository root containing the main `Makefile`, inside a bash terminal enter: +From the repository root containing the main make file, inside a bash terminal enter: ```bash make dev bootstrap @@ -201,7 +189,6 @@ make prod bootstrap > Note: production environment values currently include placeholders in [infrastructure/environments/prod/variables.sh](environments/prod/variables.sh), so update those first. ## Common next commands - After bootstrap succeeds, typical Terraform workflow is: ```bash @@ -211,40 +198,33 @@ make dev terraform-apply ``` --- - ## Troubleshooting - -- ### WSL error: `env: bash\r not found` +**WSL error: `env: bash\r not found`** Cause: shell files are saved with CRLF. Fix: - Convert to LF endings. - - Keep .gitattributes enforcing LF for shell scripts. -- ### set: `invalid option pipefail` +**set: `invalid option pipefail`** Cause: usually CRLF line ending symptom. Fix: - Convert affected shell files to LF. -- ### Unable to resolve hub subscription +**Unable to resolve hub subscription** Cause: HUB_SUBSCRIPTION value does not match a known subscription display name. Fix: - Verify values in environment variable files. - Validate account access with Azure CLI. -- ### Required Entra group not found +**Required Entra group not found** Cause: missing group or permission issue when querying Entra. Fix: - Verify naming pattern `screening__`. - Confirm your account can query Entra groups. - use `az login --tenant xxxx` to log into the specified tenant -- ### Failed to clone `dtos-devops-templates` during `terraform-init` +**Failed to clone `dtos-devops-templates` during `terraform-init`** Cause: network access to GitHub blocked, or invalid TERRAFORM_MODULES_REF. Fix: - - Check connectivity and credentials to github.com. - - Verify TERRAFORM_MODULES_REF in environment variables. - - - - - + - Check connectivity and credentials to GitHub.com. + - Verify TERRAFORM_MODULES_REF in environment variables. \ No newline at end of file diff --git a/scripts/bash/run_bootstrap.sh b/scripts/bash/run_bootstrap.sh index bcaef30..a0abda3 100644 --- a/scripts/bash/run_bootstrap.sh +++ b/scripts/bash/run_bootstrap.sh @@ -81,7 +81,7 @@ userGroupName="screening_${APP_SHORT_NAME}_${ENV_CONFIG}" check_prerequisites() { local command_name template subscription_id - + userGroupPrincipalID=$(az ad group show --group "$userGroupName" --query id -o tsv 2>/dev/null || true) if [ -z "$userGroupPrincipalID" ]; then log_warn "Required Entra group '[[$userGroupName]]' was not found or cannot be read" diff --git a/scripts/make/environment.mk b/scripts/make/environment.mk index b617727..b36d3d0 100644 --- a/scripts/make/environment.mk +++ b/scripts/make/environment.mk @@ -13,4 +13,4 @@ dev: # Provide a shortcut for dev environment - make dev @Environment prod: # Provide a shortcut for production environment - make prod @Environment $(eval export ENV_CONFIG=prod) - $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) \ No newline at end of file + $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) diff --git a/scripts/make/shared.mk b/scripts/make/shared.mk index 6762d76..5190de3 100644 --- a/scripts/make/shared.mk +++ b/scripts/make/shared.mk @@ -29,7 +29,7 @@ _install-uv: # Install uv toolset if not present fi uv --version - + # This script parses all the make target descriptions and renders the help output. HELP_SCRIPT = \ \ diff --git a/scripts/make/terraform.mk b/scripts/make/terraform.mk index b40e168..47698b6 100644 --- a/scripts/make/terraform.mk +++ b/scripts/make/terraform.mk @@ -28,7 +28,7 @@ _check-paths: terraform-init: _check-paths terraform-fetch-modules set-az-account get-subscription-ids # Initialise Terraform and backend storage - make terraform-init @Terraform $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) $(eval export ARM_USE_AZUREAD=true) - + # Don't specify '-upgrade' because plan/apply must honour the lock file. \ terraform -chdir="$(TF_DIR)" init \ -reconfigure \ @@ -60,3 +60,6 @@ terraform-fetch-modules: # Git clone the DevOps Templates repo if it doesn't exi git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_REF} \ https://github.com/NHSDigital/dtos-devops-templates.git "$(TF_MODULES_DIR)"; \ fi + +terraform-fmt: # Format Terraform files - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform fmt command, default is '-recursive'] @Terraform + terraform fmt -recursive -chdir="$(TF_DIR)" \ No newline at end of file From d4a231872e3c45ba3b462a8876e5c8a21bcc3a03 Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:16:59 +0100 Subject: [PATCH 3/7] DSTA-669: linting and whitespace removals --- .github/actions/lint-terraform/action.yaml | 15 ++++++ Makefile | 10 ++-- infrastructure/README.md | 56 ++++++++++++++-------- scripts/make/terraform.mk | 8 ++-- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/.github/actions/lint-terraform/action.yaml b/.github/actions/lint-terraform/action.yaml index 4b7d96c..f5868a9 100644 --- a/.github/actions/lint-terraform/action.yaml +++ b/.github/actions/lint-terraform/action.yaml @@ -7,6 +7,21 @@ inputs: runs: using: "composite" steps: + - name: "Resolve Terraform version" + id: terraform-version + shell: bash + run: | + version=$(awk '/^terraform[[:space:]]+/ { print $2; exit }' .tool-versions) + if [ -z "$version" ]; then + echo "Could not find terraform version in .tool-versions" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: "Set up Terraform" + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ steps.terraform-version.outputs.version }} - name: "Check Terraform format" shell: bash run: | diff --git a/Makefile b/Makefile index a463800..2bd80b8 100644 --- a/Makefile +++ b/Makefile @@ -24,11 +24,11 @@ help: # Print help @Others # Bootstrap & Environment # --------------------------------------------------------------------------- # Configure development environment (main) @Configuration -config: - _install-tools - _install-uv - githooks-config - dependencies +config: + _install-tools + _install-uv + githooks-config + dependencies dependencies: # Install dependencies needed to build and test the project @Pipeline @if [ -f nbss/pyproject.toml ]; then \ diff --git a/infrastructure/README.md b/infrastructure/README.md index 806d046..3fdda70 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,16 +1,18 @@ # Infrastructure Guide ## What this contains + The infrastructure folder contains the IaC definitions and environment configuration used to bootstrap Azure prerequisites and then run Terraform safely. ## General IaC process flow + At a high level, the delivery flow is: ```mermaid --- title: Boostrap process flow config: - look: handDrawn + look: handDrawn --- flowchart LR A("`1. Tools`") --> B("`2. Terraform state`") @@ -34,19 +36,21 @@ flowchart LR Terraform requires remote state backend resources. In this repository, bootstrap creates those resources first so later Terraform commands can run consistently. ## Using `make` + The repository uses make targets to provide a single, repeatable command interface for developers and pipelines. The main make file depends on other make files to create relevant targets: - [Main make file](../Makefile) - the main entry file -- [scripts/make/environment.mk](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment -- [scripts/make/azure.mk](../scripts/make/azure.mk) - targets for Azure cloud commands -- [scripts/make/bootstrap.mk](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources -- [scripts/make/terraform.mk](../scripts/make/terraform.mk) - targets for all Terraform commands -- [scripts/bash/run_bootstrap.sh](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process +- [Environment targets](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment +- [Azure targets](../scripts/make/azure.mk) - targets for Azure cloud commands +- [Bootstrap targets](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources +- [Terraform targets](../scripts/make/terraform.mk) - targets for all Terraform commands +- [Bootstrap orchestrator](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process ## The bootstrap process -We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. + +We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. > This process is necessary before running any of the Terraform-related make targets @@ -57,7 +61,7 @@ The following diagram shows an overview of what the bootstrap process does: title: Bootstrap process flow config: look: handDrawn ---- +--- flowchart LR A("`fa:fa-spinner Run **make dev bootstrap**`") --> BS @@ -96,6 +100,7 @@ BS --> BSS | Return bootstrap outputs | Provides IDs for verification and downstream automation. | ## Bootstrap prerequisites + Before running bootstrap, several tools and other requirements must be in place: - Install Azure CLI and authenticate with `az login`. @@ -105,6 +110,7 @@ Before running bootstrap, several tools and other requirements must be in place: - On Windows with WSL, you might run into CRLF issues so please ensure the shell and make files use LF endings. ## Bootstrap inputs + Many bootstrap inputs have default values which are defined in separate environment files. The environment make file loads the environment variables per environment target specified (`dev`, `prod`) Environment target definitions: @@ -128,6 +134,7 @@ Common variables: | HUB_SUBSCRIPTION | Full display name of the hub subscription used to resolve hub subscription ID. | ## Bicep modules + Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing initial Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. Each Bicep module covers a single concern, and the top-level `main.bicep` coordinates its dependencies via explicit module outputs rather than implied assumptions. @@ -135,13 +142,14 @@ Each Bicep module covers a single concern, and the top-level `main.bicep` coordi The Bicep bootstrap modules are found in [infrastructure/bootstrap](bootstrap). | Bicep file | Creates or configures | Outputs | -| --- | --- | --- | +| --- | --- | --- | | [main.bicep](bootstrap/main.bicep) | | storageAccountId, storagePrivateDNSZoneId, storagePrivateEndpointId, infraResourceGroupId | | [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, Terraform state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | | [dns.bicep](bootstrap/dns.bicep) | Private DNS zone lookup | | privateDNSZoneID | | [privateEndpoint.bicep](bootstrap/privateEndpoint.bicep) | Private endpoint wiring | | Existing hub VNet and subnet, resourceID, privateDNSZoneID | privateEndpointID | ### Bicep deployment parameters + The bootstrap template [infrastructure/bootstrap/main.bicep](bootstrap/main.bicep) accepts: - `enableSoftDelete` @@ -153,6 +161,7 @@ The bootstrap template [infrastructure/bootstrap/main.bicep](bootstrap/main.bice - `userGroupPrincipalID` ## Outputs from bootstrap + Human-readable output from script execution includes: - Resolved hub subscription name and ID. @@ -168,6 +177,7 @@ Bicep deployment outputs include: - infraResourceGroupId ## How to run bootstrap + From the repository root containing the main make file, inside a bash terminal enter: ```bash @@ -189,6 +199,7 @@ make prod bootstrap > Note: production environment values currently include placeholders in [infrastructure/environments/prod/variables.sh](environments/prod/variables.sh), so update those first. ## Common next commands + After bootstrap succeeds, typical Terraform workflow is: ```bash @@ -197,34 +208,41 @@ make dev terraform-plan make dev terraform-apply ``` ---- +--- + ## Troubleshooting + **WSL error: `env: bash\r not found`** Cause: shell files are saved with CRLF. Fix: - - Convert to LF endings. - - - Keep .gitattributes enforcing LF for shell scripts. + +- Convert to LF endings. +- Keep .gitattributes enforcing LF for shell scripts. **set: `invalid option pipefail`** Cause: usually CRLF line ending symptom. Fix: - - Convert affected shell files to LF. + +- Convert affected shell files to LF. **Unable to resolve hub subscription** Cause: HUB_SUBSCRIPTION value does not match a known subscription display name. Fix: - - Verify values in environment variable files. - - Validate account access with Azure CLI. + +- Verify values in environment variable files. +- Validate account access with Azure CLI. **Required Entra group not found** Cause: missing group or permission issue when querying Entra. Fix: - - Verify naming pattern `screening__`. - - Confirm your account can query Entra groups. + +- Verify naming pattern `screening__`. +- Confirm your account can query Entra groups. - use `az login --tenant xxxx` to log into the specified tenant **Failed to clone `dtos-devops-templates` during `terraform-init`** Cause: network access to GitHub blocked, or invalid TERRAFORM_MODULES_REF. Fix: - - Check connectivity and credentials to GitHub.com. - - Verify TERRAFORM_MODULES_REF in environment variables. \ No newline at end of file + +- Check connectivity and credentials to GitHub.com. +- Verify TERRAFORM_MODULES_REF in environment variables. diff --git a/scripts/make/terraform.mk b/scripts/make/terraform.mk index 47698b6..8ae63da 100644 --- a/scripts/make/terraform.mk +++ b/scripts/make/terraform.mk @@ -1,5 +1,5 @@ -.PHONY: terraform-init terraform-validate terraform-plan terraform-apply terraform-destroy terraform-fetch-modules _check-paths -.SILENT: terraform-validate terraform-init terraform-fetch-modules +.PHONY: terraform-init terraform-validate terraform-plan terraform-apply terraform-destroy terraform-fetch-modules _check-paths terraform-fmt +.SILENT: terraform-validate terraform-init terraform-fetch-modules terraform-plan terraform-apply terraform-destroy _check-paths terraform-fmt TF_DIR ?= infrastructure/terraform TF_VARS ?= infrastructure/environments/${ENV_CONFIG}/variables.tfvars @@ -54,6 +54,7 @@ terraform-destroy: terraform-init # Destroy Terraform resources - make terraform terraform-validate: terraform-init # Validate Terraform changes - make terraform-validate @Terraform terraform -chdir="$(TF_DIR)" validate + @echo "✅ Terraform validation successful" terraform-fetch-modules: # Git clone the DevOps Templates repo if it doesn't exist on disk. @Terraform @if [ ! -d "$(TF_MODULES_DIR)/.git" ]; then \ @@ -62,4 +63,5 @@ terraform-fetch-modules: # Git clone the DevOps Templates repo if it doesn't exi fi terraform-fmt: # Format Terraform files - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform fmt command, default is '-recursive'] @Terraform - terraform fmt -recursive -chdir="$(TF_DIR)" \ No newline at end of file + terraform -chdir="$(TF_DIR)" fmt -recursive + @echo "✅ Terraform files formatted successfully" From 68c3119abfc0792fe3b3aef27daa3ed7aa9dce4e Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:36:21 +0100 Subject: [PATCH 4/7] DSTA-669: minor folder name updates --- .gitattributes | 1 - infrastructure/README.md | 2 +- scripts/{bash => bootstrap}/run_bootstrap.sh | 0 scripts/make/bootstrap.mk | 2 +- 4 files changed, 2 insertions(+), 3 deletions(-) rename scripts/{bash => bootstrap}/run_bootstrap.sh (100%) diff --git a/.gitattributes b/.gitattributes index 068c0c1..f0f1678 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,5 @@ scripts/tests/test.mk linguist-vendored scripts/init.mk linguist-vendored scripts/shellscript-linter.sh linguist-vendored -# Normalize shell scripts to LF to avoid shebang CRLF issues *.sh text eol=lf scripts/** text eol=lf diff --git a/infrastructure/README.md b/infrastructure/README.md index 3fdda70..cf6f07e 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -46,7 +46,7 @@ The main make file depends on other make files to create relevant targets: - [Azure targets](../scripts/make/azure.mk) - targets for Azure cloud commands - [Bootstrap targets](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources - [Terraform targets](../scripts/make/terraform.mk) - targets for all Terraform commands -- [Bootstrap orchestrator](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process +- [Bootstrap orchestrator](../scripts/bootstrap/run_bootstrap.sh) - script to orchestrate the bootstrap process ## The bootstrap process diff --git a/scripts/bash/run_bootstrap.sh b/scripts/bootstrap/run_bootstrap.sh similarity index 100% rename from scripts/bash/run_bootstrap.sh rename to scripts/bootstrap/run_bootstrap.sh diff --git a/scripts/make/bootstrap.mk b/scripts/make/bootstrap.mk index 82edc11..4f5c362 100644 --- a/scripts/make/bootstrap.mk +++ b/scripts/make/bootstrap.mk @@ -4,5 +4,5 @@ bootstrap: set-az-account get-subscription-ids # Initialise Terraform resources - make bootstrap @Bootstrap @echo STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) - @bash scripts/bash/run_bootstrap.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" + @bash scripts/bootstrap/run_bootstrap.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" From d60f792207655819eb7501cfdcf4d06762dd49a8 Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:44:19 +0100 Subject: [PATCH 5/7] DSTA-669: remove Terraform and unneeded make files Update Lint Terraform pre-commit hook to manual to prevent scanning --- .gitattributes | 1 - Makefile | 16 ++-- infrastructure/README.md | 88 ++++++++++---------- infrastructure/terraform/main.tf | 0 infrastructure/terraform/variables.tf | 0 scripts/{bash => bootstrap}/run_bootstrap.sh | 0 scripts/make/azure.mk | 2 - scripts/make/bootstrap.mk | 2 +- scripts/make/shared.mk | 86 ------------------- scripts/make/terraform.mk | 62 -------------- 10 files changed, 53 insertions(+), 204 deletions(-) delete mode 100644 infrastructure/terraform/main.tf delete mode 100644 infrastructure/terraform/variables.tf rename scripts/{bash => bootstrap}/run_bootstrap.sh (100%) delete mode 100644 scripts/make/shared.mk delete mode 100644 scripts/make/terraform.mk diff --git a/.gitattributes b/.gitattributes index 068c0c1..f0f1678 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,5 @@ scripts/tests/test.mk linguist-vendored scripts/init.mk linguist-vendored scripts/shellscript-linter.sh linguist-vendored -# Normalize shell scripts to LF to avoid shebang CRLF issues *.sh text eol=lf scripts/** text eol=lf diff --git a/Makefile b/Makefile index a463800..88cb336 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,19 @@ .PHONY: help workflow config dependencies githooks-config githooks-run .SILENT: help workflow .NOTPARALLEL: # this is because make -j could cause race conditions +.ONESHELL: +.SHELLFLAGS := -ce + +MAKEFLAGS += --no-print-directory # '+=' preserves caller-supplied flags +SHELL := /bin/bash ifeq (,$(filter oneshell,$(.FEATURES))) $(error .ONESHELL not supported (GNU Make 3.82+ required, found $(MAKE_VERSION))) endif -include scripts/make/shared.mk include scripts/make/environment.mk include scripts/make/bootstrap.mk include scripts/make/azure.mk -include scripts/make/terraform.mk # --------------------------------------------------------------------------- # Help & Meta @@ -25,10 +28,10 @@ help: # Print help @Others # --------------------------------------------------------------------------- # Configure development environment (main) @Configuration config: - _install-tools - _install-uv - githooks-config - dependencies + _install-tools + _install-uv + githooks-config + dependencies dependencies: # Install dependencies needed to build and test the project @Pipeline @if [ -f nbss/pyproject.toml ]; then \ @@ -52,4 +55,3 @@ githooks-run: # Run git hooks configured in this repository @Operations pre-commit run \ --config scripts/config/pre-commit.yaml \ --all-files - diff --git a/infrastructure/README.md b/infrastructure/README.md index 9e7c2e5..cf6f07e 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -12,7 +12,7 @@ At a high level, the delivery flow is: --- title: Boostrap process flow config: - look: handDrawn + look: handDrawn --- flowchart LR A("`1. Tools`") --> B("`2. Terraform state`") @@ -29,9 +29,9 @@ flowchart LR | --- | --- | | 1. Setup local environment | Ensure required tools are installed and authenticated (make, bash, Azure CLI, Terraform, git). | | 2. Setup Terraform state | Create state backend resources (storage and private connectivity) before standard Terraform operations. | -| 3. Initialise Terraform | Run terraform init to configure backend and download providers and modules. | -| 4. Generate a resource plan | Run terraform plan to see the proposed delta between current and desired state. | -| 5. Apply the resource plan | Run terraform apply to execute approved changes. | +| 3. Initialise Terraform | Run `terraform init` to configure backend and download providers and modules. | +| 4. Generate a resource plan | Run `terraform plan` to see the proposed delta between current and desired state. | +| 5. Apply the resource plan | Run `terraform apply` to execute approved changes. | Terraform requires remote state backend resources. In this repository, bootstrap creates those resources first so later Terraform commands can run consistently. @@ -41,16 +41,16 @@ The repository uses make targets to provide a single, repeatable command interfa The main make file depends on other make files to create relevant targets: -- [Makefile](../Makefile) - the main entry file -- [scripts/make/environment.mk](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment -- [scripts/make/azure.mk](../scripts/make/azure.mk) - targets for Azure cloud commands -- [scripts/make/bootstrap.mk](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources -- [scripts/make/terraform.mk](../scripts/make/terraform.mk) - targets for all Terraform commands -- [scripts/bash/run_bootstrap.sh](../scripts/bash/run_bootstrap.sh) - script to orchestrate the bootstrap process +- [Main make file](../Makefile) - the main entry file +- [Environment targets](../scripts/make/environment.mk) - targets for setting up environment variables per deployment environment +- [Azure targets](../scripts/make/azure.mk) - targets for Azure cloud commands +- [Bootstrap targets](../scripts/make/bootstrap.mk) - targets to establish initial Terraform resources +- [Terraform targets](../scripts/make/terraform.mk) - targets for all Terraform commands +- [Bootstrap orchestrator](../scripts/bootstrap/run_bootstrap.sh) - script to orchestrate the bootstrap process ## The bootstrap process -We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. +We use a bootstrap process to provision minimum Azure foundation required for Terraform state management. This process typically only needs to be run once per environment, however if you tear down all resources in the target environment and start afresh, bootstrap ensures Terraform state resources are available. > This process is necessary before running any of the Terraform-related make targets @@ -61,7 +61,7 @@ The following diagram shows an overview of what the bootstrap process does: title: Bootstrap process flow config: look: handDrawn ---- +--- flowchart LR A("`fa:fa-spinner Run **make dev bootstrap**`") --> BS @@ -111,7 +111,7 @@ Before running bootstrap, several tools and other requirements must be in place: ## Bootstrap inputs -Many bootstrap inputs have default values which are defined in separate environment files. The environment makefile loads the environment variables per environment target specified (`dev`, `prod`) +Many bootstrap inputs have default values which are defined in separate environment files. The environment make file loads the environment variables per environment target specified (`dev`, `prod`) Environment target definitions: @@ -133,19 +133,18 @@ Common variables: | AZURE_SUBSCRIPTION | Full display name of the application subscription used for 'az account set'. | | HUB_SUBSCRIPTION | Full display name of the hub subscription used to resolve hub subscription ID. | - ## Bicep modules -Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing inital Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. +Bicep is used because it's native to Azure Resource Manager, supports subscription-scope deployments, and allows us to easily compose focused modules. For establishing initial Terraform resources, this means we establish predictable orchestration with clear parameters, outputs, and preflight checks via what-if scenarios. Each Bicep module covers a single concern, and the top-level `main.bicep` coordinates its dependencies via explicit module outputs rather than implied assumptions. The Bicep bootstrap modules are found in [infrastructure/bootstrap](bootstrap). | Bicep file | Creates or configures | Outputs | -| --- | --- | --- | +| --- | --- | --- | | [main.bicep](bootstrap/main.bicep) | | storageAccountId, storagePrivateDNSZoneId, storagePrivateEndpointId, infraResourceGroupId | -| [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, terraform-state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | +| [terraformStorage.bicep](bootstrap/terraformStorage.bicep) | Terraform state backend resources | Storage account, blob service, Terraform state container, role assignment for Entra group | userGroupPrincipalID and target resource group scope | storageAccountID | | [dns.bicep](bootstrap/dns.bicep) | Private DNS zone lookup | | privateDNSZoneID | | [privateEndpoint.bicep](bootstrap/privateEndpoint.bicep) | Private endpoint wiring | | Existing hub VNet and subnet, resourceID, privateDNSZoneID | privateEndpointID | @@ -153,14 +152,13 @@ The Bicep bootstrap modules are found in [infrastructure/bootstrap](bootstrap). The bootstrap template [infrastructure/bootstrap/main.bicep](bootstrap/main.bicep) accepts: -- enableSoftDelete -- envConfig -- region -- storageAccountRGName -- storageAccountName -- appShortName -- userGroupPrincipalID -- infraResourceGroupName (optional, defaults internally) +- `enableSoftDelete` +- `envConfig` +- `region` +- `storageAccountRGName` +- `storageAccountName` +- `appShortName` +- `userGroupPrincipalID` ## Outputs from bootstrap @@ -180,7 +178,7 @@ Bicep deployment outputs include: ## How to run bootstrap -From the repository root containing the main `Makefile`, inside a bash terminal enter: +From the repository root containing the main make file, inside a bash terminal enter: ```bash make dev bootstrap @@ -210,41 +208,41 @@ make dev terraform-plan make dev terraform-apply ``` ---- +--- ## Troubleshooting -- ### WSL error: `env: bash\r not found` +**WSL error: `env: bash\r not found`** Cause: shell files are saved with CRLF. Fix: - - Convert to LF endings. - - - Keep .gitattributes enforcing LF for shell scripts. -- ### set: `invalid option pipefail` +- Convert to LF endings. +- Keep .gitattributes enforcing LF for shell scripts. + +**set: `invalid option pipefail`** Cause: usually CRLF line ending symptom. Fix: - - Convert affected shell files to LF. -- ### Unable to resolve hub subscription +- Convert affected shell files to LF. + +**Unable to resolve hub subscription** Cause: HUB_SUBSCRIPTION value does not match a known subscription display name. Fix: - - Verify values in environment variable files. - - Validate account access with Azure CLI. -- ### Required Entra group not found +- Verify values in environment variable files. +- Validate account access with Azure CLI. + +**Required Entra group not found** Cause: missing group or permission issue when querying Entra. Fix: - - Verify naming pattern `screening__`. - - Confirm your account can query Entra groups. + +- Verify naming pattern `screening__`. +- Confirm your account can query Entra groups. - use `az login --tenant xxxx` to log into the specified tenant -- ### Failed to clone `dtos-devops-templates` during `terraform-init` +**Failed to clone `dtos-devops-templates` during `terraform-init`** Cause: network access to GitHub blocked, or invalid TERRAFORM_MODULES_REF. Fix: - - Check connectivity and credentials to github.com. - - Verify TERRAFORM_MODULES_REF in environment variables. - - - - +- Check connectivity and credentials to GitHub.com. +- Verify TERRAFORM_MODULES_REF in environment variables. diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf deleted file mode 100644 index e69de29..0000000 diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/bash/run_bootstrap.sh b/scripts/bootstrap/run_bootstrap.sh similarity index 100% rename from scripts/bash/run_bootstrap.sh rename to scripts/bootstrap/run_bootstrap.sh diff --git a/scripts/make/azure.mk b/scripts/make/azure.mk index 89a8113..1abc9ab 100644 --- a/scripts/make/azure.mk +++ b/scripts/make/azure.mk @@ -11,7 +11,6 @@ set-az-account: # Set the Azure account for the environment - make set-az-accoun az account show >/dev/null az account set --subscription "${AZURE_SUBSCRIPTION_NAME}" - get-subscription-ids: # Retrieve the hub subscription ID based on the subscription name in ${HUB_SUBSCRIPTION} - make get-subscription-ids @Azure $(eval HUB_SUBSCRIPTION_NAME=$(subst ",,$(HUB_SUBSCRIPTION))) $(eval HUB_SUBSCRIPTION_ID=$(shell az account show --query id --output tsv --subscription "${HUB_SUBSCRIPTION_NAME}")) @@ -30,4 +29,3 @@ get-subscription-ids: # Retrieve the hub subscription ID based on the subscripti echo HUB_SUBSCRIPTION_ID=${HUB_SUBSCRIPTION_ID} echo ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} echo - diff --git a/scripts/make/bootstrap.mk b/scripts/make/bootstrap.mk index 82edc11..4f5c362 100644 --- a/scripts/make/bootstrap.mk +++ b/scripts/make/bootstrap.mk @@ -4,5 +4,5 @@ bootstrap: set-az-account get-subscription-ids # Initialise Terraform resources - make bootstrap @Bootstrap @echo STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) - @bash scripts/bash/run_bootstrap.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" + @bash scripts/bootstrap/run_bootstrap.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" diff --git a/scripts/make/shared.mk b/scripts/make/shared.mk deleted file mode 100644 index 6762d76..0000000 --- a/scripts/make/shared.mk +++ /dev/null @@ -1,86 +0,0 @@ - -.PHONY: _install-tool _install-tools _install-uv shellscript-lint-all -.ONESHELL: -.SHELLFLAGS := -ce - -MAKEFLAGS += --no-print-directory # '+=' preserves caller-supplied flags -SHELL := /bin/bash - -_install-tool: # Install asdf dependency - mandatory: name=[listed in the '.tool-versions' file]; optional: version=[if not listed] - echo ${name} - asdf plugin add ${name} ||: - asdf install ${name} $(or ${version},) - -_install-tools: # Install all the tools listed in .tool-versions - for plugin in $$(grep ^[a-z] .tool-versions | sed 's/[[:space:]].*//'); do \ - $(MAKE) _install-tool name="$${plugin}" ; \ - done - -_install-uv: # Install uv toolset if not present - if command -v uv >/dev/null 2>&1; then - echo "uv already installed: $$(uv --version)" - exit 0 - fi - - curl -LsSf https://astral.sh/uv/install.sh | sh - - if ! command -v uv >/dev/null 2>&1 && [ -x "$$HOME/.local/bin/uv" ]; then - export PATH="$$HOME/.local/bin:$$PATH" - fi - - uv --version - -# This script parses all the make target descriptions and renders the help output. -HELP_SCRIPT = \ - \ - use Text::Wrap; \ - %help_info; \ - my $$max_command_length = 0; \ - my $$terminal_width = `tput cols` || 120; chomp($$terminal_width); \ - \ - while(<>){ \ - next if /^_/; \ - \ - if (/^([\w-_]+)\s*:.*\#(.*?)(@(\w+))?\s*$$/) { \ - my $$command = $$1; \ - my $$description = $$2; \ - $$description =~ s/@\w+//; \ - my $$category_key = $$4 // 'Others'; \ - (my $$category_name = $$category_key) =~ s/(?<=[a-z])([A-Z])/\ $$1/g; \ - $$category_name = lc($$category_name); \ - $$category_name =~ s/^(.)/\U$$1/; \ - \ - push @{$$help_info{$$category_name}}, [$$command, $$description]; \ - $$max_command_length = (length($$command) > 37) ? 40 : $$max_command_length; \ - } \ - } \ - \ - my $$description_width = $$terminal_width - $$max_command_length - 4; \ - $$Text::Wrap::columns = $$description_width; \ - \ - for my $$category (sort { $$a eq 'Others' ? 1 : $$b eq 'Others' ? -1 : $$a cmp $$b } keys %help_info) { \ - print "\033[1m$$category\033[0m:\n\n"; \ - for my $$item (sort { $$a->[0] cmp $$b->[0] } @{$$help_info{$$category}}) { \ - my $$description = $$item->[1]; \ - my @desc_lines = split("\n", wrap("", "", $$description)); \ - my $$first_line_description = shift @desc_lines; \ - \ - $$first_line_description =~ s/(\w+)(\|\w+)?=/\033[3m\033[93m$$1$$2\033[0m=/g; \ - \ - my $$formatted_command = $$item->[0]; \ - $$formatted_command = substr($$formatted_command, 0, 37) . "..." if length($$formatted_command) > 37; \ - \ - print sprintf(" \033[0m\033[34m%-$${max_command_length}s\033[0m%s %s\n", $$formatted_command, $$first_line_description); \ - for my $$line (@desc_lines) { \ - $$line =~ s/(\w+)(\|\w+)?=/\033[3m\033[93m$$1$$2\033[0m=/g; \ - print sprintf(" %-$${max_command_length}s %s\n", " ", $$line); \ - } \ - print "\n"; \ - } \ - } - -shellscript-lint-all: # Lint all shell scripts in the scripts directory, do not fail on error, just print the error messages @Quality - for file in $$(find scripts -type f -name "*.sh"); do \ - file=$${file} scripts/shellscript-linter.sh ||: ; \ - done - diff --git a/scripts/make/terraform.mk b/scripts/make/terraform.mk deleted file mode 100644 index b40e168..0000000 --- a/scripts/make/terraform.mk +++ /dev/null @@ -1,62 +0,0 @@ -.PHONY: terraform-init terraform-validate terraform-plan terraform-apply terraform-destroy terraform-fetch-modules _check-paths -.SILENT: terraform-validate terraform-init terraform-fetch-modules - -TF_DIR ?= infrastructure/terraform -TF_VARS ?= infrastructure/environments/${ENV_CONFIG}/variables.tfvars -TF_MODULES_DIR := infrastructure/modules/dtos-devops-templates - -_check-paths: - @echo "TF_DIR: $(TF_DIR)" - @echo "TF_VARS: $(TF_VARS)" - @echo "TF_MODULES_DIR: $(TF_MODULES_DIR)" - - @if [ ! -d "$(TF_DIR)" ]; then \ - echo "ERROR: TF_DIR does not exist: $(TF_DIR)"; \ - exit 1; \ - fi - @if ! find "$(TF_DIR)" -maxdepth 1 -type f -name "*.tf" | grep -q .; then \ - echo "ERROR: TF_DIR contains no Terraform *.tf files: $(TF_DIR)"; \ - exit 1; \ - fi - @if [ ! -f "$(TF_VARS)" ]; then \ - echo "ERROR: TF_VARS does not exist: $(TF_VARS)"; \ - exit 1; \ - fi - - @echo "✅ Terraform paths are valid" - -terraform-init: _check-paths terraform-fetch-modules set-az-account get-subscription-ids # Initialise Terraform and backend storage - make terraform-init @Terraform - $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) - $(eval export ARM_USE_AZUREAD=true) - - # Don't specify '-upgrade' because plan/apply must honour the lock file. \ - terraform -chdir="$(TF_DIR)" init \ - -reconfigure \ - -backend-config="subscription_id=${HUB_SUBSCRIPTION_ID}" \ - -backend-config="resource_group_name=${STORAGE_ACCOUNT_RG}" \ - -backend-config="storage_account_name=${STORAGE_ACCOUNT_NAME}" \ - -backend-config="key=${ENVIRONMENT}.tfstate"; \ - - $(eval export TF_VAR_app_short_name=${APP_SHORT_NAME}) - $(eval export TF_VAR_environment=${ENVIRONMENT}) - $(eval export TF_VAR_env_config=${ENV_CONFIG}) - $(eval export TF_VAR_hub=${HUB}) - $(eval export TF_VAR_hub_subscription_id=${HUB_SUBSCRIPTION_ID}) - -terraform-plan: terraform-init # Plan Terraform changes - make terraform-plan @Terraform - terraform -chdir="$(TF_DIR)" plan -var-file "$(TF_VARS)" - -terraform-apply: terraform-init # Apply Terraform plan changes - make terraform-apply @Terraform - terraform -chdir="$(TF_DIR)" apply -var-file "$(TF_VARS)" ${AUTO_APPROVE} - -terraform-destroy: terraform-init # Destroy Terraform resources - make terraform-destroy @Terraform - terraform -chdir="$(TF_DIR)" destroy -var-file "$(TF_VARS)" ${AUTO_APPROVE} - -terraform-validate: terraform-init # Validate Terraform changes - make terraform-validate @Terraform - terraform -chdir="$(TF_DIR)" validate - -terraform-fetch-modules: # Git clone the DevOps Templates repo if it doesn't exist on disk. @Terraform - @if [ ! -d "$(TF_MODULES_DIR)/.git" ]; then \ - git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_REF} \ - https://github.com/NHSDigital/dtos-devops-templates.git "$(TF_MODULES_DIR)"; \ - fi From 615210b03b031e4210c5354928029e78451faf71 Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:23:21 +0100 Subject: [PATCH 6/7] DSTA-669: disable Terraform linting --- .github/actions/lint-terraform/action.yaml | 16 +++++++++++++--- .github/workflows/cicd-1-pull-request.yaml | 1 - .github/workflows/stage-1-commit.yaml | 2 ++ Makefile | 2 +- scripts/bootstrap/run_bootstrap.sh | 2 +- scripts/make/environment.mk | 2 +- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/actions/lint-terraform/action.yaml b/.github/actions/lint-terraform/action.yaml index 4b7d96c..a62d824 100644 --- a/.github/actions/lint-terraform/action.yaml +++ b/.github/actions/lint-terraform/action.yaml @@ -1,6 +1,10 @@ name: "Lint Terraform" description: "Lint Terraform" inputs: + enabled: + description: "Whether Terraform linting should run" + required: false + default: "false" root-modules: description: "Comma separated list of root module directories to validate, content of the 'infrastructure/environments' is checked by default" required: false @@ -8,14 +12,20 @@ runs: using: "composite" steps: - name: "Check Terraform format" + if: ${{ inputs.enabled == 'true' }} shell: bash + env: + FORCE_USE_DOCKER: "true" run: | - check_only=true scripts/githooks/check-terraform-format.sh + check_only=true FORCE_USE_DOCKER=true scripts/githooks/check-terraform-format.sh - name: "Validate Terraform" + if: ${{ inputs.enabled == 'true' }} shell: bash + env: + FORCE_USE_DOCKER: "true" run: | stacks=${{ inputs.root-modules }} for dir in $(find infrastructure/environments -type f -name '*.tf' -exec dirname {} \; | sort -u; echo ${stacks//,/$'\n'}); do - dir=$dir opts="-backend=false" make terraform-init - dir=$dir make terraform-validate + dir=$dir opts="-backend=false" make -f scripts/terraform/terraform.mk terraform-init + dir=$dir make -f scripts/terraform/terraform.mk terraform-validate done diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 9074c8a..174528d 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -84,7 +84,6 @@ jobs: export TERRAFORM_VERSION="${{ steps.variables.outputs.terraform_version }}" export VERSION="${{ steps.variables.outputs.version }}" export DOES_PULL_REQUEST_EXIST="${{ steps.pr_exists.outputs.does_pull_request_exist }}" - env | sort commit-stage: # Recommended maximum execution time is 2 minutes name: "Commit stage" diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index b27b7d0..cca9dfa 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -86,3 +86,5 @@ jobs: uses: actions/checkout@v7 - name: "Lint Terraform" uses: ./.github/actions/lint-terraform + with: + enabled: false diff --git a/Makefile b/Makefile index 88cb336..bb4e97b 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ help: # Print help @Others # Bootstrap & Environment # --------------------------------------------------------------------------- # Configure development environment (main) @Configuration -config: +config: _install-tools _install-uv githooks-config diff --git a/scripts/bootstrap/run_bootstrap.sh b/scripts/bootstrap/run_bootstrap.sh index bcaef30..a0abda3 100644 --- a/scripts/bootstrap/run_bootstrap.sh +++ b/scripts/bootstrap/run_bootstrap.sh @@ -81,7 +81,7 @@ userGroupName="screening_${APP_SHORT_NAME}_${ENV_CONFIG}" check_prerequisites() { local command_name template subscription_id - + userGroupPrincipalID=$(az ad group show --group "$userGroupName" --query id -o tsv 2>/dev/null || true) if [ -z "$userGroupPrincipalID" ]; then log_warn "Required Entra group '[[$userGroupName]]' was not found or cannot be read" diff --git a/scripts/make/environment.mk b/scripts/make/environment.mk index b617727..b36d3d0 100644 --- a/scripts/make/environment.mk +++ b/scripts/make/environment.mk @@ -13,4 +13,4 @@ dev: # Provide a shortcut for dev environment - make dev @Environment prod: # Provide a shortcut for production environment - make prod @Environment $(eval export ENV_CONFIG=prod) - $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) \ No newline at end of file + $(eval include infrastructure/environments/$(ENV_CONFIG)/variables.sh) From 5faf149056e8a4109496d16fdd32cbee4333a6de Mon Sep 17 00:00:00 2001 From: Michael Justus <209924279+micjustus-nc@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:29:12 +0100 Subject: [PATCH 7/7] DSTA-669: disable Terraform linting --- .github/actions/lint-terraform/action.yaml | 8 +------- .github/workflows/stage-1-commit.yaml | 20 +++++++++----------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/actions/lint-terraform/action.yaml b/.github/actions/lint-terraform/action.yaml index a62d824..2614fb4 100644 --- a/.github/actions/lint-terraform/action.yaml +++ b/.github/actions/lint-terraform/action.yaml @@ -12,17 +12,11 @@ runs: using: "composite" steps: - name: "Check Terraform format" - if: ${{ inputs.enabled == 'true' }} shell: bash - env: - FORCE_USE_DOCKER: "true" run: | - check_only=true FORCE_USE_DOCKER=true scripts/githooks/check-terraform-format.sh + check_only=true scripts/githooks/check-terraform-format.sh - name: "Validate Terraform" - if: ${{ inputs.enabled == 'true' }} shell: bash - env: - FORCE_USE_DOCKER: "true" run: | stacks=${{ inputs.root-modules }} for dir in $(find infrastructure/environments -type f -name '*.tf' -exec dirname {} \; | sort -u; echo ${stacks//,/$'\n'}); do diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index cca9dfa..51797ac 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -77,14 +77,12 @@ jobs: fetch-depth: 0 # Full history is needed to compare branches - name: "Check English usage" uses: ./.github/actions/check-english-usage - lint-terraform: - name: "Lint Terraform" - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: "Checkout code" - uses: actions/checkout@v7 - - name: "Lint Terraform" - uses: ./.github/actions/lint-terraform - with: - enabled: false + # lint-terraform: + # name: "Lint Terraform" + # runs-on: ubuntu-latest + # timeout-minutes: 2 + # steps: + # - name: "Checkout code" + # uses: actions/checkout@v7 + # - name: "Lint Terraform" + # uses: ./.github/actions/lint-terraform