Skip to content

Commit

Permalink
Terraform bootstrap recipe (#26)
Browse files Browse the repository at this point in the history
Co-authored-by: Antti Kivimäki <[email protected]>
  • Loading branch information
Ilkka Poutanen and majori authored Dec 15, 2023
1 parent 288af4f commit 4c5f820
Show file tree
Hide file tree
Showing 10 changed files with 493 additions and 0 deletions.
60 changes: 60 additions & 0 deletions examples/terraform-bootstrap/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions examples/terraform-bootstrap/recipe.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 }}
Original file line number Diff line number Diff line change
@@ -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 }}
Original file line number Diff line number Diff line change
@@ -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 -}}
2 changes: 2 additions & 0 deletions examples/terraform-bootstrap/templates/terraform/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.terraform
terraform.tfstate.d
Loading

0 comments on commit 4c5f820

Please sign in to comment.