├── .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 |
--------------------------------------------------------------------------------