Terraform and Azure DevOps: Awesome.

Published: Mar 5, 2021 by Isaac Johnson

It’s always fun when two things I really like come together. I was thrilled to see the news out of Ignite from one my favourite companies, Hashicorp that it added support for one of my favourite tools, Azure DevOps in Terraform.  I jumped on the chance to get this a try.

Update Terraform

You can check your TF version locally:

$ terraform -version

Your version of Terraform is out of date! The latest version
is 0.14.7. You can update by downloading from https://www.terraform.io/downloads.html
Terraform v0.13.0

So we can update with brew (for mac)

$ brew tap hashicorp/tap
Error: homebrew-core is a shallow clone. To `brew update` first run:
  git -C "/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core" fetch --unshallow
This restriction has been made on GitHub's request because updating shallow
clones is an extremely expensive operation due to the tree layout and traffic of
Homebrew/homebrew-core. We don't do this for you automatically to avoid
repeatedly performing an expensive unshallow operation in CI systems (which
should instead be fixed to not use shallow clones). Sorry for the inconvenience!
==> Tapping hashicorp/tap
Cloning into '/usr/local/Homebrew/Library/Taps/hashicorp/homebrew-tap'...
remote: Enumerating objects: 56, done.
remote: Counting objects: 100% (56/56), done.
remote: Compressing objects: 100% (48/48), done.
remote: Total 868 (delta 23), reused 23 (delta 8), pack-reused 812
Receiving objects: 100% (868/868), 169.09 KiB | 2.11 MiB/s, done.
Resolving deltas: 100% (443/443), done.
Tapped 1 cask and 8 formulae (45 files, 256.0KB).

then once tapped, update

$ brew upgrade hashicorp/tap/terraform
Error: homebrew-core is a shallow clone. To `brew update` first run:
  git -C "/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core" fetch --unshallow
This restriction has been made on GitHub's request because updating shallow
clones is an extremely expensive operation due to the tree layout and traffic of
Homebrew/homebrew-core. We don't do this for you automatically to avoid
repeatedly performing an expensive unshallow operation in CI systems (which
should instead be fixed to not use shallow clones). Sorry for the inconvenience!
==> Upgrading 1 outdated package:
hashicorp/tap/terraform 0.13.0_1 -> 0.14.7
==> Upgrading hashicorp/tap/terraform 0.13.0_1 -> 0.14.7 
==> Downloading https://releases.hashicorp.com/terraform/0.14.7/terraform_0.14.7_darwin_amd64.zip
Already downloaded: /Users/johnsi10/Library/Caches/Homebrew/downloads/5bca2f75c0636f7a9db39b1344ab109f360a808256fd2fa4a577e81cda2ec9c3--terraform_0.14.7_darwin_amd64.zip
🍺 /usr/local/Cellar/terraform/0.14.7: 3 files, 79.3MB, built in 8 seconds
Removing: /usr/local/Cellar/terraform/0.13.0_1... (6 files, 67.5MB)

verify

$ terraform -version
Terraform v0.14.7
$ terraform -install-autocomplete

Create a TF block for our Azure DevOps org

First, we need a PAT for our Org. Then we could set

$ export AZDO_ORG_SERVICE_URL=”https://dev.azure.com/princessking”
$ export AZDO_PERSONAL_ACCESS_TOKEN=496224730a48430a93d1824e3658a0f3

Or we could pack it in the TF code

provider "azuredevops" {
  version = ">= 0.1.0"
  org_service_url = "https://dev.azure.com/princessking/"
  personal_access_token = "496224730a48430a93d1824e3658a0f3"
}

We’ll start with the latter.

terraform {
  required_providers {
    azuredevops = {
      source = "microsoft/azuredevops"
      version = ">=0.1.0"
    }
  }
}

provider "azuredevops" {
  version = ">= 0.1.0"
  org_service_url = "https://dev.azure.com/princessking/"
  personal_access_token = "496224730a48430a93d1824e3658a0f3"
}

resource "azuredevops_project" "project" {
  name = "IDJTestProject"
  description = "IDJ Test Description"
  visibility = "public"
  version_control = "Git"
  work_item_template = "Agile"
}

