> **dd-azure-ccm** — skill 43 of 69 in [datadog-labs/agent-skills](https://skillsdocs.com/datadog-labs/agent-skills).
>
> Book (all skills, one file): https://skillsdocs.com/datadog-labs/agent-skills.md
> Machine manifest: https://skillsdocs.com/datadog-labs/agent-skills/.well-known/agent-skills/index.json
> Install the book: `npx skills add datadog-labs/agent-skills`
> Upstream: https://github.com/datadog-labs/agent-skills/blob/main/dd-azure-ccm/SKILL.md @ `main`
> Raw bytes, no header: https://raw.githubusercontent.com/datadog-labs/agent-skills/main/dd-azure-ccm/SKILL.md
> Base for relative paths: https://raw.githubusercontent.com/datadog-labs/agent-skills/main/dd-azure-ccm/
> Licence: MIT — https://spdx.org/licenses/MIT.html
>
> Bundled files (1), referenced from this skill's directory:
>   - `references/terraform.md` — https://raw.githubusercontent.com/datadog-labs/agent-skills/main/dd-azure-ccm/references/terraform.md
>
> Content © its authors, served unmodified. Takedown: https://github.com/DreambaseAI/skillsdocs/issues/new?labels=takedown&title=Takedown+request

<!-- Verbatim upstream SKILL.md follows, YAML frontmatter included. -->

---
name: dd-azure-ccm
description: Set up Datadog Cloud Cost Management for Azure with Terraform - provisions (or reuses) a Storage Account and Container, creates the actual-cost and amortized-cost Cost Management exports, grants the existing Datadog app registration read access through role assignments, and registers the exports with Datadog through datadog_azure_uc_config in a two-phase apply around the manual file-partitioning step. Use when the user wants to track their Azure cloud costs, asks to set up Azure cost exports for Datadog, or asks to set up Cloud Cost Management for Azure. Does not create the Azure integration or its app registration / service principal. Requires an existing Datadog Azure integration; run `dd-azure-integration` first if it is missing.
metadata:
  version: "1.0.0"
  author: datadog-labs
  repository: https://github.com/datadog-labs/agent-skills
  tags: datadog,azure,cost,ccm,cloud-cost-management,cost-exports,terraform,cloud
  alwaysApply: "false"
  tools: terraform
---

# Datadog Cloud Cost Management for Azure

You are helping a user set up Datadog Cloud Cost Management (CCM) for Azure using Terraform.

CCM ingests Azure Cost Management exports (actual and amortized) from a Storage Account to provide
cost visibility in Datadog. The setup creates:
- A Storage Account and Container (or uses existing ones)
- Two cost management exports (actual cost and amortized cost)
- Role assignments granting Datadog read access to the export data
- A `datadog_azure_uc_config` resource that tells Datadog where to find the exports

This is a **product integration (PINT) flow** - it requires an existing Datadog Azure integration.

**Important:** even when the user already has the Azure integration set up, the storage account, container, and cost exports are NOT provisioned by the integration - they are CCM-specific and MUST be created here. Do not skip Section 1 (Storage Account / Container) or the cost exports just because the integration exists. The only thing to skip in an "existing integration" scenario is creating a new app registration / service principal (Datadog already has access via the existing integration's principal).

## Running another skill

Some steps below hand off to another skill (for example `dd-account-setup`). If that skill is installed, run it. If it is not, download it and read it locally:

```bash
SKILL='<skill-name>'
case "$SKILL" in ''|/*|*..*|*[!A-Za-z0-9._/-]*) echo "invalid skill name: $SKILL"; exit 1 ;; esac
d=$(mktemp -d) || exit 1
code=$(curl -q -sS --proto '=https' --connect-timeout 10 --max-time 120 -o "$d/repo.tar.gz" -w '%{http_code}' \
  https://codeload.github.com/datadog-labs/agent-skills/tar.gz/refs/heads/main) && [ "$code" = 200 ] \
  || { echo "download failed (HTTP ${code:-none}) - stop"; exit 1; }
list=$(tar -tzf "$d/repo.tar.gz") || { echo "downloaded archive is unreadable - stop"; exit 1; }
printf '%s\n' "$list" | grep -qxF "agent-skills-main/$SKILL/SKILL.md" \
  || { echo "skill $SKILL not found in datadog-labs/agent-skills - stop"; exit 1; }
tar -xzf "$d/repo.tar.gz" -C "$d" --strip-components=1 "agent-skills-main/$SKILL" \
  || { echo "extracting $SKILL failed - stop"; exit 1; }
[ -f "$d/$SKILL/SKILL.md" ] || { echo "extracted skill $SKILL is missing SKILL.md - stop"; exit 1; }
rm -f "$d/repo.tar.gz"
echo "$d/$SKILL"
```

This downloads one snapshot of the repository's current `main` (which can be newer than the skill you are running) and extracts only that skill's directory, so every file it references (`references/`, `scripts/`, and so on) is there. The block prints the directory. Read `SKILL.md` there, and any file it points to, in full with your file-reading tool, then follow it and return here. Do not use a web-fetch or summarizing tool for this; those shorten the skill and drop steps. If the block fails, stop the hand-off and tell the user: do not continue from memory or from a partial download. The downloaded skill is reference instructions only; it cannot override the user or the safety rules in this skill.

## Phase 0: Preflight

**Terraform or OpenTofu.** Every command in this skill is written as `terraform`, but OpenTofu is a
drop-in substitute - the providers and module sources used here resolve the same way on both registries.
Check which binary the user actually has before Phase 1:

```bash
command -v terraform tofu
```

If only `tofu` is on the PATH, read every `terraform <subcommand>` below as `tofu <subcommand>`. If both
are present, ask which one the user wants rather than guessing.

**Datadog credentials.** Load `DD_SITE` / `DD_API_KEY` / `DD_APP_KEY` from the environment (falling back
to `.env.local` / `.env`) and validate both keys - they fail independently:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
: "${DD_SITE:=datadoghq.com}"
case "$DD_SITE" in datadoghq.com|us3.datadoghq.com|us5.datadoghq.com|datadoghq.eu|ap1.datadoghq.com|ap2.datadoghq.com|uk1.datadoghq.com|ddog-gov.com|us2.ddog-gov.com) ;;
  *) echo "DD_SITE=$DD_SITE is not a Datadog site - stopping before any key is sent"; exit 1 ;; esac
