diff --git a/src/ecs/constants.ts b/src/ecs/constants.ts index 34b182670..5f224ee06 100644 --- a/src/ecs/constants.ts +++ b/src/ecs/constants.ts @@ -44,3 +44,9 @@ export const DatadogEcsBaseDefaultProps: DatadogECSBaseProps = { timeout: Duration.seconds(5), }, }; + +/** + * IDs of the global tags warnings + */ +export const GlobalTagsWhitespaceWarningId = "datadog-cdk-constructs-v2:globalTagsWhitespace"; +export const AgentTagsCommaWarningId = "datadog-cdk-constructs-v2:agentTagsComma"; diff --git a/src/ecs/fargate/README.md b/src/ecs/fargate/README.md index 9831fc211..7b02f0014 100644 --- a/src/ecs/fargate/README.md +++ b/src/ecs/fargate/README.md @@ -160,7 +160,7 @@ For more general information, reference the [Datadog ECS Fargate Docs](https://d | `site` | `string` | The Datadog site to send data to. | | `clusterName` | `string` | The cluster name to use for tagging. | | `environmentVariables` | `Record` | Datadog Agent environment variables. Used to customize your Datadog Agent configuration. | -| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Overrides any `DD_TAGS` values in `environmentVariables`. | +| `globalTags` | `string` | Global tags to apply to all data sent by the Agent and the Fluent Bit log router. Use a comma-separated list of `key:value` pairs, for example `env:prod,team:payments`. Tag values can't contain commas or whitespace. Overrides any `DD_TAGS` values in `environmentVariables`. | | `checksCardinality` | `Cardinality` | The tag cardinality for checks run by the Agent. | | `dogstatsd` | `DogstatsdFeatureConfig` | DogStatsD feature configuration. | | `apm` | `APMFeatureConfig` | APM feature configuration. | diff --git a/src/ecs/fargate/datadog-ecs-fargate.ts b/src/ecs/fargate/datadog-ecs-fargate.ts index e41c47798..cb65e40d0 100644 --- a/src/ecs/fargate/datadog-ecs-fargate.ts +++ b/src/ecs/fargate/datadog-ecs-fargate.ts @@ -42,6 +42,7 @@ import { DatadogECSFargateProps, LoggingType } from "./interfaces"; import { copyAwsLogsDriver, mergeFargateProps, validateECSFargateProps } from "./utils"; import { addCdkConstructVersionTag, + addGlobalTagsWarnings, configureEcsPolicies, getSecretApiKey, isCpuArchitectureArm64, @@ -107,6 +108,7 @@ export class DatadogECSFargateTaskDefinition extends ecs.FargateTaskDefinition { this.datadogProps = this.getCompleteProps(props, datadogProps); validateECSBaseProps(this.datadogProps); validateECSFargateProps(this.datadogProps); + addGlobalTagsWarnings(this, this.datadogProps); this.datadogContainer = this.createAgentContainer(this.datadogProps); @@ -483,7 +485,8 @@ export class DatadogECSFargateTaskDefinition extends ecs.FargateTaskDefinition { const logServiceName = name ?? fluentbitLogDriverConfig.serviceName; const logSourceName = name ?? fluentbitLogDriverConfig.sourceName; - let logTags = this.datadogProps.envVarManager.retrieve("DD_TAGS"); + // Fluent Bit expects comma-separated tags, so use globalTags instead of the converted DD_TAGS + let logTags = this.datadogProps.globalTags ?? this.datadogProps.envVarManager.retrieve("DD_TAGS"); if (this.datadogProps.clusterName !== undefined) { if (logTags === undefined) { logTags = ""; diff --git a/src/ecs/fargate/environment.ts b/src/ecs/fargate/environment.ts index d5905d0ba..f4e55d0d4 100644 --- a/src/ecs/fargate/environment.ts +++ b/src/ecs/fargate/environment.ts @@ -11,6 +11,7 @@ import { FargateDefaultEnvVars } from "./constants"; import { DatadogECSFargateProps } from "./interfaces"; import * as versionJson from "../../../version.json"; import { EnvVarManager } from "../environment"; +import { toAgentTags } from "../utils"; export class FargateEnvVarManager extends EnvVarManager { constructor(props: DatadogECSFargateProps) { @@ -34,7 +35,9 @@ export class FargateEnvVarManager extends EnvVarManager { "and the props. The environment variable will be overwritten.", ); } - this.add("DD_TAGS", props.globalTags); + if (props.globalTags !== undefined) { + this.add("DD_TAGS", toAgentTags(props.globalTags)); + } this.add("DD_CLUSTER_NAME", props.clusterName); if (props.dogstatsd!.isOriginDetectionEnabled) { diff --git a/src/ecs/interfaces.ts b/src/ecs/interfaces.ts index c96768042..d78143769 100644 --- a/src/ecs/interfaces.ts +++ b/src/ecs/interfaces.ts @@ -89,6 +89,8 @@ export interface DatadogECSBaseProps { readonly environmentVariables?: Record; /** * Global tags to apply to all data sent by the Agent. + * Use a comma-separated list of key:value pairs, for example "env:prod,team:payments". + * Tag values can't contain commas or whitespace. * Overrides any DD_TAGS values in environmentVariables. */ readonly globalTags?: string; diff --git a/src/ecs/managed-instances/README.md b/src/ecs/managed-instances/README.md index e50f15716..733832c44 100644 --- a/src/ecs/managed-instances/README.md +++ b/src/ecs/managed-instances/README.md @@ -132,7 +132,7 @@ For more general information, reference the [Datadog ECS Managed Instances Docs] | `site` | `string` | The Datadog site to send data to. | | `clusterName` | `string` | The cluster name to use for tagging. Sets `DD_CLUSTER_NAME`. | | `environmentVariables` | `Record` | Datadog Agent environment variables. Used to customize your Datadog Agent configuration. Variables the construct sets from its own properties (for example `DD_APM_ENABLED`, `DD_USE_DOGSTATSD`, `DD_TAGS`) take precedence over the same variable here. | -| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Overrides any `DD_TAGS` values in `environmentVariables`. | +| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Use a comma-separated list of `key:value` pairs, for example `env:prod,team:payments`. Tag values can't contain commas or whitespace. Overrides any `DD_TAGS` values in `environmentVariables`. | | `checksCardinality` | `Cardinality` | The tag cardinality for checks run by the Agent. | | `dogstatsd` | `DogstatsdFeatureConfig` | DogStatsD feature configuration. | | `apm` | `APMFeatureConfig` | APM feature configuration. | diff --git a/src/ecs/managed-instances/datadog-ecs-managed-instances.ts b/src/ecs/managed-instances/datadog-ecs-managed-instances.ts index d2c3505a1..3054e6230 100644 --- a/src/ecs/managed-instances/datadog-ecs-managed-instances.ts +++ b/src/ecs/managed-instances/datadog-ecs-managed-instances.ts @@ -23,7 +23,7 @@ import { DatadogECSManagedInstancesInternalProps } from "./internal.interfaces"; import { mergeManagedInstancesProps, validateECSManagedInstancesProps } from "./utils"; import * as versionJson from "../../../version.json"; import { TagKeys } from "../../constants"; -import { getSecretApiKey, validateECSBaseProps } from "../utils"; +import { addGlobalTagsWarnings, getSecretApiKey, validateECSBaseProps } from "../utils"; type VolumeProperty = ecs.CfnDaemonTaskDefinition.VolumeProperty; type MountPointProperty = ecs.CfnDaemonTaskDefinition.MountPointProperty; @@ -137,6 +137,7 @@ export class DatadogECSManagedInstancesDaemonTaskDefinition extends Construct { this.datadogProps = this.getCompleteProps(datadogProps); validateECSBaseProps(this.datadogProps); validateECSManagedInstancesProps(this.datadogProps); + addGlobalTagsWarnings(this, this.datadogProps); this.executionRole = this.resolveExecutionRole(); this.taskRole = this.resolveTaskRole(); diff --git a/src/ecs/managed-instances/environment.ts b/src/ecs/managed-instances/environment.ts index 3efcdec19..bd6e9b9f7 100644 --- a/src/ecs/managed-instances/environment.ts +++ b/src/ecs/managed-instances/environment.ts @@ -11,6 +11,7 @@ import { ContainerCriSocketPath, ManagedInstancesDefaultEnvVars } from "./consta import { DatadogECSManagedInstancesProps } from "./interfaces"; import * as versionJson from "../../../version.json"; import { EnvVarManager } from "../environment"; +import { toAgentTags } from "../utils"; export class ManagedInstancesEnvVarManager extends EnvVarManager { constructor(props: DatadogECSManagedInstancesProps, isSocketRequired: boolean, isProtocolRequired: boolean) { @@ -35,7 +36,9 @@ export class ManagedInstancesEnvVarManager extends EnvVarManager { "and the props. The environment variable will be overwritten.", ); } - this.add("DD_TAGS", props.globalTags); + if (props.globalTags !== undefined) { + this.add("DD_TAGS", toAgentTags(props.globalTags)); + } this.add("DD_CLUSTER_NAME", props.clusterName); if (props.dogstatsd!.isEnabled && props.dogstatsd!.isOriginDetectionEnabled) { diff --git a/src/ecs/utils.ts b/src/ecs/utils.ts index 0bc33816a..232428d72 100644 --- a/src/ecs/utils.ts +++ b/src/ecs/utils.ts @@ -6,13 +6,14 @@ * Copyright 2020-2026 Datadog, Inc. */ -import { Tags } from "aws-cdk-lib"; +import { Annotations, Fn, Tags, Token } from "aws-cdk-lib"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; import log from "loglevel"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "./constants"; import { DatadogECSBaseProps } from "./interfaces"; import * as versionJson from "../../version.json"; import { invalidSiteError, siteList, TagKeys } from "../constants"; @@ -111,3 +112,53 @@ export function addCdkConstructVersionTag(task: ecs.TaskDefinition): void { includeResourceTypes: ["AWS::ECS::TaskDefinition"], }); } + +/** + * Returns true for a JSON array of tags, which the Agent also accepts in DD_TAGS. + */ +function isJsonArrayTags(tags: string): boolean { + return tags.trimStart().startsWith("["); +} + +/** + * Converts comma-separated global tags to the whitespace-separated format that the Agent reads from DD_TAGS. + * A JSON array of tags is passed through unchanged. For a token, CloudFormation does the conversion at deploy time. + */ +export function toAgentTags(globalTags: string): string { + if (isJsonArrayTags(globalTags)) { + return globalTags; + } + if (Token.isUnresolved(globalTags)) { + return Fn.join(" ", Fn.split(",", globalTags)); + } + return globalTags.replace(/,/g, " "); +} + +/** + * Warns about global tags and DD_TAGS values that the construct can't format correctly for the Agent. + */ +export function addGlobalTagsWarnings(scope: Construct, props: DatadogECSBaseProps): void { + const globalTags = props.globalTags; + if (globalTags === undefined) { + const agentTags = props.environmentVariables?.DD_TAGS; + if (agentTags !== undefined && agentTags.includes(",") && !isJsonArrayTags(agentTags)) { + Annotations.of(scope).addWarningV2( + AgentTagsCommaWarningId, + "DD_TAGS in `environmentVariables` contains commas, but the Datadog Agent separates tags with whitespace. Set `globalTags` instead, which accepts comma-separated tags.", + ); + } + return; + } + if (isJsonArrayTags(globalTags)) { + return; + } + for (const tag of globalTags.split(",")) { + if (/\S\s+\S/.test(tag)) { + Annotations.of(scope).addWarningV2( + GlobalTagsWhitespaceWarningId, + `\`globalTags\` item "${tag.trim()}" contains whitespace. Separate tags with commas. Tag values can't contain whitespace.`, + ); + return; + } + } +} diff --git a/src/sample/ecs_fargate/index.ts b/src/sample/ecs_fargate/index.ts index 3c4713f23..637a36dea 100644 --- a/src/sample/ecs_fargate/index.ts +++ b/src/sample/ecs_fargate/index.ts @@ -48,9 +48,7 @@ export class ExampleStack extends Stack { apiKeySecret: secret, clusterName: cluster.clusterName, isDatadogDependencyEnabled: false, - environmentVariables: { - DD_TAGS: "team:cont-p, owner:container-monitoring", - }, + globalTags: "team:cont-p,owner:container-monitoring", dogstatsd: { isEnabled: true, }, @@ -131,7 +129,7 @@ export class ExampleStack extends Stack { }, { apiKeySecret: secret, - globalTags: "team:cont-p, owner:container-monitoring", + globalTags: "team:cont-p,owner:container-monitoring", }, ); task.addContainer("DummyDogstatsd", { diff --git a/test/ecs/fargate/agent.spec.ts b/test/ecs/fargate/agent.spec.ts index 9e14283ce..d6170ec6f 100644 --- a/test/ecs/fargate/agent.spec.ts +++ b/test/ecs/fargate/agent.spec.ts @@ -97,7 +97,7 @@ describe("DatadogECSFargateTaskDefinition", () => { Environment: Match.arrayWith([ Match.objectEquals({ Name: "DD_TAGS", - Value: "tag1:value1,tag2:value2", + Value: "tag1:value1 tag2:value2", }), Match.objectEquals({ Name: "DD_CUSTOM_ENV_VAR", diff --git a/test/ecs/fargate/logging.spec.ts b/test/ecs/fargate/logging.spec.ts index 0a5634222..349866b10 100644 --- a/test/ecs/fargate/logging.spec.ts +++ b/test/ecs/fargate/logging.spec.ts @@ -1,8 +1,9 @@ import * as cdk from "aws-cdk-lib"; -import { Template, Match } from "aws-cdk-lib/assertions"; +import { Annotations, Template, Match } from "aws-cdk-lib/assertions"; import * as ecs from "aws-cdk-lib/aws-ecs"; import { Construct } from "constructs"; import * as ecsDatadog from "../../../src/ecs"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "../../../src/ecs/constants"; import { ParseJsonFirelensConfigFileType } from "../../../src/ecs/fargate/constants"; describe("DatadogECSFargateLogging", () => { @@ -446,4 +447,100 @@ describe("DatadogECSFargateLogging", () => { ]), }); }); + + describe("global tags", () => { + const agentContainer = (template: Template) => { + const [taskDefinition] = Object.values(template.findResources("AWS::ECS::TaskDefinition")); + return taskDefinition.Properties.ContainerDefinitions.find((c: any) => c.Name === "datadog-agent"); + }; + const agentTags = (template: Template) => + agentContainer(template).Environment.find((e: any) => e.Name === "DD_TAGS")?.Value; + const logTags = (template: Template) => agentContainer(template).LogConfiguration.Options.dd_tags; + + it.each([ + ["env:prod,team:payments", "env:prod team:payments", "env:prod,team:payments, ecs_cluster_name:test-cluster"], + ["env:prod, team:payments", "env:prod team:payments", "env:prod, team:payments, ecs_cluster_name:test-cluster"], + ["env:prod", "env:prod", "env:prod, ecs_cluster_name:test-cluster"], + ])("sends %p to the Agent as %p and to the log router as %p", (globalTags, expectedAgentTags, expectedLogTags) => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + clusterName: "test-cluster", + globalTags, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual(expectedAgentTags); + expect(logTags(template)).toEqual(expectedLogTags); + }); + + it("sends global tags to the log router unchanged when clusterName is not set", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod,team:payments", + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + }); + + it("passes a JSON array of global tags to the Agent and the log router unchanged", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: '["env:prod","team:payments"]', + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual('["env:prod","team:payments"]'); + expect(logTags(template)).toEqual('["env:prod","team:payments"]'); + }); + + it("converts token global tags for the Agent with CloudFormation functions and keeps them for the log router", () => { + const param = new cdk.CfnParameter(stack, "Tags"); + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: param.valueAsString, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual({ "Fn::Join": [" ", { "Fn::Split": [",", { Ref: "Tags" }] }] }); + expect(logTags(template)).toEqual({ Ref: "Tags" }); + }); + + it("uses global tags instead of DD_TAGS in environmentVariables for the Agent and the log router", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod,team:payments", + environmentVariables: { DD_TAGS: "old:value" }, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + }); + + it("sends DD_TAGS in environmentVariables unchanged when global tags are not set", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + environmentVariables: { DD_TAGS: "env:prod,team:payments" }, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod,team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + Annotations.fromStack(stack).hasWarning("*", Match.stringLikeRegexp(`\\[ack: ${AgentTagsCommaWarningId}\\]`)); + }); + + it("warns when a global tags item contains whitespace", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod team:payments", + }); + + Annotations.fromStack(stack).hasWarning( + "*", + Match.stringLikeRegexp(`\\[ack: ${GlobalTagsWhitespaceWarningId}\\]`), + ); + }); + }); }); diff --git a/test/ecs/managed-instances/agent.spec.ts b/test/ecs/managed-instances/agent.spec.ts index c1d471fd6..5ee7a9193 100644 --- a/test/ecs/managed-instances/agent.spec.ts +++ b/test/ecs/managed-instances/agent.spec.ts @@ -206,6 +206,21 @@ describe("DatadogECSManagedInstancesDaemonTaskDefinition - agent container", () }); }); + it("converts comma-separated global tags to whitespace-separated DD_TAGS", () => { + datadogProps = { ...datadogProps, globalTags: "env:prod,team:payments" }; + new ecsDatadog.DatadogECSManagedInstancesDaemonTaskDefinition(scope, id, datadogProps); + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::ECS::DaemonTaskDefinition", { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Name: "datadog-agent", + Environment: Match.arrayWith([Match.objectEquals({ Name: "DD_TAGS", Value: "env:prod team:payments" })]), + }), + ]), + }); + }); + it("does not set DD_CLUSTER_NAME when clusterName is not provided", () => { new ecsDatadog.DatadogECSManagedInstancesDaemonTaskDefinition(scope, id, datadogProps); const template = Template.fromStack(stack); diff --git a/test/ecs/utils.spec.ts b/test/ecs/utils.spec.ts index a8e1e9d2f..0cb163962 100644 --- a/test/ecs/utils.spec.ts +++ b/test/ecs/utils.spec.ts @@ -1,12 +1,16 @@ import * as cdk from "aws-cdk-lib"; +import { Annotations, Match } from "aws-cdk-lib/assertions"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import { Construct } from "constructs"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "../../src/ecs/constants"; import { validateECSBaseProps, isOperatingSystemLinux, getSecretApiKey, addCdkConstructVersionTag, + addGlobalTagsWarnings, + toAgentTags, } from "../../src/ecs/utils"; describe("utils", () => { @@ -189,4 +193,79 @@ describe("utils", () => { }); }); }); + + describe("toAgentTags", () => { + it.each([ + ["env:prod,team:payments", "env:prod team:payments"], + ["env:prod, team:payments", "env:prod team:payments"], + ["env:prod", "env:prod"], + ["env:prod,", "env:prod "], + ["", ""], + ['["env:prod","team:payments"]', '["env:prod","team:payments"]'], + [' ["env:prod", "team:payments"]', ' ["env:prod", "team:payments"]'], + ])("converts %p to %p", (globalTags, expected) => { + expect(toAgentTags(globalTags)).toEqual(expected); + }); + + it("converts a token with CloudFormation functions", () => { + const param = new cdk.CfnParameter(stack, "Tags"); + const agentTags = toAgentTags(param.valueAsString); + expect(stack.resolve(agentTags)).toEqual({ "Fn::Join": [" ", { "Fn::Split": [",", { Ref: "Tags" }] }] }); + }); + + it("converts a token inside literal tags with CloudFormation functions", () => { + const param = new cdk.CfnParameter(stack, "Env"); + const agentTags = toAgentTags(`env:${param.valueAsString},team:payments`); + expect(stack.resolve(agentTags)).toEqual({ + "Fn::Join": [" ", { "Fn::Split": [",", { "Fn::Join": ["", ["env:", { Ref: "Env" }, ",team:payments"]] }] }], + }); + }); + }); + + describe("addGlobalTagsWarnings", () => { + const warning = (id: string) => Match.stringLikeRegexp(`\\[ack: ${id}\\]`); + + it.each(["env:prod,team:payments", "env:prod, team:payments", "env:prod"])( + "does not warn for comma-separated global tags %p", + (globalTags) => { + addGlobalTagsWarnings(scope, { globalTags, environmentVariables: { DD_TAGS: "a:1,b:2" } }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }, + ); + + it.each(["env:prod team:payments", "env:prod,team:Data Platform"])( + "warns when a global tags item in %p contains whitespace", + (globalTags) => { + addGlobalTagsWarnings(scope, { globalTags }); + Annotations.fromStack(stack).hasWarning("*", warning(GlobalTagsWhitespaceWarningId)); + }, + ); + + it.each([ + ["a token", (param: cdk.CfnParameter) => param.valueAsString], + ["a token inside literal tags", (param: cdk.CfnParameter) => `env:${param.valueAsString},team:payments`], + ])("does not warn when global tags contain %s", (_, globalTags) => { + const param = new cdk.CfnParameter(stack, "Tags"); + addGlobalTagsWarnings(scope, { globalTags: globalTags(param) }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }); + + it("warns when DD_TAGS in environmentVariables contains commas and global tags are not set", () => { + addGlobalTagsWarnings(scope, { environmentVariables: { DD_TAGS: "env:prod,team:payments" } }); + Annotations.fromStack(stack).hasWarning("*", warning(AgentTagsCommaWarningId)); + }); + + it.each([ + [{ globalTags: '["env:prod", "team:Data Platform"]' }], + [{ environmentVariables: { DD_TAGS: '["env:prod","team:payments"]' } }], + ])("does not warn for a JSON array of tags in %p", (props) => { + addGlobalTagsWarnings(scope, props); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }); + + it("does not warn when DD_TAGS in environmentVariables is whitespace-separated", () => { + addGlobalTagsWarnings(scope, { environmentVariables: { DD_TAGS: "env:prod team:payments" } }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }); + }); });