diff --git a/examples/terraform-bootstrap/README.md b/examples/terraform-bootstrap/README.md new file mode 100644 index 00000000..a4299920 --- /dev/null +++ b/examples/terraform-bootstrap/README.md @@ -0,0 +1,60 @@ +# Terraform Bootstrap Example recipe + +This recipe demonstrates how to bootstrap Terraform state management in Azure. +The goal is to have separate state storage for a number of different +environments, all on Azure, and to manage the state storage itself using +Terraform. This is a common pattern in IaC projects. + +The recipe is mainly intended to be used together with GitHub Actions, but that +functionality is optional and managed by a user setting that is set when +executing the recipe. If GitHub Actions is used, pipeline files are generated +such that IaC changes flow through a format check, validate, plan and apply +pipeline. + +## Prerequisites + +Pre-creating resources is optional, but permissions management is easier if you +do. The following resources are required: + +1. A subscription +2. (OPTIONAL) As many resource groups as you want to have environments (e.g. dev, + qa, prod). These resource groups should be empty, but they don't have to be. +3. A service principal with contributor access to the resource groups, and a + client secret for the SP. + +### Generating a service principal + +1. Go to Azure Portal and the Entra ID blade and add an Enterprise Application. + It does not matter what the redirect URL is. Everything can be left at default. + An App Registration should also be generated in your chosen tenant. +2. For each resource group, go to the IAM blade and add the application as a + contributor. +3. Go to the Certificates & secrets blade for the Service Principal and add a + client secret. Copy the secret value. + +## Usage + +Authenticate to Azure: + +```shell +az login +``` + +You can use either your own account (if you have the necessary permissions on +the target subscription) of the Service Principal generated earlier. + +Run the following commands: + +```shell +cd terraform && task init +``` + +If you chose to generate a CI/CD pipeline, the init task will prompt for the +subscription ID, tenant ID, client ID and client secret for each of the +environments. Use the values for the Service Principal generated earlier. +These values will be stored as secrets in GitHub. + +If you used the Service Principal credentials when running `task init`, you are +done. If not, you need to also assign the "Storage Blob Data Contributor" role +to the Service Principal on the storage accounts created by the recipe. You can +do this in the Azure Portal in IAM blades of the resource groups. diff --git a/examples/terraform-bootstrap/recipe.yml b/examples/terraform-bootstrap/recipe.yml new file mode 100644 index 00000000..ea339969 --- /dev/null +++ b/examples/terraform-bootstrap/recipe.yml @@ -0,0 +1,25 @@ +apiVersion: v1 +name: terraform-bootstrap +version: v0.0.1 +description: Set up Terraform basics like state file bootstrapping +initHelp: Install Task from https://taskfile.dev and run `task init` in the 'terraform' subdirectory of the project directory to set up terraform. +vars: + - name: ENVIRONMENTS + description: Comma-separated list of environments to create, e.g. "dev, qa, prod" + columns: [NAME, RESOURCE_GROUP_NAME] + + - name: SERVICE_NAME + description: Service name + + - name: CREATE_RESOURCE_GROUPS + confirm: true + + - name: RESOURCE_GROUP_LOCATION + if: CREATE_RESOURCE_GROUPS == true + options: + - "North Europe" + - "West Europe" + # ... + + - name: CREATE_GITHUB_ACTIONS_PIPELINE + confirm: true diff --git a/examples/terraform-bootstrap/templates/.github/workflows/terraform-apply.yml b/examples/terraform-bootstrap/templates/.github/workflows/terraform-apply.yml new file mode 100644 index 00000000..93afeae5 --- /dev/null +++ b/examples/terraform-bootstrap/templates/.github/workflows/terraform-apply.yml @@ -0,0 +1,52 @@ +{{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} +name: Terraform Apply + +on: + workflow_call: + inputs: + ENVIRONMENT: + required: true + type: string + TERRAFORM_VERSION: + required: true + type: string + secrets: + ARM_SUBSCRIPTION_ID: + required: true + ARM_TENANT_ID: + required: true + ARM_CLIENT_ID: + required: true + ARM_CLIENT_SECRET: + required: true + +env: + TF_IN_AUTOMATION: "true" + +jobs: + plan: + runs-on: ubuntu-latest + environment: {{ "${{ inputs.ENVIRONMENT }}" }} + env: + ARM_CLIENT_ID: {{ "${{ secrets.ARM_CLIENT_ID }}" }} + ARM_CLIENT_SECRET: {{ "${{ secrets.ARM_CLIENT_SECRET }}" }} + ARM_SUBSCRIPTION_ID: {{ "${{ secrets.ARM_SUBSCRIPTION_ID }}" }} + ARM_TENANT_ID: {{ "${{ secrets.ARM_TENANT_ID }}" }} + steps: + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: {{ "${{ inputs.TERRAFORM_VERSION }}" }} + + - name: Download the plan + uses: actions/download-artifact@v2 + with: + name: terraform-plan-{{ "${{ inputs.ENVIRONMENT }}" }} + + - name: Restore run permissions + run: chmod -R +x .terraform + + - name: Terraform Apply + id: apply + run: terraform apply -input=false -no-color terraform.tfplan +{{- end }} \ No newline at end of file diff --git a/examples/terraform-bootstrap/templates/.github/workflows/terraform-plan.yml b/examples/terraform-bootstrap/templates/.github/workflows/terraform-plan.yml new file mode 100644 index 00000000..be021e42 --- /dev/null +++ b/examples/terraform-bootstrap/templates/.github/workflows/terraform-plan.yml @@ -0,0 +1,93 @@ +{{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} +name: Terraform Plan + +on: + workflow_call: + inputs: + ENVIRONMENT: + required: true + type: string + TERRAFORM_VERSION: + required: true + type: string + TERRAFORM_BACKEND_STORAGE_NAME: + required: true + type: string + RESOURCE_GROUP_NAME: + required: true + type: string + secrets: + ARM_SUBSCRIPTION_ID: + required: true + ARM_TENANT_ID: + required: true + ARM_CLIENT_ID: + required: true + ARM_CLIENT_SECRET: + required: true + +env: + TF_IN_AUTOMATION: "true" + +jobs: + plan: + runs-on: ubuntu-latest + env: + ARM_CLIENT_ID: {{ "${{ secrets.ARM_CLIENT_ID }}" }} + ARM_CLIENT_SECRET: {{ "${{ secrets.ARM_CLIENT_SECRET }}" }} + ARM_SUBSCRIPTION_ID: {{ "${{ secrets.ARM_SUBSCRIPTION_ID }}" }} + ARM_TENANT_ID: {{ "${{ secrets.ARM_TENANT_ID }}" }} + steps: + - name: Check out repository code + uses: actions/checkout@v2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: {{ "${{ inputs.TERRAFORM_VERSION }}" }} + + - name: Terraform Format + id: fmt + run: terraform fmt -check + working-directory: ./terraform + + - name: Terraform Init + id: init + run: >- + terraform init + -backend-config="resource_group_name={{ "${{ inputs.RESOURCE_GROUP_NAME }}" }}" + -backend-config="storage_account_name={{ "${{ inputs.TERRAFORM_BACKEND_STORAGE_NAME }}" }}" + -backend-config="container_name=tfstate" + -backend-config="key=tfstate_" + + working-directory: ./terraform + + - name: Terraform Workspace + id: workspace + run: terraform workspace select {{ "${{ inputs.ENVIRONMENT }}" }} + working-directory: ./terraform + + - name: Terraform Validate + id: validate + run: terraform validate -no-color + working-directory: ./terraform + + - name: Terraform Plan + id: plan + run: terraform plan -out=terraform.tfplan -no-color + working-directory: ./terraform + continue-on-error: true + env: + TF_VAR_resource_group_name: {{ "${{ inputs.RESOURCE_GROUP_NAME }}" }} + + - name: Terraform Plan Status + if: steps.plan.outcome == 'failure' + run: exit 1 + + - name: Archive Terraform plan + uses: actions/upload-artifact@v2 + with: + name: terraform-plan-{{ "${{ inputs.ENVIRONMENT }}" }} + path: ./terraform + retention-days: 7 +{{- end }} \ No newline at end of file diff --git a/examples/terraform-bootstrap/templates/.github/workflows/terraform.yml b/examples/terraform-bootstrap/templates/.github/workflows/terraform.yml new file mode 100644 index 00000000..60b0c57f --- /dev/null +++ b/examples/terraform-bootstrap/templates/.github/workflows/terraform.yml @@ -0,0 +1,43 @@ +{{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE -}} +name: Terraform CI/CD +on: + push: + branches: + - main + paths: + - "terraform/**" + - ".github/workflows/terraform*.yml" + +jobs: + {{- range $i, $env := .Variables.ENVIRONMENTS }} + {{- if (gt $i 0) }} + {{/* Add empty line if not the first job */ -}} + {{ end }} + build-{{ $env.NAME }}: + name: Build for {{ $env.NAME | upper }} + uses: ./.github/workflows/terraform-plan.yml + with: + ENVIRONMENT: {{ $env.NAME }} + TERRAFORM_VERSION: {{ "${{ vars.TERRAFORM_VERSION }}" }} + TERRAFORM_BACKEND_STORAGE_NAME: {{ template "storage_account_name_prefix" $ }}{{ template "resource_tag" $ }}{{ $env.NAME }} + RESOURCE_GROUP_NAME: {{ $env.RESOURCE_GROUP_NAME }} + secrets: + ARM_CLIENT_ID: {{ printf "${{ secrets.ARM_CLIENT_ID_%s }}" ($env.NAME | upper) }} + ARM_CLIENT_SECRET: {{ printf "${{ secrets.ARM_CLIENT_SECRET_%s }}" ($env.NAME | upper) }} + ARM_SUBSCRIPTION_ID: {{ printf "${{ secrets.ARM_SUBSCRIPTION_ID_%s }}" ($env.NAME | upper) }} + ARM_TENANT_ID: {{ printf "${{ secrets.ARM_TENANT_ID_%s }}" ($env.NAME | upper) }} + + deploy-{{ $env.NAME }}: + name: Deploy to {{ $env.NAME | upper }} + needs: build-{{ $env.NAME }} + uses: ./.github/workflows/terraform-apply.yml + with: + ENVIRONMENT: {{ $env.NAME }} + TERRAFORM_VERSION: {{ "${{ vars.TERRAFORM_VERSION }}"}} + secrets: + ARM_CLIENT_ID: {{ printf "${{ secrets.ARM_CLIENT_ID_%s }}" ($env.NAME | upper) }} + ARM_CLIENT_SECRET: {{ printf "${{ secrets.ARM_CLIENT_SECRET_%s }}" ($env.NAME | upper) }} + ARM_SUBSCRIPTION_ID: {{ printf "${{ secrets.ARM_SUBSCRIPTION_ID_%s }}" ($env.NAME | upper) }} + ARM_TENANT_ID: {{ printf "${{ secrets.ARM_TENANT_ID_%s }}" ($env.NAME | upper) }} + {{- end }} +{{- end -}} diff --git a/examples/terraform-bootstrap/templates/terraform/.gitignore b/examples/terraform-bootstrap/templates/terraform/.gitignore new file mode 100644 index 00000000..3a27d746 --- /dev/null +++ b/examples/terraform-bootstrap/templates/terraform/.gitignore @@ -0,0 +1,2 @@ +.terraform +terraform.tfstate.d \ No newline at end of file diff --git a/examples/terraform-bootstrap/templates/terraform/Taskfile.yml b/examples/terraform-bootstrap/templates/terraform/Taskfile.yml new file mode 100644 index 00000000..1c05851b --- /dev/null +++ b/examples/terraform-bootstrap/templates/terraform/Taskfile.yml @@ -0,0 +1,133 @@ +version: "3" + +{{- $envs := list }} +{{- range $env := .Variables.ENVIRONMENTS -}} +{{- $envs = append $envs $env.NAME -}} +{{- end }} + +vars: + ENVIRONMENTS: {{ $envs | join "," }} + +tasks: + init: + cmds: + - for: { var: ENVIRONMENTS, split: "," } + task: init-environment + vars: + ENVIRONMENT: "{{ "{{ .ITEM }}" }}" + {{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} + - task: gh:set-ci-vars + {{- end }} + + init-environment: + {{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} + internal: true + requires: + vars: + - ENVIRONMENT + cmds: + - >- + read -p "Enter CI Service Principal Client ID for {{ `{{ .ENVIRONMENT }}` }}: " CI_ARM_CLIENT_ID; + read -p "Enter subscription ID for {{ `{{ .ENVIRONMENT }}` }}: " ARM_SUBSCRIPTION_ID; + task gh:add-ci-service-principals ARM_SUBSCRIPTION_ID=$ARM_SUBSCRIPTION_ID ARM_CLIENT_ID=$CI_ARM_CLIENT_ID ENVIRONMENT={{ `{{ .ENVIRONMENT }}` }}; + task init-environment-with-service-provider ARM_SUBSCRIPTION_ID=$ARM_SUBSCRIPTION_ID ARM_CLIENT_ID=$CI_ARM_CLIENT_ID ENVIRONMENT={{ `{{ .ENVIRONMENT }}` }}; + + init-environment-with-service-provider: + {{- end }} + deps: [az:login] + requires: + vars: + - ENVIRONMENT + {{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} + - ARM_SUBSCRIPTION_ID + - ARM_CLIENT_ID + {{ end }} + cmds: + - rm -Rf .terraform/terraform.tfstate backend.tf # Remove backend from previous run + - terraform init + - terraform workspace new {{ `{{ .ENVIRONMENT }}` }} + # Create storage account and container for remote state + - >- + terraform apply + -target azurerm_storage_container.tfstate + -target azurerm_role_assignment.tfstate + -auto-approve + -input=false + # Save output values to variables before migrating the backend + - >- + RESOURCE_GROUP_NAME=$(terraform output -raw resource_group_name); + STORAGE_ACCOUNT_NAME=$(terraform output -raw tfstate_storage_account_name); + STORAGE_CONTAINER_NAME=$(terraform output -raw tfstate_storage_container_name); + + terraform apply + -target local_file.backend_config + -auto-approve + -input=false; + + terraform init + -migrate-state + -force-copy + -input=false + -backend-config="resource_group_name=$RESOURCE_GROUP_NAME" + -backend-config="storage_account_name=$STORAGE_ACCOUNT_NAME" + -backend-config="container_name=$STORAGE_CONTAINER_NAME" + -backend-config="key=tfstate_"; + + {{- if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE }} + echo "Adding owner role assignment to the CI service principal..."; + NEW_ROLE_ASSIGNMENT_ID=$(az role assignment create + --role "Storage Blob Data Owner" + --assignee {{ `{{ .ARM_CLIENT_ID }}` }} + --scope "/subscriptions/{{ `{{.ARM_SUBSCRIPTION_ID}}` }}/resourceGroups/${RESOURCE_GROUP_NAME}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT_NAME}/blobServices/default/containers/${STORAGE_CONTAINER_NAME}" + --output tsv + --query id); + + echo Grabbing old role assignment ID from terraform...; + OLD_ROLE_ASSIGNMENT_ID=$(terraform output -raw tfstate_storage_role_assignment_id); + + echo Removing old role assignment from terraform state...; + terraform state rm azurerm_role_assignment.tfstate; + + echo Importing new role assignment into state on top of the one created for the logged in user...; + terraform import azurerm_role_assignment.tfstate ${NEW_ROLE_ASSIGNMENT_ID}; + + echo Removing old role assignment...; + az role assignment delete --ids ${OLD_ROLE_ASSIGNMENT_ID}; + {{ end }} + - rm -Rf terraform.tfstate.d # Remove local state files + + az:login: + cmds: + - az login + status: + - az account show + + gh:login: + cmds: + - gh auth login + status: + - gh auth token + + {{ if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE -}} + gh:add-ci-service-principals: + deps: [gh:login] + requires: + vars: + - ENVIRONMENT + - ARM_SUBSCRIPTION_ID + - ARM_CLIENT_ID + cmds: + - gh secret set ARM_CLIENT_ID_{{ `{{ upper .ENVIRONMENT }}` }} --body {{ `{{ .ARM_CLIENT_ID }}` }} + - gh secret set ARM_CLIENT_SECRET_{{ `{{ upper .ENVIRONMENT }}` }} + - gh secret set ARM_SUBSCRIPTION_ID_{{ `{{ upper .ENVIRONMENT }}` }} --body {{ `{{ .ARM_SUBSCRIPTION_ID }}` }} + - gh secret set ARM_TENANT_ID_{{ `{{ upper .ENVIRONMENT }}` }} + {{- end }} + + {{ if .Variables.CREATE_GITHUB_ACTIONS_PIPELINE -}} + gh:set-ci-vars: + deps: [gh:login] + cmds: + - gh variable set TERRAFORM_VERSION --body 1.6.6 + {{- end }} +# TODO: task to delete state resources for a particular env +# TODO: validate resource group name when prompting diff --git a/examples/terraform-bootstrap/templates/terraform/provider.tf b/examples/terraform-bootstrap/templates/terraform/provider.tf new file mode 100644 index 00000000..ab91b248 --- /dev/null +++ b/examples/terraform-bootstrap/templates/terraform/provider.tf @@ -0,0 +1,3 @@ +provider "azurerm" { + features {} +} diff --git a/examples/terraform-bootstrap/templates/terraform/state-output.tf b/examples/terraform-bootstrap/templates/terraform/state-output.tf new file mode 100644 index 00000000..2d099864 --- /dev/null +++ b/examples/terraform-bootstrap/templates/terraform/state-output.tf @@ -0,0 +1,15 @@ +output "resource_group_name" { + value = {{ template "rg_block_type" . }}.azurerm_resource_group.main.name +} + +output "tfstate_storage_account_name" { + value = azurerm_storage_account.tfstate.name +} + +output "tfstate_storage_container_name" { + value = azurerm_storage_container.tfstate.name +} + +output "tfstate_storage_role_assignment_id" { + value = azurerm_role_assignment.tfstate.id +} \ No newline at end of file diff --git a/examples/terraform-bootstrap/templates/terraform/state-storage.tf b/examples/terraform-bootstrap/templates/terraform/state-storage.tf new file mode 100644 index 00000000..aa0f64a1 --- /dev/null +++ b/examples/terraform-bootstrap/templates/terraform/state-storage.tf @@ -0,0 +1,67 @@ +{{- define "rg_block_type" -}} +{{- ternary "resource" "data" .Variables.CREATE_RESOURCE_GROUPS -}} +{{- end -}} + +{{- define "resource_tag" -}} +{{- printf "%.6s" (sha1sum .ID) -}} +{{- end -}} + +{{- define "storage_account_name_prefix" -}} +{{- printf "tfs%.11s" (regexReplaceAll "[^a-z0-9]" (.Variables.SERVICE_NAME | lower) "") -}} +{{- end -}} + +locals { + resource_groups = { + {{- range $index, $env := .Variables.ENVIRONMENTS }} + {{ $env.NAME | quote }} : {{ $env.RESOURCE_GROUP_NAME | quote }} + {{- end }} + "default" : {{ (index .Variables.ENVIRONMENTS 0).RESOURCE_GROUP_NAME | quote }} + } + + resource_tag = "{{ template "resource_tag" . }}" +} + +data "azurerm_client_config" "current" { +} + +{{ if .Variables.CREATE_RESOURCE_GROUPS -}} +resource "azurerm_resource_group" "main" { + name = local.resource_groups[terraform.workspace] + location = {{ .Variables.RESOURCE_GROUP_LOCATION | quote }} +} +{{- else -}} +data "azurerm_resource_group" "main" { + name = local.resource_groups[terraform.workspace] +} +{{- end }} + +resource "azurerm_storage_account" "tfstate" { + name = "{{ template "storage_account_name_prefix" . }}{{ template "resource_tag" . }}${terraform.workspace}" + resource_group_name = {{ template "rg_block_type" . }}.azurerm_resource_group.main.name + location = {{ template "rg_block_type" . }}.azurerm_resource_group.main.location + account_tier = "Standard" + account_replication_type = "LRS" + min_tls_version = "TLS1_2" +} + +resource "azurerm_storage_container" "tfstate" { + name = "tfstate" + storage_account_name = azurerm_storage_account.tfstate.name +} + +resource "azurerm_role_assignment" "tfstate" { + scope = azurerm_storage_container.tfstate.resource_manager_id + role_definition_name = "Storage Blob Data Owner" + principal_id = data.azurerm_client_config.current.object_id +} + +resource "local_file" "backend_config" { + filename = "backend.tf" + content = <<-EOT +terraform { + backend "azurerm" { + use_azuread_auth = true + } +} +EOT +}