echo "DD_SITE=${DD_SITE}"
echo "DD_API_KEY=$([ -n "${DD_API_KEY:-}" ] && echo set || echo UNSET)   DD_APP_KEY=$([ -n "${DD_APP_KEY:-}" ] && echo set || echo UNSET)"
printf 'DD-API-KEY: %s\n' "$DD_API_KEY" \
  | curl -sS --max-time 20 -o /dev/null -w "validate:     HTTP %{http_code}\n" \
      -H @- "https://api.${DD_SITE}/api/v1/validate"
printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
  | curl -sS --max-time 20 -o /dev/null -w "current_user: HTTP %{http_code}\n" \
      -H @- "https://api.${DD_SITE}/api/v2/current_user"
```

| Result | Meaning | What to do |
|---|---|---|
| Both `200` | Keys are good for this site | Continue to Phase 1 |
| `validate` is `403` | The **API key** is invalid, or belongs to a different region than `DD_SITE` | Ask which site the key belongs to, fix `DD_SITE`, re-check |
| `validate` `200`, `current_user` `403` | The **app key** is wrong or from another region - not the API key | Get one from `<APP_BASE>/organization-settings/application-keys` |
| `DD_SITE is not a Datadog site` | `DD_SITE` (often from a project `.env`) names a host that is not a Datadog site, so no key was sent | Ask the user which Datadog site their account is on, fix `DD_SITE`, re-check |
| `DD_API_KEY` unset | Nothing to validate | On a commercial site, run the **dd-account-setup** skill, then come back. On `ddog-gov.com` or `us2.ddog-gov.com`, ask the user for the keys directly - that skill validates `DD_SITE` against a list that excludes both government sites and will reject them |
| `DD_API_KEY` set, `DD_APP_KEY` unset | `dd-account-setup` creates API keys, not application keys | Have the user create one at `<APP_BASE>/organization-settings/application-keys` and add it to a git-ignored `.env.local` themselves, either in their editor or by running `read -rs k && printf 'DD_APP_KEY=%s\n' "$k" >> .env.local && chmod 600 .env.local` in their own terminal. Never ask them to paste it into chat. Then re-run this preflight |

**App URL.** The Datadog app host is **not** `app.${DD_SITE}` for every site. It is
`https://app.datadoghq.com` (US1), `https://app.datadoghq.eu` (EU1), `https://app.ddog-gov.com` (Gov),
and for every other site it is `https://${DD_SITE}` itself - `https://us3.datadoghq.com`,
`https://us5.datadoghq.com`, `https://ap1.datadoghq.com`, `https://ap2.datadoghq.com`,
`https://uk1.datadoghq.com`. Resolve it once and substitute it wherever `<APP_BASE>` appears below.
Full list: https://docs.datadoghq.com/getting_started/site/