resource "azuredevops_git_repository" "repo" {
  project_id = azuredevops_project.project.id
  name = "MyGitREPO"
  initialization {
    init_type = "Clean"
  }
}

resource "azuredevops_user_entitlement" "user1" {
  principal_name = "tristan.cormac.moriarty@gmail.com"
  account_license_type = "basic"
}

resource "azuredevops_user_entitlement" "user2" {
  principal_name = "isaac.johnson@cdc.com"
  account_license_type = "stakeholder"
}

data "azuredevops_group" "group" {
  project_id = azuredevops_project.project.id
  name = "Build Administrators"
}

resource "azuredevops_group_membership" "membership" {
  group = data.azuredevops_group.group.descriptor
  members = [
    azuredevops_user_entitlement.user1.descriptor,
    azuredevops_user_entitlement.user2.descriptor
  ]
}

Use Terraform init to setup our connection

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding microsoft/azuredevops versions matching ">= 0.1.0"...
- Installing microsoft/azuredevops v0.1.2...
- Installed microsoft/azuredevops v0.1.2 (signed by a HashiCorp partner, key ID 6F0B91BDE98478CF)

Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.


Warning: Version constraints inside provider configuration blocks are deprecated

  on devops.tf line 12, in provider "azuredevops":
  12: version = ">= 0.1.0"

Terraform 0.13 and earlier allowed provider version constraints inside the
provider configuration block, but that is now deprecated and will be removed
in a future version of Terraform. To silence this warning, move the provider
version constraint into the required_providers block.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Then plan to allow Terraform to figure out what it needs to do.  This step will check the validity of our PAT

$ terraform plan -out plan

