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
2 changes: 2 additions & 0 deletions packages/base/src/commands/ecs-fargate/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {EcsFargateInstrumentCommand} from './instrument'
import {EcsFargateUninstrumentCommand} from './uninstrument'

// prettier-ignore
export const commands = [
EcsFargateInstrumentCommand,
EcsFargateUninstrumentCommand,
]
229 changes: 229 additions & 0 deletions packages/base/src/commands/ecs-fargate/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import {Option} from 'clipanion'

import {FIPS_ENV_VAR, FIPS_IGNORE_ERROR_ENV_VAR} from '../../constants'
import {toBoolean} from '../../helpers/env'
import {enableFips} from '../../helpers/fips'
import {dryRunTag} from '../../helpers/renderer'
import {ENV_VAR_REGEX} from '../../helpers/serverless/constants'
import {DEFAULT_CONFIG_PATHS, removeUndefinedValues, resolveConfigFromFile} from '../../helpers/utils'

import {BaseCommand} from '../..'

export type EcsFargateConfigOptions = Partial<{
// Task definition targeting options
taskDefinitions: string[]
region: string
profile: string

// Rollout options
ecsServices: string[]
cluster: string

// Configuration options
apiKeySecretArn: string
agentImage: string
agentSocket: boolean
logCollection: boolean
service: string
environment: string
version: string
extraTags: string
envVars: string[]
// no-dd-sa:typescript-best-practices/boolean-prop-naming
sourceCodeIntegration: boolean
// no-dd-sa:typescript-best-practices/boolean-prop-naming
uploadGitMetadata: boolean
tracing: string
logLevel: string
// no-dd-sa:typescript-best-practices/boolean-prop-naming
appsec: boolean
llmobs: string
}>

/**
* The family a `--task-definition` names, whether it is given as a family, a `family:revision`, or
* a full task definition ARN.
*/
const familyFromTaskDefinition = (taskDefinition: string): string =>
(taskDefinition.split('/').pop() ?? taskDefinition).split(':')[0]

/**
* Finds families that are named more than once as a task definition family can only be instrumented once
*/
const duplicateFamilies = (taskDefinitions: string[]): string[] => {
const seen = new Set<string>()
const duplicates = new Set<string>()
for (const family of taskDefinitions.map(familyFromTaskDefinition)) {
if (seen.has(family)) {
duplicates.add(family)
}
seen.add(family)
}

return [...duplicates]
}

/**
* Derive the cluster from an ECS service ARN
*/
const clusterFromServiceArn = (service: string): string | undefined => {
if (!service.startsWith('arn:')) {
return undefined
}

const segments = service.split('/')

return segments.length === 3 ? segments[1] : undefined
}

/**
* The cluster the services to update run in, derived from `--cluster` or from the service ARNs naming them.
* Conflicting Clusters are rejected
*/
const resolveCluster = (cluster: string | undefined, services: string[]): [string | undefined, string[]] => {
const named = [
...new Set(services.map(clusterFromServiceArn).filter((value): value is string => value !== undefined)),
]

if (cluster) {
const conflicting = named.filter((value) => value !== cluster)

return [
cluster,
conflicting.length > 0
? [`--cluster ${cluster} is not the cluster the service ARNs name (${conflicting.join(', ')}).`]
: [],
]
}

if (named.length > 1) {
return [
undefined,
[`The service ARNs name several clusters (${named.join(', ')}), and a run updates services in one cluster.`],
]
}

return [named[0], []]
}