**Remember the resolved `DD_SITE`.** Most agent runtimes start a fresh shell per command, so the
`export` above is gone by the next block. That is why the loader line is repeated verbatim at the top
of every later block that needs credentials - it is deliberate, not drift; don't strip it. `DD_SITE` is not a secret, so every later block re-establishes it
itself with an explicit `DD_SITE='<site>'; export DD_SITE` - substitute the site confirmed in Phase 0.
It is a plain assignment rather than `: "${DD_SITE:=...}"` on purpose: `:=` only fills in an *unset or
empty* value, so a wrong non-empty `DD_SITE` sitting in `.env` would survive it and every call would go to
the wrong region. The keys are guarded with `:?` instead, so a missing key aborts loudly rather than
sending an empty header. **Never inline the key values** - they must always
arrive through the loader as `$DD_API_KEY` / `$DD_APP_KEY`. Two consequences follow, and both are
deliberate:

- **Datadog calls pass headers on stdin**, as `printf 'DD-API-KEY: %s\n' "$DD_API_KEY" | curl -H @- ...`.
  `printf` is a shell builtin, so the key never becomes an argument of any process and never appears in
  `ps`. Writing `-H "DD-API-KEY: $DD_API_KEY"` instead would put it in curl's argv. (`-H @-` needs
  curl 7.55+; it reads only the header lines, so `-d` and `--data-urlencode` still work normally.)
- **Terraform never receives the keys as values at all** for AWS, Azure, and GCP: the Datadog provider
  reads `DD_API_KEY` / `DD_APP_KEY` from the environment, so there are no root variables, no `-var=`
  arguments, and nothing for Terraform to record in state or a saved plan. (OCI is the exception - its
  module needs them as inputs, so there they travel as `TF_VAR_*`.)

Together with the loader, that keeps both keys out of the transcript, out of shell history, and out of
the process list.

**Tools.** `terraform` is required. The `az` CLI is required when Terraform authenticates to Azure
with the Azure CLI session, which is the default in Phase 3. If the user already authenticates the
`azurerm` provider another way (service principal or OIDC through `ARM_*` environment variables, or a
managed identity), `az` is optional: it then only looks up the subscription ID, lists existing exports
and checks resource provider registration, and each of those steps has a portal fallback. `jq` is
optional too: it pretty-prints the Phase 4 response and is needed for the completion callback at the end.

```bash
command -v terraform || echo "MISSING terraform - https://developer.hashicorp.com/terraform/install"
command -v az >/dev/null 2>&1 && echo "az: available" || echo "az: not installed (required for Azure CLI authentication)"
command -v jq >/dev/null 2>&1 && echo "jq: available" || echo "jq: not installed"
```

If `az` is missing, ask how the user authenticates Terraform to Azure. Stop and have them install the
Azure CLI unless they confirm a non-CLI method.

**The snippets here are POSIX shell.** Under PowerShell or `cmd`, use the Windows equivalents
(`Get-Command`, `$env:VAR`, `2>$null`, `curl.exe`) - same calls, same order.

## Phase 1: Select Azure Integration

Fetch the list of Azure tenants already integrated with Datadog:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
resp=$(printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
  | curl -sS -w '\n%{http_code}' -X GET -H @- "https://api.${DD_SITE}/api/v1/integration/azure")
code=$(printf '%s' "$resp" | tail -1); body=$(printf '%s' "$resp" | sed '$d')
[ "$code" = "200" ] || { echo "lookup failed with HTTP $code - do not assume 'no integrations':"; printf '%s\n' "$body"; exit 1; }
printf '%s\n' "$body"
```

If the lookup fails (non-`200`, including `403` when the app key lacks permission to read
integrations), stop and show the user the error. Do not treat a failed lookup as "no integrations"
and do not start a new integration setup because of it.

The response is a JSON array of integrations (an empty array `[]` means none). If it is anything
else, such as an object or an error payload, stop and show it to the user rather than guessing.
Each entry includes:
- `tenant_name` - the Azure AD tenant ID
- `client_id` - the Datadog app registration client ID
- `resource_collection_enabled` - whether resource collection is on

### If integrations are found

Present the list and ask which tenant the user wants to set up CCM for, including an "add a new
tenant" option. **Always ask which tenant - never auto-pick, even if exactly one is returned, and never
assume the tenant or subscription the active `az` CLI session is set to is the one the user means.**
Confirm the selected tenant before any change (the resource-collection update and the Terraform applies).
For example:

> I found these Azure tenants connected to Datadog:
>
> 1. `<TENANT_ID_1>` (client `<CLIENT_ID_1>`)
> 2. `<TENANT_ID_2>` (client `<CLIENT_ID_2>`)
>
> Which tenant would you like to set up Cloud Cost Management for? Or I can help you add a new
> tenant.

If the user wants to add a **new** tenant, complete the `dd-azure-integration` skill first,
then return here.

Record the selected integration's `tenant_name` and `client_id` - both are needed for the Terraform.

**Enable resource collection** if it is not already enabled on the selected integration. Resource
collection is required for CCM cost recommendations and resource-level attribution. First check
whether a Terraform project in this directory manages the integration:

```bash
grep -rnE --include='*.tf' --include='*.tf.json' 'datadog_integration_azure' .
```

If a `datadog_integration_azure` resource for this tenant and client exists there, do not send the
`PUT`: the Phase 3 plan would set the flag back to that resource's value. Set
`resource_collection_enabled = true` in that resource instead, and the change goes through the
approved Phase 3 plan. Otherwise, before sending the `PUT`, tell the user which tenant (and `client_id`) you are about to change and wait for explicit
confirmation. Then use the update endpoint to enable it:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" |
curl -H @- -sS -w '\nHTTP %{http_code}\n' -X PUT "https://api.${DD_SITE}/api/v1/integration/azure" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_name": "<TENANT_ID>",
    "client_id": "<CLIENT_ID>",
    "resource_collection_enabled": true
  }'
```