Error: Request returned status: 401 Unauthorized

  on devops.tf line 11, in provider "azuredevops":
  11: provider "azuredevops" {

As you can see our PAT expired, so we can use a new one. This time i’ll remove it from the block and use one locally:

$ export AZDO_PERSONAL_ACCESS_TOKEN=a3493c601c094afea3b810bca6261e2d
$ terraform plan -out plan

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # data.azuredevops_group.group will be read during apply
  # (config refers to values not yet known)
 <= data "azuredevops_group" "group" {
      + descriptor = (known after apply)
      + id = (known after apply)
      + name = "Build Administrators"
      + origin = (known after apply)
      + origin_id = (known after apply)
      + project_id = (known after apply)
    }

  # azuredevops_git_repository.repo will be created
  + resource "azuredevops_git_repository" "repo" {
      + default_branch = (known after apply)
      + id = (known after apply)
      + is_fork = (known after apply)
      + name = "MyGitREPO"
      + project_id = (known after apply)
      + remote_url = (known after apply)
      + size = (known after apply)
      + ssh_url = (known after apply)
      + url = (known after apply)
      + web_url = (known after apply)

      + initialization {
          + init_type = "Clean"
        }
    }

  # azuredevops_group_membership.membership will be created
  + resource "azuredevops_group_membership" "membership" {
      + group = (known after apply)
      + id = (known after apply)
      + members = (known after apply)
      + mode = "add"
    }

  # azuredevops_project.project will be created
  + resource "azuredevops_project" "project" {
      + description = "IDJ Test Description"
      + id = (known after apply)
      + name = "IDJTestProject"
      + process_template_id = (known after apply)
      + version_control = "Git"
      + visibility = "private"
      + work_item_template = "Agile"
    }

  # azuredevops_user_entitlement.user1 will be created
  + resource "azuredevops_user_entitlement" "user1" {
      + account_license_type = "basic"
      + descriptor = (known after apply)
      + id = (known after apply)
      + licensing_source = "account"
      + origin = (known after apply)
      + origin_id = (known after apply)
      + principal_name = "tristan.cormac.moriarty@gmail.com"
    }

  # azuredevops_user_entitlement.user2 will be created
  + resource "azuredevops_user_entitlement" "user2" {
      + account_license_type = "stakeholder"
      + descriptor = (known after apply)
      + id = (known after apply)
      + licensing_source = "account"
      + origin = (known after apply)
      + origin_id = (known after apply)
      + principal_name = "isaac.johnson@cdc.com"
    }

Plan: 5 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

This plan was saved to: plan

To perform exactly these actions, run the following command to apply:
    terraform apply "plan"

Then Apply to apply it.  This step will create and update resources.

$ terraform apply "plan"
azuredevops_user_entitlement.user1: Creating...
azuredevops_project.project: Creating...
azuredevops_user_entitlement.user2: Creating...
azuredevops_user_entitlement.user1: Creation complete after 2s [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_user_entitlement.user2: Creation complete after 2s [id=aa6bd2e7-a889-464f-aebc-b1a60408f0af]
azuredevops_project.project: Creation complete after 6s [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
data.azuredevops_group.group: Reading...
azuredevops_git_repository.repo: Creating...
data.azuredevops_group.group: Read complete after 1s [id=vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg]
azuredevops_group_membership.membership: Creating...
azuredevops_git_repository.repo: Creation complete after 2s [id=220e7ed9-19a2-4306-a401-f3d1d28afcea]
azuredevops_group_membership.membership: Still creating... [10s elapsed]
azuredevops_group_membership.membership: Creation complete after 16s [id=894385949183117216]

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

Validation

We can see our new private project:

Now what if we change a user, and perhaps set a project to public?

$ git diff
diff --git a/devops.tf b/devops.tf
index e41c127..bf7247a 100644
--- a/devops.tf
+++ b/devops.tf
@@ -15,7 +15,7 @@ provider "azuredevops" {
 resource "azuredevops_project" "project" {
   name = "IDJTestProject"
   description = "IDJ Test Description"
- visibility = "private"
+ visibility = "public"
   version_control = "Git"
   work_item_template = "Agile"
 }
@@ -30,7 +30,7 @@ resource "azuredevops_git_repository" "repo" {
 
 resource "azuredevops_user_entitlement" "user1" {
   principal_name = "tristan.cormac.moriarty@gmail.com"
- account_license_type = "basic"
+ account_license_type = "stakeholder"
 }
 
 resource "azuredevops_user_entitlement" "user2" {

When we plan:

$ terraform plan -out plan
azuredevops_user_entitlement.user2: Refreshing state... [id=aa6bd2e7-a889-464f-aebc-b1a60408f0af]
azuredevops_project.project: Refreshing state... [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
azuredevops_user_entitlement.user1: Refreshing state... [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_git_repository.repo: Refreshing state... [id=220e7ed9-19a2-4306-a401-f3d1d28afcea]
azuredevops_group_membership.membership: Refreshing state... [id=894385949183117216]

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  ~ update in-place
-/+ destroy and then create replacement
 <= read (data resources)

Terraform will perform the following actions:

  # data.azuredevops_group.group will be read during apply
  # (config refers to values not yet known)
 <= data "azuredevops_group" "group" {
      ~ descriptor = "vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg" -> (known after apply)
      ~ id = "vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg" -> (known after apply)
        name = "Build Administrators"
      ~ origin = "vsts" -> (known after apply)
      ~ origin_id = "3d7c5533-219d-4486-b7a7-45897b28e6dd" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

  # azuredevops_group_membership.membership must be replaced
-/+ resource "azuredevops_group_membership" "membership" {
      ~ group = "vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg" -> (known after apply) # forces replacement
      ~ id = "894385949183117216" -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # azuredevops_project.project will be updated in-place
  ~ resource "azuredevops_project" "project" {
        id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2"
        name = "IDJTestProject"
      ~ visibility = "private" -> "public"
        # (5 unchanged attributes hidden)
    }

  # azuredevops_user_entitlement.user1 will be updated in-place
  ~ resource "azuredevops_user_entitlement" "user1" {
      ~ account_license_type = "express" -> "stakeholder"
        id = "92480170-cc1b-454d-aee9-f54f788b9cbb"
        # (5 unchanged attributes hidden)
    }

Plan: 1 to add, 2 to change, 1 to destroy.

------------------------------------------------------------------------

This plan was saved to: plan

To perform exactly these actions, run the following command to apply:
    terraform apply "plan"

We see it now will note the group ID.. and then change the project and user.

$ terraform apply "plan"
azuredevops_group_membership.membership: Destroying... [id=894385949183117216]
azuredevops_user_entitlement.user1: Modifying... [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_project.project: Modifying... [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
azuredevops_group_membership.membership: Destruction complete after 1s
azuredevops_project.project: Modifications complete after 2s [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
data.azuredevops_group.group: Reading... [id=vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg]
data.azuredevops_group.group: Read complete after 0s [id=vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg]
azuredevops_user_entitlement.user1: Modifications complete after 3s [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_group_membership.membership: Creating...
azuredevops_group_membership.membership: Still creating... [10s elapsed]
azuredevops_group_membership.membership: Creation complete after 16s [id=894385949183117216]

Apply complete! Resources: 1 added, 2 changed, 1 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

Verification

Now let us assume we wish to expand this further…..

Let’s now add a Variable Group (Library) and build definition

resource "azuredevops_variable_group" "vars" {
  project_id = azuredevops_project.project.id
  name = "Infrastructure Pipeline Variables"
  description = "Managed by Terraform"
  allow_access = true

  variable {
    name = "myusername"
    value = "isaac"
  }

  variable {
    name = "mypassword"
    secret_value = "p@ssword123"
    is_secret = true
  }
}

resource "azuredevops_build_definition" "build" {
  project_id = azuredevops_project.project.id
  name = "Sample Build Definition"
  path = "\\templates"

  ci_trigger {
    use_yaml = true
  }

  repository {
    repo_type = "TfsGit"
    repo_id = azuredevops_git_repository.repo.id
    branch_name = azuredevops_git_repository.repo.default_branch
    yml_path = "azure-pipelines.yml"
  }

  variable_groups = [
    azuredevops_variable_group.vars.id
  ]

  variable {
    name = "PipelineVariable"
    value = "Go Microsoft!"
  }

  variable {
    name = "PipelineSecret"
    secret_value = "ZGV2cw"
    is_secret = true
  }
}

Plan it

$ terraform plan -out plan
azuredevops_project.project: Refreshing state... [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
azuredevops_user_entitlement.user1: Refreshing state... [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_user_entitlement.user2: Refreshing state... [id=aa6bd2e7-a889-464f-aebc-b1a60408f0af]
azuredevops_git_repository.repo: Refreshing state... [id=220e7ed9-19a2-4306-a401-f3d1d28afcea]
azuredevops_group_membership.membership: Refreshing state... [id=894385949183117216]

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azuredevops_build_definition.build will be created
  + resource "azuredevops_build_definition" "build" {
      + agent_pool_name = "Hosted Ubuntu 1604"
      + id = (known after apply)
      + name = "Sample Build Definition"
      + path = "\\templates"
      + project_id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2"
      + revision = (known after apply)
      + variable_groups = (known after apply)

      + ci_trigger {
          + use_yaml = true
        }

      + repository {
          + branch_name = "refs/heads/master"
          + repo_id = "220e7ed9-19a2-4306-a401-f3d1d28afcea"
          + repo_type = "TfsGit"
          + report_build_status = true
          + yml_path = "azure-pipelines.yml"
        }

      + variable {
          + allow_override = true
          + is_secret = false
          + name = "PipelineVariable"
          + value = "Go Microsoft!"
        }
      + variable {
          + allow_override = true
          + is_secret = true
          + name = "PipelineSecret"
          + secret_value = (sensitive value)
        }
    }

  # azuredevops_variable_group.vars will be created
  + resource "azuredevops_variable_group" "vars" {
      + allow_access = true
      + description = "Managed by Terraform"
      + id = (known after apply)
      + name = "Infrastructure Pipeline Variables"
      + project_id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2"

      + variable {
          + content_type = (known after apply)
          + enabled = (known after apply)
          + expires = (known after apply)
          + is_secret = false
          + name = "myusername"
          + value = "isaac"
        }
      + variable {
          + content_type = (known after apply)
          + enabled = (known after apply)
          + expires = (known after apply)
          + is_secret = true
          + name = "mypassword"
          + secret_value = (sensitive value)
        }
    }

Plan: 2 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

This plan was saved to: plan

To perform exactly these actions, run the following command to apply:
    terraform apply "plan"

And Apply

$ terraform apply "plan"
azuredevops_variable_group.vars: Creating...
azuredevops_variable_group.vars: Creation complete after 1s [id=19]
azuredevops_build_definition.build: Creating...
azuredevops_build_definition.build: Creation complete after 0s [id=75]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

And we can see the repo created:

And a build definition, albeit already empty

What about really onboarding a YAML build..

Using my nodejs example, i will need to set my Dockerhub user and pass:

- script: |
    echo $(docker-pass) | docker login -u $(docker-username) --password-stdin
    echo now tag
    docker tag idjohnson/node-demo idjohnson/idjdemo:$(Build.BuildId)
    docker tag idjohnson/node-demo idjohnson/idjdemo:$(Build.SourceVersion)
    docker tag idjohnson/node-demo idjohnson/idjdemo:latest
    docker push idjohnson/idjdemo
  displayName: 'Run a multi-line script'

We can see the changes:

$ git diff
diff --git a/devops.tf b/devops.tf
index d1216dd..79e592d 100644
--- a/devops.tf
+++ b/devops.tf
@@ -24,9 +24,11 @@ resource "azuredevops_git_repository" "repo" {
   project_id = azuredevops_project.project.id
   name = "MyGitREPO"
   initialization {
- init_type = "Clean"
+ init_type = "Import"
+ source_type = "Git"
+ source_url = "https://github.com/idjohnson/nodejs-image-demo.git”
   }
-}
+} 
 
 resource "azuredevops_variable_group" "vars" {
   project_id = azuredevops_project.project.id
@@ -49,7 +51,6 @@ resource "azuredevops_variable_group" "vars" {
 resource "azuredevops_build_definition" "build" {
   project_id = azuredevops_project.project.id
   name = "Sample Build Definition"
- path = "\\templates"
 
   ci_trigger {
     use_yaml = true
@@ -58,7 +59,7 @@ resource "azuredevops_build_definition" "build" {
   repository {
     repo_type = "TfsGit"
     repo_id = azuredevops_git_repository.repo.id
     branch_name = azuredevops_git_repository.repo.default_branch
     yml_path = "azure-pipelines.yml"
   }
 
@@ -67,13 +68,13 @@ resource "azuredevops_build_definition" "build" {
   ]
 
   variable {
- name = "PipelineVariable"
- value = "Go Microsoft!"
+ name = "docker-username"
+ value = "idjohnson"
   }
 
   variable {
- name = "PipelineSecret"
- secret_value = "ZGV2cw"
+ name = "docker-pass"
+ secret_value = "NotMyRealPasswordObviously"
     is_secret = true
   }
 }

We can now plan:

$ terraform plan -out plan
azuredevops_user_entitlement.user1: Refreshing state... [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_project.project: Refreshing state... [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
azuredevops_user_entitlement.user2: Refreshing state... [id=aa6bd2e7-a889-464f-aebc-b1a60408f0af]
azuredevops_variable_group.vars: Refreshing state... [id=19]
azuredevops_git_repository.repo: Refreshing state... [id=220e7ed9-19a2-4306-a401-f3d1d28afcea]
azuredevops_build_definition.build: Refreshing state... [id=75]
azuredevops_group_membership.membership: Refreshing state... [id=894385949183117216]

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # azuredevops_build_definition.build will be updated in-place
  ~ resource "azuredevops_build_definition" "build" {
        id = "75"
        name = "Sample Build Definition"
      ~ path = "\\templates" -> "\\"
        # (4 unchanged attributes hidden)


      ~ repository {
          ~ branch_name = "refs/heads/master" -> (known after apply)
          ~ repo_id = "220e7ed9-19a2-4306-a401-f3d1d28afcea" -> (known after apply)
            # (3 unchanged attributes hidden)
        }

      - variable {
          - allow_override = true -> null
          - is_secret = false -> null
          - name = "PipelineVariable" -> null
          - value = "Go Microsoft!" -> null
        }
      + variable {
          + allow_override = true
          + is_secret = false
          + name = "docker-username"
          + value = "idjohnson"
        }
      - variable {
          - allow_override = true -> null
          - is_secret = true -> null
          - name = "PipelineSecret" -> null
          - secret_value = (sensitive value)
        }
      + variable {
          + allow_override = true
          + is_secret = true
          + name = "docker-pass"
          + secret_value = (sensitive value)
        }
        # (1 unchanged block hidden)
    }

  # azuredevops_git_repository.repo must be replaced
-/+ resource "azuredevops_git_repository" "repo" {
      ~ default_branch = "refs/heads/master" -> (known after apply)
      ~ id = "220e7ed9-19a2-4306-a401-f3d1d28afcea" -> (known after apply)
      ~ is_fork = false -> (known after apply)
        name = "MyGitREPO"
      ~ remote_url = "https://princessking.visualstudio.com/IDJTestProject/_git/MyGitREPO" -> (known after apply)
      ~ size = 197 -> (known after apply)
      ~ ssh_url = "princessking@vs-ssh.visualstudio.com:v3/princessking/IDJTestProject/MyGitREPO" -> (known after apply)
      ~ url = "https://princessking.visualstudio.com/941e1874-6f2f-40c8-b9c8-6b26d5790ef2/_apis/git/repositories/220e7ed9-19a2-4306-a401-f3d1d28afcea" -> (known after apply)
      ~ web_url = "https://princessking.visualstudio.com/IDJTestProject/_git/MyGitREPO" -> (known after apply)
        # (1 unchanged attribute hidden)

      ~ initialization {
          ~ init_type = "Clean" -> "Import"
          + source_type = "Git" # forces replacement
          + source_url = "https://github.com/idjohnson/nodejs-image-demo.git" # forces replacement
        }
    }

Plan: 1 to add, 1 to change, 1 to destroy.

------------------------------------------------------------------------

This plan was saved to: plan

To perform exactly these actions, run the following command to apply:
    terraform apply "plan"

And apply

$ terraform apply "plan"
azuredevops_git_repository.repo: Destroying... [id=220e7ed9-19a2-4306-a401-f3d1d28afcea]
azuredevops_git_repository.repo: Destruction complete after 1s
azuredevops_git_repository.repo: Creating...
azuredevops_git_repository.repo: Creation complete after 1s [id=d247f80f-c264-4921-a1e0-47c5badb57ca]
azuredevops_build_definition.build: Modifying... [id=75]
azuredevops_build_definition.build: Modifications complete after 1s [id=75]

Apply complete! Resources: 1 added, 1 changed, 1 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

And we can see that works:

And the repo is fully populated:

And we can clearly see it populated the requisite variables:

Destroying it all!

Let’s now delete the whole lot of it:

$ terraform destroy

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # azuredevops_build_definition.build will be destroyed
  - resource "azuredevops_build_definition" "build" {
      - agent_pool_name = "Hosted Ubuntu 1604" -> null
      - id = "75" -> null
      - name = "Sample Build Definition" -> null
      - path = "\\" -> null
      - project_id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2" -> null
      - revision = 2 -> null
      - variable_groups = [
          - 19,
        ] -> null

      - ci_trigger {
          - use_yaml = true -> null
        }

      - repository {
          - branch_name = "refs/heads/master" -> null
          - repo_id = "d247f80f-c264-4921-a1e0-47c5badb57ca" -> null
          - repo_type = "TfsGit" -> null
          - report_build_status = true -> null
          - yml_path = "azure-pipelines.yml" -> null
        }

      - variable {
          - allow_override = true -> null
          - is_secret = false -> null
          - name = "docker-username" -> null
          - value = "idjohnson" -> null
        }
      - variable {
          - allow_override = true -> null
          - is_secret = true -> null
          - name = "docker-pass" -> null
          - secret_value = (sensitive value)
        }
    }

  # azuredevops_git_repository.repo will be destroyed
  - resource "azuredevops_git_repository" "repo" {
      - default_branch = "refs/heads/master" -> null
      - id = "d247f80f-c264-4921-a1e0-47c5badb57ca" -> null
      - is_fork = false -> null
      - name = "MyGitREPO" -> null
      - project_id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2" -> null
      - remote_url = "https://princessking.visualstudio.com/IDJTestProject/_git/MyGitREPO" -> null
      - size = 35386 -> null
      - ssh_url = "princessking@vs-ssh.visualstudio.com:v3/princessking/IDJTestProject/MyGitREPO" -> null
      - url = "https://princessking.visualstudio.com/941e1874-6f2f-40c8-b9c8-6b26d5790ef2/_apis/git/repositories/d247f80f-c264-4921-a1e0-47c5badb57ca" -> null
      - web_url = "https://princessking.visualstudio.com/IDJTestProject/_git/MyGitREPO" -> null

      - initialization {
          - init_type = "Import" -> null
          - source_type = "Git" -> null
          - source_url = "https://github.com/idjohnson/nodejs-image-demo.git" -> null
        }
    }

  # azuredevops_group_membership.membership will be destroyed
  - resource "azuredevops_group_membership" "membership" {
      - group = "vssgp.Uy0xLTktMTU1MTM3NDI0NS0yMjA4MDQ4MDYzLTM5NTM3ODMzNjgtMjQ1MDEzOTMxNi0zNzE5NTA1NTY4LTAtMC0wLTEtMg" -> null
      - id = "894385949183117216" -> null
      - members = [
          - "bnd.dXBuOldpbmRvd3MgTGl2ZSBJRFx0cmlzdGFuLmNvcm1hYy5tb3JpYXJ0eUBnbWFpbC5jb20",
          - "bnd.dXBuOldpbmRvd3MgTGl2ZSBJRFxpc2FhYy5qb2huc29uQGNkYy5jb20",
        ] -> null
      - mode = "add" -> null
    }

  # azuredevops_project.project will be destroyed
  - resource "azuredevops_project" "project" {
      - description = "IDJ Test Description" -> null
      - features = {} -> null
      - id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2" -> null
      - name = "IDJTestProject" -> null
      - process_template_id = "adcc42ab-9882-485e-a3ed-7678f01f66bc" -> null
      - version_control = "Git" -> null
      - visibility = "public" -> null
      - work_item_template = "Agile" -> null
    }

  # azuredevops_user_entitlement.user1 will be destroyed
  - resource "azuredevops_user_entitlement" "user1" {
      - account_license_type = "stakeholder" -> null
      - descriptor = "bnd.dXBuOldpbmRvd3MgTGl2ZSBJRFx0cmlzdGFuLmNvcm1hYy5tb3JpYXJ0eUBnbWFpbC5jb20" -> null
      - id = "92480170-cc1b-454d-aee9-f54f788b9cbb" -> null
      - licensing_source = "account" -> null
      - origin = "msa" -> null
      - origin_id = "0003400101F384EC" -> null
      - principal_name = "tristan.cormac.moriarty@gmail.com" -> null
    }

  # azuredevops_user_entitlement.user2 will be destroyed
  - resource "azuredevops_user_entitlement" "user2" {
      - account_license_type = "stakeholder" -> null
      - descriptor = "bnd.dXBuOldpbmRvd3MgTGl2ZSBJRFxpc2FhYy5qb2huc29uQGNkYy5jb20" -> null
      - id = "aa6bd2e7-a889-464f-aebc-b1a60408f0af" -> null
      - licensing_source = "account" -> null
      - origin = "msa" -> null
      - principal_name = "isaac.johnson@cdc.com" -> null
    }

  # azuredevops_variable_group.vars will be destroyed
  - resource "azuredevops_variable_group" "vars" {
      - allow_access = true -> null
      - description = "Managed by Terraform" -> null
      - id = "19" -> null
      - name = "Infrastructure Pipeline Variables" -> null
      - project_id = "941e1874-6f2f-40c8-b9c8-6b26d5790ef2" -> null

      - variable {
          - enabled = false -> null
          - is_secret = false -> null
          - name = "myusername" -> null
          - value = "isaac" -> null
        }
      - variable {
          - enabled = false -> null
          - is_secret = true -> null
          - name = "mypassword" -> null
          - secret_value = (sensitive value)
        }
    }

Plan: 0 to add, 0 to change, 7 to destroy.

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

azuredevops_group_membership.membership: Destroying... [id=894385949183117216]
azuredevops_build_definition.build: Destroying... [id=75]
azuredevops_group_membership.membership: Destruction complete after 1s
azuredevops_user_entitlement.user2: Destroying... [id=aa6bd2e7-a889-464f-aebc-b1a60408f0af]
azuredevops_user_entitlement.user1: Destroying... [id=92480170-cc1b-454d-aee9-f54f788b9cbb]
azuredevops_build_definition.build: Destruction complete after 1s
azuredevops_git_repository.repo: Destroying... [id=d247f80f-c264-4921-a1e0-47c5badb57ca]
azuredevops_variable_group.vars: Destroying... [id=19]
azuredevops_git_repository.repo: Destruction complete after 0s
azuredevops_variable_group.vars: Destruction complete after 0s
azuredevops_project.project: Destroying... [id=941e1874-6f2f-40c8-b9c8-6b26d5790ef2]
azuredevops_user_entitlement.user2: Destruction complete after 1s
azuredevops_user_entitlement.user1: Destruction complete after 1s
azuredevops_project.project: Destruction complete after 2s

Refreshing the pipeline page shows its now gone

And the project is now fully removed.

Summary

This is fantastic.  I can only assume the azuredevops provider will continue to grow.  There are a few minor things I cannot do - such as create a Variable Group (Library) backed by AKV.. But then i can easily think of workarounds - namely use a YAML AKS task and an Azure RM service connection - both pipelines and Azure RM Service connections can be automated in terraform and are more scalable anyways.

Imagine some of the power here - say you had a work item automation that parsed tickets and created agent pools of a size using terraform (just VMSS in the subscription), then attached them to the given project with the same terraform! How easly we could expand the power of Azure DevOps through code.
Or consider using ANY kubernetes provider to create a cluster then the azuredevops_serviceendpoint_kubernetes to attach it your project!  You could even orchestrate a testing pipeline with terraform that created a cluster on the fly, launched the service with helm, tested it, then tore down the whole stack to save money.

Full Devops.tf file

Here is the final devops file (with passwords changed of course).  I personally always find code examples easiest to learn.

terraform {
  required_providers {
    azuredevops = {
      source = "microsoft/azuredevops"
      version = ">=0.1.0"
    }
  }
}

provider "azuredevops" {
  org_service_url = "https://dev.azure.com/princessking/"
}

resource "azuredevops_project" "project" {
  name = "IDJTestProject"
  description = "IDJ Test Description"
  visibility = "public"
  version_control = "Git"
  work_item_template = "Agile"
}

resource "azuredevops_git_repository" "repo" {
  project_id = azuredevops_project.project.id
  name = "MyGitREPO"
  initialization {
    init_type = "Import"
    source_type = "Git"
    source_url = "https://github.com/idjohnson/nodejs-image-demo.git"
  }
} 

resource "azuredevops_variable_group" "vars" {
  project_id = azuredevops_project.project.id
  name = "Infrastructure Pipeline Variables"
  description = "Managed by Terraform"
  allow_access = true

  variable {
    name = "myusername"
    value = "isaac"
  }

  variable {
    name = "mypassword"
    secret_value = "p@ssword123"
    is_secret = true
  }
}

resource "azuredevops_build_definition" "build" {
  project_id = azuredevops_project.project.id
  name = "Sample Build Definition"

  ci_trigger {
    use_yaml = true
  }

  repository {
    repo_type = "TfsGit"
    repo_id = azuredevops_git_repository.repo.id
    branch_name = azuredevops_git_repository.repo.default_branch
    yml_path = "azure-pipelines.yml"
  }

  variable_groups = [
    azuredevops_variable_group.vars.id
  ]

  variable {
    name = "docker-username"
    value = "idjohnson"
  }

  variable {
    name = "docker-pass"
    secret_value = "Notmyrealpassword!"
    is_secret = true
  }
}

resource "azuredevops_user_entitlement" "user1" {
  principal_name = "tristan.cormac.moriarty@gmail.com"
  account_license_type = "stakeholder"
}

resource "azuredevops_user_entitlement" "user2" {
  principal_name = "isaac.johnson@cdc.com"
  account_license_type = "stakeholder"
}

data "azuredevops_group" "group" {
  project_id = azuredevops_project.project.id
  name = "Build Administrators"
}

resource "azuredevops_group_membership" "membership" {
  group = data.azuredevops_group.group.descriptor
  members = [
    azuredevops_user_entitlement.user1.descriptor,
    azuredevops_user_entitlement.user2.descriptor
  ]
}
hashicorp terraform azure-devops

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes