Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aws_cloud_cost_cur2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 0.0.1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nit: Since the repo is for cloudformation mentioning aws in the path doesn't bring much

Copy link
Contributor

@klivan klivan Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this matters: you're using aws_cloud_cost_cur2 in the bucket everywhere, but the folder where this is located is called cloud_cost_cur2. Also, all other folder in this repo start with aws_.
Should we make it aws_cloud_cost_cur2 as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I'll change it to aws_cloud_cost_cur2 for consistency in the repo.


Initial version
49 changes: 49 additions & 0 deletions aws_cloud_cost_cur2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Datadog AWS Cloud Cost Setup (CUR 2.0)

[![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](ADDLINK)

## Overview

This template sets up AWS Cloud Cost Management for Datadog using **CUR 2.0 (Data Exports)**. It creates the necessary AWS resources and configures the Datadog integration automatically.

## AWS Resources

This template creates the following:

- **S3 Bucket** (optional) - For storing cost and usage data
- Bucket policy allowing `bcm-data-exports.amazonaws.com` to write reports
- **BCM Data Export** (optional) - CUR 2.0 export with hourly granularity in Parquet format
- **IAM Policy** - Grants Datadog read access to the S3 bucket and Cost Explorer APIs
- Attached to your existing Datadog integration role
- **Lambda Function** - Calls the Datadog API to configure CCM with the data export details

## Parameters

| Parameter | Description | Required |
|-----------|-------------|----------|
| `DatadogApiKey` | Datadog API key | Yes |
| `DatadogAppKey` | Datadog APP key | Yes |
| `DatadogSite` | Datadog site (e.g., datadoghq.com) | No (default: datadoghq.com) |
| `CloudCostBucketName` | S3 bucket name for cost reports | Yes |
| `CloudCostBucketRegion` | AWS region of the S3 bucket | Yes |
| `CloudCostReportPrefix` | Prefix for report files in S3 | Yes |
| `CloudCostReportName` | Name of the data export | Yes |
| `DatadogIntegrationRole` | Name of your Datadog integration IAM role | Yes |
| `CreateCloudCostReport` | Create a new data export (true/false) | No (default: true) |
| `CreateCloudCostBucket` | Create a new S3 bucket (true/false) | No (default: true) |

## Publishing the Template

Use the release script to upload the template to an S3 bucket:

```sh
./release.sh <bucket_name>
```

Use `--private` to prevent granting public access (useful for testing):

```sh
./release.sh <bucket_name> --private
```

The template will be available at `s3://<bucket_name>/aws_cloud_cost_cur2/v0.0.1/main.yaml`.
130 changes: 130 additions & 0 deletions aws_cloud_cost_cur2/datadog_ccm_api_call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3

import json
import logging
import urllib.request
import urllib.error
import cfnresponse

LOGGER = logging.getLogger()
LOGGER.setLevel(logging.INFO)

API_CALL_SOURCE_HEADER_VALUE = "cfn-ccm-cur2"


def get_datadog_account_uuid(event):
"""Get the Datadog account UUID for this AWS account."""
api_key = event["ResourceProperties"]["APIKey"]
app_key = event["ResourceProperties"]["APPKey"]
api_url = event["ResourceProperties"]["ApiURL"]
account_id = event["ResourceProperties"]["AccountId"]

url = f"https://api.{api_url}/api/v2/integration/aws/accounts?aws_account_id={account_id}"
headers = {
"DD-API-KEY": api_key,
"DD-APPLICATION-KEY": app_key,
"Dd-Aws-Api-Call-Source": API_CALL_SOURCE_HEADER_VALUE,
}
request = urllib.request.Request(url, headers=headers)
request.get_method = lambda: "GET"
try:
response = urllib.request.urlopen(request)
data = json.loads(response.read())
if len(data.get("data", [])) == 0:
return None, "No Datadog integration found for this AWS account"
if len(data["data"]) > 1:
return None, "Multiple Datadog integrations found for this AWS account"
return data["data"][0]["id"], None
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8")
return None, f"Failed to get account: {e.code} - {error_body}"


def patch_ccm_config(event, uuid):
"""PATCH the Datadog account with CCM data export config."""
api_key = event["ResourceProperties"]["APIKey"]
app_key = event["ResourceProperties"]["APPKey"]
api_url = event["ResourceProperties"]["ApiURL"]
bucket_name = event["ResourceProperties"]["BucketName"]
bucket_region = event["ResourceProperties"]["BucketRegion"]
report_name = event["ResourceProperties"]["ReportName"]
report_prefix = event["ResourceProperties"]["ReportPrefix"]

url = f"https://api.{api_url}/api/v2/integration/aws/accounts/{uuid}"
headers = {
"DD-API-KEY": api_key,
"DD-APPLICATION-KEY": app_key,
"Dd-Aws-Api-Call-Source": API_CALL_SOURCE_HEADER_VALUE,
"Content-Type": "application/json",
"Accept": "application/json",
}

payload = {
"data": {
"type": "account",
"attributes": {
"ccm_config": {
"data_export_configs": [
{
"report_name": report_name,
"report_prefix": report_prefix,
"report_type": "CUR2.0",
"bucket_name": bucket_name,
"bucket_region": bucket_region,
}
]
}
}
}
}

data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=headers)
request.get_method = lambda: "PATCH"

try:
response = urllib.request.urlopen(request)
return response.getcode(), json.loads(response.read())
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8")
return e.code, {"error": error_body}


def handler(event, context):
"""Handle Lambda event from AWS CloudFormation."""
LOGGER.info(f"Received event: {json.dumps(event)}")

if event["RequestType"] == "Delete":
# On delete, we don't remove the CCM config - just succeed
LOGGER.info("Delete request - no action needed for CCM config")
cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "Delete successful"})
return

try:
# Get the Datadog account UUID
uuid, error = get_datadog_account_uuid(event)
if error:
LOGGER.error(f"Failed to get account UUID: {error}")
cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": error})
return

LOGGER.info(f"Found Datadog account UUID: {uuid}")

# PATCH the CCM config
status_code, response_data = patch_ccm_config(event, uuid)

if status_code == 200:
LOGGER.info("Successfully configured CCM data export")
cfnresponse.send(event, context, cfnresponse.SUCCESS, {
"Message": "CCM data export configured successfully",
"AccountUUID": uuid,
})
else:
error_msg = f"API returned {status_code}: {response_data}"
LOGGER.error(error_msg)
cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": error_msg})

except Exception as e:
LOGGER.exception("Exception during processing")
cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": str(e)})

Loading
Loading