import * as cdk from 'aws-cdk-lib'; import { aws_ec2 as ec2, aws_ecs as ecs, aws_iam as iam, aws_logs as logs, aws_stepfunctions as stepfunctions, RemovalPolicy, Stack, } from 'aws-cdk-lib'; import * as autoscaling from 'aws-cdk-lib/aws-autoscaling'; import { MachineImageType } from 'aws-cdk-lib/aws-ecs'; import { RetentionDays } from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; import { amiRootDevice, Architecture, BaseProvider, IRunnerProvider, IRunnerProviderStatus, Os, runnerEnvironment, RunnerImage, RunnerProviderProps, RunnerVersion, StorageOptions, providerParam, RunnerConfig, runnerDefaultLabels, } from './common'; import { cleanEcsTag, ecsRunCommand, ecsTags, grantEcsRunTask } from './fargate'; import { IRunnerImageBuilder, RunnerImageBuilder, RunnerImageBuilderProps, RunnerImageComponent } from '../image-builders'; import { MINIMAL_EC2_SSM_SESSION_MANAGER_POLICY_STATEMENT, MINIMAL_ECS_SSM_SESSION_MANAGER_POLICY_STATEMENT } from '../utils'; /** * Properties for EcsRunnerProvider. */ export interface EcsRunnerProviderProps extends RunnerProviderProps { /** * Runner image builder used to build Docker images containing GitHub Runner and all requirements. * * The image builder determines the OS and architecture of the runner. * * @default EcsRunnerProvider.imageBuilder() */ readonly imageBuilder?: IRunnerImageBuilder; /** * GitHub Actions labels used for this provider. * * These labels are used to identify which provider should spawn a new on-demand runner. Every job sends a webhook with the labels it's looking for * based on runs-on. We match the labels from the webhook with the labels specified here. If all the labels specified here are present in the * job's labels, this provider will be chosen and spawn a new runner. * * @default ['ecs'] */ readonly labels?: string[]; /** * GitHub Actions runner group name. * * If specified, the runner will be registered with this group name. Setting a runner group can help managing access to self-hosted runners. It * requires a paid GitHub account and organization level runner registration. * * The group must exist or the runner will not start. * * Users will still be able to trigger this runner with the correct labels. But the runner will only be able to run jobs from repos allowed to use the group. * * @default undefined */ readonly group?: string; /** * VPC to launch the runners in. * * @default default account VPC */ readonly vpc?: ec2.IVpc; /** * Subnets to run the runners in. * * @default ECS default */ readonly subnetSelection?: ec2.SubnetSelection; /** * Security groups to assign to the task. * * @default a new security group */ readonly securityGroups?: ec2.ISecurityGroup[]; /** * Existing ECS cluster to use. * * Jobs from all providers using the same cluster can share instances. Only share a cluster between providers whose jobs are equally trusted. * * @see https://github.com/CloudSnorkel/cdk-github-runners#job-isolation * * @default a new cluster */ readonly cluster?: ecs.Cluster; /** * Existing capacity provider to use. * * Make sure the AMI used by the capacity provider is compatible with ECS. * * Runner containers use bridge networking and share the instance with other jobs. Its launch template must require IMDSv2 and set the * metadata hop limit to 1 (`requireImdsv2: true` and `httpPutResponseHopLimit: 1`), or jobs can take the instance role. A warning is * added when we can't confirm both. * * @default new capacity provider */ readonly capacityProvider?: ecs.AsgCapacityProvider; /** * The number of cpu units used by the task. 1024 units is 1 vCPU. Fractions of a vCPU are supported. * * @default 1024 */ readonly cpu?: number; /** * The amount (in MiB) of memory used by the task. * * @default 3500, unless `memoryReservationMiB` is used and then it's undefined */ readonly memoryLimitMiB?: number; /** * The soft limit (in MiB) of memory to reserve for the container. * * @default undefined */ readonly memoryReservationMiB?: number; /** * Instance type of ECS cluster instances. Only used when creating a new cluster. * * @default m6i.large or m6g.large */ readonly instanceType?: ec2.InstanceType; /** * The minimum number of instances to run in the cluster. Only used when creating a new cluster. * * @default 0 */ readonly minInstances?: number; /** * The maximum number of instances to run in the cluster. Only used when creating a new cluster. * * @default 5 */ readonly maxInstances?: number; /** * Size of volume available for launched cluster instances. This modifies the boot volume size and doesn't add any additional volumes. * * Each instance can be used by multiple runners, so make sure there is enough space for all of them. * * @default default size for AMI (usually 30GB for Linux and 50GB for Windows) */ readonly storageSize?: cdk.Size; /** * Options for runner instance storage volume. */ readonly storageOptions?: StorageOptions; /** * Support building and running Docker images by enabling Docker-in-Docker (dind) and the required privileged mode for the runner container. * * A job running in a privileged container can take over the instance, access other jobs running on it, and affect jobs that run on it * later. All jobs that can run on the same cluster should be equally trusted. Disable this if your jobs don't need Docker. * * Disabling Docker-in-Docker can also speed up runner provisioning as the runner doesn't have to wait for Docker to boot. * * @see https://github.com/CloudSnorkel/cdk-github-runners#job-isolation * * @default true */ readonly dockerInDocker?: boolean; /** * Use spot capacity. * * @default false (true if spotMaxPrice is specified) */ readonly spot?: boolean; /** * Maximum price for spot instances. */ readonly spotMaxPrice?: string; /** * ECS placement strategies to influence task placement. * * Example: [ecs.PlacementStrategy.packedByCpu()] * * @default undefined (no placement strategies) */ readonly placementStrategies?: ecs.PlacementStrategy[]; /** * ECS placement constraints to influence task placement. * * Example: [ecs.PlacementConstraint.memberOf('ecs-placement')] * * @default undefined (no placement constraints) */ readonly placementConstraints?: ecs.PlacementConstraint[]; /** * Number of GPUs to request for the runner task. When set, the task will be scheduled on GPU-capable instances. * * Requires a GPU-capable instance type (e.g., g4dn.xlarge for 1 GPU, g4dn.12xlarge for 4 GPUs) and GPU AMI. * When creating a new cluster, instanceType defaults to g4dn.xlarge and the ECS Optimized GPU AMI is used. * * You must ensure that the task's container image includes the CUDA runtime. Provide a CUDA-enabled base image * via `baseDockerImage`, use an image builder that starts from a GPU-capable image (such as nvidia/cuda), or add * an image component that installs the CUDA runtime into the image. * * @default undefined (no GPU) */ readonly gpu?: number; /** * Additional tags to apply to launched runner tasks. * * These additional tags are set on top of `Name`, `GitHubRunners:Provider`, `GitHubRunners:Repo`, and `GitHubRunners:Labels`. * You may not override tags with the `GitHubRunners:` prefix. * * @default no additional tags */ readonly tags?: { [key: string]: string }; } /** * Runner config for the Ecs family fragment. * * @internal */ interface EcsRunnerConfig extends RunnerConfig { readonly clusterArn: string; readonly taskDefinitionFamily: string; readonly containerName: string; readonly capacityProviderName: string; readonly enableExecuteCommand: boolean; readonly placementStrategies: any[]; readonly placementConstraints: any[]; } /** * GitHub Actions runner provider using ECS on EC2 to execute jobs. * * ECS can be useful when you want more control of the infrastructure running the GitHub Actions Docker containers. You can control the autoscaling * group to scale down to zero during the night and scale up during work hours. This way you can still save money, but have to wait less for * infrastructure to spin up. * * Jobs share instances, and by default run in privileged containers on Linux that can take over the instance. All jobs that can run on the same * cluster should be equally trusted. See https://github.com/CloudSnorkel/cdk-github-runners#job-isolation * * This construct is not meant to be used by itself. It should be passed in the providers property for GitHubRunners. */ export class EcsRunnerProvider extends BaseProvider implements IRunnerProvider { /** @internal */ public static readonly _FAMILY = 'ecs'; /** * The fragment that runs any ECS provider. Reads the config {@link _runnerConfig} generated for it from * `$.providerParams`. Renders what EcsRunTask used to render per provider, minus NetworkConfiguration because * ECS task definitions use bridge networking. * * @internal */ public static _stateMachineFragment(scope: Construct): stepfunctions.IChainable { const p = providerParam; return new stepfunctions.CustomState(scope, 'ECS Runner', { stateJson: { Type: 'Task', Resource: `arn:${cdk.Aws.PARTITION}:states:::ecs:runTask.sync`, Parameters: { 'Cluster.$': p('clusterArn'), 'TaskDefinition.$': p('taskDefinitionFamily'), 'Overrides': { ContainerOverrides: [{ 'Name.$': p('containerName'), 'Environment': runnerEnvironment((name, value) => ({ 'Name': name, 'Value.$': value })), }], }, 'PropagateTags': 'TASK_DEFINITION', 'Tags.$': p('tags'), // the provider's tags, already merged with the standard runner tags by the orchestrator 'CapacityProviderStrategy': [{ 'CapacityProvider.$': p('capacityProviderName'), }], // ready-made arrays in the shape ecs:runTask wants, empty when nothing is configured 'PlacementConstraints.$': p('placementConstraints'), 'PlacementStrategy.$': p('placementStrategies'), 'EnableExecuteCommand.$': p('enableExecuteCommand'), }, }, }); } /** * Create new image builder that builds ECS specific runner images. * * You can customize the OS, architecture, VPC, subnet, security groups, etc. by passing in props. * * You can add components to the image builder by calling `imageBuilder.addComponent()`. * * The default OS is Ubuntu running on x64 architecture. * * Included components: * * `RunnerImageComponent.requiredPackages()` * * `RunnerImageComponent.runnerUser()` * * `RunnerImageComponent.git()` * * `RunnerImageComponent.githubCli()` * * `RunnerImageComponent.awsCli()` * * `RunnerImageComponent.docker()` * * `RunnerImageComponent.githubRunner()` */ public static imageBuilder(scope: Construct, id: string, props?: RunnerImageBuilderProps) { return RunnerImageBuilder.new(scope, id, { os: Os.LINUX_UBUNTU, architecture: Architecture.X86_64, components: [ RunnerImageComponent.requiredPackages(), RunnerImageComponent.runnerUser(), RunnerImageComponent.git(), RunnerImageComponent.githubCli(), RunnerImageComponent.awsCli(), RunnerImageComponent.docker(), RunnerImageComponent.githubRunner(props?.runnerVersion ?? RunnerVersion.latest()), ], ...props, }); } /** * Cluster hosting the task hosting the runner. */ private readonly cluster: ecs.Cluster; /** * Capacity provider used to scale the cluster. * * Use capacityProvider.autoScalingGroup to access the auto scaling group. This can help set up custom scaling policies. */ readonly capacityProvider: ecs.AsgCapacityProvider; /** * ECS task hosting the runner. */ private readonly task: ecs.Ec2TaskDefinition; /** * Container definition hosting the runner. */ private readonly container: ecs.ContainerDefinition; /** * Labels associated with this provider. */ readonly labels: string[]; /** * VPC used for hosting the runner task. */ private readonly vpc?: ec2.IVpc; /** * Subnets used for hosting the runner task. */ private readonly subnetSelection?: ec2.SubnetSelection; /** * Grant principal used to add permissions to the runner role. */ readonly grantPrincipal: iam.IPrincipal; /** * The network connections associated with this resource. */ readonly connections: ec2.Connections; /** * Docker image loaded with GitHub Actions Runner and its prerequisites. The image is built by an image builder and is specific to ECS tasks. */ private readonly image: RunnerImage; /** * Log group where provided runners will save their logs. * * Note that this is not the job log, but the runner itself. It will not contain output from the GitHub Action but only metadata on its execution. */ readonly logGroup: logs.ILogGroup; /** * Security groups associated with this provider. */ private readonly securityGroups: ec2.ISecurityGroup[]; /** * Run docker in docker. */ private readonly dind: boolean; /** * Runner group name. */ private readonly group?: string; /** * Include default labels (arch, os, self-hosted) for runner. */ private readonly defaultLabels: boolean; /** * ECS placement strategies to influence task placement. */ private readonly placementStrategies?: ecs.PlacementStrategy[]; /** * ECS placement constraints to influence task placement. */ private readonly placementConstraints?: ecs.PlacementConstraint[]; /** * Number of GPUs requested for the runner task (0 = no GPU). */ private readonly gpuCount: number; /** * Tags set on launched runner tasks. */ private readonly tags: { [key: string]: string }; constructor(scope: Construct, id: string, props?: EcsRunnerProviderProps) { super(scope, id, props); this.labels = props?.labels ?? ['ecs']; this.group = props?.group; this.defaultLabels = props?.defaultLabels ?? true; this.vpc = props?.vpc ?? ec2.Vpc.fromLookup(this, 'default vpc', { isDefault: true }); this.subnetSelection = props?.subnetSelection; this.securityGroups = props?.securityGroups ?? [new ec2.SecurityGroup(this, 'security group', { vpc: this.vpc })]; this.connections = new ec2.Connections({ securityGroups: this.securityGroups }); this.placementStrategies = props?.placementStrategies; this.placementConstraints = props?.placementConstraints; this.gpuCount = props?.gpu ?? 0; this.tags = ecsTags(this, props?.tags ?? {}); this.cluster = props?.cluster ? props.cluster : new ecs.Cluster( this, 'cluster', { vpc: this.vpc, enableFargateCapacityProviders: false, }, ); // all providers add this tag, but ECS/Fargate tags need to be cleaned cdk.Tags.of(this).add('GitHubRunners:Provider', cleanEcsTag(this, 'provider tag', this.node.path)); if (props?.storageOptions && !props?.storageSize) { cdk.Annotations.of(this).addError('storageSize is required when storageOptions are specified'); } const defaultImageBuilderArchitecture = !props?.capacityProvider && props?.instanceType?.architecture === ec2.InstanceArchitecture.ARM_64 ? Architecture.ARM64 : Architecture.X86_64; const imageBuilder = props?.imageBuilder ?? EcsRunnerProvider.imageBuilder(this, 'Image Builder', { architecture: defaultImageBuilderArchitecture, }); const image = this.image = imageBuilder.bindDockerImage(); if (props?.capacityProvider) { if (props?.minInstances || props?.maxInstances || props?.instanceType || props?.storageSize || props?.spot || props?.spotMaxPrice) { cdk.Annotations.of(this).addWarning('When using a custom capacity provider, minInstances, maxInstances, instanceType, storageSize, spot, and spotMaxPrice will be ignored.'); } this.capacityProvider = props.capacityProvider; this.warnOnContainerImdsAccess(); } else { const spot = props?.spot ?? props?.spotMaxPrice !== undefined; const launchTemplate = new ec2.LaunchTemplate(this, 'Launch Template', { machineImage: this.defaultClusterInstanceAmi(), instanceType: props?.instanceType ?? this.defaultClusterInstanceType(), blockDevices: (props?.storageSize || props?.storageOptions) ? [ { deviceName: amiRootDevice(this, this.defaultClusterInstanceAmi().getImage(this).imageId).ref, volume: { ebsDevice: { deleteOnTermination: true, volumeSize: props?.storageSize?.toGibibytes() ?? 30, volumeType: props?.storageOptions?.volumeType, iops: props?.storageOptions?.iops, throughput: props?.storageOptions?.throughput, }, }, }, ] : undefined, spotOptions: spot ? { requestType: ec2.SpotRequestType.ONE_TIME, maxPrice: props?.spotMaxPrice ? parseFloat(props?.spotMaxPrice) : undefined, } : undefined, requireImdsv2: true, // containers on the bridge network are one hop away, so this keeps jobs from reaching IMDS and taking the instance role. // tasks get their own role from the ECS agent endpoint, which this doesn't affect. some AMIs, like AL2023, default to 2. httpPutResponseHopLimit: 1, securityGroup: this.securityGroups[0], role: new iam.Role(this, 'Launch Template Role', { assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), }), userData: ec2.UserData.forOperatingSystem(image.os.is(Os.WINDOWS) ? ec2.OperatingSystemType.WINDOWS : ec2.OperatingSystemType.LINUX), }); this.securityGroups.slice(1).map(sg => launchTemplate.connections.addSecurityGroup(sg)); const autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'Auto Scaling Group', { vpc: this.vpc, launchTemplate, vpcSubnets: this.subnetSelection, minCapacity: props?.minInstances ?? 0, maxCapacity: props?.maxInstances ?? 5, }); this.capacityProvider = props?.capacityProvider ?? new ecs.AsgCapacityProvider(this, 'Capacity Provider', { autoScalingGroup, spotInstanceDraining: false, // waste of money to restart jobs as the restarted job won't have a token }); } this.capacityProvider.autoScalingGroup.addUserData( // we don't exit on errors because all of these commands are optional ...this.loginCommands(), this.pullCommand(), ...this.ecsSettingsCommands(), ); this.capacityProvider.autoScalingGroup.role.addToPrincipalPolicy(MINIMAL_EC2_SSM_SESSION_MANAGER_POLICY_STATEMENT); image.imageRepository.grantPull(this.capacityProvider.autoScalingGroup); this.cluster.addAsgCapacityProvider( this.capacityProvider, { spotInstanceDraining: false, machineImageType: MachineImageType.AMAZON_LINUX_2, }, ); this.logGroup = new logs.LogGroup(this, 'logs', { retention: props?.logRetention ?? RetentionDays.ONE_MONTH, removalPolicy: RemovalPolicy.DESTROY, }); this.dind = (props?.dockerInDocker ?? true) && !image.os.is(Os.WINDOWS); this.task = new ecs.Ec2TaskDefinition(this, 'task'); this.container = this.task.addContainer( 'runner', { image: ecs.AssetImage.fromEcrRepository(image.imageRepository, image.imageTag), cpu: props?.cpu ?? 1024, memoryLimitMiB: props?.memoryLimitMiB ?? (props?.memoryReservationMiB ? undefined : 3500), memoryReservationMiB: props?.memoryReservationMiB, gpuCount: this.gpuCount > 0 ? this.gpuCount : undefined, logging: ecs.AwsLogDriver.awsLogs({ logGroup: this.logGroup, streamPrefix: 'runner', }), command: ecsRunCommand(this.image.os, this.dind), user: image.os.is(Os.WINDOWS) ? undefined : 'runner', privileged: this.dind, }, ); this.grantPrincipal = this.task.taskRole; // permissions for SSM Session Manager this.task.taskRole.addToPrincipalPolicy(MINIMAL_ECS_SSM_SESSION_MANAGER_POLICY_STATEMENT); } private defaultClusterInstanceType() { if (this.gpuCount > 0) { if (!this.image.architecture.is(Architecture.X86_64)) { throw new Error('ECS GPU is only supported for x64 architecture. GPU instances (g4dn, g5, p3, etc.) are x64 only.'); } if (this.gpuCount <= 1) { return ec2.InstanceType.of(ec2.InstanceClass.G4DN, ec2.InstanceSize.XLARGE); } if (this.gpuCount <= 4) { return ec2.InstanceType.of(ec2.InstanceClass.G4DN, ec2.InstanceSize.XLARGE12); } if (this.gpuCount <= 8) { return ec2.InstanceType.of(ec2.InstanceClass.P3, ec2.InstanceSize.XLARGE16); } throw new Error(`Unsupported GPU count: ${this.gpuCount}`); } if (this.image.architecture.is(Architecture.X86_64)) { return ec2.InstanceType.of(ec2.InstanceClass.M6I, ec2.InstanceSize.LARGE); } if (this.image.architecture.is(Architecture.ARM64)) { return ec2.InstanceType.of(ec2.InstanceClass.M6G, ec2.InstanceSize.LARGE); } throw new Error(`Unable to find instance type for ECS instances for ${this.image.architecture.name}`); } /** * Warn when we can't confirm the custom capacity provider keeps containers away from IMDS. Only launch templates and launch configurations * created in this app can be checked. */ private warnOnContainerImdsAccess() { // the cfn properties can also be IResolvable, but the L2 constructs always set plain objects. anything else can't be confirmed and warns. const cfnAsg = this.capacityProvider.autoScalingGroup.node.defaultChild as any; const target = (id: any) => { const token = typeof id === 'string' ? cdk.Tokenization.reverseString(id).firstToken : undefined; return token instanceof cdk.Reference ? token.target : undefined; }; const launchTemplateSpec = cfnAsg.launchTemplate ?? cfnAsg.mixedInstancesPolicy?.launchTemplate?.launchTemplateSpecification; const launchTemplate = target(launchTemplateSpec?.launchTemplateId ?? launchTemplateSpec?.launchTemplateName); const launchConfiguration = target(cfnAsg.launchConfigurationName); // only resolve the metadata options, and in the stack that owns them, so we don't touch anything else the user put in there let metadataOptions: any; try { if (launchTemplate instanceof ec2.CfnLaunchTemplate) { metadataOptions = cdk.Stack.of(launchTemplate).resolve((launchTemplate.launchTemplateData as any)?.metadataOptions); } else if (launchConfiguration instanceof autoscaling.CfnLaunchConfiguration) { metadataOptions = cdk.Stack.of(launchConfiguration).resolve(launchConfiguration.metadataOptions); } } catch { // can't tell, so warn below } if (metadataOptions?.httpTokens !== 'required' || metadataOptions?.httpPutResponseHopLimit !== 1) { cdk.Annotations.of(this).addWarningV2( '@cloudsnorkel/cdk-github-runners:ecsCapacityProviderImds', 'Unable to confirm the custom capacity provider launch template requires IMDSv2 with a hop limit of 1. ' + 'Without it, jobs can reach IMDS and take the instance role. Set `requireImdsv2: true` and `httpPutResponseHopLimit: 1` on the launch template.', ); } } private defaultClusterInstanceAmi() { let os = ec2.OperatingSystemType.LINUX; let ssmPath: string | undefined; if (this.image.os.isIn(Os._ALL_LINUX_VERSIONS)) { if (this.gpuCount > 0 && this.image.architecture.is(Architecture.X86_64)) { ssmPath = '/aws/service/ecs/optimized-ami/amazon-linux-2023/gpu/recommended/image_id'; } else if (this.image.architecture.is(Architecture.X86_64)) { ssmPath = '/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id'; } else if (this.image.architecture.is(Architecture.ARM64)) { ssmPath = '/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended/image_id'; } } if (this.image.os.is(Os.WINDOWS)) { os = ec2.OperatingSystemType.WINDOWS; ssmPath = '/aws/service/ami-windows-latest/Windows_Server-2019-English-Full-ECS_Optimized/image_id'; } if (!ssmPath) { throw new Error(`Unable to find AMI for ECS instances for ${this.image.os.name}/${this.image.architecture.name} (gpuCount=${this.gpuCount})`); } // resolved by EC2 on every launch, so new instances always get the latest AMI without redeploying return ec2.MachineImage.resolveSsmParameterAtLaunch(ssmPath, { os }); } private pullCommand() { if (this.image.os.is(Os.WINDOWS)) { return `Start-Job -ScriptBlock { docker pull ${this.image.imageRepository.repositoryUri}:${this.image.imageTag} }`; } return `docker pull ${this.image.imageRepository.repositoryUri}:${this.image.imageTag} &`; } private loginCommands() { const thisStack = Stack.of(this); if (this.image.os.is(Os.WINDOWS)) { return [`(Get-ECRLoginCommand).Password | docker login --username AWS --password-stdin ${thisStack.account}.dkr.ecr.${thisStack.region}.amazonaws.com`]; } return [ 'yum install -y awscli || dnf install -y awscli', `aws ecr get-login-password --region ${thisStack.region} | docker login --username AWS --password-stdin ${thisStack.account}.dkr.ecr.${thisStack.region}.amazonaws.com`, ]; } private ecsSettingsCommands() { // don't let ECS accumulate too many stopped tasks that can end up very big in our case // the default is 10m duration with 1h jitter which can end up with 1h10m delay for cleaning up stopped tasks if (this.image.os.is(Os.WINDOWS)) { return [ '[Environment]::SetEnvironmentVariable("ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION", "5s", "Machine")', '[Environment]::SetEnvironmentVariable("ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION_JITTER", "5s", "Machine")', // https://github.com/aws/aws-cdk/issues/36805 '[Environment]::SetEnvironmentVariable("ECS_ENABLE_TASK_IAM_ROLE", "true", "Machine")', '(Get-Content "C:\\Program Files\\WindowsPowerShell\\Modules\\ECSTools\\ECSTools.psm1").Replace(\'if ($EnableTaskIAMRole) {\', \'$EnableTaskIAMRole = $true; if ($EnableTaskIAMRole) {\') | Set-Content "C:\\Program Files\\WindowsPowerShell\\Modules\\ECSTools\\ECSTools.psm1" -Force', ]; } return [ 'echo ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION=5s >> /etc/ecs/ecs.config', 'echo ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION_JITTER=5s >> /etc/ecs/ecs.config', ]; } /** * Config for the shared ECS fragment. * * @internal */ _runnerConfig(): EcsRunnerConfig { // these are static per provider, so we render them the way ecs:runTask wants them right here // that means uppercasing the first letter of every key, exactly like EcsEc2LaunchTarget does const uppercaseKeys = (obj: Record) => { const ret: Record = {}; for (const key of Object.keys(obj)) { ret[key.slice(0, 1).toUpperCase() + key.slice(1)] = obj[key]; } return ret; }; const placementStrategies = (this.placementStrategies ?? []).flatMap(s => s.toJson().map(uppercaseKeys)); const placementConstraints = (this.placementConstraints ?? []).flatMap(c => c.toJson().map(uppercaseKeys)); return { family: EcsRunnerProvider._FAMILY, provider: this.node.path, clusterArn: this.cluster.clusterArn, taskDefinitionFamily: this.task.family, containerName: this.container.containerName, capacityProviderName: this.capacityProvider.capacityProviderName, enableExecuteCommand: this.image.os.isIn(Os._ALL_LINUX_VERSIONS), // always an array, even an empty one // a missing key makes the JSONata resolve to nothing and the state fails with States.QueryEvaluationError placementStrategies, placementConstraints, // the cleaned up provider path plus whatever the user asked for // see selectProviderParams() in runner.ts, which merges the rest of the standard runner tags in at runtime tags: Object.entries(this.tags).map(([Key, Value]) => ({ Key, Value })), cleanLabels: true, runnerGroup: this.group ?? '', defaultLabels: runnerDefaultLabels(this.image.os, this.image.architecture, this.defaultLabels), }; } /** * @internal */ _grantStateMachine(stateMachineRole: iam.IGrantable) { grantEcsRunTask(this, stateMachineRole, this.task); } /** * @internal */ _status(statusFunctionRole: iam.IGrantable): IRunnerProviderStatus { this.image.imageRepository.grant(statusFunctionRole, 'ecr:DescribeImages'); return { type: this.constructor.name, labels: this.labels, constructPath: this.node.path, vpcArn: this.vpc?.vpcArn, securityGroups: this.securityGroups.map(sg => sg.securityGroupId), roleArn: this.task.taskRole.roleArn, logGroup: this.logGroup.logGroupName, image: { imageRepository: this.image.imageRepository.repositoryUri, imageTag: this.image.imageTag, imageBuilderLogGroup: this.image.logGroup?.logGroupName, }, }; } }