Skip to content

About

Terraform module: terraform-azuredevops-feed

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

🔷 Azure DevOps Feed Terraform Module

Provisions an Azure Artifacts feed together with its role assignments and retention policy behind one composite contract — organization- or project-scoped, secure-by-default reader grants, typed for_each permission collections, and a single managed retention policy. Built for azuredevops v1.x.

Terraform azuredevops module type resources


🧩 Overview

This composite module creates and wires together everything a packaging feed needs:

  • 📦 An Azure Artifacts feed (azuredevops_feed) — host NuGet, npm, Maven, Python, Cargo, and Universal Packages.
  • 🌐 Organization- or project-scoped placement via an optional project_id (omit for an org-level feed).
  • 👥 Feed role assignments (azuredevops_feed_permission) — a typed for_each map granting reader / collaborator / contributor / administrator to identity descriptors.
  • 🧹 A retention policy (azuredevops_feed_retention_policy) — automatically prunes old package versions while protecting recently downloaded ones.
  • 🔧 Optional feed features (permanent_delete, restore) and per-resource timeouts.

💡 Why it matters: a feed is useless until the right pipelines and people can read and publish to it, and unbounded without retention. This module turns "create a feed, then click through Feed Settings" into one reviewable, version-pinned declaration.


❤️ 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

flowchart LR
 project["terraform-azuredevops-project<br/>(emits project_id)"]
 feed["terraform-azuredevops-feed<br/>(this module)"]
 artifacts["terraform-azuredevops-<br/>serviceendpoint_artifacts"]
 group["terraform-azuredevops-group<br/>(emits descriptor)"]
 pipelines["build / release<br/>pipelines"]

 project -. "project_id (optional)".-> feed
 group -. "identity_descriptor".-> feed
 feed -- "feed_id" --> artifacts
 feed -- "feed_id" --> pipelines

 style feed fill:#8957E5,color:#fff
 style project fill:#0078D4,color:#fff
Loading
  • project_id is optional here — unlike most of the suite, the feed keystone can live at the organization level with no project at all.
  • identity_descriptor values typically come from terraform-azuredevops-group (descriptor) or built-in/Entra identities.
  • Downstream, the emitted feed_id wires into artifact service connections and pipelines that publish/consume packages.

🧬 What this module builds

flowchart TD
 feed["azuredevops_feed.this<br/>(keystone — org or project scoped)"]
 perm["azuredevops_feed_permission.this<br/>for_each = var.permissions<br/>(map, 0..N role grants)"]
 ret["azuredevops_feed_retention_policy.this<br/>for_each = retention_policy != null ? {policy=…}: {}<br/>(0..1 per feed)"]

 feed -- "feed_id" --> perm
 feed -- "feed_id" --> ret

 style feed fill:#8957E5,color:#fff
 style perm fill:#161B22,color:#fff
 style ret fill:#161B22,color:#fff
Loading

Resource inventory (3 resources):

  • azuredevops_feed.this — the keystone feed. Renders optional features and timeouts dynamic blocks.
  • azuredevops_feed_permission.this — for_each over var.permissions (map(object)), one role assignment per entry. No count.
  • azuredevops_feed_retention_policy.this — for_each over a { policy = … } toggle map so the policy is created 0-or-1 times without count.

Children inherit the keystone's project_id by default and may override it per entry.


✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
microsoft/azuredevops >= 1.0, < 2.0 (current GA line v1.15.x)

The module declares the provider requirement only — it configures no provider {} block. The root module supplies the org URL and auth (PAT or Azure AD service principal).


🔑 Required Azure DevOps Scopes / Auth

The Terraform identity must be able to create feeds and administer feed permissions and settings. In Azure Artifacts terms that means the running identity is a Feed Owner on the target feed (granted automatically to Project Collection Administrators and Azure Artifacts Administrators).

Scope / Role PAT scope Service-principal role Required for
Packaging Packaging (Read, Write & Manage) Feed Owner (auto for PCA / Azure Artifacts Admin) Creating/updating/deleting azuredevops_feed
Feed administration Packaging (Read, Write & Manage) Feed Owner on the feed Managing azuredevops_feed_permission (only Feed Owners can add/remove role assignments)
Retention policy Packaging (Read, Write & Manage) Feed Owner on the feed Managing azuredevops_feed_retention_policy (Feed Owner required to set retention)
Project scope (optional) Project and Team (Read) member of the target project Resolving project_id for a project-scoped feed

⚠️ Organization-scoped feeds and "who can create feeds" are collection-level controls. Creating an org-level feed (or any feed where the identity is not already a Feed Owner) requires Project Collection Administrator rights, or an explicit Azure Artifacts "Who can create feeds / Who can administer feeds" delegation. Confirm the running identity is granted the collection-level role before apply — a missing grant surfaces as a 403 Forbidden, not a validation error.


