Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🛡️ Azure DevOps Pipeline Checks Terraform Module

Aggregates every Azure Pipelines approval & check — manual approval, branch control, business hours, exclusive lock, required template, and Invoke REST API — behind one project-scoped boundary, attaching them to any protected resource (environment, service connection, agent queue, repository, secure file, or variable group). Each check type is an independently-optional for_each collection. Built for azuredevops v1.x.

Terraform azuredevops module type resources


🧩 Overview

This module declares pipeline approvals & checks that gate when a YAML pipeline stage may consume a protected resource. It manages all six provider check_* resource types as peer collections:

  • ✅ Approval (azuredevops_check_approval) — manual approval by named approvers before a stage runs.
  • 🌿 Branch control (azuredevops_check_branch_control) — restrict which branches may use the resource; optionally require branch protection.
  • 🕒 Business hours (azuredevops_check_business_hours) — only pass inside a configured time-of-day window on selected days.
  • 🔒 Exclusive lock (azuredevops_check_exclusive_lock) — serialize access so only one run uses the resource at a time.
  • 📐 Required template (azuredevops_check_required_template) — force pipelines to extend an approved YAML template.
  • 🌐 REST API (azuredevops_check_rest_api) — call an external endpoint and gate the run on the response.

💡 Why it matters: checks are configured by the resource owner, not in the pipeline YAML — so a pipeline author cannot remove the gate. This module makes that governance reproducible: production environments, ARM service connections, and secret variable groups get the same enforced approval/branch/time controls on every apply.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

Checks are cross-cutting: they consume a project_id from the project module and a target_resource_id from whichever module owns the protected resource.

flowchart TD
 project["terraform-azuredevops-project<br/>(project_id)"]
 env["terraform-azuredevops-environment"]
 se["terraform-azuredevops-serviceendpoint-azure"]
 vg["terraform-azuredevops-variable-group"]
 pool["terraform-azuredevops-agent-pool"]
 repo["terraform-azuredevops-git-repository"]
 checks["terraform-azuredevops-pipeline-checks"]

 project -->|project_id| checks
 env -->|target_resource_id<br/>type=environment| checks
 se -->|target_resource_id<br/>type=endpoint| checks
 vg -->|target_resource_id<br/>type=variablegroup| checks
 pool -->|target_resource_id<br/>type=queue| checks
 repo -->|target_resource_id<br/>type=repository| checks

 style checks fill:#8957E5,color:#fff
 style project fill:#0078D4,color:#fff
Loading

🧬 What this module builds

This is an aggregation module — there is no primary this resource. Every check type is a sibling for_each collection, each fed the shared project_id and a resolved target. Enable any subset; unused collections default to {} and create nothing.

flowchart TD
 inputs["project_id<br/>+ target_resource_id / type<br/>(per-entry override)"]

 approval["azuredevops_check_approval<br/>.approval { for_each }"]
 branch["azuredevops_check_branch_control<br/>.branch_control { for_each }"]
 hours["azuredevops_check_business_hours<br/>.business_hours { for_each }"]
 lock["azuredevops_check_exclusive_lock<br/>.exclusive_lock { for_each }"]
 template["azuredevops_check_required_template<br/>.required_template { for_each }"]
 rest["azuredevops_check_rest_api<br/>.rest_api { for_each }"]

 inputs --> approval
 inputs --> branch
 inputs --> hours
 inputs --> lock
 inputs --> template
 inputs --> rest

 style approval fill:#8957E5,color:#fff
 style branch fill:#8957E5,color:#fff
 style hours fill:#8957E5,color:#fff
 style lock fill:#8957E5,color:#fff
 style template fill:#8957E5,color:#fff
 style rest fill:#8957E5,color:#fff
Loading

Resource inventory (6 types, all for_each):

  • azuredevops_check_approval.approval
  • azuredevops_check_branch_control.branch_control
  • azuredevops_check_business_hours.business_hours
  • azuredevops_check_exclusive_lock.exclusive_lock
  • azuredevops_check_required_template.required_template
  • azuredevops_check_rest_api.rest_api

✅ Provider / Versions

terraform {
  required_version = ">= 1.12.0"
  required_providers {
    azuredevops = {
      source  = "microsoft/azuredevops"
      version = ">= 1.0, < 2.0"
    }
  }
}