/**
* What `instrument` and `uninstrument` have in common: which task definitions to rewrite, which
* services to roll the new revision out to, and how to reach AWS. Both name their targets the same
* way, so the flags naming them are declared and validated once.
*
* A subcommand adds its own flags, reports them through `additionalConfig`, and validates them in
* `additionalErrors`.
*/
export abstract class EcsFargateCommand extends BaseCommand {
protected dryRun = Option.Boolean('-d,--dry,--dry-run', false, {
description: 'Preview the changes the command would apply',
})

private taskDefinitions = Option.Array('--task-definition,--taskDefinition', {
description: 'The family, family:revision, or ARN of the task definition. Can be specified multiple times.',
})
private region = Option.String('-r,--region', {
description: 'The AWS region the task definition lives in',
})
private profile = Option.String('--profile', {
description:
'Specify the AWS named profile credentials to use. See the AWS CLI documentation on named profiles: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html',
})
private ecsServices = Option.Array('--ecs-service,--ecsService', {
description:
'The name of an ECS service to update to the newly registered revision, so that the change rolls out without a manual deployment. Can be specified multiple times.',
})
private cluster = Option.String('--cluster', {
description:
'The ECS cluster the services named by `--ecs-service` run in. Not needed when those are full ARNs, which name their own cluster. Omit it for the `default` cluster of the region.',
})
private configPath = Option.String('--config', {
description: 'Path to the configuration file.',
})

private fips = Option.Boolean('--fips', false)
private fipsIgnoreError = Option.Boolean('--fips-ignore-error', false)
private fipsConfig = {
fips: toBoolean(process.env[FIPS_ENV_VAR]) ?? false,
fipsIgnoreError: toBoolean(process.env[FIPS_IGNORE_ERROR_ENV_VAR]) ?? false,
}

public get dryRunPrefix(): string {
return this.dryRun ? `${dryRunTag} ` : ''
}

/**
* The configuration the subcommand's own flags contribute.
*/
public get additionalConfig(): Partial<EcsFargateConfigOptions> {
return {}
}

public enableFips(): void {
enableFips(this.fips || this.fipsConfig.fips, this.fipsIgnoreError || this.fipsConfig.fipsIgnoreError)
}

/**
* The configuration to run with, along with everything wrong with it. The caller reports the
* problems, so that a run with several mistakes in it names them all at once.
*/
public async ensureConfig(): Promise<[EcsFargateConfigOptions, string[]]> {
const flags: EcsFargateConfigOptions = {
taskDefinitions: this.taskDefinitions,
region: this.region,
profile: this.profile,
ecsServices: this.ecsServices,
cluster: this.cluster,
...this.additionalConfig,
}

let fileConfig: EcsFargateConfigOptions
try {
fileConfig = (
await resolveConfigFromFile<{ecsFargate: EcsFargateConfigOptions}>(
{ecsFargate: {}},
{configPath: this.configPath, defaultConfigPaths: DEFAULT_CONFIG_PATHS}
)
).ecsFargate
} catch (error) {
return [flags, [`Could not read the configuration file: ${error instanceof Error ? error.message : error}`]]
}

const config: EcsFargateConfigOptions = {...fileConfig, ...removeUndefinedValues(flags)}

const errors: string[] = []
if (!config.taskDefinitions?.length) {
errors.push('No task definitions specified. Use --task-definition.')
}

const duplicates = duplicateFamilies(config.taskDefinitions ?? [])
if (duplicates.length > 0) {
errors.push(
`--task-definition names the same task definition family more than once (${duplicates.join(
', '
)}). A run acts on one revision per family, so name the revision to act on once.`
)
}

if (config.envVars?.some((envVar) => !ENV_VAR_REGEX.test(envVar))) {
errors.push('All env vars must be in the format `KEY=VALUE`')
}
if (config.cluster && !config.ecsServices?.length) {
errors.push('--cluster names the cluster of the services to update, so it only applies with --ecs-service.')
}

errors.push(...this.additionalErrors(config))

const [cluster, clusterErrors] = resolveCluster(config.cluster, config.ecsServices ?? [])
errors.push(...clusterErrors)

return [{...config, cluster}, errors]
}

/**
* Everything wrong with the configuration that only the subcommand can tell is wrong.
*/
protected additionalErrors(_config: EcsFargateConfigOptions): string[] {
return []
}
}
Loading
Loading