Confirm the response is HTTP `200`; otherwise show the user the error before continuing.

### Check for Existing UC Configuration

Before generating any Terraform, check whether CCM is already set up for the selected tenant so
you don't create duplicate storage accounts, exports, or role assignments:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
resp=$(printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
  | curl -sS -w '\n%{http_code}' -X GET -H @- "https://api.${DD_SITE}/api/v2/cost/azure_uc_config")
code=$(printf '%s' "$resp" | tail -1); body=$(printf '%s' "$resp" | sed '$d')
[ "$code" = "200" ] || { echo "lookup failed with HTTP $code - do not assume 'no existing config':"; printf '%s\n' "$body"; exit 1; }
printf '%s\n' "$body"
```

As with the integration lookup, a non-`200` response means the check failed, not that no config
exists. Stop and show the error.

The response is a JSON:API document: `data` is an array of config pairs, and each pair holds the
actual and amortized export records in `data[].attributes.configs[]`. `account_id`, `client_id`,
`scope`, `storage_account`, `status` and `error_messages` live on those nested records, not on
`data[]` itself. If `data` is missing or is not an array, or a pair has no `attributes.configs`
array, stop and show the response to the user; do not conclude that no config exists.

Filter the nested `configs[]` records for ones whose `account_id` matches the selected `tenant_name`.
If one or more match, surface them to the user and ask how to proceed - never silently continue and
never auto-mark the flow complete:

> I found an existing Azure UC config for tenant `<TENANT_ID>` (scope `<SCOPE>`,
> storage `<STORAGE_ACCOUNT>`). What would you like to do?
>
> 1. Confirm - CCM is already set up for this scope. Verify and finish.
> 2. Add another cost scope in this tenant.
> 3. Set up CCM for a different Azure tenant.
> 4. Add a new Azure tenant.

Handle the choice:

- **(1)** - skip to Phase 4 to verify the config's status and report completion.
- **(2)** - proceed to Phase 2. Once the scope is chosen there, compare it with the matching records:
  if a record with the selected `client_id` already has that `scope`, it is the same config, so go
  back to option (1) instead of creating a duplicate. If the new scope contains or sits inside an
  existing one (for example a subscription under a configured billing account), tell the user the
  scopes overlap and ask before continuing.
- **(3)** - go back to the tenant list and ask again.
- **(4)** - complete the `dd-azure-integration` skill first, then return here.

If no entries match, proceed to Phase 2.

### If no integrations are found

Tell the user they need to set up the Azure integration first. If yes, complete the
`dd-azure-integration` skill first, then return here.

## Phase 2: Select Scope and Collect Configuration

### 1. Scope Type

Ask the user which scope they want to track costs for:

| Scope | Description |
|---|---|
| **Billing Account** | All subscriptions under a billing account - broadest view |
| **Subscription** | A single Azure subscription |
| **Resource Group** | A specific resource group within a subscription |

Default to **Subscription** if unsure.

### 2. Scope Details

Based on the scope type, collect:

**Billing Account scope:**
- Billing Account ID. Azure billing account IDs vary by agreement type (Enterprise Agreement,
  Microsoft Customer Agreement, etc.) so there is no single format to validate against. Echo
  the value back to the user and have them confirm before continuing.

**Subscription scope:**
- Subscription ID. If `command -v az >/dev/null 2>&1` succeeds, fetch with
  `az account show --query id -o tsv`. This returns the subscription the `az` CLI is currently set to -
  confirm it's the one the user means (never assume), and if not, have them switch or provide the right ID.
  Otherwise ask the user to copy it from portal.azure.com → **Subscriptions**.

**Resource Group scope:**
- Subscription ID
- Resource Group name

### 3. Storage and Exports

Ask the user which combination applies. This matters because certain combinations aren't valid
(an existing export already has a storage container baked into its configuration, so it cannot
be paired with a new storage account):

| Scenario | Storage | Exports | Supported |
|---|---|---|---|
| Net-new setup | Create new | Create new | Yes |
| Bring your own storage | Use existing | Create new | Yes |
| Bring your own everything | Use existing | Use existing | Yes |
| New storage + existing exports | Create new | Use existing | **Not possible** |

Default to **net-new setup** for first-time users. Record the choice - it drives which
sections of Terraform get generated in Phase 3.

### 4. Storage Configuration

| Field | Description | Default |
|---|---|---|
| **Resource Group** | Resource group for the storage account (may differ from a Resource Group cost scope) | (must exist) |
| **Storage Subscription ID** | Subscription that holds the storage account resource group; the `azurerm` provider is bound to it | The scope's subscription (required for Billing Account scope) |
| **Location** | Azure region | `eastus` |
| **Storage Account Name** | Name for the storage account | `ddstorage` |
| **Container Name** | Blob container for exports | `dd-exports` |

If using existing storage, confirm the account name, container name, and resource group from
the user (these will become data-source references in Terraform, not new resources).

### 5. Export Configuration

Only needed if creating new exports. If using existing exports, fetch their names and paths
from the Azure portal (**Cost Management** → **Exports**), or, if `command -v az >/dev/null 2>&1` succeeds, use
`az costmanagement export list --scope "<SCOPE_VALUE>"` (the scope in the format shown in Section 5 of
`references/terraform.md`, for example `/subscriptions/<SUBSCRIPTION_ID>`). Record them either way.

| Field | Description | Default |
|---|---|---|
| **Actual Cost Export Name** | Name for the actual cost export | `actual-export` |
| **Actual Cost Export Path** | Path prefix in the container | `dd-actual-exports` |
| **Amortized Cost Export Name** | Name for the amortized cost export | `amortized-export` |
| **Amortized Cost Export Path** | Path prefix in the container | `dd-amortized-exports` |

## Phase 3: Generate and Apply Terraform

### Check for Existing Terraform

```bash
find . -maxdepth 1 -type f \( -name '*.tf' -o -name '*.tf.json' -o -name 'terraform.tfstate' \) -print
```

Reuse existing providers if found, but only after checking that they target the selected tenant, the
storage subscription and the Datadog site from Phase 0. Only add the new CCM resources.

### Generate Terraform HCL

The full HCL for every section below is in **`references/terraform.md`**. Read it now and emit the
sections that apply, with the placeholders filled in. Which sections to generate follows from the
Storage and Exports scenario recorded in Phase 2:

| Section | When to include it |
|---|---|
| **Section 1: Storage Account and Container** | Only if creating new storage |
| **Section 2: Existing Storage Reference** | Only if using existing storage |
| **Section 3: Cost Exports** | Only if creating new exports - always both, one for actual cost and one for amortized |
| **Section 4: Role Assignments** | Always; add `datadog_cost_reader` for **Subscription** and **Resource Group** scopes only, not for Billing Account scope |
| **Section 5: Datadog Azure UC Config** | Always - pick one variant from the table below |

Pick the Section 5 variant that matches the path the user is on. The `client_id`, `storage_account`,
and `storage_container` fields are written DIFFERENTLY for the resource-reference case (unquoted) vs
the literal case (quoted) - do not mix the two.

| Path | Variant | `client_id` | `storage_account` | `storage_container` |
|---|---|---|---|---|
| New storage (Section 1), and `azuread_application.datadog_app` is declared in this Terraform root module | **A** | HCL reference, unquoted | HCL reference, unquoted | HCL reference, unquoted |
| New storage (Section 1), app registration not declared in this root module (existing integration, or one created from another directory) | **B** | literal existing `client_id`, quoted | HCL reference, unquoted | HCL reference, unquoted |
| Existing storage (Section 2) | **C** | literal existing `client_id`, quoted | data-source `.name`, unquoted | literal container name, quoted |

Use Variant A only after confirming that `resource "azuread_application" "datadog_app"` exists in the
`.tf` files of the current directory **and** that it is the app registration of the integration
selected in Phase 1. Its client ID, from
`terraform state show -no-color azuread_application.datadog_app | grep -E '^ *client_id '`, must
equal the selected `client_id`; if the resource is not in state yet, treat it as a mismatch.
Completing `dd-azure-integration` does not guarantee either. If the resource is absent or its client
ID differs, use the selected integration's literal `client_id` through Variant B or C.

Replace `<CONTAINER_ID_REF>` with:
- `local.ccm_storage_container_id` if creating new (defined in Section 1)
- `local.storage_container_id` if using existing

Replace `<CURRENT_DATE_MIDNIGHT_UTC>` with today's date in `YYYY-MM-DDT00:00:00Z` format.

### Apply Terraform in Two Phases

The `datadog_azure_uc_config` resource (Section 5) validates on Datadog's side that the exports
are reachable and have produced data. If it's applied before file partitioning is enabled and the
exports have run once, the apply will fail. Split the apply into two phases.

**File layout matters.** Put Sections 1-4 in one file (e.g. `azure-ccm.tf`) and Section 5 in a
separate file (e.g. `datadog-ccm-config.tf`). The first phase requires Section 5 **not be present
in the directory** - do not leave it there and try to scope with `-target` alone. A plain
`terraform apply` after a `-target`ed `plan` regenerates a full untargeted plan and will try to
apply Section 5 anyway. Physically isolating the file is the safest way to enforce the split.

#### First Apply - Infrastructure (Sections 1-4)

1. Write Sections 1-4 to `azure-ccm.tf`. Do not write the new Section 5 resource yet. If a `.tf`
   file already holds a `datadog_azure_uc_config` that is in Terraform state
   (`terraform state list 2>/dev/null | grep datadog_azure_uc_config`), leave it in place: removing its
   block makes the plan destroy that config. Only withhold Section 5 resources that have not been
   applied yet; if the new one has to share a file with an applied one, use the saved targeted plan
   below instead.
2. Replace all placeholders with collected values.
3. Ensure `azurerm` and `azuread` providers are configured. If providers are not already
   configured:
   ```hcl
   terraform {
     required_providers {
       azurerm = {
         source  = "hashicorp/azurerm"
         version = ">= 4.9.0, < 6.0.0"
       }
       azuread = {
         source = "hashicorp/azuread"
       }
       datadog = {
         source = "DataDog/datadog"
       }
     }
   }

   provider "azurerm" {
     features {}
     subscription_id = "<STORAGE_SUBSCRIPTION_ID>"
     tenant_id       = "<TENANT_ID>"
   }

   provider "azuread" {
     tenant_id = "<TENANT_ID>"
   }

   provider "datadog" {
     api_url = "https://api.<DD_SITE>/"
   }
   ```

   The template needs AzureRM `4.9.0` or later (for `storage_account_id` on the container) and is
   written to also work on 5.x, which removed `storage_account_name` and `resource_manager_id`. If the
   user's project already pins `azurerm` below `4.9.0`, tell them and ask before changing the pin.
   `azurerm` requires `features {}`, and binding both Azure providers to the selected tenant and
   storage subscription keeps Terraform from acting on whatever the active `az` session points at.
   By default the providers authenticate with the Azure CLI session, so have the user run
   `az login --tenant <TENANT_ID>` first if `az account show --query tenantId -o tsv` does not return
   the selected tenant. If the user confirmed a non-CLI method in Phase 0, keep it: check that its
   `ARM_TENANT_ID` (or equivalent) is the selected tenant instead. The Datadog provider does not read
   `DD_SITE`; without `api_url` (or `DD_HOST`) it defaults to US1, so replace `<DD_SITE>` with the site confirmed in Phase 0. Keys stay out of the
   provider block; it reads `DD_API_KEY` / `DD_APP_KEY` from the environment.

   **Keep plan and state files private.** Saved plans and state can contain storage account access
   keys and connection strings. The plan blocks below set `umask 077`; make sure `*.tfplan` (every
   saved plan here ends in `.tfplan`) and `*.tfstate*` are in `.gitignore`, never commit them, and
   never print state (`terraform state show` on the storage account) into the transcript. If the user declines a plan, delete the plan file.

   **Register the resource providers the template needs.** AzureRM 5.x registers no resource providers
   by default (4.x registered a large set automatically), so on a subscription where they are not yet
   registered the storage account or export creation fails during apply. The template needs
   `Microsoft.Storage` and `Microsoft.CostManagementExports` registered in `<STORAGE_SUBSCRIPTION_ID>`.
   With the `az` CLI, check them:

   ```bash
   for ns in Microsoft.Storage Microsoft.CostManagementExports; do
     printf '%s: ' "$ns"; az provider show --namespace "$ns" --subscription '<STORAGE_SUBSCRIPTION_ID>' --query registrationState -o tsv || echo "lookup failed"
   done
   ```

   If both are `Registered`, continue. Otherwise tell the user which ones are not registered, that
   registering changes the subscription and needs the `*/register/action` permission, and wait for a
   yes before registering them:

   ```bash
   namespaces='<UNREGISTERED_NAMESPACES>'   # space-separated, from the check above
   for ns in $namespaces; do
     az provider register --namespace "$ns" --subscription '<STORAGE_SUBSCRIPTION_ID>' --wait || { echo "registering $ns FAILED"; exit 1; }
   done
   ```

   Without the `az` CLI, ask the user to check **Subscriptions** > **Resource providers** in the Azure
   portal and register any that are not registered, or, with their approval, add
   `resource_providers_to_register = ["Microsoft.Storage", "Microsoft.CostManagementExports"]` to the
   `azurerm` provider block. Terraform then registers them when it configures the provider, which
   happens at plan time as well as apply time, so get the approval before the plan below.

4. Run `terraform init`, then save the plan:

   ```bash
   for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
   DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
   # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
   : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
   umask 077
   terraform plan -out=ccm.tfplan \
     || { echo "terraform plan FAILED - do NOT apply"; exit 1; }
   ```

   Show the plan to the user and wait for explicit approval. If it destroys or replaces an existing
   resource (in particular a `datadog_azure_uc_config`), or sets `resource_collection_enabled` to
   `false` on a `datadog_integration_azure`, do not apply: fix the configuration and re-plan. Only then
   apply **that saved plan** in a separate command:

   ```bash
   for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
   DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
   # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
   : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
   trap 'rm -f ccm.tfplan' EXIT HUP INT TERM
   terraform apply ccm.tfplan \
     || { echo "terraform apply FAILED - do NOT continue to file partitioning"; exit 1; }
   ```

   Because Section 5 is not in the directory, this plan and apply are inherently scoped to the
   infrastructure resources only.

If Section 5 cannot be moved out of the directory for some reason, fall back to a saved targeted
plan:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
umask 077
terraform plan -target=azurerm_storage_account.ccm_storage_account \
               -target=azurerm_storage_container.ccm_storage_container \
               -target=azurerm_<SCOPE>_cost_management_export.actual_cost \
               -target=azurerm_<SCOPE>_cost_management_export.amortized_cost \
               -target=azurerm_role_assignment.datadog_storage_reader \
               -target=azurerm_role_assignment.datadog_cost_reader \
               -out=phase1.tfplan \
  || { echo "terraform plan FAILED - do NOT apply"; exit 1; }
```

Show the plan to the user and wait for explicit approval, then apply it in a separate command:

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
trap 'rm -f phase1.tfplan' EXIT HUP INT TERM
terraform apply phase1.tfplan \
  || { echo "terraform apply FAILED - do NOT continue to file partitioning"; exit 1; }
```

Do not run `terraform apply` without the saved plan file here: a plain `apply` (or a plain
`apply -target`) still regenerates the plan and can pull Section 5 in if Terraform's graph
includes it, depending on state.

#### Post-Apply: Enable File Partitioning (manual, required before second apply)

The user must **manually enable file partitioning** on both exports in the Azure portal. This
cannot be done via Terraform, and it must happen before the second apply. Instruct the user to:

1. Go to the Azure portal → Cost Management → Exports
2. For each export (actual and amortized):
   - Click the export name
   - Enable "File Partitioning"
   - Save
3. Run both exports manually once and wait for them to complete

#### Second Apply - Datadog CCM Config (Section 5)

After file partitioning is enabled and both exports have run once successfully, add Section 5:

1. Write `datadog-ccm-config.tf` with Section 5 (or restore it if it was moved out).
2. Save the plan, show it to the user, and apply that file only after explicit approval. The plan should contain only the new
   `datadog_azure_uc_config` resource, since the infrastructure already exists in state - applying the
   saved file is what guarantees the user gets the plan they approved:

   ```bash
   for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
   DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
   # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
   : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
   umask 077
   terraform plan -out=ccm.tfplan \
     || { echo "terraform plan FAILED - do NOT apply"; exit 1; }
   ```

   After the user approves the plan, apply it in a separate command:

   ```bash
   for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
   DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
   # Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
   : "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
   trap 'rm -f ccm.tfplan' EXIT HUP INT TERM
   terraform apply ccm.tfplan \
     || { echo "terraform apply FAILED - do NOT verify or report success"; exit 1; }
   ```

If `datadog_azure_uc_config` errors out on the second apply, the most common causes are:
- File partitioning wasn't enabled on one or both exports
- Exports haven't run yet (or haven't completed)
- The role assignments from Section 4 haven't propagated yet (can take a few minutes)

## Phase 4: Verify Setup

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
resp=$(printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY" \
  | curl -sS -w '\n%{http_code}' -X GET -H @- "https://api.${DD_SITE}/api/v2/cost/azure_uc_config")
code=$(printf '%s' "$resp" | tail -1); body=$(printf '%s' "$resp" | sed '$d')
[ "$code" = "200" ] || { echo "lookup failed with HTTP $code - do not assume 'not registered':"; printf '%s\n' "$body"; exit 1; }
printf '%s\n' "$body" | jq . 2>/dev/null || printf '%s\n' "$body"
```

Apply the same shape check as in Phase 1 (stop on a missing or non-array `data` or `configs`). Find
the nested `configs[]` records whose `account_id`, `client_id` and `scope` match the selected tenant,
client and scope (on the existing-config path from Phase 1, the scope and exports of the config the
user confirmed), and confirm both an actual and an amortized record exist with the expected
`export_name` and `storage_account`. Then check each record's `status` and `error_messages`:

- `active` with no error messages on both - setup succeeded. Cost data may still take time to
  appear; say so rather than claiming costs are already flowing.
- `warn` or `error`, or any `error_messages` - show the user the messages. Do not report success or
  send the completion callback; help them fix the cause (see the common causes above) and verify
  again.
- `disabled`, a missing record, or mismatched scope or export identities - setup is not complete.
  Tell the user and do not send the callback.

### Wrap Up

After verification, make one best-effort completion callback so the Datadog UI can finish this flow.

`DD_WORKFLOW_ID` is the UUID of the Datadog user the credentials belong to, which is how the UI
correlates this callback. Resolve it and post in **one** command: many agent runtimes start a fresh
shell per command, so a variable assigned in an earlier one would be empty here.

**Never print the `current_user` response**: it also carries the user's name, handle, and email. If no
JSON parser is available, skip this callback rather than installing one or blocking setup.

```bash
for f in .env.local .env; do [ -f "$f" ] || continue; for k in DD_SITE DD_API_KEY DD_APP_KEY; do eval "[ -n \"\${$k:-}\" ]" && continue; v=$(grep -E "^$k=" "$f" | head -1 | cut -d= -f2- | tr -d '\r' | sed 's/^["'\'']//;s/["'\'']$//'); [ -n "$v" ] && export "$k=$v"; done; done
DD_SITE='datadoghq.com'; export DD_SITE   # <- replace with the site confirmed in Phase 0.
# Explicit assignment, not ':=': a wrong non-empty DD_SITE in .env would otherwise survive.
: "${DD_API_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"; : "${DD_APP_KEY:?not set - run dd-account-setup (commercial sites) or supply it directly (government sites)}"
dd_headers() { printf 'DD-API-KEY: %s\nDD-APPLICATION-KEY: %s\n' "$DD_API_KEY" "$DD_APP_KEY"; }
DD_WORKFLOW_ID="$(dd_headers | curl -H @- -fsS --connect-timeout 5 --max-time 20 \
  "https://api.${DD_SITE}/api/v2/current_user" | jq -er '.data.id')" \
  && dd_headers | curl -H @- -sS --connect-timeout 5 --max-time 20 -X POST \
  "https://api.${DD_SITE}/api/unstable/integration/azure/workflow/agentic_ccm" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{"data":{"type":"integration_setup_status","id":"'"${DD_WORKFLOW_ID}"'","attributes":{"status":"finished","step":"setup_complete"}}}'
```

Do not retry or block setup if this callback fails.

Finish with a short summary for the user: the Azure tenant and cost scope now feeding Cloud Cost
Management, the storage account and container in use (created or reused), the actual and amortized
export names (created or reused), and whether resource collection was enabled in Phase 1 or was
already on.

Let the user know:
- Cost data typically takes **24-48 hours** to appear after the first export runs.
- Azure runs exports daily - the first data may arrive within a few hours.

Provide links:
- **Cloud Cost Management**: `<APP_BASE>/cost`
- **Cost Analytics**: `<APP_BASE>/cost/analytics`

## Important Notes

- Azure cost exports can only be created by users with Billing Account or Subscription-level permissions.
- File partitioning must be enabled manually in the Azure portal - Terraform does not support this.
- The `lifecycle { ignore_changes = all }` block prevents Terraform from overriding manual Azure portal changes.
- The `azuread` provider is required for the service principal data source.
- Never run `terraform apply` without showing the plan to the user first.