📁 Module Structure

terraform-azuredevops-feed/
├── providers.tf # terraform >= 1.12, azuredevops >= 1.0 < 2.0 — no provider{} block
├── variables.tf # name, project_id, features, permissions, retention_policy, timeouts
├── main.tf # azuredevops_feed.this + feed_permission.this + feed_retention_policy.this
├── outputs.tf # id, feed_id, name, project_id, permission_ids, permission_identity_ids, retention_policy_id
├── SCOPE.md # cross-module contract + required scopes/auth + provider gotchas
└── README.md # this file

⚙️ Quick Start

Smallest working call — an organization-scoped feed:

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

  name = "casey-shared-packages"
}

A project-scoped feed, wiring project_id from terraform-azuredevops-project:

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

  name       = "lending-platform-packages"
  project_id = module.project.project_id
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
project_id (optional) string terraform-azuredevops-project — omit for an organization-scoped feed
permissions[*].identity_descriptor string terraform-azuredevops-group (descriptor), built-in or Entra group descriptors

ℹ️ This module is organization-scoped by default — project_id is optional, not required. There are no other cross-module inputs; the org URL and auth come from the provider configuration in the root.

Emits

Output Description Consumed by
id The feed ID (primary output) downstream module references
feed_id Resource-specific feed ID terraform-azuredevops-serviceendpoint-artifacts, publish/restore pipeline tasks
name Feed name logging / audit
project_id Project ID, or null when org-scoped scope-aware wiring / audit
permission_ids Map of permission resource IDs by handle audit
permission_identity_ids Map of resolved identity IDs by handle audit
retention_policy_id Retention policy ID, or null when none audit

ℹ️ No sensitive outputs. This resource family emits only IDs, names, and identity descriptors — never secrets.


🧠 Architecture Notes

  • Org-vs-project scope is chosen once and is effectively permanent. Per Microsoft Learn, organization-scoped feeds cannot be converted to project-scoped feeds. The provider treats a project_id change as a destroy/recreate, and recreation can collide with the name-reservation window (below). Decide scope deliberately.
  • Feed name reservation. Because of an ADO limitation, a feed name can be reserved for up to 15 minutes after a permanent delete. A rename or scope change (both recreate the feed) may fail with a transient name conflict during that window — re-run the apply after it clears.
  • Role names map to the Artifacts UI as follows:
Module role Azure Artifacts UI role Can…
reader Feed Reader list / download / restore packages
collaborator Feed and Upstream Reader + save packages from upstream sources
contributor Feed Publisher + publish / promote / deprecate packages
administrator Feed Owner + delete packages, edit settings, manage upstreams & permissions

The default consumer grant should be reader; reserve administrator for the few identities that must manage the feed.

  • Retention deletes only when BOTH conditions are met. A package version is removed only when the maximum versions per package (count_limit) is reached and that version hasn't been downloaded within days_to_keep_recently_downloaded_packages. Packages promoted to a view (@release, @prerelease) are exempt from retention.
  • Single retention policy per feed. The retention policy is modeled as an optional(object) rendered through a { policy = … } toggle map — at most one exists. feed_id and project_id on the policy are immutable.
  • Eventual consistency. Feed and permission writes propagate asynchronously across the Artifacts service. A freshly applied permission may take a short time to take effect for a pipeline's build identity.
  • Pipeline access is the common gotcha, not a module input. Pipelines authenticate as a Build Service identity ([Project] Build Service ([Org]) or Project Collection Build Service ([Org])). To let a pipeline publish, add that identity to permissions with contributor. New org-scoped feeds grant the Project Collection Build Service the collaborator role by default.
  • No secrets, no sensitive. Identity descriptors are references, not credentials, so the module declares no sensitive variables or outputs and needs no nonsensitive unwrapping.

📚 Example Library (copy-paste)

1 · Organization-scoped feed (minimal)
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name = "casey-shared-packages"
}
2 · Project-scoped feed
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id
}
3 · Feed with a retention policy
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id

  retention_policy = {
    count_limit                               = 30 # keep up to 30 versions per package
    days_to_keep_recently_downloaded_packages = 30 # but never prune anything downloaded in the last 30 days
  }
}
4 · Read-only access for a team (secure default)
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id

  permissions = {
    developers = {
      identity_descriptor = module.dev_group.descriptor
      role                = "reader"
      display_name        = "Lending Platform Developers"
    }
  }
}
5 · Grant a pipeline build identity publish rights
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id

  permissions = {
    # The pipeline's project Build Service identity must be a Contributor to publish.
    build_service = {
      identity_descriptor = data.azuredevops_group.build_service.descriptor
      role                = "contributor"
      display_name        = "Project Build Service (publish)"
    }
  }
}
6 · Tiered roles — readers, publishers, owners
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "platform-packages"
  project_id = module.project.project_id

  permissions = {
    consumers = {
      identity_descriptor = module.consumers_group.descriptor
      role                = "reader"
    }
    publishers = {
      identity_descriptor = module.publishers_group.descriptor
      role                = "contributor"
    }
    owners = {
      identity_descriptor = module.platform_admins.descriptor
      role                = "administrator"
      display_name        = "Platform Admins (feed owners)"
    }
  }
}
7 · Collaborator role (read + save from upstream sources)
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name = "casey-upstream-cache"

  permissions = {
    # Collaborators can pull packages from upstream sources into the feed.
    engineers = {
      identity_descriptor = module.engineers_group.descriptor
      role                = "collaborator"
    }
  }
}
8 · Feed features — permanent delete and restore
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name = "ephemeral-ci-feed"

  features = {
    permanent_delete = true # purge immediately on destroy (skips soft-delete recycle)
    restore          = false
  }
}
9 · Restore a soft-deleted feed on create
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name = "recovered-packages"

  features = {
    restore          = true # attempt to restore a soft-deleted feed of this name
    permanent_delete = false
  }
}
10 · Per-permission project_id override
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  # Organization-scoped feed (no module-level project_id) …
  name = "org-shared-feed"

  permissions = {
    # … but this grant is evaluated in the context of a specific project.
    proj_team = {
      identity_descriptor = module.team_group.descriptor
      role                = "reader"
      project_id          = module.lending_project.project_id
    }
  }
}
11 · Custom timeouts on the feed and a child
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name = "slow-region-feed"

  timeouts = {
    create = "15m"
    delete = "15m"
  }

  retention_policy = {
    count_limit                               = 50
    days_to_keep_recently_downloaded_packages = 14
    timeouts = {
      create = "15m"
    }
  }
}
12 · Retention + read access (typical project feed)
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id

  retention_policy = {
    count_limit                               = 25
    days_to_keep_recently_downloaded_packages = 30
  }

  permissions = {
    developers = {
      identity_descriptor = module.dev_group.descriptor
      role                = "reader"
    }
    ci = {
      identity_descriptor = data.azuredevops_group.build_service.descriptor
      role                = "contributor"
    }
  }
}
13 · Consuming feed_id in an artifacts service connection
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "platform-packages"
  project_id = module.project.project_id
}

# A downstream artifacts endpoint / pipeline references the emitted feed_id.
output "feed_for_pipeline" {
  value = module.feed.feed_id
}
14 · End-to-end composition (project → group → feed) — finale
# 1) Foundation project
module "project" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"

  name = "Lending Platform"
}

# 2) An identity group to grant feed access to
module "dev_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-group?ref=v1.0.0"

  project_id   = module.project.project_id
  display_name = "Lending Platform Developers"
}

# 3) Build service identity (existing)
data "azuredevops_group" "build_service" {
  project_id = module.project.project_id
  name       = "Project Collection Build Service Accounts"
}

# 4) The feed, wiring project_id + descriptors, with retention and tiered roles
module "feed" {
  source = "git::https://github.com/microsoftexpert/terraform-azuredevops-feed?ref=v1.0.0"

  name       = "lending-platform-packages"
  project_id = module.project.project_id

  features = {
    permanent_delete = false
    restore          = false
  }

  retention_policy = {
    count_limit                               = 30
    days_to_keep_recently_downloaded_packages = 30
  }

  permissions = {
    developers = {
      identity_descriptor = module.dev_group.descriptor
      role                = "reader"
      display_name        = "Developers (read)"
    }
    ci_publish = {
      identity_descriptor = data.azuredevops_group.build_service.descriptor
      role                = "contributor"
      display_name        = "CI publish"
    }
  }
}

output "feed_id" {
  value = module.feed.feed_id
}

📥 Inputs

Full input schema
Name Type Default Required Description
name string — ✅ Feed name. Effectively immutable (recreate on change); reserved up to 15 min after permanent delete.
project_id string null — Project scope. null ⇒ organization-scoped. Immutable. Also the default scope for children.
features object null — { permanent_delete = optional(bool,false), restore = optional(bool,false) }
permissions map(object) {} — Role assignments keyed by handle. See schema below.
retention_policy object null — At-most-one retention policy. See schema below.
timeouts object {} — { create, read, update, delete } (all optional(string)) for the feed.

