├── README.md ├── Terraform ├── Terraform-Apply-Steps.yml ├── Terraform-Apply.yml ├── Terraform-Plan-Readiness.yml ├── Terraform-Plan-Steps.yml ├── Terraform-Plan.yml ├── Terraform-Publish-Plan-To-Wiki.yml ├── Terraform-Stages.yml └── azure-pipelines.yml └── TerraformV2 ├── README.md ├── apply.yml ├── azure-pipelines.yml ├── lint.yml ├── plan.yml ├── stages.png └── tags_logs.png /README.md: -------------------------------------------------------------------------------- 1 | # Azure Devops YAML Pipelines 2 | 3 | Example Azure Devops YAML Pipelines 4 | 5 | ## Prerequisites and Assumptions 6 | 7 | Many tasks take advantage of the `$(System.AccessToken)` variable. To gain access to this the build pipeline must enable the `Allow scripts to access the OAuth token` option in the Agent Job preferences pane. 8 | 9 | Modifications to the working directories and URIs in the tasks may need to be customized depending on how the Terraform repo and the AzDO Organization wiki folders are organized. 10 | 11 | ## Terraform 12 | 13 | Pipeline and templates for use in Terraform plan and apply. The [azure-pipelines.yml](Terraform/azure-pipelines.yml) is the base pipeline from which all other template pipelines are called. 14 | 15 | When setting up a Terraform pipeline for a new stack you can copy the `azure-pipelines.yml` file to the root of your stack and edit it as needed. 16 | -------------------------------------------------------------------------------- /Terraform/Terraform-Apply-Steps.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - checkout: self 3 | clean: true 4 | persistCredentials: true 5 | - task: JamiePhillips.Terraform.TerraformTool.TerraformTool@0 6 | displayName: 'Use Terraform $(terraform.version)' 7 | inputs: 8 | version: '$(terraform.version)' 9 | - download: current 10 | artifact: 'tfplan' 11 | - script: | 12 | mkdir -p $(STATE_KEY)/Terraform 13 | tar -xzvf tfplan/$(STATE_KEY).tar.gz --directory $(STATE_KEY)/Terraform 14 | displayName: 'Extract Artifact to $(STATE_KEY)/Terraform' 15 | workingDirectory: '$(Pipeline.Workspace)' 16 | - script: | 17 | if [[ ! -f .terraform/terraform.tfstate ]] ; then 18 | echo 'terraform.tfstate (.terraform/terraform.tfstate) is not there, aborting.' 19 | exit 20 | else 21 | cat .terraform/terraform.tfstate 22 | fi 23 | workingDirectory: '$(Pipeline.Workspace)/$(STATE_KEY)/Terraform' 24 | displayName: 'Check for terraform.tfstate' 25 | - task: AzureCLI@1 26 | displayName: 'Setup Authentication' 27 | inputs: 28 | azureSubscription: '$(SUBSCRIPTION_NAME)' 29 | addSpnToEnvironment: true 30 | scriptLocation: inlineScript 31 | failOnStandardError: 'true' 32 | inlineScript: | 33 | echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query="id" -o tsv)" 34 | echo "##vso[task.setvariable variable=ARM_CLIENT_ID]${servicePrincipalId}" 35 | echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]${servicePrincipalKey}" 36 | echo "##vso[task.setvariable variable=ARM_TENANT_ID]$(az account show --query="tenantId" -o tsv)" 37 | echo "##vso[task.setvariable variable=ARM_ACCESS_KEY]$(az storage account keys list -n ${STORAGE_ACCOUNT} --query="[0].value" -o tsv)" 38 | - script: | 39 | git config --global --list | grep url. | awk -F '.instead' '{print $1}' | while read line 40 | do 41 | echo $line 42 | git config --global --remove-section $line 43 | done 44 | git config --global url."https://azdo:$(System.AccessToken)@dev.azure.com/organization".insteadOf https://dev.azure.com/organization 45 | git config --global url."https://azdo:$(System.AccessToken)@organization".insteadOf https://organization 46 | git init 47 | displayName: 'Redirect git URLs to use the Access Token So Modules Can Be Pulled In' 48 | - script: | 49 | terraform apply -auto-approve -no-color -input=false tfplan 50 | displayName: 'Terraform Apply' 51 | workingDirectory: '$(Pipeline.Workspace)/$(STATE_KEY)/Terraform' 52 | env: 53 | TF_IN_AUTOMATION: true 54 | TF_VAR_subscription_id: "$(ARM_SUBSCRIPTION_ID)" 55 | TF_VAR_client_id: "$(ARM_CLIENT_ID)" 56 | TF_VAR_client_secret: "$(ARM_CLIENT_SECRET)" 57 | TF_VAR_tenant_id: "$(ARM_TENANT_ID)" 58 | TF_VAR_vm_username: "$(vm_username)" 59 | TF_VAR_vm_password: "$(vm_password)" 60 | - script: | 61 | git config --global --remove-section url.https://azdo:$(System.AccessToken)@dev.azure.com/organization 62 | git config --global --remove-section url.https://azdo:$(System.AccessToken)@organization 63 | displayName: 'Remove git URL Redirect' 64 | condition: failed() -------------------------------------------------------------------------------- /Terraform/Terraform-Apply.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - deployment: Apply 3 | displayName: 'Terraform Apply' 4 | environment: Production 5 | pool: My Agent Pool 6 | workspace: 7 | clean: all 8 | strategy: 9 | runOnce: 10 | deploy: 11 | steps: 12 | - template: Terraform-Apply-Steps.yml -------------------------------------------------------------------------------- /Terraform/Terraform-Plan-Readiness.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - job: Validate 3 | displayName: 'Terraform Validate' 4 | pool: My Agent Pool 5 | steps: 6 | - checkout: self 7 | - script: | 8 | terraform validate -check-variables=false 9 | workingDirectory: '$(TFPATH)' 10 | displayName: 'Terraform Validate' 11 | 12 | - job: TFLint 13 | displayName: 'TFLint' 14 | pool: My Agent Pool 15 | steps: 16 | - checkout: self 17 | - script: | 18 | python3 -m pip install --upgrade pip 19 | python3 -m pip install setuptools 20 | python3 -m pip install lastversion 21 | TFLINT_VERSION=$(lastversion wata727/tflint) 22 | if ! tflint --version | grep -q ${TFLINT_VERSION}; then 23 | rm tflint* 24 | wget https://github.com/wata727/tflint/releases/download/v$TFLINT_VERSION/tflint_linux_amd64.zip 25 | unzip tflint_linux_amd64.zip 26 | install tflint /usr/local/bin 27 | fi 28 | cd $(TFPATH) 29 | tflint 30 | displayName: 'Run TFLint' 31 | continueOnError: 'true' 32 | 33 | - job: Readiness 34 | displayName: 'Build Readiness' 35 | variables: 36 | ORGANIZATION: 'my-org' 37 | PROJECT: 'my-project' 38 | pool: My Agent Pool 39 | steps: 40 | - checkout: self 41 | clean: true 42 | persistCredentials: true 43 | - script: | 44 | BRANCH=`echo $(Build.SourceBranch) | sed 's/refs\/heads\///'` 45 | echo "##vso[task.setvariable variable=BRANCH]$BRANCH" 46 | displayName: 'Set Branch' 47 | - script: | 48 | git --version 49 | git config --global user.email "azdo@myorg.com" 50 | git config --global user.name "azdo" 51 | git checkout $(BRANCH) 52 | git pull 53 | git branch -a 54 | git status 55 | displayName: 'Checkout $(BRANCH)' 56 | workingDirectory: '$(TFPATH)' 57 | - script: | 58 | # CHECK THAT variables HAVE A description 59 | # CURRENTLY ONLY MATCHES WHEN AT LEAST ONE description APPEARS WITH VARIABLES 60 | v=`grep -d skip -Pz --only-matching '(?<=variable)(\n|.)*?(?=})' * | grep description` 61 | if [ -z "${v// }" ] 62 | then 63 | echo "$v FAIL: Variables should have a description... See https://$(ORGANIZATION).visualstudio.com/$(PROJECT)/_wiki/wikis/Terraform.wiki?wikiVersion=GBwikiMaster&pagePath=%2FDevelopment%20Standards%2FTerraform%2Fv3%20Code%20Standards&anchor=variables" 64 | exit 1 65 | else 66 | echo "$v SUCCESS: all variables have a description" 67 | fi 68 | workingDirectory: '$(TFPATH)' 69 | displayName: 'Variables have Description' 70 | enabled: false 71 | - script: | 72 | cat < README.md 73 | # $(Build.Repository.Name) - $(Build.DefinitionName) 74 | 75 | [![Build status](https://$(ORGANIZATION).visualstudio.com/$(PROJECT)/_apis/build/status/$(Build.Repository.Name)%20Repo/$(Build.DefinitionName))](https://$(ORGANIZATION).visualstudio.com/$(PROJECT)/_build/latest?definitionId=$(System.DefinitionId)) 76 | 77 | ___ 78 | 79 | ## Usage 80 | 81 | ADD USAGE DOCUMENTATION 82 | 83 | ___ 84 | 85 | ## Inputs and Outputs 86 | 87 | _Generated with [terraform-docs](https://github.com/segmentio/terraform-docs) and [pre-commit](https://www.unixdaemon.net/tools/terraform-precommit-hooks/)_ 88 | 89 | 90 | 91 | 92 | 93 | EOT 94 | workingDirectory: '$(TFPATH)' 95 | displayName: 'Check for README.md, and create if missing' 96 | - script: | 97 | # NEED TO INSTALL THIS HERE UNTIL terraform-docs GETS ONTO THE AGENTS 98 | go get github.com/segmentio/terraform-docs 99 | ln -sf ~/go/bin/terraform-docs /usr/local/bin/terraform-docs 100 | workingDirectory: '$(TFPATH)' 101 | displayName: 'Install terraform-docs' 102 | - script: | 103 | python3 -m pip install --upgrade pip 104 | python3 -m pip install setuptools 105 | python3 -m pip install pre-commit 106 | cat < .pre-commit-config.yaml 107 | - repo: git://github.com/antonbabenko/pre-commit-terraform 108 | rev: v1.12.0 109 | hooks: 110 | - id: terraform_fmt 111 | - id: terraform_docs 112 | - id: terraform_validate_no_variables 113 | EOF 114 | git add .pre-commit-config.yaml 115 | # pre-commit autoupdate (comment out until 0.12) 116 | displayName: 'Install and Configure pre-commit-terraform' 117 | - script: | 118 | echo "pre-commit run --files *" 119 | pre-commit run --files * 120 | if [ "$?" -eq "1" ] 121 | then 122 | echo "... second pre-commit attempt ..." 123 | pre-commit run --files * 124 | fi 125 | displayName: 'Run pre-commit-terraform on $(TFPATH)' 126 | workingDirectory: '$(TFPATH)' 127 | failOnStderr: false 128 | continueOnError: true 129 | - script: | 130 | git branch -a 131 | git add . 132 | git commit -am 'code readiness - linting, formatting, docs [***NO_CI***]' 133 | git push 134 | displayName: 'Commit code corrections to $(BRANCH)' 135 | workingDirectory: '$(TFPATH)' 136 | continueOnError: true 137 | 138 | -------------------------------------------------------------------------------- /Terraform/Terraform-Plan-Steps.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - checkout: self 3 | clean: true 4 | persistCredentials: true 5 | - task: JamiePhillips.Terraform.TerraformTool.TerraformTool@0 6 | displayName: 'Use Terraform $(terraform.version)' 7 | inputs: 8 | version: '$(terraform.version)' 9 | - task: AzureCLI@1 10 | displayName: 'Setup Authentication' 11 | inputs: 12 | azureSubscription: '$(SUBSCRIPTION_NAME)' 13 | addSpnToEnvironment: true 14 | scriptLocation: inlineScript 15 | failOnStandardError: 'true' 16 | inlineScript: | 17 | echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query="id" -o tsv)" 18 | echo "##vso[task.setvariable variable=ARM_CLIENT_ID]${servicePrincipalId}" 19 | echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]${servicePrincipalKey}" 20 | echo "##vso[task.setvariable variable=ARM_TENANT_ID]$(az account show --query="tenantId" -o tsv)" 21 | echo "##vso[task.setvariable variable=ARM_ACCESS_KEY]$(az storage account keys list -n ${STORAGE_ACCOUNT} --query="[0].value" -o tsv)" 22 | - script: | 23 | git config --global --list | grep url. | awk -F '.instead' '{print $1}' | while read line 24 | do 25 | echo $line 26 | git config --global --remove-section $line 27 | done 28 | git config --global url."https://azdo:$(System.AccessToken)@dev.azure.com/organization".insteadOf https://dev.azure.com/organization 29 | git config --global url."https://azdo:$(System.AccessToken)@organization".insteadOf https://organization 30 | git init 31 | displayName: 'Redirect git URLs to use the Access Token So Modules Can Be Pulled In' 32 | - script: | 33 | terraform init -no-color -input=false 34 | terraform plan -out=tfplan -no-color -input=false 35 | displayName: 'Terraform Plan' 36 | workingDirectory: '$(TFPATH)' 37 | env: 38 | TF_IN_AUTOMATION: true 39 | TF_VAR_subscription_id: "$(ARM_SUBSCRIPTION_ID)" 40 | TF_VAR_client_id: "$(ARM_CLIENT_ID)" 41 | TF_VAR_client_secret: "$(ARM_CLIENT_SECRET)" 42 | TF_VAR_tenant_id: "$(ARM_TENANT_ID)" 43 | TF_VAR_vm_username: "$(vm_username)" 44 | TF_VAR_vm_password: "$(vm_password)" 45 | - script: | 46 | echo "Compressing $(TFPATH) directory..." 47 | tar -czf $(STATE_KEY).tar.gz -C $(TFPATH) . 48 | displayName: 'Compress $(TFPATH) Artifact' 49 | - publish: $(STATE_KEY).tar.gz 50 | artifact: 'tfplan' 51 | - script: | 52 | git config --global --remove-section url.https://azdo:$(System.AccessToken)@dev.azure.com/organization 53 | git config --global --remove-section url.https://azdo:$(System.AccessToken)@organization 54 | displayName: 'Remove git URL Redirect' 55 | condition: failed() -------------------------------------------------------------------------------- /Terraform/Terraform-Plan.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - job: Plan 3 | displayName: 'Terraform Plan' 4 | pool: My Agent Pool 5 | workspace: 6 | clean: all 7 | steps: 8 | - template: Terraform-Plan-Steps.yml -------------------------------------------------------------------------------- /Terraform/Terraform-Publish-Plan-To-Wiki.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - job: Publish 3 | displayName: 'Publish to Wiki' 4 | pool: My Agent Pool 5 | workspace: 6 | clean: all 7 | steps: 8 | - checkout: none 9 | - download: current 10 | artifact: 'tfplan' 11 | - script: | 12 | mkdir -p $(TFPATH) 13 | tar -xzvf tfplan/$(STATE_KEY).tar.gz --directory $(TFPATH) 14 | workingDirectory: '$(Pipeline.Workspace)' 15 | 16 | - script: | 17 | git config --global user.email "azdo@organization.com" 18 | git config --global user.name "azdo" 19 | displayName: 'Git Config' 20 | 21 | - script: | 22 | if [ -d "wiki" ]; then rm -rf wiki; fi 23 | git clone https://azdo:$(System.AccessToken)@dev.azure.com/ORGANIZATION/PROJECT/_git/Terraform-Plans.wiki wiki 24 | workingDirectory: '$(Agent.WorkFolder)' 25 | displayName: 'Git Clone Wiki' 26 | continueOnError: 'true' 27 | 28 | - script: | 29 | terraform show -no-color tfplan > tfplan.out 30 | workingDirectory: '$(Pipeline.Workspace)/$(TFPATH)' 31 | displayName: 'Terraform Show - Output' 32 | 33 | - script: | 34 | bn=$(Build.DefinitionName) 35 | build_name=${bn// /_} 36 | template="wiki/Builds/Template.md" 37 | file_name="wiki/Builds/${build_name}/$(Build.BuildNumber).md" 38 | tfout="$(Pipeline.Workspace)/$(TFPATH)/tfplan.out" 39 | mkdir -p "wiki/Builds/${build_name}" 40 | 41 | cat ${template} > ${file_name} 42 | cat <> ${file_name} 43 | 44 | ## Build Details 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
Build Reason$(Build.Reason)
Requestor$(Build.RequestedFor)
Repository$(Build.Repository.Uri)
Branch$(Build.SourceBranchName)
Latest Commit$(Build.SourceVersion)
Latest Commit Message
$(Build.SourceVersionMessage)
71 | 72 | ## Terraform Show Output 73 | 74 | \`\`\` sh 75 | EOT 76 | 77 | cat ${tfout} >> ${file_name} 78 | cat <> ${file_name} 79 | \`\`\` 80 | EOT 81 | workingDirectory: '$(Agent.WorkFolder)' 82 | displayName: 'Write Terraform Show to Wiki' 83 | 84 | - script: | 85 | # WORKING DIRECTORY IS $(Agent.WorkFolder)/wiki/Builds 86 | bn=$(Build.DefinitionName) 87 | build_name=${bn// /_} 88 | 89 | # CD INTO THE build_name DIRECTORY 90 | cd ${build_name} 91 | 92 | # CREATE THE Archives DIRECTORY IF IT DOESN'T EXIST 93 | mkdir -p "Archives" 94 | 95 | # FIND ALL FILES IN THE CURRENT WORKING DIRECTORY | 96 | # REVERSE SORT | 97 | # GET ALL BUT THE LATEST 10 FILES | 98 | # MOVE THEM TO THE Archives FOLDER 99 | find * -maxdepth 0 -type f | sort -nr | awk 'NR > 10' | xargs -i mv {} ./Archives/ 100 | workingDirectory: '$(Agent.WorkFolder)/wiki/Builds' 101 | displayName: 'Archive Old Builds' 102 | 103 | - script: | 104 | git pull 105 | git add . 106 | git commit -m "Build $(Build.BuildNumber)" 107 | git push https://azdo:$(System.AccessToken)@dev.azure.com/ORGANIZATION/PROJECT/_git/Terraform-Plans.wiki 108 | echo "### Link To Build ###" 109 | echo "https://dev.azure.com/ORGANIZATION/PROJECT/_wiki/wikis/Terraform-Plans.wiki?wikiVersion=GBwikiMaster&pagePath=%2FBuilds%2F$(Build.DefinitionName)%2F$(Build.BuildNumber)" 110 | workingDirectory: '$(Agent.WorkFolder)/wiki' 111 | displayName: 'Git Push Wiki - View Link Here' -------------------------------------------------------------------------------- /Terraform/Terraform-Stages.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - stage: Lint 3 | displayName: 'Linting and Readiness Checks' 4 | jobs: 5 | - template: Terraform-Plan-Readiness.yml 6 | 7 | - stage: Plan 8 | displayName: 'Terraform Plan' 9 | jobs: 10 | - template: Terraform-Plan.yml 11 | 12 | - stage: Publish 13 | displayName: 'Publish Plan to Wiki' 14 | jobs: 15 | - template: Terraform-Publish-Plan-To-Wiki.yml 16 | 17 | - stage: Apply 18 | displayName: 'Terraform Apply' 19 | jobs: 20 | - template: Terraform-Apply.yml -------------------------------------------------------------------------------- /Terraform/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | name: $(BuildDefinitionName).$(DayOfYear)$(Rev:.r) 2 | trigger: 3 | batch: 'true' 4 | branches: 5 | include: 6 | - master 7 | - feature/* 8 | variables: 9 | - name: STATE_KEY 10 | value: 'MYSTATE' 11 | - name: TFPATH 12 | value: 'PATH/TO/TERRAFORM' 13 | - name: STORAGE_ACCOUNT 14 | value: 'SOME_STORAGE_ACCOUNT' 15 | - name: SUBSCRIPTION_NAME 16 | value: 'SERVICE_CONNECTION_NAME' 17 | - group: 'Global-Variable-Group' 18 | 19 | stages: 20 | - template: Terraform-Stages.yml -------------------------------------------------------------------------------- /TerraformV2/README.md: -------------------------------------------------------------------------------- 1 | # Terraform Pipelines - Advanced 2 | 3 | These pipelines take advantage of templates, parameters, and more advanced expressions to handle loops. 4 | 5 | It was designed around a hub/spoke model that uses Terraform Workspaces to sub-divide environments/regions. 6 | 7 | The example here is showing a simple dependency model for one hub/spoke with one workspace in each. More advanced dependency models are possible that could potentially have multiple hubs and spokes with multiple workspaces in each. 8 | 9 | ![stages](./stages.png) 10 | 11 | ## Main File 12 | 13 | The pipeline begins with `azure-pipelines.yml`. 14 | 15 | ### Variables 16 | 17 | TF_VERSION: Terraform Version 18 | 19 | STATE_STORAGE_ACCOUNT: The name of the storage account where STATE will be stored. 20 | 21 | STATE_SUBSCRIPTION: Subscription where the state storage account resides. 22 | 23 | ### Stages 24 | 25 | The `stages` section defines the list of stage templates that will be called. 26 | 27 | #### Lint Template 28 | 29 | For each hub/spoke, a list of workspace names is passed as parameters. 30 | 31 | In the `lint.yml` template... 32 | 33 | For each state defined (e.g. hub/spoke) it will generate a separate Stage. 34 | 35 | Within each of those stages: 36 | 37 | - It will loop over each workspace and run `terraform validate` 38 | - It will run `tflint` 39 | 40 | #### Plan Template 41 | 42 | The Plan template is called twice, one for each subscription (NonProd and Prod in this example). 43 | 44 | The parameters set the workspaces for hub and spoke, and whether the stage should have a `dependsOn` set for another workspace. 45 | 46 | In the `plan.yml` template... 47 | 48 | For each state defined (e.g. hub/spoke) it will generate a plan stage for each workspace in that state. 49 | 50 | Within each of those stages: 51 | 52 | - It sets a `dependsOn` to the appropriate Validate_and_Lint stage. 53 | - It will also set an additional `dependsOn` if it is specified in the parameters. In this example, the spoke Plan stage will depend on the Apply stage for it's hub. This ensures any requirements from hub are completed before the spoke attempts to plan. 54 | 55 | - It runs a Terraform Plan. 56 | - If no changes are detected, the stage completes without creating any artifacts. 57 | - If an error occurs, the stage is marked as failed. 58 | - If changes are detected, an issue is logged to the AzDO pipeline UI marking the changes, a Build Tag is generated on the pipeline, and the artifact is created. 59 | 60 | #### Apply Template 61 | 62 | The Apply template is called twice, one for each subscription (NonProd and Prod in this example). 63 | 64 | The parameters set the workspaces for hub and spoke, and whether the stage should have a `dependsOn` set for another workspace. 65 | 66 | In the `apply.yml` template... 67 | 68 | For each state defined (e.g. hub/spoke) it will generate an apply stage for each workspace in that state. 69 | 70 | Within each of those stages: 71 | 72 | - It sets a `dependsOn` to the appropriate Plan stage. 73 | - It will also set an additional `dependsOn` if it is specified in the parameters. In this example, the spoke Apply stage will depend on the Apply stage for it's hub. This ensures any requirements from hub are completed before the spoke attempts to apply. 74 | 75 | - It retrieves the Build Tags that have been created on this build to determine if a plan artifact was created. If no Build Tag is found for the workspace, the Apply deployment will be skipped. Otherwise... 76 | 77 | - It runs a Terraform Apply 78 | 79 | The Apply template takes advantage of Environments and Deployments so that Approval checks can be required before an Apply is allowed to continue. 80 | 81 | ## Notifications 82 | 83 | Build tags and issue logging have been added to this pipeline example. You can see in this image, two tags have been added near the top, and two log notifications have been added under the Warnings section. 84 | 85 | ![tags_and_logs](./tags_logs.png) 86 | 87 | ### Build Tags 88 | 89 | The Build Tags are added to the build if a Terraform Plan generates changes and will create an artifact. 90 | 91 | In the Apply stages, the existence of Build Tags are checked to determine if an Apply stage should be run or skipped. 92 | 93 | ### Issue Logs 94 | 95 | An Issue Log is generated if a Terraform Plan generates changes and will create an artifact. 96 | 97 | It creates a Warning log in the UI that when clicked on, drills straight through to the job log to show the plan output. This assists in finding the plans in the build logs. 98 | 99 | -------------------------------------------------------------------------------- /TerraformV2/apply.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: states 3 | type: object 4 | - name: service_connection 5 | 6 | stages: 7 | - ${{ each state in parameters.states }}: 8 | - ${{ each wkspc in state.value.workspaces }}: 9 | - stage: Apply_${{ wkspc }} 10 | dependsOn: 11 | - Plan_${{ wkspc }} 12 | - ${{ if state.value.dependsOn }}: 13 | - Apply_${{ state.value.dependsOn }} 14 | jobs: 15 | - job: get_build_tags 16 | displayName: "Get Build Tags" 17 | steps: 18 | - script: | 19 | PROJECT=$( printf "%s\n" "$(System.TeamProject)" | sed 's/ /%20/g' ) 20 | URL="$(System.CollectionUri)$PROJECT/_apis/build/builds/$(Build.BuildId)/tags?api-version=5.1" 21 | echo "###vso[task.setvariable variable=BuildTags;isOutput=true]$(curl -s -u azdo:$(System.AccessToken) --request GET $URL | jq -r '.value | join(" ")')" 22 | name: GetTags 23 | - deployment: Apply_${{ wkspc }} 24 | dependsOn: get_build_tags 25 | condition: contains(dependencies.get_build_tags.outputs['GetTags.BuildTags'], '${{ wkspc }}_tfplan') 26 | displayName: "Terraform Apply ${{ wkspc }}" 27 | environment: Terraform-Apply 28 | strategy: 29 | runOnce: 30 | deploy: 31 | steps: 32 | - checkout: self 33 | clean: true 34 | persistCredentials: true 35 | - download: current 36 | artifact: "tfplan_${{ wkspc }}" 37 | - script: | 38 | mkdir -p ${{ wkspc }} 39 | tar -xzvf tfplan_${{ wkspc }}/${{ wkspc }}.tar.gz --directory ${{ wkspc }} 40 | displayName: "Extract Artifact to ${{ wkspc }}" 41 | workingDirectory: "$(Pipeline.Workspace)" 42 | - task: JamiePhillips.Terraform.TerraformTool.TerraformTool@0 43 | displayName: "Use Terraform $(TF_VERSION)" 44 | inputs: 45 | version: "$(TF_VERSION)" 46 | - task: AzureCLI@1 47 | displayName: "Setup Authentication" 48 | inputs: 49 | azureSubscription: "${{ parameters.service_connection }}" 50 | addSpnToEnvironment: true 51 | scriptLocation: inlineScript 52 | failOnStandardError: "true" 53 | inlineScript: | 54 | echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query="id" -o tsv)" 55 | echo "##vso[task.setvariable variable=ARM_CLIENT_ID]${servicePrincipalId}" 56 | echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]${servicePrincipalKey}" 57 | echo "##vso[task.setvariable variable=ARM_TENANT_ID]$(az account show --query="tenantId" -o tsv)" 58 | echo "##vso[task.setvariable variable=ARM_ACCESS_KEY]$(az storage account keys list -n ${STATE_STORAGE_ACCOUNT} --subscription "${STATE_SUBSCRIPTION}" --query="[0].value" -o tsv)" 59 | - script: | 60 | WORKSPACE=`echo ${{ wkspc }} | tr "_" -` 61 | terraform workspace select ${WORKSPACE} 62 | terraform apply -auto-approve -no-color -input=false tfplan 63 | displayName: "Terraform Apply ${{ wkspc }}" 64 | workingDirectory: "$(Pipeline.Workspace)/${{ wkspc }}/${{ state.key }}" 65 | env: 66 | TF_IN_AUTOMATION: true 67 | -------------------------------------------------------------------------------- /TerraformV2/azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | name: $(BuildDefinitionName).$(DayOfYear)$(Rev:.r) 2 | trigger: 3 | batch: 'true' 4 | branches: 5 | include: 6 | - master 7 | - feature/* 8 | pool: 9 | vmImage: 'ubuntu-latest' 10 | 11 | variables: 12 | - name: TF_VERSION 13 | value: '0.12.24' 14 | - name: STATE_STORAGE_ACCOUNT 15 | value: 'nctfstate' 16 | - name: STATE_SUBSCRIPTION 17 | value: 'MySub' 18 | 19 | stages: 20 | - template: lint.yml 21 | parameters: 22 | service_connection: 'Terraform MySub NonProd' 23 | states: 24 | hub: 25 | - hub-centralus-np 26 | - hub-centralus-prod 27 | spoke: 28 | - spoke-eastus-np 29 | - spoke-eastus-prod 30 | 31 | # Plan NonProd 32 | - template: plan.yml 33 | parameters: 34 | service_connection: 'Terraform MySub NonProd' 35 | states: 36 | hub: 37 | workspaces: 38 | - hub_centralus_np 39 | spoke: 40 | dependsOn: hub_centralus_np 41 | workspaces: 42 | - spoke_eastus_np 43 | 44 | # Apply NonProd 45 | - template: apply.yml 46 | parameters: 47 | service_connection: 'Terraform MySub NonProd' 48 | states: 49 | hub: 50 | workspaces: 51 | - hub_centralus_np 52 | spoke: 53 | dependsOn: hub_centralus_np 54 | workspaces: 55 | - spoke_eastus_np 56 | 57 | # Plan Prod 58 | - template: plan.yml 59 | parameters: 60 | service_connection: 'Terraform MySub Prod' 61 | states: 62 | hub: 63 | dependsOn: hub_centralus_np 64 | workspaces: 65 | - hub_centralus_prod 66 | spoke: 67 | dependsOn: hub_centralus_prod 68 | workspaces: 69 | - spoke_eastus_prod 70 | 71 | # Apply Prod 72 | - template: apply.yml 73 | parameters: 74 | service_connection: 'Terraform MySub Prod' 75 | states: 76 | hub: 77 | workspaces: 78 | - hub_centralus_prod 79 | spoke: 80 | dependsOn: hub_centralus_prod 81 | workspaces: 82 | - spoke_eastus_prod -------------------------------------------------------------------------------- /TerraformV2/lint.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: states 3 | type: object 4 | - name: service_connection 5 | 6 | stages: 7 | - ${{ each state in parameters.states }}: 8 | - stage: Validate_and_Lint_${{ state.key }} 9 | dependsOn: [] 10 | jobs: 11 | - job: Validate_${{ state.key }} 12 | displayName: "Terraform Validate ${{ state.key }}" 13 | steps: 14 | - checkout: self 15 | - task: JamiePhillips.Terraform.TerraformTool.TerraformTool@0 16 | displayName: "Use Terraform $(TF_VERSION)" 17 | inputs: 18 | version: "$(TF_VERSION)" 19 | - task: AzureCLI@1 20 | displayName: "Setup Authentication" 21 | inputs: 22 | azureSubscription: "${{ parameters.service_connection }}" 23 | addSpnToEnvironment: true 24 | scriptLocation: inlineScript 25 | failOnStandardError: "true" 26 | inlineScript: | 27 | echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query="id" -o tsv)" 28 | echo "##vso[task.setvariable variable=ARM_CLIENT_ID]${servicePrincipalId}" 29 | echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]${servicePrincipalKey}" 30 | echo "##vso[task.setvariable variable=ARM_TENANT_ID]$(az account show --query="tenantId" -o tsv)" 31 | echo "##vso[task.setvariable variable=ARM_ACCESS_KEY]$(az storage account keys list -n ${STATE_STORAGE_ACCOUNT} --subscription "${STATE_SUBSCRIPTION}" --query="[0].value" -o tsv)" 32 | - ${{ each wkspc in state.value }}: 33 | - script: | 34 | terraform version 35 | terraform init -input=false 36 | terraform workspace list 37 | WORKSPACE=`echo ${{ wkspc }} | tr "_" -` 38 | terraform workspace select ${WORKSPACE} 39 | terraform validate 40 | workingDirectory: "${{ state.key }}" 41 | displayName: "Terraform Validate" 42 | 43 | - job: TFLint_${{ state.key }} 44 | displayName: "TFLint ${{ state.key }}" 45 | steps: 46 | - checkout: self 47 | - script: | 48 | curl -L "$(curl -Ls https://api.github.com/repos/terraform-linters/tflint/releases/latest | grep -o -E "https://.+?_linux_amd64.zip")" -o tflint.zip && unzip tflint.zip && rm tflint.zip 49 | ./tflint -v 50 | ./tflint 51 | workingDirectory: "${{ state.key }}" 52 | displayName: "Run TFLint" 53 | -------------------------------------------------------------------------------- /TerraformV2/plan.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: states 3 | type: object 4 | - name: service_connection 5 | 6 | stages: 7 | - ${{ each state in parameters.states }}: 8 | - ${{ each wkspc in state.value.workspaces }}: 9 | - stage: Plan_${{ wkspc }} 10 | dependsOn: 11 | - Validate_and_Lint_${{ state.key }} 12 | - ${{ if state.value.dependsOn }}: 13 | - Apply_${{ state.value.dependsOn }} 14 | jobs: 15 | - job: Plan_${{ wkspc }} 16 | displayName: "Terraform Plan ${{ wkspc }}" 17 | steps: 18 | - checkout: self 19 | clean: true 20 | persistCredentials: true 21 | - task: JamiePhillips.Terraform.TerraformTool.TerraformTool@0 22 | displayName: "Use Terraform $(TF_VERSION)" 23 | inputs: 24 | version: "$(TF_VERSION)" 25 | - task: AzureCLI@1 26 | displayName: "Setup Authentication" 27 | inputs: 28 | azureSubscription: "${{ parameters.service_connection }}" 29 | addSpnToEnvironment: true 30 | scriptLocation: inlineScript 31 | failOnStandardError: "true" 32 | inlineScript: | 33 | echo "##vso[task.setvariable variable=ARM_SUBSCRIPTION_ID]$(az account show --query="id" -o tsv)" 34 | echo "##vso[task.setvariable variable=ARM_CLIENT_ID]${servicePrincipalId}" 35 | echo "##vso[task.setvariable variable=ARM_CLIENT_SECRET]${servicePrincipalKey}" 36 | echo "##vso[task.setvariable variable=ARM_TENANT_ID]$(az account show --query="tenantId" -o tsv)" 37 | echo "##vso[task.setvariable variable=ARM_ACCESS_KEY]$(az storage account keys list -n ${STATE_STORAGE_ACCOUNT} --subscription "${STATE_SUBSCRIPTION}" --query="[0].value" -o tsv)" 38 | - script: | 39 | terraform init -no-color -input=false 40 | WORKSPACE=`echo ${{ wkspc }} | tr "_" -` 41 | terraform workspace select ${WORKSPACE} 42 | terraform plan -out=tfplan -no-color -input=false -detailed-exitcode 43 | PLAN_STATUS=$? 44 | echo "##vso[task.setvariable variable=PLAN_STATUS]$(echo ${PLAN_STATUS})" 45 | if [[ $PLAN_STATUS -eq 0 ]]; then 46 | echo "=========== No changes required, skipping plan file creation ===========" 47 | rm -f tfplan 48 | touch NOPLAN 49 | elif [[ $PLAN_STATUS -eq 1 ]]; then 50 | exit 1 51 | elif [[ $PLAN_STATUS -eq 2 ]]; then 52 | echo "##vso[task.logissue type=warning]Plan Created: ${{ wkspc }}" 53 | echo "##vso[build.addbuildtag]${{ wkspc }}_tfplan" 54 | fi 55 | displayName: "Terraform Plan ${{ wkspc }}" 56 | name: TerraformPlan 57 | workingDirectory: "${{ state.key }}" 58 | env: 59 | TF_IN_AUTOMATION: true 60 | - script: | 61 | WORKSPACE=`echo ${{ wkspc }} | tr "_" -` 62 | echo "Compressing ${{ state.key }} directory and environment/${WORKSPACE} vars file..." 63 | tar -czf ${{ wkspc }}.tar.gz ${{ state.key }}/ environments/ 64 | displayName: "Compress ${{ wkspc }} Artifact" 65 | condition: eq(variables['PLAN_STATUS'], '2') 66 | - publish: ${{ wkspc }}.tar.gz 67 | artifact: "tfplan_${{ wkspc }}" 68 | condition: eq(variables['PLAN_STATUS'], '2') 69 | -------------------------------------------------------------------------------- /TerraformV2/stages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattMencel/azdo-pipeline-examples/72ffd74f7f305e73b0c942bc01a757b8464db93f/TerraformV2/stages.png -------------------------------------------------------------------------------- /TerraformV2/tags_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattMencel/azdo-pipeline-examples/72ffd74f7f305e73b0c942bc01a757b8464db93f/TerraformV2/tags_logs.png --------------------------------------------------------------------------------