ℹ️ The module declares the provider requirement only — it configures no provider {} block. The root/spec supplies the org URL + PAT or Azure AD service principal.


🔑 Required Azure DevOps Scopes / Auth

Managing approvals & checks goes through the Approvals and Checks (Check Configurations) REST API, gated by the Pipeline Resources PAT scope. The running identity must also hold the Administrator role on the protected resource it attaches checks to.

Scope / Role PAT scope Service-principal role Required for
Manage checks (create / update / delete) Pipeline Resources — Use & Manage (vso.pipelineresources_manage) Administrator on the target resource type (Endpoint / Environment / Agent Pool / Library / Repository Administrators) every check_* collection
Read / evaluate checks Pipeline Resources — Use User on the target resource drift detection on terraform plan
Open a resource to all pipelines — Project Administrators only if the protected resource is opened project-wide (out of scope here)
Org-level resource administration — Project Collection Administrators org-scoped agent pools used as targets

⚠️ Creating checks requires the resource-type Administrator role (e.g. Endpoint Administrators, Environment Administrators), and org-wide changes require Project Collection Administrators. A plain Build Administrator or Contributor PAT will return 403 from the check-configuration API.


📁 Module Structure

terraform-azuredevops-pipeline-checks/
├── providers.tf # required_version + azuredevops provider pin (no provider block)
├── variables.tf # project_id, shared target refs, 6 typed for_each collection maps
├── main.tf # 6 for_each check resources named by role (no `this`)
├── outputs.tf # per-role *_ids maps + rest_api_versions + flattened ids
├── SCOPE.md # cross-module contract + required scopes/auth + gotchas
└── README.md # this file

⚙️ Quick Start

Smallest working call — a manual approval on a production environment, with project_id wired from the project module:

module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id           = module.project.project_id
  target_resource_id   = module.prod_environment.id
  target_resource_type = "environment"

  approval = {
    prod_gate = {
      approvers    = [module.platform_group.id]
      instructions = "Approve production deployment after change review."
    }
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
project_id string terraform-azuredevops-project
target_resource_id string the protected-resource module (environment / serviceendpoint_* / agent_pool / variable_group / git_repository)

Emits

Output Description Consumed by
approval_ids Map of approval check IDs keyed by collection key audit / downstream
branch_control_ids Map of branch control check IDs audit / downstream
business_hours_ids Map of business hours check IDs audit / downstream
exclusive_lock_ids Map of exclusive lock check IDs audit / downstream
required_template_ids Map of required template check IDs audit / downstream
rest_api_ids Map of REST API check IDs audit / downstream
rest_api_versions Map of REST API check versions drift inspection
ids Flattened map of all check IDs, keyed <role>/<collection key> access review / audit

🧠 Architecture Notes

  • Project-scoped, cross-cutting. Every check_* resource takes project_id, but the target it protects is owned by another module. One module instance can protect several resources because target_resource_id / target_resource_type are overridable per collection entry (the module-level values are defaults).
  • Target types. endpoint, environment, queue, repository, securefile, variablegroup. For repository targets the provider expects the composite ID "<project_id>.<repository_id>".
  • YAML pipelines only. Approvals and checks apply to YAML pipelines; classic pipelines do not honor them.
  • Evaluation order (per Azure Pipelines). Categories run sequentially, in parallel within a category: ① static checks (branch control, required template) → ② pre-dynamic approval → ③ dynamic checks (approval, business hours, REST API, Invoke Function) → ④ post-dynamic approval → ⑤ exclusive lock. If a category fails, later categories don't run.
  • Immutable fields. project_id, target_resource_id, and target_resource_type are immutable on every check — changing any forces destroy/recreate.
  • No Terraform timeouts block. These resources expose no operation-timeouts block; the timeout field is a check resource attribute in minutes (approval/exclusive-lock default 43200 = 30 days; branch control / business hours / REST API default 1440 = 1 day).
  • Branch refs must be fully qualified — refs/heads/<branch> (e.g. refs/heads/main, refs/heads/releases/*).
  • REST API secrets are write-context only. Don't embed credentials in headers/body; surface them through a linked variable group via variable_group_name. success_criteria applies only when completion_event = ApiResponse; retry_interval is irrelevant for Callback.
  • Eventual consistency. A check can briefly lag visibility on the protected resource after create; re-running plan usually settles it.

📚 Example Library (copy-paste)

1 · Manual approval (single approver)
module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id           = module.project.project_id
  target_resource_id   = module.prod_environment.id
  target_resource_type = "environment"

  approval = {
    prod_gate = {
      approvers    = [module.platform_group.id]
      instructions = "Approve production deployment."
    }
  }
}
2 · Multi-approver with minimum count and self-approval blocked
approval = {
  prod_gate = {
    approvers                  = [module.platform_group.id, module.security_group.id]
    minimum_required_approvers = 2
    requester_can_approve      = false
    instructions               = "Two approvers required; requester may not self-approve."
    timeout                    = 4320 # 3 days, in minutes
  }
}
3 · Branch control — restrict to main only
branch_control = {
  main_only = {
    display_name     = "Deploy from main only"
    allowed_branches = "refs/heads/main"
  }
}
4 · Branch control — require branch protection on release branches
branch_control = {
  protected_releases = {
    display_name                     = "Protected release branches"
    allowed_branches                 = "refs/heads/main, refs/heads/releases/*"
    verify_branch_protection         = true
    ignore_unknown_protection_status = false
  }
}
5 · Business hours — weekday daytime window
business_hours = {
  weekdays = {
    display_name = "Business hours only"
    start_time   = "07:00"
    end_time     = "18:00"
    time_zone    = "UTC"
    monday       = true
    tuesday      = true
    wednesday    = true
    thursday     = true
    friday       = true
  }
}
6 · Exclusive lock — serialize deployments
exclusive_lock = {
  serialize = {
    timeout = 43200 # 30 days, in minutes
  }
}
7 · Required template (Azure Git)
required_template = {
  enforce_prod_template = {
    required_templates = [
      {
        repository_type = "azuregit"
        repository_name = "DevOpsInfrastructure"
        repository_ref  = "refs/heads/main"
        template_path   = "templates/production.yml"
      }
    ]
  }
}
8 · Required template (GitHub Enterprise + multiple templates)
required_template = {
  enforce = {
    required_templates = [
      {
        repository_type = "githubenterprise"
        repository_name = "org/pipeline-templates"
        repository_ref  = "refs/heads/main"
        template_path   = "secure/build.yml"
      },
      {
        repository_type = "azuregit"
        repository_name = "DevOpsInfrastructure"
        repository_ref  = "refs/heads/main"
        template_path   = "templates/deploy.yml"
      }
    ]
  }
}
9 · REST API check — gate on an external API response
rest_api = {
  cmdb_gate = {
    display_name                    = "CMDB change-window check"
    connected_service_name_selector = "connectedServiceName"
    connected_service_name          = module.generic_endpoint.service_endpoint_name
    method                          = "POST"
    headers                         = jsonencode({ contentType = "application/json" })
    body                            = jsonencode({ env = "prod" })
    completion_event                = "ApiResponse"
    success_criteria                = "eq(root['status'], 'approved')"
    retry_interval                  = 5
    url_suffix                      = "changes/validate"
  }
}
10 · REST API check — async Callback completion
rest_api = {
  async_gate = {
    display_name                    = "Async approval webhook"
    connected_service_name_selector = "connectedServiceNameARM"
    connected_service_name          = module.serviceendpoint_azure.service_endpoint_name
    method                          = "POST"
    completion_event                = "Callback" # service calls back when done
    url_suffix                      = "gates/start"
  }
}
11 · Protect a service connection (project-wired)
module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id           = module.project.project_id
  target_resource_id   = module.serviceendpoint_azure.service_endpoint_id
  target_resource_type = "endpoint"

  branch_control = {
    prod_branch = {
      display_name     = "Prod connection from main"
      allowed_branches = "refs/heads/main"
    }
  }
  approval = {
    prod_conn = { approvers = [module.platform_group.id] }
  }
}
12 · Protect a variable group
module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id           = module.project.project_id
  target_resource_id   = module.prod_variable_group.variable_group_id
  target_resource_type = "variablegroup"

  approval       = { vg_gate = { approvers = [module.security_group.id] } }
  exclusive_lock = { serialize = {} }
}
13 · Protect a repository (composite target ID)
module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id           = module.project.project_id
  target_resource_id   = "${module.project.project_id}.${module.repo.repository_id}"
  target_resource_type = "repository"

  required_template = {
    enforce = {
      required_templates = [{
        repository_type = "azuregit"
        repository_name = "DevOpsInfrastructure"
        repository_ref  = "refs/heads/main"
        template_path   = "templates/ci.yml"
      }]
    }
  }
}
14 · Per-entry target override (one module, multiple resources)
module "pipeline_checks" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"

  project_id = module.project.project_id
  # no module-level target — each entry sets its own

  approval = {
    env_gate = {
      target_resource_id   = module.prod_environment.id
      target_resource_type = "environment"
      approvers            = [module.platform_group.id]
    }
    conn_gate = {
      target_resource_id   = module.serviceendpoint_azure.service_endpoint_id
      target_resource_type = "endpoint"
      approvers            = [module.security_group.id]
    }
  }
}
15 · 🎯 End-to-end composition (mandatory finale)
module "project" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
  name   = "Payments-Platform"
}

module "platform_group" {
  source       = "git::https://github.com/microsoftexpert/terraform-azuredevops-group?ref=v1.0.0"
  project_id   = module.project.project_id
  display_name = "Platform Approvers"
}

module "prod_environment" {
  source     = "git::https://github.com/microsoftexpert/terraform-azuredevops-environment?ref=v1.0.0"
  project_id = module.project.project_id
  name       = "production"
}

module "serviceendpoint_azure" {
  source     = "git::https://github.com/microsoftexpert/terraform-azuredevops-serviceendpoint-azure?ref=v1.0.0"
  project_id = module.project.project_id
  #... ARM connection config...
}

# Gate BOTH the environment and the ARM connection from one checks module
module "pipeline_checks" {
  source     = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
  project_id = module.project.project_id

  approval = {
    prod_env = {
      target_resource_id         = module.prod_environment.id
      target_resource_type       = "environment"
      approvers                  = [module.platform_group.id]
      minimum_required_approvers = 1
      instructions               = "Approve production deployment."
    }
  }

  branch_control = {
    prod_conn = {
      target_resource_id   = module.serviceendpoint_azure.service_endpoint_id
      target_resource_type = "endpoint"
      display_name         = "Prod ARM connection from main"
      allowed_branches     = "refs/heads/main"
    }
  }

  business_hours = {
    prod_env_window = {
      target_resource_id   = module.prod_environment.id
      target_resource_type = "environment"
      display_name         = "Deploy during business hours"
      start_time           = "08:00"
      end_time             = "17:00"
      time_zone            = "UTC"
      monday               = true
      tuesday              = true
      wednesday            = true
      thursday             = true
      friday               = true
    }
  }

  exclusive_lock = {
    prod_env_serial = {
      target_resource_id   = module.prod_environment.id
      target_resource_type = "environment"
    }
  }
}

output "all_check_ids" {
  value = module.pipeline_checks.ids
}

📥 Inputs

Full input schemas

Top-level

Name Type Default Description
project_id string — (required) Owning project ID. Immutable on every check.
target_resource_id string null Default protected-resource ID; overridable per entry.
target_resource_type string null Default target type: endpoint/environment/queue/repository/securefile/variablegroup.

approval — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 approvers = list(string) # required
 instructions = optional(string)
 minimum_required_approvers = optional(number)
 requester_can_approve = optional(bool, false)
 timeout = optional(number, 43200)
}

branch_control — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 display_name = string # required
 allowed_branches = optional(string, "*")
 verify_branch_protection = optional(bool, false)
 ignore_unknown_protection_status = optional(bool, false)
 timeout = optional(number, 1440)
}

business_hours — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 display_name = string # required
 start_time = string # required "HH:MM"
 end_time = string # required "HH:MM"
 time_zone = string # required
 monday..sunday = optional(bool, false)
 timeout = optional(number, 1440)
}

exclusive_lock — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 timeout = optional(number, 43200)
}

required_template — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 required_templates = list(object({ # required, ≥1
 template_path = string
 repository_name = string
 repository_ref = string
 repository_type = optional(string, "azuregit") # azuregit|github|githubenterprise|bitbucket
 }))
}

rest_api — map(object({…})), default {}

{
 target_resource_id = optional(string)
 target_resource_type = optional(string)
 display_name = string # required
 connected_service_name_selector = string # required: connectedServiceName|connectedServiceNameARM
 connected_service_name = string # required
 method = string # required: OPTIONS|GET|HEAD|POST|PUT|DELETE|TRACE|PATCH
 body = optional(string)
 headers = optional(string)
 retry_interval = optional(number)
 success_criteria = optional(string)
 url_suffix = optional(string)
 variable_group_name = optional(string)
 completion_event = optional(string, "Callback") # Callback|ApiResponse
 timeout = optional(number, 1440)
}

🧾 Outputs

Output Type Sensitive Description
approval_ids map(string) no Approval check IDs keyed by collection key
branch_control_ids map(string) no Branch control check IDs
business_hours_ids map(string) no Business hours check IDs
exclusive_lock_ids map(string) no Exclusive lock check IDs
required_template_ids map(string) no Required template check IDs
rest_api_ids map(string) no REST API check IDs
rest_api_versions map(string) no REST API check versions
ids map(string) no Flattened all-check IDs keyed <role>/<key>

ℹ️ No outputs are sensitive — check IDs and versions are not secrets. Secrets used by a REST API check live in a linked variable group, not in this module's state.


🧱 Design Principles

  • Aggregation, not composite — no dominant resource, so there is no this; every type is named by role.
  • Independently optional — each collection defaults to {}; use any, all, or none.
  • Type is the contract — deeply-typed map(object(...)) with optional defaults and validation {} for every closed value set (target types, HTTP method, selector, completion event, repository type, HH:MM format).
  • Secure defaults — requester_can_approve = false; provider-aligned timeouts; secrets steered to variable groups.
  • Total renderer — main.tf is a pure projection; the only dynamic block is the repeating required_template.

🚀 Runbook

cd C:\GitHubCode\newazuredevopsmodules\terraform-azuredevops-pipeline-checks
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ terraform plan / apply require live org credentials (org URL + PAT with Pipeline Resources (Use & Manage), or an Azure AD service principal with resource-type Administrator). The offline gate above is sufficient for structural correctness. Never test against the production org — use a dedicated non-production organization.


🧪 Testing

  • ✅ terraform init -backend=false — succeeds.
  • ✅ terraform validate — Success! The configuration is valid.
  • ✅ terraform fmt -check — no diff.
  • 🔁 Post-apply (non-prod org, optional): confirm checks via the Approvals and Checks REST API or the Approvals and checks tab on the protected resource.

💬 Example Output

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

Outputs:

all_check_ids = {
 "approval/prod_env" = "12"
 "branch_control/prod_conn" = "13"
 "business_hours/prod_env_window" = "14"
 "exclusive_lock/prod_env_serial" = "15"
}

🔍 Troubleshooting

Symptom Likely cause Fix
403 Forbidden / TF400813 from the check-configuration API PAT missing Pipeline Resources (Use & Manage), or identity is not Administrator on the target resource Re-issue PAT with the scope; grant resource-type Administrator (Endpoint/Environment/etc.).
The resource with id... does not exist Wrong target_resource_id, or org-vs-project mismatch (org-scoped agent pool vs project queue) Wire the ID from the owning module's output; use the queue ID (project-scoped), not the org pool ID.
Repository check fails to find target target_resource_id not in composite form Use "<project_id>.<repository_id>" with target_resource_type = "repository".
Branch control never matches Branch names not fully qualified Use refs/heads/<branch> (comma-separated), not short names.
REST API check stuck pending completion_event = "Callback" but the service never calls back Use ApiResponse + success_criteria, or ensure the callback is wired.
Check created but not enforced on runs Classic pipeline, or check just created (eventual consistency) Use a YAML pipeline; re-run plan to settle visibility.
coalesce error: "all arguments null" Neither entry nor module-level target set Set target_resource_id on the entry or the module.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."

About

Terraform module: terraform-azuredevops-pipeline-checks

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages