Ctrl K
ComunidadGuías…

src/providers/ecs.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<EcsRunnerConfig>;
    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<string, any>) => {
      const ret: Record<string, any> = {};
      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,
      },
    };
  }
}