├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── bootstrap ├── azure.agents.tf ├── azure.managed.identities.tf ├── azure.resource.groups.tf ├── azure.storage.tf ├── azure.virtual.network.tf ├── azuredevops.agents.tf ├── azuredevops.environments.tf ├── azuredevops.groups.tf ├── azuredevops.pipelines.tf ├── azuredevops.project.tf ├── azuredevops.repositories.tf ├── azuredevops.repository.files.tf ├── azuredevops.service.connections.tf ├── azuredevops.variable.groups.tf ├── data.tf ├── locals.tf ├── main.tf ├── outputs.tf ├── terraform.tf └── variables.tf ├── example-module ├── .gitignore ├── config │ ├── dev.tfvars │ ├── prod.tfvars │ └── test.tfvars ├── locals.tf ├── main.tf ├── terraform.tf └── variables.tf └── pipelines ├── main ├── cd.yaml └── ci.yaml └── templates ├── cd-template.yaml ├── ci-template.yaml └── helpers ├── terraform-apply.yaml ├── terraform-init.yaml ├── terraform-installer.yaml └── terraform-plan.yaml /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | crash.*.log 11 | 12 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 13 | # password, private keys, and other secrets. These should not be part of version 14 | # control as they are data points which are potentially sensitive and subject 15 | # to change depending on the environment. 16 | #*.tfvars 17 | *.tfvars.json 18 | 19 | # Ignore override files as they are usually used to override resources locally and so 20 | # are not checked in 21 | override.tf 22 | override.tf.json 23 | *_override.tf 24 | *_override.tf.json 25 | 26 | # Include override files you do wish to add to version control using negated pattern 27 | # !example_override.tf 28 | 29 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 30 | # example: *tfplan* 31 | 32 | # Ignore CLI configuration files 33 | .terraformrc 34 | terraform.rc 35 | 36 | .terraform.lock.hcl 37 | terraform.tfvars 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - terraform 5 | - hcl 6 | - yaml 7 | name: Using Azure DevOps Pipelines Workload identity federation (OIDC) with Azure for Terraform Deployments 8 | description: A sample showing how to configure Azure DevOps Workload identity federation (OIDC) connection to Azure with Terraform and then use that configuration to deploy resources with Terraform. The sample also demonstrates bootstrapping CI / CD with Terraform and how to implement a number of best practices. 9 | products: 10 | - azure 11 | - azure-devops 12 | urlFragment: azure-devops-terraform-oidc-ci-cd 13 | --- 14 | 15 | # Using Azure DevOps Pipelines Workload identity federation (OIDC) with Azure for Terraform Deployments 16 | 17 | This is a two part sample. The first part demonstrates how to configure Azure and Azure DevOps for credential free deployment with Terraform. The second part demonstrates an end to end Continuous Delivery Pipeline for Terraform. 18 | 19 | ## Content 20 | 21 | | File/folder | Description | 22 | |-------------|-------------| 23 | | `bootstrap` | The Terraform to configure Azure and Azure DevOps ready for Workload identity federation (OIDC) or Managed Identity authentication. | 24 | | `example-module` | Some Terraform with Azure Resources for the demo to deploy. | 25 | | `pipelines` | The templated Azure DevOps Pipelines for the demo. | 26 | | `.gitignore` | Define what to ignore at commit time. | 27 | | `CHANGELOG.md` | List of changes to the sample. | 28 | | `CONTRIBUTING.md` | Guidelines for contributing to the sample. | 29 | | `README.md` | This README file. | 30 | | `LICENSE.md` | The license for the sample. | 31 | 32 | ## Features 33 | 34 | This sample includes the following features: 35 | 36 | * Setup 6 Azure User Assigned Managed Identities with Federation ready for Azure DevOps Workload identity federation (OIDC). 37 | * Setup an Azure Storage Account for State file management. 38 | * Setup Azure DevOps repository and environments ready to deploy Terraform with Workload identity federation (OIDC). 39 | * Run a Continuous Delivery pipeline for Terraform using Workload identity federation (OIDC) auth for state and deploying resources to Azure. 40 | * Run a Pull Request workflow with some basic static analysis. 41 | 42 | ## Getting Started 43 | 44 | ### Prerequisites 45 | 46 | - HashiCorp Terraform CLI: [Download](https://www.terraform.io/downloads) 47 | - Azure CLI: [Download](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-windows?tabs=azure-cli#install-or-update) 48 | - An Azure Subscription: [Free Account](https://azure.microsoft.com/en-gb/free/search/) 49 | - An Azure DevOps Organization and Project: [Free Organization](https://aex.dev.azure.com/signup/) 50 | - A free pipeline or billing is required 51 | 52 | ### Installation 53 | 54 | - Clone the repository locally and then follow the Demo / Lab. 55 | 56 | ### Quickstart 57 | 58 | The instructions for this sample are in the form of a Lab. Follow along with them to get up and running. 59 | 60 | ## Demo / Lab 61 | 62 | ### Lab overview 63 | 64 | This lab has the following phases: 65 | 66 | 1. Bootstrap Azure and Azure DevOps for Terraform CI / CD. 67 | 1. Run the Continuous Delivery pipeline for Terraform. 68 | 1. Make a change and submit a Pull Request and see the CI pipeline run. 69 | 70 | ### Bootstrap Overview and Best Practices 71 | 72 | This demo lab creates and is scoped to resource groups. This is to ensure the lab only requires a single subscription and can be run by anyone without the overhead of creating multiple subscriptions. However, for a production scenario we recommend scoping to subscriptions and using [subscription demoncratization](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/landing-zone/design-principles#subscription-democratization). 73 | 74 | The bootstrap implements a number of best practices for Terraform in Azure DevOps that you should take note of as you run through the lab: 75 | 76 | - Governed pipelines: The pipelines are stored in a separate repository to the code they deploy. This allows you to govern the pipelines and ensure that only approved templates are used. This is enforced by the required template setting on the service connections. 77 | - Approvals: The production environment requires approval to apply to it. This is enforced on the prod-apply service connection. This is not configured on the environment by design to ensure that the approval is to use the identity and cannot be bypassed. 78 | - Environment locks: The environments are locked with an exclusive to prevent parallel deployments from running at the same time. The pipeline includes the `lockBehavior: sequential` setting to ensure that the pipeline will wait for the lock to be released before running, so it queues rather just failing. 79 | - Workload Identity Federation (OIDC): The service connections and User Assigned Managed Identities are configured to use Workload Identity Federation (OIDC) authenticate to Azure. This means that you don't need to store any secrets in Azure DevOps. 80 | - Pipeline Stages: By default the pipeline is configured with dependencies between the environments. This means that the pipeline will run the dev stage, then the test stage and finally the prod stage. We also provide a parameter to target a specific environment to demonstrate a GitOps type approach too. 81 | - Separate Plan and Apply Identities: The bootstrap creates separate plan and apply identities and service connections per environment. This is to implement the principal of least privilege. The plan identity has read only access to the resource group and the apply identity has contributor access to the resource group. 82 | 83 | ### Generate a PAT (Personal Access Token) in Azure DevOps 84 | 85 | 1. Navigate to [dev.azure.com](https://dev.azure.com). 86 | 1. Login and select the `User Settings` icon in the top right and then `Personal access tokens`. 87 | 1. Click `New token`. 88 | 1. Type `Demo_OIDC` into the `Name` field. 89 | 1. Click `Show all scopes` down at the bottom of the dialog. 90 | 1. Check these scopes: 91 | 1. `Agent Pools`: `Read & manage` 92 | 1. `Build`: `Read & execute` 93 | 1. `Code`: `Full` 94 | 1. `Environment`: `Read & manage` 95 | 1. `Graph`: `Read & manage` 96 | 1. `Pipeline Resources`: `Use & manage` 97 | 1. `Project and Team`: `Read, write, & manage` 98 | 1. `Service Connections`: `Read, query, & manage` 99 | 1. `Variable Groups`: `Read, create, & manage` 100 | 1. Click `Create` 101 | 1. > IMPORTANT: Copy the token and save it somewhere. 102 | 103 | ### Clone the repo and setup your variables 104 | 105 | 1. Clone this repository to your local machine if you haven't already. 106 | 1. Open the repo in Visual Studio Code. (Hint: In a terminal you can open Visual Studio Code by navigating to the folder and running `code .`). 107 | 1. Navigate to the `bootstrap` folder and create a new file called `terraform.tfvars`. 108 | 1. In the `terraform.tfvars` file add the following: 109 | 110 | ```terraform 111 | location = "" 112 | organization_name = "" 113 | # You can omit this is you don't want to demo approvals on the production environment. Remove this whole approvers block to omit. 114 | approvers = { 115 | user1 = "" 116 | } 117 | ``` 118 | 119 | e.g. 120 | 121 | ```terraform 122 | location = "uksouth" 123 | organization_name = "my-organization" 124 | approvers = { 125 | user1 = "demouser@example.com" 126 | } 127 | ``` 128 | 129 | If you wish to use Microsoft-hosted agents and public networking add this setting to `terraform.tfvars`: 130 | 131 | ```terraform 132 | use_self_hosted_agents = false 133 | ``` 134 | 135 | If you wish to use Container Apps (scale to zero) add this setting to `terraform.tfvars`: 136 | 137 | >NOTE: Container App takes longer to provision than Container Instances. 138 | 139 | ```terraform 140 | self_hosted_agent_type = "azure_container_app" 141 | ``` 142 | 143 | ### Apply the Terraform 144 | 145 | 1. Open the Visual Studio Code Terminal and navigate the `bootstrap` folder. 146 | 1. Run `az login -T ""` and follow the prompts to login to Azure with your account. 147 | 1. Run `az account show`. If you are not connected to you test subscription, change it by running `az account set --subscription ""` 148 | 1. Run `$env:ARM_SUBSCRIPTION_ID = $(az account show --query id -o tsv)` to set the subscription id required by azurerm provider v4. 149 | 1. Run `$env:TF_VAR_personal_access_token = ""` to set the PAT you generated earlier. 150 | 1. Run `terraform init`. 151 | 1. Run `terraform plan -out tfplan`. 152 | 1. The plan will complete. Review the plan and see what is going to be created. 153 | 1. Run `terraform apply tfplan`. 154 | 1. Wait for the apply to complete. 155 | 1. You will see three outputs from this run. These are the Service Principal Ids that you will require in the next step. Save them somewhere. 156 | 157 | ### Check what has been created 158 | 159 | #### User Assigned Managed Identity 160 | 161 | 1. Login to the [Azure Portal](https://portal.azure.com) with your Global Administrator account. 162 | 1. Navigate to your Subscription and select `Resource groups`. 163 | 1. Click the resource group with `identity` (e.g. `rg-dema-identity-mgt-uksouth-001`). 164 | 1. You should see 6 newly created User Assigned Managed Identities, 2 per environment. 165 | 1. Look for a `Managed Identity` resource post-fixed with `dev-plan` and click it. 166 | 167 | #### Federated Credentials 168 | 1. Click on `Federated Credentials`. 169 | 1. There should only be one credential in the list, select that and take a look at the configuration. 170 | 1. Examine the `Subject identifier` and ensure you understand how it is built up. 171 | 172 | #### Resource Group and permissions 173 | 174 | 1. Navigate to your Subscription and select `Resource groups`. 175 | 1. You should see four newly created resource groups. 176 | 1. Click the resource group with `env-dev` (e.g. `rg-dema-env-dev-uksouth-001`) 177 | 1. Select `Access control (IAM)` and select `Role assignments`. 178 | 1. Under the `Reader` role, you should see that your `dev-plan` Managed Identity has been granted access directly to the resource group. 179 | 1. Under the `Contributor` role, you should see that your `dev-apply` Managed Identity has been granted access directly to the resource group. 180 | 181 | #### State storage account 182 | 183 | 1. Navigate to your Subscription and select `Resource groups`. 184 | 1. Click the resource group with `state` (e.g. `rg-dema-state-mgt-uksouth-001`). 185 | 1. You should see a single storage account in there, click on it. 186 | 1. Select `Containers`. You should see a `dev`, `test` and `prod` container. 187 | 1. Select the `dev` container. 188 | 1. Click `Access Control (IAM)` and select `Role assignments`. 189 | 1. Scroll down to `Storage Blob Data Owner`. You should see your `dev-plan` and `dev-apply` Managed Identities have been assigned that role. 190 | 191 | #### Azure DevOps Repository 192 | 193 | 1. Open Azure DevOps in your browser (login if you need to). 194 | 1. Navigate to your organisation and project. 195 | 1. Click `Repos`, then select your new repo in the drop down at the top of the page (e.g. `dema-mgt-main`). Click on it. 196 | 1. You should see some files under source control. 197 | 198 | #### Azure DevOps Template Repository 199 | 200 | 1. Navigate to your organisation and project. 201 | 1. Click `Repos`, then select your new repo in the drop down at the top of the page (e.g. `dema-mgt-template`). Click on it. 202 | 1. You should see some files under source control. 203 | 204 | #### Azure DevOps Environments 205 | 206 | 1. Hover over `Pipelines`, then select `Environments`. 207 | 1. You should see 3 environments called `dev`, `test` and `prod`. 208 | 1. Click on the `dev` environment and take a look at the settings. 209 | 1. Note the exclusive lock on the environment, this stops parralel deployments from planning at the same time as another plan and apply. 210 | 211 | #### Azure DevOps Variable Group 212 | 213 | 1. Hover over `Pipelines`, then select `Library`. 214 | 1. You should see 3 variable groups called `dev`, `test` and `prod`. 215 | 1. Click on the `dev` environment and take a look at the variables. 216 | 217 | #### Azure DevOps Service Connections 218 | 219 | 1. Click `Project Settings` in the bottom left corner. 220 | 1. Click `Service connections` under the `Pipelines` section. 221 | 1. There should be 3 service connections configured for Managed Identity or Workload Identity Federation depending on the option you choose. 222 | 1. Click on one of the service connections and click `Edit` to look at the settings. 223 | 1. Look at the approvals and required template check. The required template check will ensure that the pipeline is using the template specified in the template repository. 224 | 225 | #### Azure DevOps Agent Pools (self hosted agents option only) 226 | 227 | 1. Click `Project Settings` in the bottom left corner. 228 | 1. Click `Agent pools` under the `Pipelines` section. 229 | 1. There 1 new agent pool configured. 230 | 1. Click on it and navigate to the `Agents` tab, you should see 4 agents in the pool ready to accept runs. (You may only see 1 placeholder agent if you chose the Container Apps option). 231 | 232 | #### Azure DevOps Pipelines 233 | 234 | 1. Click on `Pipelines` 235 | 1. You should see 2 pipeline in the list. Click on each in turn. 236 | 1. Click on `Edit` and examine the pipeline code. 237 | 238 | ### Run the Pipeline 239 | 240 | 1. Select `Pipelines`, then click on the `02 - Continuous Delivery` pipeline you created. 241 | 1. Click the `Run pipeline` in the top right, then click `Run` in the dialog. 242 | 1. Wait for the run to appear or refresh the screen, then click on the run to see the details. 243 | 1. You will see each environment being deployed one after the other. 244 | 1. If you added approver, you'll need to appove the Production apply stage. 245 | 1. Drill into the log for one of the environments and look at the steps that were run. 246 | 1. Run the workflow again and take a look at the log to compare what happens on the Day 2 run. 247 | 248 | ### Submit a PR 249 | 250 | 1. Clone your new repository and open it in Visual Studio Code. 251 | 1. Create a new branch, call it whatever you want. 252 | 1. Open the `config/dev.tfvars` file. 253 | 1. Add a new tag: 254 | 255 | ```terraform 256 | tags = { 257 | deployed_by = "terraform" 258 | environment = "dev" 259 | owner = "Fred Bloggs" 260 | } 261 | ``` 262 | 263 | 1. Commit and push the change. 264 | 1. Raise a pull request. 265 | 1. You'll see the Azure DevOps Pipeline running in the pull request. This is because we created a branch policy to enforce this. 266 | 1. The `Terraform Format Check` step will fail for `main.tf`. Fix it, commit and push your change. 267 | 1. Wait for the CI Pipeline to run again and pass. 268 | 1. Examine the `Terraform Plan Check` step and see what is going to be changed. 269 | 1. Merge the Pull Request. 270 | 1. Navigate to `Pipelines` and watch the run. 271 | 272 | ### Clean up 273 | 274 | 1. Run `terraform destroy` in the `bootstrap` folder to clean up the resources created by the bootstrap. 275 | 276 | >NOTE: The destroy may fail the first time due to dependency between service connections and federated credentials. If this happens, run `terraform destroy` again and it should succeed. 277 | 278 | ## Resources 279 | 280 | - [Terraform Steps for Azure DevOps](https://github.com/microsoft/azure-pipelines-terraform/blob/main/Tasks/TerraformTask/TerraformTaskV4/README.md) 281 | - [Terraform azurerm provider OIDC configuration](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_oidc) 282 | - [Azure DevOps OIDC Docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers) 283 | - [Azure External Identity Docs](https://learn.microsoft.com/en-us/azure/active-directory/develop/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp) 284 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | -------------------------------------------------------------------------------- /bootstrap/azure.agents.tf: -------------------------------------------------------------------------------- 1 | module "azure_devops_agents" { 2 | source = "Azure/avm-ptn-cicd-agents-and-runners/azurerm" 3 | version = "0.3.2" 4 | 5 | count = var.use_self_hosted_agents ? 1 : 0 6 | 7 | resource_group_creation_enabled = false 8 | resource_group_name = module.resource_group["agents"].name 9 | postfix = local.resource_names.agent_compute_postfix_name 10 | container_instance_name_prefix = local.resource_names.container_instance_prefix_name 11 | container_registry_name = local.resource_names.container_registry_name 12 | location = var.location 13 | compute_types = [var.self_hosted_agent_type] 14 | container_instance_count = 4 15 | version_control_system_type = "azuredevops" 16 | version_control_system_personal_access_token = var.personal_access_token 17 | version_control_system_organization = local.organization_name_url 18 | version_control_system_pool_name = azuredevops_agent_pool.this[0].name 19 | virtual_network_creation_enabled = false 20 | virtual_network_id = module.virtual_network[0].resource_id 21 | container_app_subnet_id = module.virtual_network[0].subnets["agents"].resource_id 22 | container_instance_subnet_id = module.virtual_network[0].subnets["agents"].resource_id 23 | container_registry_private_endpoint_subnet_id = module.virtual_network[0].subnets["private_endpoints"].resource_id 24 | container_instance_use_availability_zones = var.agent_use_availability_zones 25 | depends_on = [azuredevops_pipeline_authorization.service_connection, azuredevops_pipeline_authorization.environment, azuredevops_pipeline_authorization.agent_pool] 26 | } 27 | -------------------------------------------------------------------------------- /bootstrap/azure.managed.identities.tf: -------------------------------------------------------------------------------- 1 | module "user_assigned_managed_identity" { 2 | source = "Azure/avm-res-managedidentity-userassignedidentity/azurerm" 3 | version = "0.3.3" 4 | 5 | for_each = local.environment_split 6 | location = var.location 7 | name = each.value.user_assigned_managed_identity_name 8 | resource_group_name = module.resource_group["identity"].name 9 | } 10 | 11 | resource "azurerm_federated_identity_credential" "this" { 12 | for_each = local.environment_split 13 | parent_id = module.user_assigned_managed_identity[each.key].resource_id 14 | name = "${var.organization_name}-${local.azure_devops_project_name}-${each.key}" 15 | resource_group_name = module.resource_group["identity"].name 16 | audience = [local.default_audience_name] 17 | issuer = azuredevops_serviceendpoint_azurerm.this[each.key].workload_identity_federation_issuer 18 | subject = azuredevops_serviceendpoint_azurerm.this[each.key].workload_identity_federation_subject 19 | } 20 | -------------------------------------------------------------------------------- /bootstrap/azure.resource.groups.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | resource_groups = merge({ 3 | state = { 4 | name = local.resource_names.resource_group_state_name 5 | } 6 | identity = { 7 | name = local.resource_names.resource_group_identity_name 8 | } 9 | }, var.use_self_hosted_agents ? { 10 | agents = { 11 | name = local.resource_names.resource_group_agents_name 12 | } 13 | } : {}) 14 | 15 | resource_groups_environments = { for env_key, env_value in local.environments : env_key => { 16 | name = env_value.resource_group_name 17 | role_assignments = { 18 | reader = { 19 | role_definition_id_or_name = "Reader" 20 | principal_id = module.user_assigned_managed_identity["${env_key}-plan"].principal_id 21 | } 22 | contributor = { 23 | role_definition_id_or_name = "Contributor" 24 | principal_id = module.user_assigned_managed_identity["${env_key}-apply"].principal_id 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | module "resource_group" { 32 | source = "Azure/avm-res-resources-resourcegroup/azurerm" 33 | version = "0.2.1" 34 | for_each = local.resource_groups 35 | location = var.location 36 | name = each.value.name 37 | } 38 | 39 | module "resource_group_environments" { 40 | source = "Azure/avm-res-resources-resourcegroup/azurerm" 41 | version = "0.2.1" 42 | for_each = local.resource_groups_environments 43 | location = var.location 44 | name = each.value.name 45 | role_assignments = each.value.role_assignments 46 | } 47 | -------------------------------------------------------------------------------- /bootstrap/azure.storage.tf: -------------------------------------------------------------------------------- 1 | module "private_dns_zone_storage_account" { 2 | source = "Azure/avm-res-network-privatednszone/azurerm" 3 | version = "0.3.2" 4 | 5 | count = var.use_self_hosted_agents ? 1 : 0 6 | 7 | resource_group_name = module.resource_group["state"].name 8 | domain_name = "privatelink.blob.core.windows.net" 9 | 10 | virtual_network_links = { 11 | vnet_link = { 12 | vnetlinkname = "storage-account" 13 | vnetid = module.virtual_network[0].resource_id 14 | } 15 | } 16 | } 17 | 18 | module "storage_account" { 19 | source = "Azure/avm-res-storage-storageaccount/azurerm" 20 | version = "0.5.0" 21 | name = local.resource_names.storage_account_name 22 | location = var.location 23 | resource_group_name = module.resource_group["state"].name 24 | account_tier = "Standard" 25 | account_replication_type = "ZRS" 26 | public_network_access_enabled = !var.use_self_hosted_agents 27 | network_rules = var.use_self_hosted_agents ? {} : null 28 | 29 | containers = { for env_key, env_value in var.environments : env_key => { 30 | name = env_key 31 | public_access = "None" 32 | role_assignments = { 33 | user_assignment_managed_identity-plan = { 34 | role_definition_id_or_name = "Storage Blob Data Contributor" 35 | principal_id = module.user_assigned_managed_identity["${env_key}-plan"].principal_id 36 | } 37 | user_assignment_managed_identity-apply = { 38 | role_definition_id_or_name = "Storage Blob Data Contributor" 39 | principal_id = module.user_assigned_managed_identity["${env_key}-apply"].principal_id 40 | } 41 | } 42 | } 43 | } 44 | 45 | private_endpoints_manage_dns_zone_group = true 46 | private_endpoints = var.use_self_hosted_agents ? { blob = { 47 | name = local.resource_names.storage_account_private_endpoint_name 48 | subnet_resource_id = module.virtual_network[0].subnets["private_endpoints"].resource_id 49 | subresource_name = "blob" 50 | private_dns_zone_resource_ids = [module.private_dns_zone_storage_account[0].resource_id] 51 | } 52 | } : {} 53 | } 54 | -------------------------------------------------------------------------------- /bootstrap/azure.virtual.network.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | address_space_split = split("/", var.address_space) 3 | address_space_start_ip = local.address_space_split[0] 4 | address_space_size = tonumber(local.address_space_split[1]) 5 | order_by_size = { for key, value in var.subnets_and_sizes : "${format("%03s", value)}||${key}" => value } 6 | virtual_network_address_space = "${local.address_space_start_ip}/${local.address_space_size}" 7 | subnet_keys = keys(local.order_by_size) 8 | subnet_new_bits = [for size in values(local.order_by_size) : size - local.address_space_size] 9 | cidr_subnets = cidrsubnets(local.virtual_network_address_space, local.subnet_new_bits...) 10 | subnets = { for key, value in local.order_by_size : split("||", key)[1] => local.cidr_subnets[index(local.subnet_keys, key)] } 11 | 12 | subnet_delegation_type = var.self_hosted_agent_type == "azure_container_app" ? "Microsoft.App/environments" : "Microsoft.ContainerInstance/containerGroups" 13 | subnet_delegations = { for key, value in var.subnets_and_sizes : key => key == "agents" ? [ 14 | { 15 | name = local.subnet_delegation_type 16 | service_delegation = { 17 | name = local.subnet_delegation_type 18 | } 19 | } 20 | ] : [] } 21 | } 22 | 23 | module "virtual_network" { 24 | source = "Azure/avm-res-network-virtualnetwork/azurerm" 25 | version = "0.8.1" 26 | count = var.use_self_hosted_agents ? 1 : 0 27 | name = local.resource_names.virtual_network_name 28 | location = var.location 29 | resource_group_name = module.resource_group["agents"].name 30 | address_space = [var.address_space] 31 | subnets = { for subnet_key, subnet_address_space in local.subnets : subnet_key => { 32 | name = subnet_key 33 | address_prefixes = [subnet_address_space] 34 | delegation = local.subnet_delegations[subnet_key] 35 | } } 36 | } 37 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.agents.tf: -------------------------------------------------------------------------------- 1 | resource "azuredevops_agent_pool" "this" { 2 | count = var.use_self_hosted_agents ? 1 : 0 3 | name = local.resource_names.agent_pool_name 4 | auto_provision = false 5 | auto_update = true 6 | } 7 | 8 | resource "azuredevops_agent_queue" "this" { 9 | count = var.use_self_hosted_agents ? 1 : 0 10 | project_id = local.azure_devops_project_id 11 | agent_pool_id = azuredevops_agent_pool.this[0].id 12 | } 13 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.environments.tf: -------------------------------------------------------------------------------- 1 | resource "azuredevops_environment" "this" { 2 | for_each = var.environments 3 | name = each.key 4 | project_id = local.azure_devops_project_id 5 | } 6 | 7 | resource "azuredevops_check_exclusive_lock" "environment" { 8 | for_each = var.environments 9 | project_id = local.azure_devops_project_id 10 | target_resource_id = azuredevops_environment.this[each.key].id 11 | target_resource_type = "environment" 12 | timeout = 43200 13 | } 14 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.groups.tf: -------------------------------------------------------------------------------- 1 | resource "azuredevops_group" "this" { 2 | scope = local.azure_devops_project_id 3 | display_name = local.resource_names.group_name 4 | description = "Approvers for the Terraform Apply" 5 | } 6 | 7 | data "azuredevops_users" "this" { 8 | for_each = var.approvers 9 | principal_name = each.value 10 | lifecycle { 11 | postcondition { 12 | condition = length(self.users) > 0 13 | error_message = "No user account found for ${each.value}, check you have entered a valid user principal name..." 14 | } 15 | } 16 | } 17 | 18 | locals { 19 | approvers = toset(flatten([for approver in data.azuredevops_users.this : 20 | [for user in approver.users : user.descriptor] 21 | ])) 22 | } 23 | 24 | resource "azuredevops_group_membership" "this" { 25 | group = azuredevops_group.this.descriptor 26 | members = local.approvers 27 | } 28 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.pipelines.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | pipelines = { 3 | ci = { 4 | name = "01 - Continuous Integration" 5 | file_path = "ci.yaml" 6 | } 7 | cd = { 8 | name = "02 - Continuous Delivery" 9 | file_path = "cd.yaml" 10 | } 11 | } 12 | pipelines_by_environment = { for environment_split in flatten([for env_key, env_value in var.environments : [ 13 | for pipeline_key, pipeline_value in local.pipelines : { 14 | composite_key = "${env_key}-${pipeline_key}" 15 | environment = env_key 16 | pipeline = pipeline_key 17 | } 18 | ]]) : environment_split.composite_key => environment_split } 19 | 20 | pipelines_by_service_connection = { for environment_split in flatten([for env_key, env_value in local.environment_split : [ 21 | for pipeline_key, pipeline_value in local.pipelines : { 22 | composite_key = "${env_key}-${pipeline_key}" 23 | service_connection = env_key 24 | pipeline = pipeline_key 25 | is_valid = env_value.type == "plan" || env_value.type == "apply" && pipeline_key == "cd" 26 | } 27 | ]]) : environment_split.composite_key => environment_split if environment_split.is_valid } 28 | } 29 | 30 | resource "azuredevops_build_definition" "this" { 31 | for_each = local.pipelines 32 | project_id = local.azure_devops_project_id 33 | name = each.value.name 34 | 35 | ci_trigger { 36 | use_yaml = true 37 | } 38 | 39 | repository { 40 | repo_type = "TfsGit" 41 | repo_id = azuredevops_git_repository.this.id 42 | branch_name = azuredevops_git_repository.this.default_branch 43 | yml_path = each.value.file_path 44 | } 45 | } 46 | 47 | resource "azuredevops_pipeline_authorization" "service_connection" { 48 | for_each = local.pipelines_by_service_connection 49 | project_id = local.azure_devops_project_id 50 | resource_id = azuredevops_serviceendpoint_azurerm.this[each.value.service_connection].id 51 | type = "endpoint" 52 | pipeline_id = azuredevops_build_definition.this[each.value.pipeline].id 53 | } 54 | 55 | resource "azuredevops_pipeline_authorization" "environment" { 56 | for_each = local.pipelines_by_environment 57 | project_id = local.azure_devops_project_id 58 | resource_id = azuredevops_environment.this[each.value.environment].id 59 | type = "environment" 60 | pipeline_id = azuredevops_build_definition.this[each.value.pipeline].id 61 | } 62 | 63 | resource "azuredevops_pipeline_authorization" "agent_pool" { 64 | for_each = var.use_self_hosted_agents ? local.pipelines : {} 65 | project_id = local.azure_devops_project_id 66 | resource_id = azuredevops_agent_queue.this[0].id 67 | type = "queue" 68 | pipeline_id = azuredevops_build_definition.this[each.key].id 69 | } 70 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.project.tf: -------------------------------------------------------------------------------- 1 | data "azuredevops_project" "this" { 2 | count = var.azure_devops_create_project ? 0 : 1 3 | name = var.azure_devops_project 4 | } 5 | 6 | resource "azuredevops_project" "this" { 7 | count = var.azure_devops_create_project ? 1 : 0 8 | name = local.resource_names.project_name 9 | } 10 | 11 | locals { 12 | azure_devops_project_name = var.azure_devops_create_project ? azuredevops_project.this[0].name : data.azuredevops_project.this[0].name 13 | azure_devops_project_id = var.azure_devops_create_project ? azuredevops_project.this[0].id : data.azuredevops_project.this[0].id 14 | } 15 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.repositories.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | default_branch = "refs/heads/main" 3 | } 4 | 5 | resource "azuredevops_git_repository" "this" { 6 | depends_on = [azuredevops_environment.this] 7 | project_id = local.azure_devops_project_id 8 | name = local.resource_names.repository_main_name 9 | default_branch = local.default_branch 10 | initialization { 11 | init_type = "Clean" 12 | } 13 | } 14 | 15 | resource "azuredevops_git_repository" "template" { 16 | depends_on = [azuredevops_environment.this] 17 | project_id = local.azure_devops_project_id 18 | name = local.resource_names.repository_template_name 19 | default_branch = local.default_branch 20 | initialization { 21 | init_type = "Clean" 22 | } 23 | } 24 | 25 | resource "azuredevops_branch_policy_min_reviewers" "this" { 26 | depends_on = [azuredevops_git_repository_file.this] 27 | project_id = local.azure_devops_project_id 28 | 29 | enabled = length(var.approvers) > 1 30 | blocking = true 31 | 32 | settings { 33 | reviewer_count = 1 34 | submitter_can_vote = false 35 | last_pusher_cannot_approve = true 36 | allow_completion_with_rejects_or_waits = false 37 | on_push_reset_approved_votes = true 38 | 39 | scope { 40 | repository_id = azuredevops_git_repository.this.id 41 | repository_ref = azuredevops_git_repository.this.default_branch 42 | match_type = "Exact" 43 | } 44 | } 45 | } 46 | 47 | resource "azuredevops_branch_policy_merge_types" "this" { 48 | depends_on = [azuredevops_git_repository_file.this] 49 | project_id = local.azure_devops_project_id 50 | 51 | enabled = true 52 | blocking = true 53 | 54 | settings { 55 | allow_squash = true 56 | allow_rebase_and_fast_forward = false 57 | allow_basic_no_fast_forward = false 58 | allow_rebase_with_merge = false 59 | 60 | scope { 61 | repository_id = azuredevops_git_repository.this.id 62 | repository_ref = azuredevops_git_repository.this.default_branch 63 | match_type = "Exact" 64 | } 65 | } 66 | } 67 | 68 | resource "azuredevops_branch_policy_build_validation" "this" { 69 | depends_on = [azuredevops_git_repository_file.this] 70 | project_id = local.azure_devops_project_id 71 | 72 | enabled = true 73 | blocking = true 74 | 75 | settings { 76 | display_name = "Terraform Validation" 77 | build_definition_id = azuredevops_build_definition.this["ci"].id 78 | valid_duration = 720 79 | 80 | scope { 81 | repository_id = azuredevops_git_repository.this.id 82 | repository_ref = azuredevops_git_repository.this.default_branch 83 | match_type = "Exact" 84 | } 85 | } 86 | } 87 | 88 | resource "azuredevops_branch_policy_min_reviewers" "template" { 89 | depends_on = [azuredevops_git_repository_file.template] 90 | project_id = local.azure_devops_project_id 91 | 92 | enabled = length(var.approvers) > 1 93 | blocking = true 94 | 95 | settings { 96 | reviewer_count = 1 97 | submitter_can_vote = false 98 | last_pusher_cannot_approve = true 99 | allow_completion_with_rejects_or_waits = false 100 | on_push_reset_approved_votes = true 101 | 102 | scope { 103 | repository_id = azuredevops_git_repository.template.id 104 | repository_ref = azuredevops_git_repository.template.default_branch 105 | match_type = "Exact" 106 | } 107 | } 108 | } 109 | 110 | resource "azuredevops_branch_policy_merge_types" "template" { 111 | depends_on = [azuredevops_git_repository_file.template] 112 | project_id = local.azure_devops_project_id 113 | 114 | enabled = true 115 | blocking = true 116 | 117 | settings { 118 | allow_squash = true 119 | allow_rebase_and_fast_forward = false 120 | allow_basic_no_fast_forward = false 121 | allow_rebase_with_merge = false 122 | 123 | scope { 124 | repository_id = azuredevops_git_repository.template.id 125 | repository_ref = azuredevops_git_repository.template.default_branch 126 | match_type = "Exact" 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.repository.files.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | environment_replacements = { for environment_key, environment_value in var.environments : "${format("%03s", environment_value.display_order)}-${environment_key}" => { 3 | name = lower(replace(environment_key, "-", "")) 4 | display_name = environment_value.display_name 5 | variable_group_name = environment_key 6 | agent_pool_type = var.use_self_hosted_agents ? "self-hosted" : "microsoft-hosted" 7 | agent_pool_name = var.use_self_hosted_agents ? "${azuredevops_agent_pool.this[0].name}" : "ubuntu-latest" 8 | service_connection_name_plan = "service-connection-${environment_key}-plan" 9 | service_connection_name_apply = "service-connection-${environment_key}-apply" 10 | environment_name = environment_key 11 | dependent_environment = environment_value.dependent_environment 12 | } } 13 | 14 | template_folder = "${path.module}/${var.example_module_path}" 15 | files = { for file in fileset(local.template_folder, "**") : file => { 16 | name = file 17 | content = file("${local.template_folder}/${file}") 18 | } } 19 | 20 | pipeline_main_replacements = { 21 | environments = local.environment_replacements 22 | project_name = local.azure_devops_project_name 23 | repository_name_templates = azuredevops_git_repository.template.name 24 | cd_template_path = "cd-template.yaml" 25 | ci_template_path = "ci-template.yaml" 26 | root_module_folder_relative_path = "." 27 | } 28 | 29 | pipeline_main_folder = "${path.module}/../pipelines/main" 30 | pipeline_main_files = { for file in fileset(local.pipeline_main_folder, "**") : file => { 31 | name = file 32 | content = templatefile("${local.pipeline_main_folder}/${file}", local.pipeline_main_replacements) 33 | } } 34 | 35 | main_repository_files = merge(local.files, local.pipeline_main_files) 36 | 37 | pipeline_template_folder = "${path.module}/../pipelines/templates" 38 | pipeline_template_files = { for file in fileset(local.pipeline_template_folder, "**") : file => { 39 | name = file 40 | content = file("${local.pipeline_template_folder}/${file}") 41 | } } 42 | } 43 | 44 | resource "azuredevops_git_repository_file" "this" { 45 | for_each = local.main_repository_files 46 | repository_id = azuredevops_git_repository.this.id 47 | file = each.key 48 | content = each.value.content 49 | branch = local.default_branch 50 | commit_message = "[skip ci]" 51 | overwrite_on_create = true 52 | } 53 | 54 | resource "azuredevops_git_repository_file" "template" { 55 | for_each = local.pipeline_template_files 56 | repository_id = azuredevops_git_repository.template.id 57 | file = each.key 58 | content = each.value.content 59 | branch = local.default_branch 60 | commit_message = "[skip ci]" 61 | overwrite_on_create = true 62 | } 63 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.service.connections.tf: -------------------------------------------------------------------------------- 1 | resource "azuredevops_serviceendpoint_azurerm" "this" { 2 | for_each = local.environment_split 3 | project_id = local.azure_devops_project_id 4 | service_endpoint_name = "service-connection-${each.key}" 5 | description = "Managed by Terraform" 6 | service_endpoint_authentication_scheme = "WorkloadIdentityFederation" 7 | credentials { 8 | serviceprincipalid = module.user_assigned_managed_identity[each.key].client_id 9 | } 10 | azurerm_spn_tenantid = data.azurerm_client_config.current.tenant_id 11 | azurerm_subscription_id = data.azurerm_client_config.current.subscription_id 12 | azurerm_subscription_name = data.azurerm_subscription.current.display_name 13 | } 14 | 15 | locals { 16 | environments_with_approvals = { for key, value in var.environments : key => value if value.has_approval } 17 | } 18 | 19 | resource "azuredevops_check_approval" "this" { 20 | for_each = length(var.approvers) == 0 ? {} : local.environments_with_approvals 21 | project_id = local.azure_devops_project_id 22 | target_resource_id = azuredevops_serviceendpoint_azurerm.this["${each.key}-apply"].id 23 | target_resource_type = "endpoint" 24 | 25 | requester_can_approve = length(var.approvers) == 1 26 | approvers = [ 27 | azuredevops_group.this.origin_id 28 | ] 29 | 30 | timeout = 43200 31 | } 32 | 33 | resource "azuredevops_check_exclusive_lock" "service_connection" { 34 | for_each = local.environment_split 35 | project_id = local.azure_devops_project_id 36 | target_resource_id = azuredevops_serviceendpoint_azurerm.this[each.key].id 37 | target_resource_type = "endpoint" 38 | timeout = 43200 39 | } 40 | 41 | resource "azuredevops_check_required_template" "this" { 42 | for_each = local.environment_split 43 | project_id = local.azure_devops_project_id 44 | target_resource_id = azuredevops_serviceendpoint_azurerm.this[each.key].id 45 | target_resource_type = "endpoint" 46 | 47 | dynamic "required_template" { 48 | for_each = { for template in each.value.required_templates : template => template } 49 | content { 50 | repository_type = "azuregit" 51 | repository_name = "${local.azure_devops_project_name}/${azuredevops_git_repository.template.name}" 52 | repository_ref = local.default_branch 53 | template_path = required_template.value 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /bootstrap/azuredevops.variable.groups.tf: -------------------------------------------------------------------------------- 1 | resource "azuredevops_variable_group" "this" { 2 | for_each = var.environments 3 | project_id = local.azure_devops_project_id 4 | name = each.key 5 | description = "Variable Group for ${each.value.display_name}" 6 | allow_access = true 7 | 8 | variable { 9 | name = "ADDITIONAL_ENVIRONMENT_VARIABLES" 10 | value = jsonencode({ 11 | TF_VAR_resource_group_name = module.resource_group_environments[each.key].name 12 | }) 13 | } 14 | 15 | variable { 16 | name = "VAR_FILE_PATH" 17 | value = "./config/${each.key}.tfvars" 18 | } 19 | 20 | variable { 21 | name = "BACKEND_AZURE_STORAGE_ACCOUNT_NAME" 22 | value = module.storage_account.name 23 | } 24 | 25 | variable { 26 | name = "BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME" 27 | value = each.key 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bootstrap/data.tf: -------------------------------------------------------------------------------- 1 | data "azurerm_client_config" "current" {} 2 | data "azurerm_subscription" "current" {} 3 | 4 | module "regions" { 5 | source = "Azure/avm-utl-regions/azurerm" 6 | version = "0.5.0" 7 | } 8 | -------------------------------------------------------------------------------- /bootstrap/locals.tf: -------------------------------------------------------------------------------- 1 | # Calculate resource names 2 | locals { 3 | name_replacements = { 4 | workload = var.resource_name_workload 5 | environment = var.resource_name_environment 6 | location = var.location 7 | location_short = var.resource_name_location_short == "" ? module.regions.regions_by_name[var.location].geo_code : var.resource_name_location_short 8 | uniqueness = random_string.unique_name.id 9 | sequence = format("%03d", var.resource_name_sequence_start) 10 | } 11 | 12 | resource_names = { for key, value in var.resource_name_templates : key => templatestring(value, local.name_replacements) } 13 | } 14 | 15 | locals { 16 | default_audience_name = "api://AzureADTokenExchange" 17 | organization_name_url = "${var.organization_name_prefix}/${var.organization_name}" 18 | } 19 | 20 | locals { 21 | environments = { for key, value in var.environments : key => { 22 | display_order = value.display_order 23 | display_name = value.display_name 24 | has_approval = value.has_approval 25 | dependent_environment = value.dependent_environment 26 | resource_group_create = value.resource_group_create 27 | resource_group_name = templatestring(value.resource_group_name_template, { 28 | workload = local.name_replacements.workload 29 | environment = key 30 | location = local.name_replacements.location 31 | sequence = local.name_replacements.sequence 32 | }) 33 | user_assigned_managed_identity_name_template = value.user_assigned_managed_identity_name_template 34 | } } 35 | environment_split_type = { 36 | plan = "plan" 37 | apply = "apply" 38 | } 39 | environment_split = { for environment_split in flatten([for env_key, env_value in local.environments : [ 40 | for split_key, split_value in local.environment_split_type : { 41 | composite_key = "${env_key}-${split_key}" 42 | environment = env_key 43 | type = split_key 44 | required_templates = split_key == local.environment_split_type.plan ? ["ci-template.yaml", "cd-template.yaml"] : ["cd-template.yaml"] 45 | user_assigned_managed_identity_name = templatestring(env_value.user_assigned_managed_identity_name_template, { 46 | workload = local.name_replacements.workload 47 | environment = env_key 48 | type = split_key 49 | location = local.name_replacements.location 50 | sequence = local.name_replacements.sequence 51 | }) 52 | } 53 | ]]) : environment_split.composite_key => environment_split } 54 | } 55 | -------------------------------------------------------------------------------- /bootstrap/main.tf: -------------------------------------------------------------------------------- 1 | resource "random_string" "unique_name" { 2 | length = 3 3 | special = false 4 | upper = false 5 | numeric = false 6 | } 7 | -------------------------------------------------------------------------------- /bootstrap/outputs.tf: -------------------------------------------------------------------------------- 1 | output "subscription_id" { 2 | value = data.azurerm_client_config.current.subscription_id 3 | } 4 | 5 | output "subscription_name" { 6 | value = data.azurerm_subscription.current.display_name 7 | } 8 | 9 | output "tenant_id" { 10 | value = data.azurerm_client_config.current.tenant_id 11 | } 12 | 13 | output "managed_identity_client_ids" { 14 | value = { for env_key, env_value in local.environment_split : env_key => module.user_assigned_managed_identity[env_key].client_id } 15 | } 16 | -------------------------------------------------------------------------------- /bootstrap/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | source = "hashicorp/azurerm" 5 | version = "~> 4.20" 6 | } 7 | azuredevops = { 8 | source = "microsoft/azuredevops" 9 | version = "~> 1.7" 10 | } 11 | } 12 | } 13 | 14 | provider "azuredevops" { 15 | org_service_url = local.organization_name_url 16 | personal_access_token = var.personal_access_token 17 | } 18 | 19 | provider "azurerm" { 20 | features { 21 | resource_group { 22 | prevent_deletion_if_contains_resources = false 23 | } 24 | storage { 25 | data_plane_available = false 26 | } 27 | } 28 | storage_use_azuread = true 29 | } 30 | -------------------------------------------------------------------------------- /bootstrap/variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | type = string 3 | description = "The location/region where the resources will be created. Must be in the short form (e.g. 'uksouth')" 4 | validation { 5 | condition = can(regex("^[a-z0-9-]+$", var.location)) 6 | error_message = "The location must only contain lowercase letters, numbers, and hyphens" 7 | } 8 | validation { 9 | condition = length(var.location) <= 20 10 | error_message = "The location must be 20 characters or less" 11 | } 12 | } 13 | 14 | variable "resource_name_location_short" { 15 | type = string 16 | description = "The short name segment for the location" 17 | default = "" 18 | validation { 19 | condition = length(var.resource_name_location_short) == 0 || can(regex("^[a-z]+$", var.resource_name_location_short)) 20 | error_message = "The short name segment for the location must only contain lowercase letters" 21 | } 22 | validation { 23 | condition = length(var.resource_name_location_short) <= 3 24 | error_message = "The short name segment for the location must be 3 characters or less" 25 | } 26 | } 27 | 28 | variable "resource_name_workload" { 29 | type = string 30 | description = "The name segment for the workload" 31 | default = "dema" 32 | validation { 33 | condition = can(regex("^[a-z0-9]+$", var.resource_name_workload)) 34 | error_message = "The name segment for the workload must only contain lowercase letters and numbers" 35 | } 36 | validation { 37 | condition = length(var.resource_name_workload) <= 4 38 | error_message = "The name segment for the workload must be 4 characters or less" 39 | } 40 | } 41 | 42 | variable "resource_name_environment" { 43 | type = string 44 | description = "The name segment for the environment" 45 | default = "mgt" 46 | validation { 47 | condition = can(regex("^[a-z0-9]+$", var.resource_name_environment)) 48 | error_message = "The name segment for the environment must only contain lowercase letters and numbers" 49 | } 50 | validation { 51 | condition = length(var.resource_name_environment) <= 4 52 | error_message = "The name segment for the environment must be 4 characters or less" 53 | } 54 | } 55 | 56 | variable "resource_name_sequence_start" { 57 | type = number 58 | description = "The number to use for the resource names" 59 | default = 1 60 | validation { 61 | condition = var.resource_name_sequence_start >= 1 && var.resource_name_sequence_start <= 999 62 | error_message = "The number must be between 1 and 999" 63 | } 64 | } 65 | 66 | variable "resource_name_templates" { 67 | type = map(string) 68 | description = "A map of resource names to use" 69 | default = { 70 | resource_group_state_name = "rg-$${workload}-state-$${environment}-$${location}-$${sequence}" 71 | resource_group_agents_name = "rg-$${workload}-agents-$${environment}-$${location}-$${sequence}" 72 | resource_group_identity_name = "rg-$${workload}-identity-$${environment}-$${location}-$${sequence}" 73 | virtual_network_name = "vnet-$${workload}-$${environment}-$${location}-$${sequence}" 74 | network_security_group_name = "nsg-$${workload}-$${environment}-$${location}-$${sequence}" 75 | nat_gateway_name = "nat-$${workload}-$${environment}-$${location}-$${sequence}" 76 | nat_gateway_public_ip_name = "pip-nat-$${workload}-$${environment}-$${location}-$${sequence}" 77 | storage_account_name = "sto$${workload}$${environment}$${location_short}$${sequence}$${uniqueness}" 78 | storage_account_private_endpoint_name = "pe-sto-$${workload}-$${environment}-$${location}-$${sequence}" 79 | agent_compute_postfix_name = "$${workload}-$${environment}-$${location}-$${sequence}" 80 | container_instance_prefix_name = "aci-$${workload}-$${environment}-$${location}" 81 | container_registry_name = "acr$${workload}$${environment}$${location}$${sequence}$${uniqueness}" 82 | project_name = "$${workload}-$${environment}" 83 | repository_main_name = "$${workload}-$${environment}-main" 84 | repository_template_name = "$${workload}-$${environment}-template" 85 | agent_pool_name = "agent-pool-$${workload}-$${environment}" 86 | group_name = "group-$${workload}-$${environment}-approvers" 87 | } 88 | } 89 | 90 | variable "environments" { 91 | type = map(object({ 92 | display_order = number 93 | display_name = string 94 | has_approval = optional(bool, false) 95 | dependent_environment = optional(string, "") 96 | resource_group_create = optional(bool, true) 97 | resource_group_name_template = optional(string, "rg-$${workload}-env-$${environment}-$${location}-$${sequence}") 98 | user_assigned_managed_identity_name_template = optional(string, "uami-$${workload}-$${environment}-$${type}-$${location}-$${sequence}") 99 | })) 100 | default = { 101 | dev = { 102 | display_order = 1 103 | display_name = "Development" 104 | } 105 | test = { 106 | display_order = 2 107 | display_name = "Test" 108 | dependent_environment = "dev" 109 | } 110 | prod = { 111 | display_order = 3 112 | display_name = "Production" 113 | has_approval = true 114 | dependent_environment = "test" 115 | } 116 | } 117 | } 118 | 119 | variable "personal_access_token" { 120 | type = string 121 | sensitive = true 122 | } 123 | 124 | variable "organization_name_prefix" { 125 | type = string 126 | default = "https://dev.azure.com" 127 | } 128 | 129 | variable "organization_name" { 130 | type = string 131 | } 132 | 133 | variable "azure_devops_project" { 134 | type = string 135 | default = null 136 | } 137 | 138 | variable "azure_devops_create_project" { 139 | type = bool 140 | default = true 141 | } 142 | 143 | variable "use_self_hosted_agents" { 144 | type = bool 145 | default = true 146 | } 147 | 148 | variable "self_hosted_agent_type" { 149 | type = string 150 | default = "azure_container_instance" 151 | validation { 152 | condition = contains(["azure_container_app", "azure_container_instance"], var.self_hosted_agent_type) 153 | error_message = "self_hosted_agent_type must be either 'azure_container_app' or 'azure_container_instance'." 154 | } 155 | } 156 | 157 | variable "address_space" { 158 | type = string 159 | description = "The virtual network address space" 160 | default = "10.0.0.0/24" 161 | } 162 | 163 | variable "subnets_and_sizes" { 164 | type = map(number) 165 | description = "The size of the subnets" 166 | default = { 167 | agents = 27 168 | private_endpoints = 29 169 | } 170 | } 171 | 172 | variable "approvers" { 173 | type = map(string) 174 | default = {} 175 | } 176 | 177 | variable "example_module_path" { 178 | type = string 179 | default = "../example-module" 180 | } 181 | 182 | variable "repository_postfix" { 183 | type = string 184 | default = "demo" 185 | } 186 | 187 | variable "repository_postfix_template" { 188 | type = string 189 | default = "demo-template" 190 | } 191 | 192 | variable "agent_use_availability_zones" { 193 | type = bool 194 | default = false 195 | description = "Use availability zones for the agent pool if using contaienr instances. This is off by default due to faults in various regions at time of authoring." 196 | } 197 | -------------------------------------------------------------------------------- /example-module/.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | crash.*.log 11 | 12 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 13 | # password, private keys, and other secrets. These should not be part of version 14 | # control as they are data points which are potentially sensitive and subject 15 | # to change depending on the environment. 16 | #*.tfvars 17 | *.tfvars.json 18 | 19 | # Ignore override files as they are usually used to override resources locally and so 20 | # are not checked in 21 | override.tf 22 | override.tf.json 23 | *_override.tf 24 | *_override.tf.json 25 | 26 | # Ignore transient lock info files created by terraform apply 27 | .terraform.tfstate.lock.info 28 | 29 | # Include override files you do wish to add to version control using negated pattern 30 | # !example_override.tf 31 | 32 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 33 | # example: *tfplan* 34 | 35 | # Ignore CLI configuration files 36 | .terraformrc 37 | terraform.rc 38 | 39 | .terraform.lock.hcl 40 | -------------------------------------------------------------------------------- /example-module/config/dev.tfvars: -------------------------------------------------------------------------------- 1 | location = "uksouth" 2 | resource_name_workload = "demo" 3 | resource_name_environment = "dev" 4 | virtual_network_address_space = ["10.0.0.0/16"] 5 | virtual_network_subnets = { 6 | example = { 7 | name = "example" 8 | address_prefixes = ["10.0.0.0/24"] 9 | } 10 | } 11 | virtual_machine_sku = "Standard_B1ls" 12 | tags = { 13 | deployed_by = "terraform" 14 | environment = "dev" 15 | } 16 | -------------------------------------------------------------------------------- /example-module/config/prod.tfvars: -------------------------------------------------------------------------------- 1 | location = "uksouth" 2 | resource_name_workload = "demo" 3 | resource_name_environment = "prod" 4 | virtual_network_address_space = ["10.2.0.0/16"] 5 | virtual_network_subnets = { 6 | example = { 7 | name = "example" 8 | address_prefixes = ["10.2.0.0/24"] 9 | } 10 | } 11 | virtual_machine_sku = "Standard_B1ls" 12 | tags = { 13 | deployed_by = "terraform" 14 | environment = "prod" 15 | } 16 | -------------------------------------------------------------------------------- /example-module/config/test.tfvars: -------------------------------------------------------------------------------- 1 | location = "uksouth" 2 | resource_name_workload = "demo" 3 | resource_name_environment = "test" 4 | virtual_network_address_space = ["10.1.0.0/16"] 5 | virtual_network_subnets = { 6 | example = { 7 | name = "example" 8 | address_prefixes = ["10.1.0.0/24"] 9 | } 10 | } 11 | virtual_machine_sku = "Standard_B1ls" 12 | tags = { 13 | deployed_by = "terraform" 14 | environment = "test" 15 | } 16 | -------------------------------------------------------------------------------- /example-module/locals.tf: -------------------------------------------------------------------------------- 1 | # Calculate resource names 2 | locals { 3 | name_replacements = { 4 | workload = var.resource_name_workload 5 | environment = var.resource_name_environment 6 | location = var.location 7 | sequence = format("%03d", var.resource_name_sequence_start) 8 | } 9 | 10 | resource_names = { for key, value in var.resource_name_templates : key => templatestring(value, local.name_replacements) } 11 | } 12 | 13 | locals { 14 | resource_group_name = var.resource_group_create ? module.resource_group[0].name : var.resource_group_name 15 | } -------------------------------------------------------------------------------- /example-module/main.tf: -------------------------------------------------------------------------------- 1 | module "resource_group" { 2 | count = var.resource_group_create ? 1 : 0 3 | source = "Azure/avm-res-resources-resourcegroup/azurerm" 4 | version = "0.2.1" 5 | location = var.location 6 | name = local.resource_names.resource_group_name 7 | tags = var.tags 8 | } 9 | 10 | module "virtual_network" { 11 | source = "Azure/avm-res-network-virtualnetwork/azurerm" 12 | version = "0.8.1" 13 | 14 | resource_group_name = local.resource_group_name 15 | location = var.location 16 | name = local.resource_names.virtual_network_name 17 | subnets = var.virtual_network_subnets 18 | address_space = var.virtual_network_address_space 19 | tags = var.tags 20 | } 21 | 22 | module "virtual_machine" { 23 | source = "Azure/avm-res-compute-virtualmachine/azurerm" 24 | version = "0.18.1" 25 | 26 | resource_group_name = local.resource_group_name 27 | os_type = "linux" 28 | name = local.resource_names.virtual_machine_name 29 | sku_size = var.virtual_machine_sku 30 | location = var.location 31 | zone = "1" 32 | encryption_at_host_enabled = false 33 | 34 | source_image_reference = { 35 | publisher = "Canonical" 36 | offer = "0001-com-ubuntu-server-jammy" 37 | sku = "22_04-lts-gen2" 38 | version = "latest" 39 | } 40 | 41 | network_interfaces = { 42 | private = { 43 | name = local.resource_names.network_interface_name 44 | ip_configurations = { 45 | private = { 46 | name = local.resource_names.network_interface_name 47 | private_ip_subnet_resource_id = module.virtual_network.subnets["example"].resource_id 48 | } 49 | } 50 | } 51 | } 52 | 53 | tags = var.tags 54 | } 55 | -------------------------------------------------------------------------------- /example-module/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | source = "hashicorp/azurerm" 5 | version = "~> 4.20" 6 | } 7 | 8 | } 9 | backend "azurerm" {} 10 | } 11 | 12 | provider "azurerm" { 13 | resource_provider_registrations = "core" 14 | features {} 15 | } 16 | -------------------------------------------------------------------------------- /example-module/variables.tf: -------------------------------------------------------------------------------- 1 | variable "resource_group_name" { 2 | type = string 3 | default = null 4 | } 5 | 6 | variable "resource_group_create" { 7 | type = bool 8 | default = false 9 | } 10 | 11 | variable "location" { 12 | type = string 13 | description = "The location/region where the resources will be created. Must be in the short form (e.g. 'uksouth')" 14 | validation { 15 | condition = can(regex("^[a-z0-9-]+$", var.location)) 16 | error_message = "The location must only contain lowercase letters, numbers, and hyphens" 17 | } 18 | validation { 19 | condition = length(var.location) <= 20 20 | error_message = "The location must be 20 characters or less" 21 | } 22 | } 23 | 24 | variable "resource_name_workload" { 25 | type = string 26 | description = "The name segment for the workload" 27 | validation { 28 | condition = can(regex("^[a-z0-9]+$", var.resource_name_workload)) 29 | error_message = "The name segment for the workload must only contain lowercase letters and numbers" 30 | } 31 | validation { 32 | condition = length(var.resource_name_workload) <= 4 33 | error_message = "The name segment for the workload must be 4 characters or less" 34 | } 35 | } 36 | 37 | variable "resource_name_environment" { 38 | type = string 39 | description = "The name segment for the environment" 40 | validation { 41 | condition = can(regex("^[a-z0-9]+$", var.resource_name_environment)) 42 | error_message = "The name segment for the environment must only contain lowercase letters and numbers" 43 | } 44 | validation { 45 | condition = length(var.resource_name_environment) <= 4 46 | error_message = "The name segment for the environment must be 4 characters or less" 47 | } 48 | } 49 | 50 | variable "resource_name_sequence_start" { 51 | type = number 52 | description = "The number to use for the resource names" 53 | default = 1 54 | validation { 55 | condition = var.resource_name_sequence_start >= 1 && var.resource_name_sequence_start <= 999 56 | error_message = "The number must be between 1 and 999" 57 | } 58 | } 59 | 60 | variable "resource_name_templates" { 61 | type = map(string) 62 | description = "A map of resource names to use" 63 | default = { 64 | resource_group_name = "rg-$${workload}-$${environment}-$${location}-$${sequence}" 65 | virtual_network_name = "vnet-$${workload}-$${environment}-$${location}-$${sequence}" 66 | virtual_machine_name = "vm-$${workload}-$${environment}-$${location}-$${sequence}" 67 | network_interface_name = "nic-$${workload}-$${environment}-$${location}-$${sequence}" 68 | } 69 | } 70 | 71 | variable "virtual_network_address_space" { 72 | type = list(string) 73 | } 74 | 75 | variable "virtual_network_subnets" { 76 | type = map(object({ 77 | name = string 78 | address_prefixes = list(string) 79 | })) 80 | } 81 | 82 | variable "virtual_machine_sku" { 83 | type = string 84 | } 85 | 86 | variable "tags" { 87 | type = map(string) 88 | } 89 | -------------------------------------------------------------------------------- /pipelines/main/cd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | trigger: 3 | branches: 4 | include: 5 | - main 6 | 7 | resources: 8 | repositories: 9 | - repository: templates 10 | type: git 11 | name: ${project_name}/${repository_name_templates} 12 | 13 | parameters: 14 | - name: environment 15 | displayName: 'Choose Environment' 16 | type: string 17 | default: All 18 | values: 19 | - All 20 | %{ for environment in environments ~} 21 | - ${environment.name} 22 | %{ endfor ~} 23 | - name: terraform_action 24 | displayName: Terraform Action to perform 25 | type: string 26 | default: 'apply' 27 | values: 28 | - 'apply' 29 | - 'destroy' 30 | - name: terraform_cli_version 31 | displayName: Terraform CLI Version 32 | type: string 33 | default: 'latest' 34 | 35 | lockBehavior: sequential 36 | 37 | extends: 38 | template: ${cd_template_path}@templates 39 | parameters: 40 | terraform_action: $${{ parameters.terraform_action }} 41 | root_module_folder_relative_path: ${root_module_folder_relative_path} 42 | environment: $${{ parameters.environment }} 43 | terraform_cli_version: $${{ coalesce(parameters.terraform_cli_version, 'latest') }} 44 | environments: 45 | %{ for environment in environments ~} 46 | - name: ${environment.name} 47 | display_name: ${environment.display_name} 48 | service_connection_name_plan: ${environment.service_connection_name_plan} 49 | service_connection_name_apply: ${environment.service_connection_name_apply} 50 | variable_group_name: ${environment.variable_group_name} 51 | agent_pool_type: ${environment.agent_pool_type} 52 | agent_pool_name: ${environment.agent_pool_name} 53 | environment_name: ${environment.environment_name} 54 | dependent_environment: ${environment.dependent_environment} 55 | %{ endfor ~} 56 | -------------------------------------------------------------------------------- /pipelines/main/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | trigger: 3 | - none 4 | 5 | resources: 6 | repositories: 7 | - repository: templates 8 | type: git 9 | name: ${project_name}/${repository_name_templates} 10 | 11 | parameters: 12 | - name: terraform_cli_version 13 | displayName: Terraform CLI Version 14 | type: string 15 | default: 'latest' 16 | 17 | lockBehavior: sequential 18 | 19 | extends: 20 | template: ${ci_template_path}@templates 21 | parameters: 22 | root_module_folder_relative_path: ${root_module_folder_relative_path} 23 | terraform_cli_version: $${{ coalesce(parameters.terraform_cli_version, 'latest') }} 24 | environments: 25 | %{ for environment in environments ~} 26 | - name: ${environment.name} 27 | display_name: ${environment.display_name} 28 | service_connection_name_plan: ${environment.service_connection_name_plan} 29 | service_connection_name_apply: ${environment.service_connection_name_apply} 30 | variable_group_name: ${environment.variable_group_name} 31 | agent_pool_type: ${environment.agent_pool_type} 32 | agent_pool_name: ${environment.agent_pool_name} 33 | environment_name: ${environment.environment_name} 34 | dependent_environment: ${environment.dependent_environment} 35 | %{ endfor ~} 36 | -------------------------------------------------------------------------------- /pipelines/templates/cd-template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: terraform_action 4 | default: 'apply' 5 | - name: environment 6 | default: All 7 | - name: environments 8 | type: object 9 | default: [] 10 | - name: root_module_folder_relative_path 11 | default: '.' 12 | - name: terraform_cli_version 13 | default: 'latest' 14 | 15 | stages: 16 | - ${{ each environment in parameters.environments }}: 17 | - ${{ if or(eq(environment.name, parameters.environment), eq(parameters.environment, 'All')) }}: 18 | - stage: ${{ environment.name }}_plan 19 | displayName: ${{ environment.display_name }} Plan 20 | variables: 21 | - group: ${{ environment.variable_group_name }} 22 | jobs: 23 | - deployment: plan 24 | displayName: Plan with Terraform 25 | pool: 26 | ${{ if eq(environment.agent_pool_type, 'self-hosted') }}: 27 | name: ${{ environment.agent_pool_name }} 28 | ${{ if eq(environment.agent_pool_type, 'microsoft-hosted') }}: 29 | vmImage: ${{ environment.agent_pool_name }} 30 | environment: ${{ environment.environment_name }} 31 | timeoutInMinutes: 0 32 | strategy: 33 | runOnce: 34 | deploy: 35 | steps: 36 | - checkout: self 37 | displayName: Checkout Terraform Module 38 | - template: helpers/terraform-installer.yaml 39 | parameters: 40 | terraformVersion: ${{ parameters.terraform_cli_version }} 41 | - template: helpers/terraform-init.yaml 42 | parameters: 43 | serviceConnection: ${{ environment.service_connection_name_plan }} 44 | backendAzureResourceGroupName: $(BACKEND_AZURE_RESOURCE_GROUP_NAME) 45 | backendAzureStorageAccountName: $(BACKEND_AZURE_STORAGE_ACCOUNT_NAME) 46 | backendAzureStorageAccountContainerName: $(BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME) 47 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 48 | - template: helpers/terraform-plan.yaml 49 | parameters: 50 | terraform_action: ${{ parameters.terraform_action }} 51 | serviceConnection: ${{ environment.service_connection_name_plan }} 52 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 53 | additionalVariables: $(ADDITIONAL_ENVIRONMENT_VARIABLES) 54 | varFilePath: $(VAR_FILE_PATH) 55 | - task: CopyFiles@2 56 | displayName: Create Module Artifact 57 | inputs: 58 | SourceFolder: '$(Build.SourcesDirectory)' 59 | Contents: | 60 | **/* 61 | !.terraform/**/* 62 | !.git/**/* 63 | !.pipelines/**/* 64 | !**/.terraform/**/* 65 | !**/.git/**/* 66 | !**/.pipelines/**/* 67 | TargetFolder: '$(Build.ArtifactsStagingDirectory)' 68 | CleanTargetFolder: true 69 | OverWrite: true 70 | - task: PublishPipelineArtifact@1 71 | displayName: Publish Module Artifact 72 | inputs: 73 | targetPath: '$(Build.ArtifactsStagingDirectory)' 74 | artifact: 'module_${{ environment.name }}' 75 | publishLocation: 'pipeline' 76 | - pwsh: | 77 | terraform ` 78 | -chdir="${{ parameters.root_module_folder_relative_path }}" ` 79 | show ` 80 | tfplan 81 | displayName: Show the Plan for Review 82 | 83 | - ${{ if or(eq(environment.name, parameters.environment), eq(parameters.environment, 'All')) }}: 84 | - stage: ${{ environment.name }}_apply 85 | displayName: ${{environment.display_name }} Apply 86 | variables: 87 | - group: ${{ environment.variable_group_name }} 88 | jobs: 89 | - deployment: apply 90 | displayName: Apply with Terraform 91 | pool: 92 | ${{ if eq(environment.agent_pool_type, 'self-hosted') }}: 93 | name: ${{ environment.agent_pool_name }} 94 | ${{ if eq(environment.agent_pool_type, 'microsoft-hosted') }}: 95 | vmImage: ${{ environment.agent_pool_name }} 96 | environment: ${{ environment.environment_name }} 97 | timeoutInMinutes: 0 98 | strategy: 99 | runOnce: 100 | deploy: 101 | steps: 102 | - download: none 103 | - task: DownloadPipelineArtifact@2 104 | displayName: Download Module Artifact 105 | inputs: 106 | buildType: 'current' 107 | artifactName: 'module_${{ environment.name }}' 108 | targetPath: '$(Build.SourcesDirectory)' 109 | - template: helpers/terraform-installer.yaml 110 | parameters: 111 | terraformVersion: ${{ parameters.terraform_cli_version }} 112 | - template: helpers/terraform-init.yaml 113 | parameters: 114 | serviceConnection: ${{ environment.service_connection_name_apply }} 115 | backendAzureResourceGroupName: $(BACKEND_AZURE_RESOURCE_GROUP_NAME) 116 | backendAzureStorageAccountName: $(BACKEND_AZURE_STORAGE_ACCOUNT_NAME) 117 | backendAzureStorageAccountContainerName: $(BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME) 118 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 119 | - template: helpers/terraform-apply.yaml 120 | parameters: 121 | terraform_action: ${{ parameters.terraform_action }} 122 | serviceConnection: ${{ environment.service_connection_name_apply }} 123 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 124 | -------------------------------------------------------------------------------- /pipelines/templates/ci-template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: environments 4 | type: object 5 | default: [] 6 | - name: root_module_folder_relative_path 7 | default: '.' 8 | - name: terraform_cli_version 9 | default: 'latest' 10 | 11 | stages: 12 | - stage: validate 13 | displayName: Validation Terraform 14 | jobs: 15 | - job: validate 16 | displayName: Validate Terraform 17 | pool: 18 | ${{ if eq(parameters.environments[0].agent_pool_type, 'self-hosted') }}: 19 | name: ${{ parameters.environments[0].agent_pool_name }} 20 | ${{ if eq(parameters.environments[0].agent_pool_type, 'microsoft-hosted') }}: 21 | vmImage: ${{ parameters.environments[0].agent_pool_name }} 22 | steps: 23 | - template: helpers/terraform-installer.yaml 24 | parameters: 25 | terraformVersion: ${{ parameters.terraform_cli_version }} 26 | - pwsh: | 27 | terraform ` 28 | -chdir="${{ parameters.root_module_folder_relative_path }}" ` 29 | fmt ` 30 | -check 31 | displayName: Terraform Format Check 32 | - pwsh: | 33 | terraform ` 34 | -chdir="${{ parameters.root_module_folder_relative_path }}" ` 35 | init ` 36 | -backend=false 37 | displayName: Terraform Init 38 | - pwsh: | 39 | terraform ` 40 | -chdir="${{ parameters.root_module_folder_relative_path }}" ` 41 | validate 42 | displayName: Terraform Validate 43 | 44 | - ${{ each environment in parameters.environments }}: 45 | - deployment: ${{ environment.name }}_plan 46 | variables: 47 | - group: ${{ environment.variable_group_name }} 48 | dependsOn: validate 49 | displayName: Validate Terraform Plan for ${{ environment.display_name }} 50 | pool: 51 | ${{ if eq(environment.agent_pool_type, 'self-hosted') }}: 52 | name: ${{ environment.agent_pool_name }} 53 | ${{ if eq(environment.agent_pool_type, 'microsoft-hosted') }}: 54 | vmImage: ${{ environment.agent_pool_name }} 55 | environment: ${{ environment.environment_name }} 56 | timeoutInMinutes: 0 57 | strategy: 58 | runOnce: 59 | deploy: 60 | steps: 61 | - checkout: self 62 | displayName: Checkout Terraform Module 63 | - template: helpers/terraform-installer.yaml 64 | parameters: 65 | terraformVersion: ${{ parameters.terraform_cli_version }} 66 | - template: helpers/terraform-init.yaml 67 | parameters: 68 | serviceConnection: ${{ environment.service_connection_name_plan }} 69 | backendAzureResourceGroupName: $(BACKEND_AZURE_RESOURCE_GROUP_NAME) 70 | backendAzureStorageAccountName: $(BACKEND_AZURE_STORAGE_ACCOUNT_NAME) 71 | backendAzureStorageAccountContainerName: $(BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME) 72 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 73 | - template: helpers/terraform-plan.yaml 74 | parameters: 75 | serviceConnection: ${{ environment.service_connection_name_plan }} 76 | root_module_folder_relative_path: ${{ parameters.root_module_folder_relative_path }} 77 | additionalVariables: $(ADDITIONAL_ENVIRONMENT_VARIABLES) 78 | varFilePath: $(VAR_FILE_PATH) 79 | - pwsh: | 80 | terraform -chdir="${{ parameters.root_module_folder_relative_path }}" show -json tfplan > tfplan.json 81 | $planJson = Get-Content -Raw tfplan.json 82 | $planObject = ConvertFrom-Json $planJson -Depth 100 83 | 84 | $items = @{} 85 | foreach($change in $planObject.resource_changes) { 86 | $key = [System.String]::Join("-", $change.change.actions) 87 | if(!$items.ContainsKey($key)) { 88 | $items[$key] = 0 89 | } 90 | $items[$key]++ 91 | } 92 | 93 | Write-Host "Plan Summary" 94 | Write-Host (ConvertTo-Json $items -Depth 10) 95 | displayName: Terraform Plan Summary 96 | - task: PublishPipelineArtifact@1 97 | displayName: Publish Plan Artifact 98 | inputs: 99 | targetPath: 'tfplan.json' 100 | artifact: 'plan_${{ environment.name }}' 101 | publishLocation: 'pipeline' 102 | -------------------------------------------------------------------------------- /pipelines/templates/helpers/terraform-apply.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: terraform_action 4 | default: 'apply' 5 | - name: serviceConnection 6 | - name: root_module_folder_relative_path 7 | default: '.' 8 | 9 | steps: 10 | - task: AzureCLI@2 11 | displayName: Terraform Apply for ${{ coalesce(parameters.terraform_action, 'Apply') }} 12 | inputs: 13 | azureSubscription: ${{ parameters.serviceConnection }} 14 | scriptType: pscore 15 | scriptLocation: inlineScript 16 | inlineScript: | 17 | # Get settings from service connection 18 | $subscriptionId = $(az account show --query id -o tsv) 19 | 20 | # Logout of Azure CLI to prove we are not using that auth method 21 | az logout 22 | 23 | $env:ARM_TENANT_ID = $env:AZURESUBSCRIPTION_TENANT_ID 24 | $env:ARM_SUBSCRIPTION_ID = $subscriptionId 25 | $env:ARM_CLIENT_ID = $env:AZURESUBSCRIPTION_CLIENT_ID 26 | $env:ARM_OIDC_AZURE_SERVICE_CONNECTION_ID = $env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID 27 | $env:ARM_USE_OIDC = "true" 28 | 29 | # Run Terraform Apply 30 | $command = "terraform" 31 | $arguments = @() 32 | $arguments += "-chdir=${{ parameters.root_module_folder_relative_path }}" 33 | $arguments += "apply" 34 | $arguments += "-auto-approve" 35 | $arguments += "tfplan" 36 | Write-Host "Running: $command $arguments" 37 | & $command $arguments 38 | 39 | env: 40 | ARM_OIDC_REQUEST_TOKEN: $(System.AccessToken) 41 | -------------------------------------------------------------------------------- /pipelines/templates/helpers/terraform-init.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: serviceConnection 4 | - name: backendAzureResourceGroupName 5 | - name: backendAzureStorageAccountName 6 | - name: backendAzureStorageAccountContainerName 7 | - name: backendAzureStorageAccountContainerKeyName 8 | default: terraform.tfstate 9 | - name: root_module_folder_relative_path 10 | default: '.' 11 | 12 | steps: 13 | - task: AzureCLI@2 14 | displayName: 'Terraform Init' 15 | inputs: 16 | azureSubscription: ${{ parameters.serviceConnection }} 17 | scriptType: pscore 18 | scriptLocation: inlineScript 19 | inlineScript: | 20 | # Logout of Azure CLI to prove we are not using that auth method 21 | az logout 22 | 23 | $env:ARM_TENANT_ID = $env:AZURESUBSCRIPTION_TENANT_ID 24 | $env:ARM_CLIENT_ID = $env:AZURESUBSCRIPTION_CLIENT_ID 25 | $env:ARM_OIDC_AZURE_SERVICE_CONNECTION_ID = $env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID 26 | $env:ARM_USE_OIDC = "true" 27 | 28 | $arguments = @() 29 | $arguments += "-chdir=${{ parameters.root_module_folder_relative_path }}" 30 | $arguments += "init" 31 | $arguments += "-backend-config=storage_account_name=$($env:BACKEND_AZURE_STORAGE_ACCOUNT_NAME)" 32 | $arguments += "-backend-config=container_name=$($env:BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME)" 33 | $arguments += "-backend-config=key=$($env:BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_KEY_NAME)" 34 | $arguments += "-backend-config=use_azuread_auth=true" 35 | 36 | # Run terraform init 37 | $command = "terraform" 38 | Write-Host "Running: $command $arguments" 39 | & $command $arguments 40 | 41 | env: 42 | BACKEND_AZURE_RESOURCE_GROUP_NAME: ${{ parameters.backendAzureResourceGroupName }} 43 | BACKEND_AZURE_STORAGE_ACCOUNT_NAME: ${{ parameters.backendAzureStorageAccountName }} 44 | BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME: ${{ parameters.backendAzureStorageAccountContainerName }} 45 | BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_KEY_NAME: ${{ parameters.backendAzureStorageAccountContainerKeyName }} 46 | ARM_OIDC_REQUEST_TOKEN: $(System.AccessToken) 47 | -------------------------------------------------------------------------------- /pipelines/templates/helpers/terraform-installer.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: terraformVersion 4 | default: 'latest' 5 | 6 | steps: 7 | - pwsh: | 8 | $TF_VERSION = $env:TF_VERSION 9 | $TOOLS_PATH = $env:TOOLS_PATH 10 | 11 | Write-Host "Requested Terraform CLI version: $TF_VERSION" 12 | 13 | $release = $null 14 | 15 | if($TF_VERSION -eq "latest") { 16 | $versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20" 17 | if($versionResponse.StatusCode -ne "200") { 18 | throw "Unable to query Terraform version, please check your internet connection and try again..." 19 | } 20 | $releases = ($versionResponse).Content | ConvertFrom-Json | Where-Object -Property is_prerelease -EQ $false 21 | $release = $releases[0] 22 | $TF_VERSION = $releases[0].version 23 | } else { 24 | $versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform/$($TF_VERSION)" 25 | if($versionResponse.StatusCode -ne "200") { 26 | throw "Unable to query Terraform version, please check the supplied version and try again..." 27 | } 28 | $release = ($versionResponse).Content | ConvertFrom-Json 29 | } 30 | 31 | $commandDetails = Get-Command -Name terraform -ErrorAction SilentlyContinue 32 | if($commandDetails) { 33 | Write-Host "Terraform already installed in $($commandDetails.Path), checking version" 34 | $installedVersion = terraform version -json | ConvertFrom-Json 35 | Write-Host "Installed version: $($installedVersion.terraform_version) on $($installedVersion.platform)" 36 | if($installedVersion.terraform_version -eq $TF_VERSION) { 37 | Write-Host "Installed version matches required version $TF_VERSION, skipping install" 38 | return 39 | } 40 | } 41 | 42 | $unzipdir = Join-Path -Path $TOOLS_PATH -ChildPath "terraform_$TF_VERSION" 43 | if (Test-Path $unzipdir) { 44 | Write-Host "Terraform $TF_VERSION already installed." 45 | if($os -eq "windows") { 46 | $env:PATH = "$($unzipdir);$env:PATH" 47 | } else { 48 | $env:PATH = "$($unzipdir):$env:PATH" 49 | } 50 | Write-Host "##vso[task.setvariable variable=PATH]$env:PATH" 51 | return 52 | } 53 | 54 | $os = "" 55 | if ($IsWindows) { 56 | $os = "windows" 57 | } 58 | if($IsLinux) { 59 | $os = "linux" 60 | } 61 | if($IsMacOS) { 62 | $os = "darwin" 63 | } 64 | 65 | # Enum values can be seen here: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.architecture?view=net-7.0#fields 66 | $architecture = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture).ToString().ToLower() 67 | 68 | if($architecture -eq "x64") { 69 | $architecture = "amd64" 70 | } 71 | if($architecture -eq "x86") { 72 | $architecture = "386" 73 | } 74 | 75 | $osAndArchitecture = "$($os)_$($architecture)" 76 | 77 | $supportedOsAndArchitectures = @( 78 | "darwin_amd64", 79 | "darwin_arm64", 80 | "linux_386", 81 | "linux_amd64", 82 | "linux_arm64", 83 | "windows_386", 84 | "windows_amd64" 85 | ) 86 | 87 | if($supportedOsAndArchitectures -notcontains $osAndArchitecture) { 88 | Write-Error "Unsupported OS and architecture combination: $osAndArchitecture" 89 | exit 1 90 | } 91 | 92 | $zipfilePath = "$unzipdir.zip" 93 | 94 | $url = $release.builds | Where-Object { $_.arch -eq $architecture -and $_.os -eq $os } | Select-Object -First 1 -ExpandProperty url 95 | 96 | if(!(Test-Path $TOOLS_PATH)) { 97 | New-Item -ItemType Directory -Path $TOOLS_PATH| Out-String | Write-Verbose 98 | } 99 | 100 | Invoke-WebRequest -Uri $url -OutFile "$zipfilePath" | Out-String | Write-Verbose 101 | 102 | Expand-Archive -Path $zipfilePath -DestinationPath $unzipdir 103 | 104 | $toolFileName = "terraform" 105 | 106 | if($os -eq "windows") { 107 | $toolFileName = "$($toolFileName).exe" 108 | } 109 | 110 | $toolFilePath = Join-Path -Path $unzipdir -ChildPath $toolFileName 111 | 112 | if($os -ne "windows") { 113 | $isExecutable = $(test -x $toolFilePath; 0 -eq $LASTEXITCODE) 114 | if(!($isExecutable)) { 115 | chmod +x $toolFilePath 116 | } 117 | } 118 | 119 | if($os -eq "windows") { 120 | $env:PATH = "$($unzipdir);$env:PATH" 121 | } else { 122 | $env:PATH = "$($unzipdir):$env:PATH" 123 | } 124 | Write-Host "##vso[task.setvariable variable=PATH]$env:PATH" 125 | Remove-Item $zipfilePath 126 | Write-Host "Installed Terraform CLI version: $TF_VERSION" 127 | 128 | displayName: Install Terraform 129 | env: 130 | TF_VERSION: ${{ parameters.terraformVersion }} 131 | TOOLS_PATH: $(Agent.ToolsDirectory) 132 | -------------------------------------------------------------------------------- /pipelines/templates/helpers/terraform-plan.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | parameters: 3 | - name: terraform_action 4 | default: 'apply' 5 | - name: serviceConnection 6 | - name: root_module_folder_relative_path 7 | default: '.' 8 | - name: additionalVariables 9 | default: '{}' 10 | - name: varFilePath 11 | default: '' 12 | 13 | steps: 14 | - task: AzureCLI@2 15 | displayName: Terraform Plan for ${{ coalesce(parameters.terraform_action, 'Apply') }} 16 | inputs: 17 | azureSubscription: ${{ parameters.serviceConnection }} 18 | scriptType: pscore 19 | scriptLocation: inlineScript 20 | inlineScript: | 21 | $additionalVariables = ConvertFrom-Json '${{ parameters.additionalVariables }}' 22 | foreach($var in $additionalVariables.PSObject.Properties) { 23 | if($var.Name.StartsWith("TF_VAR_")) { 24 | Write-Host "Setting: $($var.Name) = $($var.Value)" 25 | [System.Environment]::SetEnvironmentVariable($var.Name, $var.Value) 26 | } 27 | } 28 | 29 | $varFilePath = "${{ parameters.varFilePath }}" 30 | 31 | # Get settings from service connection 32 | $subscriptionId = $(az account show --query id -o tsv) 33 | 34 | # Logout of Azure CLI to prove we are not using that auth method 35 | az logout 36 | 37 | $env:ARM_TENANT_ID = $env:AZURESUBSCRIPTION_TENANT_ID 38 | $env:ARM_SUBSCRIPTION_ID = $subscriptionId 39 | $env:ARM_CLIENT_ID = $env:AZURESUBSCRIPTION_CLIENT_ID 40 | $env:ARM_OIDC_AZURE_SERVICE_CONNECTION_ID = $env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID 41 | $env:ARM_USE_OIDC = "true" 42 | 43 | # Run Terraform Plan 44 | $command = "terraform" 45 | $arguments = @() 46 | $arguments += "-chdir=${{ parameters.root_module_folder_relative_path }}" 47 | $arguments += "plan" 48 | 49 | if($varFilePath -ne "") { 50 | $arguments += "-var-file=$varFilePath" 51 | } 52 | 53 | $arguments += "-out=tfplan" 54 | $arguments += "-input=false" 55 | 56 | if ($env:TERRAFORM_ACTION -eq 'destroy') { 57 | $arguments += "-destroy" 58 | } 59 | 60 | Write-Host "Running: $command $arguments" 61 | & $command $arguments 62 | 63 | env: 64 | TERRAFORM_ACTION: ${{ coalesce(parameters.terraform_action, 'apply') }} 65 | ARM_OIDC_REQUEST_TOKEN: $(System.AccessToken) 66 | --------------------------------------------------------------------------------