Skip to content

Commit 03d44ec

Browse files
committed
pr feedback
1 parent ecdd318 commit 03d44ec

14 files changed

Lines changed: 318 additions & 40 deletions

File tree

packages/base/src/commands/ecs-fargate/instrument.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ export type EcsFargateConfigOptions = Partial<{
2525
agentImage: string
2626
}>
2727

28+
/**
29+
* The family a `--task-definition` names, whether it is given as a family, a `family:revision`, or
30+
* a full task definition ARN.
31+
*/
32+
const familyFromTaskDefinition = (taskDefinition: string): string =>
33+
(taskDefinition.split('/').pop() ?? taskDefinition).split(':')[0]
34+
35+
/**
36+
* The families named more than once, which the run cannot act on: a family has one instrumented
37+
* revision
38+
*/
39+
const duplicateFamilies = (taskDefinitions: string[]): string[] => {
40+
const seen = new Set<string>()
41+
const duplicates = new Set<string>()
42+
for (const family of taskDefinitions.map(familyFromTaskDefinition)) {
43+
if (seen.has(family)) {
44+
duplicates.add(family)
45+
}
46+
seen.add(family)
47+
}
48+
49+
return [...duplicates]
50+
}
51+
2852
/**
2953
* Derive the cluster from an ECS service ARN
3054
*/
@@ -77,7 +101,7 @@ export class EcsFargateInstrumentCommand extends BaseCommand {
77101
})
78102

79103
protected dryRun = Option.Boolean('-d,--dry,--dry-run', false, {
80-
description: 'Preview changes running command would apply',
104+
description: 'Preview the changes the command would apply',
81105
})
82106

83107
private taskDefinitions = Option.Array('--task-definition,--taskDefinition', {
@@ -156,6 +180,16 @@ export class EcsFargateInstrumentCommand extends BaseCommand {
156180
if (!config.taskDefinitions?.length) {
157181
errors.push('No task definitions specified to instrument. Use --task-definition.')
158182
}
183+
184+
const duplicates = duplicateFamilies(config.taskDefinitions ?? [])
185+
if (duplicates.length > 0) {
186+
errors.push(
187+
`--task-definition names the same task definition family more than once (${duplicates.join(
188+
', '
189+
)}). A run instruments one revision per family, so name the revision to instrument once.`
190+
)
191+
}
192+
159193
if (config.cluster && !config.ecsServices?.length) {
160194
errors.push('--cluster names the cluster of the services to update, so it only applies with --ecs-service.')
161195
}

packages/datadog-ci/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,6 @@ The following `<scope>` and `<command>` values are available.
113113

114114
- `upload`: Upload iOS dSYM files for Error Tracking (macOS only).
115115

116-
#### `ecs-fargate`
117-
118-
<sub>**README:** [📚](/packages/plugin-ecs-fargate) | **Documentation:** [🔗](https://docs.datadoghq.com/integrations/ecs_fargate/) | **Plugin:** `@datadog/datadog-ci-plugin-ecs-fargate`</sub>
119-
120-
- `instrument`: Apply Datadog instrumentation to an AWS ECS Fargate Task Definition.
121-
122116
#### `flutter-symbols`
123117

124118
<sub>**README:** [📚](/packages/base/src/commands/flutter-symbols) | **Documentation:** [🔗](https://docs.datadoghq.com/real_user_monitoring/error_tracking/flutter/)</sub>
@@ -247,6 +241,12 @@ The following are **beta** commands, you can enable them with with `DD_BETA_COMM
247241
- `correlate-image`: Correlate an image from a CD provider with its source commit. [🔗](https://docs.datadoghq.com/continuous_delivery/deployments/argocd#correlate-images-with-source-code)
248242
- `gate`: Evaluate a Deployment Gate. [🔗](https://docs.datadoghq.com/deployment_gates/)
249243

244+
#### `ecs-fargate`
245+
246+
<sub>**README:** [📚](/packages/plugin-ecs-fargate) | **Documentation:** [🔗](https://docs.datadoghq.com/integrations/aws-fargate/) | **Plugin:** `@datadog/datadog-ci-plugin-ecs-fargate`</sub>
247+
248+
- `instrument`: Apply Datadog instrumentation to an AWS ECS Fargate Task Definition.
249+
250250
#### `elf-symbols`
251251

252252
<sub>**README:** [📚](/packages/base/src/commands/elf-symbols) | **Documentation:** [🔗](https://docs.datadoghq.com/profiler/enabling/ddprof/)</sub>

packages/datadog-ci/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export * as gitMetadata from '@datadog/datadog-ci-base/commands/git-metadata/lib
1515
export * as utils from '@datadog/datadog-ci-base/helpers/utils'
1616
export {cliVersion, printVersion} from '@datadog/datadog-ci-base/version'
1717

18-
export const BETA_COMMANDS = new Set(['deployment', 'elf-symbols', 'wasm-symbols'])
18+
export const BETA_COMMANDS = new Set(['deployment', 'ecs-fargate', 'elf-symbols', 'wasm-symbols'])
1919

2020
const betaCommandsEnabled =
2121
process.env.DD_BETA_COMMANDS_ENABLED === '1' || process.env.DD_BETA_COMMANDS_ENABLED === 'true'

packages/plugin-ecs-fargate/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ You can use the CLI to instrument your AWS ECS Fargate task definitions with Dat
44

55
### `instrument`
66

7+
**Warning:** The `ecs-fargate instrument` command is in beta. It requires you to set `DD_BETA_COMMANDS_ENABLED=1`.
8+
79
Run `datadog-ci ecs-fargate instrument` to add the Datadog Agent sidecar to an ECS Fargate task definition. The command reads the task definitions you name, adds the `datadog-agent` container to each of them, and registers the result as a new revision. Nothing that is running changes until the new revision is deployed, which you can leave to the command with `--ecs-service`.
810

911
```bash
12+
export DD_BETA_COMMANDS_ENABLED=1
13+
1014
# Instrument a task definition, reading the API key from an AWS Secrets Manager secret
1115
datadog-ci ecs-fargate instrument --task-definition my-app -r us-east-1 --api-key-secret-arn <secret-arn>
1216

@@ -26,11 +30,15 @@ datadog-ci ecs-fargate instrument --task-definition my-app -r us-east-1 --api-ke
2630

2731
Application containers are given `DD_TRACE_ENABLED` and `DD_LOGS_INJECTION`, so the tracers already installed in them send traces and tie your logs to those traces. Both are only filled in when the container does not set them itself, so a task definition that has already made a choice keeps it.
2832

33+
The Agent sidecar accepts custom metrics over DogStatsD: `DD_DOGSTATSD_ORIGIN_DETECTION` and `DD_DOGSTATSD_ORIGIN_DETECTION_CLIENT` are turned on and `DD_DOGSTATSD_TAG_CARDINALITY` is set to `orchestrator`, so your metrics are tagged with the task that submitted them. These are filled in the same way, so a task definition that already sets them keeps its own values.
34+
2935
Running the command twice is safe: the Agent container is matched by name, so an already instrumented task definition is reported as such and no revision is registered. Each revision the command registers is tagged `dd_sls_ci` with the version of `datadog-ci` that created it; upgrading the CLI does not on its own produce a new revision, since that tag is not part of the comparison.
3036

3137
#### Deploying the new revision
3238

33-
Pass `--ecs-service` for each service that should run the revision the command just registered, and `--cluster` if those services are not in the `default` cluster. A service named by its full ARN already says which cluster it runs in, so `--cluster` can be left off; passing one that the ARN contradicts is an error rather than a silent choice between them. A run updates services in a single cluster, so ARNs naming more than one are reported too. Each service is matched to the task definition family it currently runs, so a run over several task definitions points each service at its own new revision, and a service already running the instrumented revision is left alone rather than redeployed. Updating a service starts an ECS deployment: the command returns as soon as ECS accepts it, and the rollout follows your service's deployment configuration.
39+
Pass `--ecs-service` for each service that should run the revision the command just registered, and `--cluster` if those services are not in the `default` cluster. A service named by its full ARN already says which cluster it runs in, so `--cluster` can be left off; passing a `--cluster` that contradicts the ARN's cluster is an error, not a silent override. A run updates services in a single cluster, so ARNs naming more than one are reported too. Each service is matched to the task definition family it currently runs, so a run over several task definitions points each service at its own new revision, and a service already running the instrumented revision is left alone rather than redeployed. Updating a service starts an ECS deployment: the command returns as soon as ECS accepts it, and the rollout follows your service's deployment configuration.
40+
41+
Because a service is matched by family, a run instruments one revision per family: naming two revisions of the same family, as `--task-definition my-app:3 --task-definition my-app:4` does, is reported rather than leaving the choice of which one to deploy to the order they were passed in.
3442

3543
Services are only updated once every task definition in the run is instrumented, so a run that fails part way through does not leave half of your services on a new revision. Tasks you start yourself with `RunTask`, and services you do not name, keep running the revision they were on.
3644

@@ -40,14 +48,21 @@ Services are only updated once every task definition in the run is instrumented,
4048

4149
You must have valid [AWS credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html#envvars-list) configured with access to the ECS actions `ecs:DescribeTaskDefinition`, `ecs:RegisterTaskDefinition`, and `ecs:TagResource`. The last one is required because the new revision is registered with tags: the ones the task definition already had, plus `dd_sls_ci`. Deploying with `--ecs-service` also needs `ecs:DescribeServices` and `ecs:UpdateService`.
4250

51+
`--profile` uses a named profile from your AWS configuration instead. A profile with an `mfa_serial` is supported: the command asks for the code when it loads the profile.
52+
4353
#### Datadog API key
4454

45-
Pass `--api-key-secret-arn` with the ARN of an AWS Secrets Manager secret holding your [Datadog API key](https://app.datadoghq.com/organization-settings/api-keys). The Agent reads the key from the secret at runtime, which keeps it out of the task definition. The task's execution role needs `secretsmanager:GetSecretValue` on that secret.
55+
Pass `--api-key-secret-arn` with the ARN of an AWS Secrets Manager secret holding your [Datadog API key](https://app.datadoghq.com/organization-settings/api-keys). The Agent reads the key from the secret at runtime, which keeps it out of the task definition. The task's execution role needs `secretsmanager:GetSecretValue` on that secret. ECS resolves secrets through that role, so a task definition with no `executionRoleArn` is reported and left alone rather than turned into a revision whose tasks cannot start.
56+
57+
If you do not pass `--api-key-secret-arn`, the command falls back to the `DD_API_KEY` environment variable and writes its value into the task definition in plain text, which it warns about. A key given this way is validated against your Datadog site before anything is registered, and is masked in the diff the command prints.
58+
59+
#### Task role
4660

47-
If you do not pass `--api-key-secret-arn`, the command falls back to the `DD_API_KEY` environment variable and writes its value into the task definition in plain text, which it warns about. A key given this way is validated against your Datadog site before anything is registered.
61+
The Agent collects ECS task metadata, which is what tags your telemetry with the task, container, and image it came from. It reads that from the ECS API as the task role, so give the task definition a `taskRoleArn` whose policy allows `ecs:ListClusters`, `ecs:ListContainerInstances`, and `ecs:DescribeContainerInstances`. The command does not change your task role, so it reports a task definition that has none.
4862

4963
#### Environment variables
5064

65+
- `DD_BETA_COMMANDS_ENABLED`: set to `1` to enable this command while it is in beta.
5166
- `DD_API_KEY` (or `DATADOG_API_KEY`): the Datadog API key to write into the task definition, used only when `--api-key-secret-arn` is not passed.
5267
- `DD_SITE` (or `DATADOG_SITE`): the [Datadog site](https://docs.datadoghq.com/getting_started/site/) to send data to. Defaults to `datadoghq.com`.
5368
- `AWS_REGION` (or `AWS_DEFAULT_REGION`): the region to use when `--region` is not passed.
@@ -59,7 +74,7 @@ You can pass the following arguments to `instrument` to specify its behavior. `-
5974
<!-- BEGIN_USAGE:instrument -->
6075
| Argument | Shorthand | Description | Default |
6176
| -------- | --------- | ----------- | ------- |
62-
| `--dry` or `--dry-run` | `-d` | Preview changes running command would apply | `false` |
77+
| `--dry` or `--dry-run` | `-d` | Preview the changes the command would apply | `false` |
6378
| `--task-definition` or `--taskDefinition` | | The family, family:revision, or ARN of the task definition to instrument. Can be specified multiple times. | |
6479
| `--region` | `-r` | The AWS region the task definition lives in | |
6580
| `--profile` | | Specify the AWS named profile credentials to use to instrument. Learn more about AWS named profiles here: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html#using-profiles | |

packages/plugin-ecs-fargate/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@aws-sdk/credential-providers": "3.1045.0",
7474
"@aws-sdk/types": "3.973.8",
7575
"@datadog/datadog-ci-base": "workspace:*",
76+
"@inquirer/prompts": "7.10.1",
7677
"@smithy/property-provider": "2.2.0",
7778
"@smithy/util-retry": "2.2.0",
7879
"aws-sdk-client-mock": "4.1.0",

packages/plugin-ecs-fargate/src/__tests__/__snapshots__/instrument.test.ts.snap

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ exports[`ecs-fargate instrument prints the diff and registers nothing on a dry r
2121
"essential": true,
2222
"image": "my-app:latest",
2323
"logConfiguration": {
24-
@@ -16,18 +24,74 @@
24+
@@ -16,19 +24,87 @@
2525
"awslogs-region": "us-east-1",
2626
"awslogs-stream-prefix": "ecs"
2727
}
@@ -31,6 +31,18 @@ exports[`ecs-fargate instrument prints the diff and registers nothing on a dry r
3131
+ {
3232
+ "environment": [
3333
+ {
34+
+ "name": "DD_DOGSTATSD_ORIGIN_DETECTION",
35+
+ "value": "true"
36+
+ },
37+
+ {
38+
+ "name": "DD_DOGSTATSD_ORIGIN_DETECTION_CLIENT",
39+
+ "value": "true"
40+
+ },
41+
+ {
42+
+ "name": "DD_DOGSTATSD_TAG_CARDINALITY",
43+
+ "value": "orchestrator"
44+
+ },
45+
+ {
3446
+ "name": "ECS_FARGATE",
3547
+ "value": "true"
3648
+ },
@@ -95,6 +107,7 @@ exports[`ecs-fargate instrument prints the diff and registers nothing on a dry r
95107
+ "value": "vXXXX"
96108
+ }
97109
+ ],
110+
"taskRoleArn": "arn:aws:iam::123456789012:role/my-app-task-role",
98111
"volumes": []
99112
}
100113
"

packages/plugin-ecs-fargate/src/__tests__/fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const fargateTaskDefinition = ({
6161
cpu: '512',
6262
memory: '1024',
6363
executionRoleArn: 'arn:aws:iam::123456789012:role/ecsTaskExecutionRole',
64+
taskRoleArn: 'arn:aws:iam::123456789012:role/my-app-task-role',
6465
containerDefinitions: [{...APP_CONTAINER}],
6566
volumes: [],
6667
...overrides,

packages/plugin-ecs-fargate/src/__tests__/instrument.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ jest.mock('@aws-sdk/credential-providers', () => ({
1515
),
1616
}))
1717

18+
const promptInput = jest.fn().mockResolvedValue('123456')
19+
jest.mock('@inquirer/prompts', () => ({input: promptInput}))
20+
1821
import type {ContainerDefinition} from '@aws-sdk/client-ecs'
22+
import type {FromIniInit} from '@aws-sdk/credential-provider-ini'
1923

2024
import {
2125
DescribeServicesCommand,
@@ -77,6 +81,7 @@ describe('ecs-fargate instrument', () => {
7781
ecsMock.on(DescribeServicesCommand).resolves({services: [fargateService()], failures: []})
7882
ecsMock.on(UpdateServiceCommand).resolves({})
7983
validateApiKey.mockClear().mockResolvedValue(true)
84+
promptInput.mockClear()
8085
fromIni
8186
.mockClear()
8287
.mockImplementation(() => () => Promise.resolve({accessKeyId: 'access-key', secretAccessKey: 'secret-key'}))
@@ -257,6 +262,33 @@ describe('ecs-fargate instrument', () => {
257262
expect(ecsMock.commandCalls(DescribeTaskDefinitionCommand)).toHaveLength(0)
258263
})
259264

265+
test('reports task definitions that resolve to the same family', async () => {
266+
const {code, context} = await runCLI([
267+
'--api-key-secret-arn',
268+
MOCK_API_KEY_SECRET_ARN,
269+
'--task-definition',
270+
'my-app:3',
271+
])
272+
273+
expect(code).toBe(1)
274+
expect(context.stdout.toString()).toContain(
275+
'--task-definition names the same task definition family more than once (my-app)'
276+
)
277+
expect(ecsMock.commandCalls(DescribeTaskDefinitionCommand)).toHaveLength(0)
278+
})
279+
280+
test('accepts task definitions of different families named in different formats', async () => {
281+
const {code} = await runCLI([
282+
'--api-key-secret-arn',
283+
MOCK_API_KEY_SECRET_ARN,
284+
'--task-definition',
285+
taskDefinitionArn('my-worker', 4),
286+
])
287+
288+
expect(code).toBe(0)
289+
expect(ecsMock.commandCalls(RegisterTaskDefinitionCommand)).toHaveLength(2)
290+
})
291+
260292
test('reports a cluster given without a service to update', async () => {
261293
const {code, context} = await runCLI(['--api-key-secret-arn', MOCK_API_KEY_SECRET_ARN, '--cluster', MOCK_CLUSTER])
262294

@@ -267,7 +299,18 @@ describe('ecs-fargate instrument', () => {
267299
test('uses the named AWS profile it is given', async () => {
268300
await runCLI(['--api-key-secret-arn', MOCK_API_KEY_SECRET_ARN, '--profile', 'my-profile'])
269301

270-
expect(fromIni).toHaveBeenCalledWith({profile: 'my-profile'})
302+
expect(fromIni).toHaveBeenCalledWith(expect.objectContaining({profile: 'my-profile'}))
303+
})
304+
305+
test('asks for a code when the named profile is backed by MFA', async () => {
306+
await runCLI(['--api-key-secret-arn', MOCK_API_KEY_SECRET_ARN, '--profile', 'my-profile'])
307+
308+
const {mfaCodeProvider} = fromIni.mock.calls[0][0] as FromIniInit
309+
expect(mfaCodeProvider).toBeDefined()
310+
await mfaCodeProvider?.('arn:aws:iam::123456789012:mfa/someone')
311+
expect(promptInput).toHaveBeenCalledWith(
312+
expect.objectContaining({message: expect.stringContaining('arn:aws:iam::123456789012:mfa/someone')})
313+
)
271314
})
272315

273316
test('reports a named profile it cannot read', async () => {
@@ -551,6 +594,15 @@ describe('ecs-fargate instrument', () => {
551594
expect(envVarsOf(registeredContainers(), AGENT_CONTAINER_NAME)).toMatchObject({DD_API_KEY: MOCK_API_KEY})
552595
})
553596

597+
test('keeps a plaintext API key out of the diff it prints', async () => {
598+
const {code, context} = await runCLI(['--dry-run'], {DATADOG_API_KEY: '', DD_API_KEY: MOCK_API_KEY})
599+
600+
expect(code).toBe(0)
601+
const output = context.stdout.toString()
602+
expect(output).toContain('DD_API_KEY')
603+
expect(output).not.toContain(MOCK_API_KEY)
604+
})
605+
554606
test('fails when no API key is available', async () => {
555607
const {code, context} = await runCLI([], {DD_API_KEY: '', DATADOG_API_KEY: ''})
556608

@@ -589,6 +641,24 @@ describe('ecs-fargate instrument', () => {
589641
expect(ecsMock.commandCalls(RegisterTaskDefinitionCommand)).toHaveLength(0)
590642
})
591643

644+
test('registers and deploys nothing when no execution role can read the API key secret', async () => {
645+
ecsMock
646+
.on(DescribeTaskDefinitionCommand)
647+
.resolves({taskDefinition: fargateTaskDefinition({executionRoleArn: undefined}), tags: []})
648+
649+
const {code, context} = await runCLI([
650+
'--api-key-secret-arn',
651+
MOCK_API_KEY_SECRET_ARN,
652+
'--ecs-service',
653+
MOCK_SERVICE,
654+
])
655+
656+
expect(code).toBe(1)
657+
expect(context.stdout.toString()).toContain('has no executionRoleArn')
658+
expect(ecsMock.commandCalls(RegisterTaskDefinitionCommand)).toHaveLength(0)
659+
expect(ecsMock.commandCalls(UpdateServiceCommand)).toHaveLength(0)
660+
})
661+
592662
test('reports a task definition that does not exist', async () => {
593663
ecsMock.on(DescribeTaskDefinitionCommand).resolves({})
594664

0 commit comments

Comments
 (0)