permissions entry schema

"<handle>" = {
 identity_descriptor = string # required — identity descriptor (e.g. group.descriptor)
 role = string # required — reader | contributor | collaborator | administrator
 display_name = optional(string) # assignment display name
 project_id = optional(string) # overrides module project_id for this entry
 timeouts = optional(object({ create, read, update, delete })) # all optional(string)
}

retention_policy schema

{
 count_limit = number # required — max versions per package (> 0)
 days_to_keep_recently_downloaded_packages = number # required — grace days (>= 0)
 project_id = optional(string) # overrides module project_id; immutable
 timeouts = optional(object({ create, read, update, delete }))
}

Validation enforced at plan time

  • name must be non-empty.
  • Each permission role ∈ {reader, contributor, collaborator, administrator}.
  • Each permission identity_descriptor must be non-empty.
  • retention_policy.count_limit > 0 and days_to_keep_recently_downloaded_packages >= 0.

🧾 Outputs

Output Description Sensitive
id The feed ID (primary output). no
feed_id Resource-specific feed ID for cross-module wiring. no
name The feed name. no
project_id Project ID, or null when org-scoped. no
permission_ids Map of feed-permission resource IDs by handle. no
permission_identity_ids Map of resolved identity IDs by handle. no
retention_policy_id Retention policy ID, or null when none (try-guarded). no

ℹ️ No sensitive outputs — the module surfaces IDs and descriptors only.


🧱 Design Principles

  • One keystone, typed children. azuredevops_feed.this is the single primary resource; permissions and retention are children rendered with for_each over map(object(...)) — never count.
  • Make the type the contract. Deeply-typed object schemas, optional with safe defaults, validation {} for every closed value set, heredoc descriptions carrying the schema inline.
  • Secure by default. reader is the recommended consumer grant; the empty call creates a feed with no extra grants and no destructive feature flags.
  • Total renderer. main.tf is a pure projection — dynamic blocks for every optional/repeating block, try(x, null) on every optional nested field.
  • Scope-flexible. project_id is optional everywhere, and children inherit it with a per-entry override.

🚀 Runbook

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

⚠️ terraform plan / apply require live organization credentials (org URL + PAT, or an Azure AD service principal). The offline gate above verifies structural correctness. For live testing use a non-production organization with a dedicated identity holding the Packaging scope — never the production org.


🧪 Testing

  • ✅ terraform init -backend=false — provider resolves (microsoft/azuredevops >= 1.0, < 2.0).
  • ✅ terraform validate — Success! The configuration is valid.
  • ✅ terraform fmt -check — no formatting differences.
  • 🔁 Type-level negative tests: an invalid role, an empty identity_descriptor, or count_limit = 0 each fail at plan time with an actionable error_message.
  • 🌐 Live (non-prod org) smoke test: apply minimal org-scoped feed → confirm in Artifacts; add a reader permission → confirm in Feed Settings › Permissions.

💬 Example Output

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

Outputs:

feed_id = "11111111-2222-3333-4444-555555555555"
id = "11111111-2222-3333-4444-555555555555"
name = "lending-platform-packages"
permission_ids = { "developers" = "…" }
project_id = "00000000-0000-0000-0000-000000000000"
retention_policy_id = "…"

🔍 Troubleshooting

Symptom Likely cause Fix
403 Forbidden / TF400813 on feed create Identity lacks Packaging (Read, Write & Manage) or is not a Feed Owner / PCA Grant the PAT scope, or add the identity as Feed Owner / Project Collection Administrator (org-scoped feeds need collection-level rights).
403 only when managing permissions or retention Identity can read the feed but is not a Feed Owner Only Feed Owners may add/remove role assignments or set retention. Elevate the running identity.
Permission has no effect for a pipeline Wrong Build Service identity, or eventual consistency Use the correct [Project] Build Service ([Org]) / Project Collection Build Service ([Org]) descriptor; re-run after propagation.
Plan wants to recreate the feed after editing project_id Feed scope is immutable; org-scoped feeds can't become project-scoped Decide scope once. If a move is truly required, create a new feed and migrate packages.
Feed create fails with a name conflict shortly after a delete Name reserved up to 15 minutes after permanent delete Wait out the reservation window and re-apply, or choose a different name.
Old versions not being deleted despite retention Retention deletes only when both conditions met, and view-promoted packages are exempt Lower count_limit / days_to_keep_recently_downloaded_packages; remember @release/@prerelease packages are protected.
Invalid value for variable … role at plan time role outside the enum Use reader, contributor, collaborator, or administrator.

🔗 Related Docs


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

About

Terraform module: terraform-azuredevops-feed

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages