diff --git a/.gitattributes b/.gitattributes index 1f16008..f0f1678 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,6 @@ scripts/terraform/** linguist-vendored scripts/tests/test.mk linguist-vendored scripts/init.mk linguist-vendored scripts/shellscript-linter.sh linguist-vendored + +*.sh text eol=lf +scripts/** text eol=lf diff --git a/.github/actions/lint-terraform/action.yaml b/.github/actions/lint-terraform/action.yaml index 4b7d96c..2614fb4 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 @@ -16,6 +20,6 @@ runs: 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 5ef2bf4..174528d 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -29,17 +29,35 @@ jobs: uses: actions/checkout@v7 - name: "Set CI/CD variables" id: variables + shell: bash run: | + set -euo pipefail + datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - BUILD_DATETIME=$datetime make 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 - echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT + + if [ -f VERSION ]; then + version_template=$(cat VERSION) + version_template=$(printf '%s' "$version_template" \ + | sed "s/\(\${yyyy}\|\$yyyy\)/$(date --date="$datetime" -u +"%Y")/g" \ + | sed "s/\(\${mm}\|\$mm\)/$(date --date="$datetime" -u +"%m")/g" \ + | sed "s/\(\${dd}\|\$dd\)/$(date --date="$datetime" -u +"%d")/g" \ + | sed "s/\(\${HH}\|\$HH\)/$(date --date="$datetime" -u +"%H")/g" \ + | sed "s/\(\${MM}\|\$MM\)/$(date --date="$datetime" -u +"%M")/g" \ + | sed "s/\(\${SS}\|\$SS\)/$(date --date="$datetime" -u +"%S")/g" \ + | sed "s/\(\${hash}\|\$hash\)/$(git rev-parse --short HEAD)/g") + printf '%s\n' "$version_template" > .version + else + printf '%s\n' "$datetime" > .version + fi + + 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" + echo "build_epoch=$(date --date="$datetime" -u +'%s')" >> "$GITHUB_OUTPUT" + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> "$GITHUB_OUTPUT" - name: "Check if pull request exists for this branch" id: pr_exists env: @@ -55,6 +73,7 @@ jobs: echo "does_pull_request_exist=false" >> $GITHUB_OUTPUT fi - name: "List variables" + shell: bash run: | export BUILD_DATETIME_LONDON="${{ steps.variables.outputs.build_datetime_london }}" export BUILD_DATETIME="${{ steps.variables.outputs.build_datetime }}" @@ -65,7 +84,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 + env | sort commit-stage: # Recommended maximum execution time is 2 minutes name: "Commit stage" needs: [metadata] diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index b27b7d0..51797ac 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -77,12 +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 + # 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 diff --git a/Makefile b/Makefile index d56e163..bb4e97b 100644 --- a/Makefile +++ b/Makefile @@ -1,36 +1,57 @@ -# 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 +.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/environment.mk +include scripts/make/bootstrap.mk +include scripts/make/azure.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 - -# ============================================================================== - -${VERBOSE}.SILENT: \ - build \ - clean \ - config \ - dependencies \ - deploy \ + @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 diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..cf6f07e --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,248 @@ +# 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: + +- [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. + +> 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 make file 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 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 | +| [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` + +## 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 make file, 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/scripts/bootstrap/run_bootstrap.sh b/scripts/bootstrap/run_bootstrap.sh new file mode 100644 index 0000000..a0abda3 --- /dev/null +++ b/scripts/bootstrap/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..1abc9ab --- /dev/null +++ b/scripts/make/azure.mk @@ -0,0 +1,31 @@ +.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..4f5c362 --- /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/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/environment.mk b/scripts/make/environment.mk new file mode 100644 index 0000000..b36d3d0 --- /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)