import * as cdk from 'aws-cdk-lib'; import { aws_ec2 as ec2, aws_iam as iam, aws_logs as logs, aws_stepfunctions as stepfunctions, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { RetentionDays } from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; import { amiRootDevice, Architecture, BaseProvider, IParameterizedRunnerProvider, IRunnerProvider, IRunnerProviderStatus, Os, providerParam, RUNNER_INPUT, RunnerAmi, RunnerConfig, runnerDefaultLabels, RunnerProviderProps, RunnerVersion, StorageOptions, } from './common'; import { AwsImageBuilderRunnerImageBuilder, BaseImage, IRunnerImageBuilder, RunnerImageBuilder, RunnerImageBuilderProps, RunnerImageBuilderType, RunnerImageComponent, } from '../image-builders'; import { ReservedTags } from '../lambda-common'; import { isGpuInstanceType, MINIMAL_EC2_SSM_SESSION_MANAGER_POLICY_STATEMENT } from '../utils'; // this script is specifically made so `poweroff` is absolutely always called // each `{}` is a variable coming from `params` below const linuxUserDataTemplate = `#!/bin/bash trap 'sleep 10; poweroff' EXIT # give cloudwatch agent 10 seconds to upload logs and ALWAYS poweroff on any type of exit set -x -o pipefail echo -n "{}" > /tmp/task-token # aws cli reads --task-token from here so set -x doesn't log it chmod 600 /tmp/task-token logGroupName="{}" runnerNamePath="{}" githubDomainPath="{}" ownerPath="{}" repoPath="{}" export ACTIONS_RUNNER_INPUT_JITCONFIG="{}" # run.sh reads --jitconfig from here so set -x doesn't log it labels="{}" export AWS_RETRY_MODE=standard # better retry touch /var/log/runner.log heartbeat () { while true; do SPOT_ACTION=$(curl -s -f -H "X-aws-ec2-metadata-token: $(curl -s -f -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 1" 2>/dev/null)" "http://169.254.169.254/latest/meta-data/spot/instance-action" 2>/dev/null) || true if [ -n "$SPOT_ACTION" ]; then aws stepfunctions send-task-failure --task-token file:///tmp/task-token --error SpotInterrupted --cause "EC2 Spot instance interruption: $SPOT_ACTION" || true exit 0 fi aws stepfunctions send-task-heartbeat --task-token file:///tmp/task-token sleep 60 done } setup_logs () { cat < /tmp/log.conf || return 1 { "logs": { "log_stream_name": "unknown", "logs_collected": { "files": { "collect_list": [ { "file_path": "/var/log/runner.log", "log_group_name": "$logGroupName", "log_stream_name": "$runnerNamePath", "timezone": "UTC" }, { "file_path": "/var/log/workflow.log", "log_group_name": "$logGroupName", "log_stream_name": "$runnerNamePath-workflow", "timezone": "UTC" } ] } } } } EOF /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c file:/tmp/log.conf || return 2 } action () { # Report workflow id for stolen runner detection (to separate log so it doesn't hang this bash function) /home/runner/job-reporter.sh "$runnerNamePath" /var/log/workflow.log # Execute the run command sudo --preserve-env=ACTIONS_RUNNER_INPUT_JITCONFIG,AWS_REGION -Hu runner /home/runner/run.sh || exit 2 # Retrieve the status STATUS=$(grep -Phors "finish job request for job [0-9a-f-]+ with result: .*" /home/runner/_diag/ | tail -n1 | awk '{print $NF}') # Check and print the job status if [ -n "$STATUS" ]; then echo CDKGHA JOB DONE "$labels" "$STATUS" fi } heartbeat & setup_logs setup_logs_status=$? if [ $setup_logs_status -ne 0 ]; then aws stepfunctions send-task-failure --task-token file:///tmp/task-token --error Runner.SetupLogs.$setup_logs_status --cause "Failed to configure CloudWatch agent (exit $setup_logs_status), no runner log was uploaded" elif action |& tee /var/log/runner.log; then aws stepfunctions send-task-success --task-token file:///tmp/task-token --task-output '{"ok": true}' |& tee -a /var/log/runner.log else aws stepfunctions send-task-failure --task-token file:///tmp/task-token --error Runner.Error.$? --cause "Check CloudWatch for full log -- $logGroupName/$runnerNamePath -- $(tail -n 1 /var/log/runner.log)" |& tee -a /var/log/runner.log fi `.replace(/\\/g, '\\\\').replace(/{/g, '\\{').replace(/}/g, '\\}').replace(/\\{\\}/g, '{}'); // this script is specifically made so `poweroff` is absolutely always called // each `{}` is a variable coming from `params` below and their order should match the linux script const windowsUserDataTemplate = ` $TASK_TOKEN = "{}" $logGroupName="{}" $runnerNamePath="{}" $githubDomainPath="{}" $ownerPath="{}" $repoPath="{}" $jitConfig="{}" $labels="{}" $Env:AWS_RETRY_MODE = "standard" # better retry # EC2Launch only starts ssm agent after user data is done, so we need to start it ourselves (it is disabled by default) Set-Service -StartupType Manual AmazonSSMAgent Start-Service AmazonSSMAgent $HeartbeatParentPid = $PID Start-Job -ScriptBlock { while ($true) { try { $spot = Invoke-RestMethod -Uri "http://169.254.169.254/latest/meta-data/spot/instance-action" -Headers @{"X-aws-ec2-metadata-token"=(Invoke-RestMethod -Method PUT -Uri "http://169.254.169.254/latest/api/token" -Headers @{"X-aws-ec2-metadata-token-ttl-seconds"="1"} -TimeoutSec 2)} -TimeoutSec 2 $spotJson = if ($spot -is [string]) { $spot } else { $spot | ConvertTo-Json -Compress } aws stepfunctions send-task-failure --task-token "$using:TASK_TOKEN" --error SpotInterrupted --cause "EC2 Spot instance interruption: $spotJson" break } catch { } aws stepfunctions send-task-heartbeat --task-token "$using:TASK_TOKEN" Start-Sleep -Seconds 60 } } function setup_logs () { # the runner report can't go in runner.log: run.cmd's Out-File holds that file open for as long as the runner is # running, which is exactly when the report is written. one file per stream, so it gets its own stream. the # subscription filter is on the log group, so the detector doesn't care. echo "{ \`"logs\`": { \`"log_stream_name\`": \`"unknown\`", \`"logs_collected\`": { \`"files\`": { \`"collect_list\`": [ { \`"file_path\`": \`"/actions/runner.log\`", \`"log_group_name\`": \`"$logGroupName\`", \`"log_stream_name\`": \`"$runnerNamePath\`", \`"timezone\`": \`"UTC\`" }, { \`"file_path\`": \`"/actions/workflow.log\`", \`"log_group_name\`": \`"$logGroupName\`", \`"log_stream_name\`": \`"$runnerNamePath-workflow\`", \`"timezone\`": \`"UTC\`" } ] } } } }" | Out-File -Encoding ASCII $Env:TEMP/log.conf & "C:/Program Files/Amazon/AmazonCloudWatchAgent/amazon-cloudwatch-agent-ctl.ps1" -a fetch-config -m ec2 -s -c file:$Env:TEMP/log.conf } function action () { cd /actions & ./job-reporter.ps1 "\${runnerNamePath}" /actions/workflow.log 2>&1 | Out-File -Encoding ASCII -Append /actions/runner.log ./run.cmd --jitconfig "\${jitConfig}" 2>&1 | Out-File -Encoding ASCII -Append /actions/runner.log if ($LASTEXITCODE -ne 0) { return 2 } $STATUS = Select-String -Path './_diag/*.log' -Pattern 'finish job request for job [0-9a-f\\-]+ with result: (.*)' | %{$_.Matches.Groups[1].Value} | Select-Object -Last 1 if ($STATUS) { echo "CDKGHA JOB DONE \${labels} $STATUS" | Out-File -Encoding ASCII -Append /actions/runner.log } return 0 } try { setup_logs $setupLogsStatus = $LASTEXITCODE if ($setupLogsStatus -ne 0) { aws stepfunctions send-task-failure --task-token "$TASK_TOKEN" --error Runner.SetupLogs.$setupLogsStatus --cause "Failed to configure CloudWatch agent (exit $setupLogsStatus), no runner log was uploaded" return } $r = action if ($r -eq 0) { aws stepfunctions send-task-success --task-token "$TASK_TOKEN" --task-output '{ }' 2>&1 | Out-File -Encoding ASCII -Append /actions/runner.log } else { $lastLine = Get-Content -Path C:/actions/runner.log -Tail 1 -ErrorAction SilentlyContinue aws stepfunctions send-task-failure --task-token "$TASK_TOKEN" --error Runner.Error.$r --cause "Check CloudWatch for full log -- $logGroupName/$runnerNamePath -- $lastLine" 2>&1 | Out-File -Encoding ASCII -Append /actions/runner.log } } finally { Start-Sleep -Seconds 10 # give cloudwatch agent its default 5 seconds buffer duration to upload logs Stop-Computer -ComputerName localhost -Force } `.replace(/\\/g, '\\\\').replace(/{/g, '\\{').replace(/}/g, '\\}').replace(/\\{\\}/g, '{}'); /** * Properties for {@link Ec2RunnerProvider} construct. */ export interface Ec2RunnerProviderProps extends RunnerProviderProps { /** * Runner image builder used to build AMI containing GitHub Runner and all requirements. * * The image builder determines the OS and architecture of the runner. * * @default Ec2RunnerProvider.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 ['ec2'] */ 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; /** * Instance type for launched runner instances. * * For GPU instance types (g4dn, g5, p3, etc.), we automatically use a GPU base image (AWS Deep Learning AMI) * with NVIDIA drivers pre-installed. If you provide your own image builder, use * `baseAmi: BaseImage.fromGpuBase(os, architecture)` or another image preloaded with NVIDIA drivers, or use * an image component to install NVIDIA drivers. * * @default m6i.large */ readonly instanceType?: ec2.InstanceType; /** * Size of volume available for launched runner instances. This modifies the boot volume size and doesn't add any additional volumes. * * @default 30GB */ readonly storageSize?: cdk.Size; /** * Options for runner instance storage volume. */ readonly storageOptions?: StorageOptions; /** * Security groups to assign to launched runner instances. * * @default a new security group */ readonly securityGroups?: ec2.ISecurityGroup[]; /** * VPC where runner instances will be launched. * * @default default account VPC */ readonly vpc?: ec2.IVpc; /** * Where to place the network interfaces within the VPC. Only the first matched subnet will be used. * * @default default VPC subnet */ readonly subnetSelection?: ec2.SubnetSelection; /** * Use spot instances to save money. Spot instances are cheaper but not always available and can be stopped prematurely. * * @default false */ readonly spot?: boolean; /** * Set a maximum price for spot instances. * * @default no max price (you will pay current spot price) */ readonly spotMaxPrice?: string; /** * Additional tags to apply to launched runner instances and their volumes. * * These additional tags are set on top of `Name`, `GitHubRunners:Provider`, `GitHubRunners:Repo`, and `GitHubRunners:Labels`, and * `GitHubRunners:Runner`. You may only override `Name`. Tags prefixed `GitHubRunners:` are reserved and rejected. Some of our reserved tags are * used for house-keeping purposes. * * @default no additional tags */ readonly tags?: { [key: string]: string }; } /** * Runner config for the Ec2 family fragment. * * @internal */ interface Ec2RunnerConfig extends RunnerConfig { readonly launchTemplateId: string; readonly instanceType: string; readonly securityGroupIds: string[]; readonly instanceProfileArn: string; readonly logGroupName: string; readonly blockDeviceMappings: any[]; readonly instanceMarketOptions: any; readonly userDataTemplateIdx: number; readonly subnet: string; } /** * GitHub Actions runner provider using EC2 to execute jobs. * * This construct is not meant to be used by itself. It should be passed in the providers property for GitHubRunners. */ export class Ec2RunnerProvider extends BaseProvider implements IRunnerProvider, IParameterizedRunnerProvider { /** @internal */ public static readonly _FAMILY = 'ec2'; /** * Big static strings the EC2 fragment reads from `$.config`. We always include both userdata templates because * the fragment puts both in a States.Array() and picks one by index at runtime. * * @internal */ public static _stateMachineConstants(): Record { return { ec2UserDataLinux: linuxUserDataTemplate, ec2UserDataWindows: windowsUserDataTemplate, }; } /** * The fragment that runs any EC2 provider. Reads the config {@link _runnerConfig} generated for it from * `$.providerParams`, and launches into whatever subnet is at `$.providerParams.subnet`. * * Multiple subnets are a chain of fallback configs instead of the chain of states we used to render, see * {@link Ec2RunnerProvider._runnerConfig}. * * @internal */ public static _stateMachineFragment(scope: Construct): stepfunctions.IChainable { const p = providerParam; return new stepfunctions.CustomState(scope, 'EC2 Runner', { stateJson: { Type: 'Task', Resource: `arn:${cdk.Aws.PARTITION}:states:::aws-sdk:ec2:runInstances.waitForTaskToken`, HeartbeatSeconds: cdk.Duration.minutes(10).toSeconds(), Parameters: { 'LaunchTemplate': { 'LaunchTemplateId.$': p('launchTemplateId'), }, 'MinCount': 1, 'MaxCount': 1, 'InstanceType.$': p('instanceType'), 'UserData.$': 'States.Base64Encode(States.Format(' + `States.ArrayGetItem(States.Array($ec2UserDataLinux, $ec2UserDataWindows), ${p('userDataTemplateIdx')}), ` + `$$.Task.Token, ${p('logGroupName')}, ${RUNNER_INPUT.name}, ${RUNNER_INPUT.domain}, ${RUNNER_INPUT.owner}, ${RUNNER_INPUT.repo}, ` + `${RUNNER_INPUT.jitConfig}, ${RUNNER_INPUT.labels}))`, 'InstanceInitiatedShutdownBehavior': 'terminate', 'IamInstanceProfile': { 'Arn.$': p('instanceProfileArn'), }, 'MetadataOptions': { HttpTokens: 'required', }, 'SecurityGroupIds.$': p('securityGroupIds'), 'SubnetId.$': p('subnet'), 'BlockDeviceMappings.$': p('blockDeviceMappings'), 'InstanceMarketOptions.$': p('instanceMarketOptions'), // the provider's tags, already merged with the standard runner tags by the orchestrator 'TagSpecifications': [ { 'ResourceType': 'instance', 'Tags.$': p('tags') }, { 'ResourceType': 'volume', 'Tags.$': p('tags') }, ], }, }, }); } /** * Create new image builder that builds EC2 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.cloudWatchAgent()` * * `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, builderType: RunnerImageBuilderType.AWS_IMAGE_BUILDER, components: [ RunnerImageComponent.requiredPackages(), RunnerImageComponent.cloudWatchAgent(), RunnerImageComponent.runnerUser(), RunnerImageComponent.git(), RunnerImageComponent.githubCli(), RunnerImageComponent.awsCli(), RunnerImageComponent.docker(), RunnerImageComponent.githubRunner(props?.runnerVersion ?? RunnerVersion.latest()), ], ...props, }); } /** * Labels associated with this provider. */ readonly labels: string[]; /** * Grant principal used to add permissions to the runner role. */ readonly grantPrincipal: iam.IPrincipal; /** * 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; private readonly group?: string; private readonly amiBuilder: IRunnerImageBuilder; private readonly ami: RunnerAmi; private readonly role: iam.Role; private readonly instanceType: ec2.InstanceType; private readonly storageSize: cdk.Size; private readonly storageOptions?: StorageOptions; private readonly spot: boolean; private readonly spotMaxPrice: string | undefined; private readonly vpc: ec2.IVpc; private readonly subnets: ec2.ISubnet[]; private readonly securityGroups: ec2.ISecurityGroup[]; private readonly defaultLabels: boolean; private readonly tags: { [key: string]: string }; private readonly instanceProfile: iam.CfnInstanceProfile; private readonly rootDeviceResource: cdk.CustomResource; constructor(scope: Construct, id: string, props?: Ec2RunnerProviderProps) { super(scope, id, props); this.labels = props?.labels ?? ['ec2']; this.group = props?.group; this.vpc = props?.vpc ?? ec2.Vpc.fromLookup(this, 'Default VPC', { isDefault: true }); this.securityGroups = props?.securityGroups ?? [new ec2.SecurityGroup(this, 'SG', { vpc: this.vpc })]; this.subnets = this.vpc.selectSubnets(props?.subnetSelection).subnets; this.instanceType = props?.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.M6I, ec2.InstanceSize.LARGE); this.storageSize = props?.storageSize ?? cdk.Size.gibibytes(30); // 30 is the minimum for Windows this.storageOptions = props?.storageOptions; this.spot = props?.spot ?? false; this.spotMaxPrice = props?.spotMaxPrice; this.defaultLabels = props?.defaultLabels ?? true; this.tags = props?.tags ?? {}; // reserved, because cleanup terminates instances by a tag with this prefix Object.keys(this.tags).filter(key => key.startsWith(ReservedTags.PREFIX)).forEach(tag => { cdk.Annotations.of(this).addError( `Tag "${tag}" can't be set: the "${ReservedTags.PREFIX}" prefix is reserved.`, ); }); if (this.subnets.length === 0) { cdk.Annotations.of(this).addError('At least one subnet is required'); } const arch = this.instanceType.architecture === ec2.InstanceArchitecture.ARM_64 ? Architecture.ARM64 : Architecture.X86_64; this.amiBuilder = props?.imageBuilder ?? Ec2RunnerProvider.imageBuilder(this, 'Ami Builder', { vpc: props?.vpc, subnetSelection: props?.subnetSelection, securityGroups: this.securityGroups, baseAmi: isGpuInstanceType(this.instanceType) ? BaseImage.fromGpuBase(Os.LINUX_UBUNTU, arch) : undefined, architecture: arch, awsImageBuilderOptions: { instanceType: arch.is(Architecture.ARM64) ? ec2.InstanceType.of(ec2.InstanceClass.M6G, ec2.InstanceSize.LARGE) : undefined, }, }); this.ami = this.amiBuilder.bindAmi(); if (this.amiBuilder instanceof AwsImageBuilderRunnerImageBuilder) { if (this.amiBuilder.storageSize && this.storageSize.toBytes() < this.amiBuilder.storageSize.toBytes()) { cdk.Annotations.of(this).addError(`Runner storage size (${this.storageSize.toGibibytes()} GiB) must be at least the same as the image builder storage size (${this.amiBuilder.storageSize.toGibibytes()} GiB)`); } } if (!this.ami.architecture.instanceTypeMatch(this.instanceType)) { cdk.Annotations.of(this).addError(`AMI architecture (${this.ami.architecture.name}) doesn't match runner instance type (${this.instanceType} / ${this.instanceType.architecture})`); } this.grantPrincipal = this.role = new iam.Role(this, 'Role', { assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), }); this.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['states:SendTaskFailure', 'states:SendTaskSuccess', 'states:SendTaskHeartbeat'], resources: ['*'], // no support for stateMachine.stateMachineArn but task tokens are very long and totally random so not the end of the world })); this.grantPrincipal.addToPrincipalPolicy(MINIMAL_EC2_SSM_SESSION_MANAGER_POLICY_STATEMENT); this.logGroup = new logs.LogGroup( this, 'Logs', { retention: props?.logRetention ?? RetentionDays.ONE_MONTH, removalPolicy: RemovalPolicy.DESTROY, }, ); this.logGroup.grantWrite(this); this.instanceProfile = new iam.CfnInstanceProfile(this, 'Instance Profile', { roles: [this.role.roleName], }); this.rootDeviceResource = amiRootDevice(this, this.ami.launchTemplate.launchTemplateId, this.ami.cacheKey); if (Construct.isConstruct(this.amiBuilder)) { // if the user didn't create a static image builder and it's a real construct, we need to wait for it this.rootDeviceResource.node.addDependency(this.amiBuilder); } } /** * @internal */ _grantStateMachine(stateMachineRole: iam.IGrantable) { // we use ec2:RunInstances because we must // we can't use fleets because they don't let us override user data, security groups or even disk size // we can't use requestSpotInstances because it doesn't support launch templates, and it's deprecated // ec2:RunInstances also seemed like the only one to immediately return an error when spot capacity is not available stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['ec2:runInstances'], resources: ['*'], })); stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['iam:PassRole'], resources: [this.role.roleArn], conditions: { StringEquals: { 'iam:PassedToService': 'ec2.amazonaws.com', }, }, })); stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['ec2:createTags'], resources: [Stack.of(this).formatArn({ service: 'ec2', resource: '*', })], })); stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['iam:CreateServiceLinkedRole'], resources: ['*'], conditions: { StringEquals: { 'iam:AWSServiceName': 'spot.amazonaws.com', }, }, })); } /** * Config for the shared EC2 fragment. * * ec2:RunInstances can only try one subnet at a time, so every subnet gets a config that falls back to the next * one. Same chain we used to render as states. * * @internal */ _runnerConfig(): Ec2RunnerConfig { const base = { family: Ec2RunnerProvider._FAMILY, provider: this.node.path, launchTemplateId: this.ami.launchTemplate.launchTemplateId, instanceType: this.instanceType.toString(), securityGroupIds: this.securityGroups.map(sg => sg.securityGroupId), instanceProfileArn: this.instanceProfile.attrArn, logGroupName: this.logGroup.logGroupName, // already in the shape ec2:runInstances wants, so the fragment can splice it in as-is blockDeviceMappings: [{ DeviceName: this.rootDeviceResource.ref, Ebs: { DeleteOnTermination: true, VolumeSize: this.storageSize.toGibibytes(), VolumeType: this.storageOptions?.volumeType, Iops: this.storageOptions?.iops, Throughput: this.storageOptions?.throughput, }, }], // empty and not missing for on-demand // the state reads this path no matter what, and a missing reference path fails at runtime instanceMarketOptions: this.spot ? { MarketType: 'spot', SpotOptions: { MaxPrice: this.spotMaxPrice, SpotInstanceType: 'one-time', }, } : {}, // index into States.Array($ec2UserDataLinux, $ec2UserDataWindows) userDataTemplateIdx: this.ami.os.is(Os.WINDOWS) ? 1 : 0, // always here, even when empty, because that's what opts us into the standard runner tags // see selectProviderParams() in runner.ts, which merges them in at runtime tags: Object.entries(this.tags).map(([Key, Value]) => ({ Key, Value })), runnerGroup: this.group ?? '', defaultLabels: runnerDefaultLabels(this.ami.os, this.ami.architecture, this.defaultLabels), }; let config: any = undefined; for (let i = this.subnets.length - 1; i >= 0; i--) { config = { ...base, subnet: this.subnets[i].subnetId, ...config ? { fallback: config } : {}, }; } return config; } /** * @internal */ _status(statusFunctionRole: iam.IGrantable): IRunnerProviderStatus { statusFunctionRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['ec2:DescribeLaunchTemplateVersions'], resources: ['*'], })); return { type: this.constructor.name, labels: this.labels, constructPath: this.node.path, securityGroups: this.securityGroups.map(sg => sg.securityGroupId), roleArn: this.role.roleArn, logGroup: this.logGroup.logGroupName, ami: { launchTemplate: this.ami.launchTemplate.launchTemplateId || 'unknown', amiBuilderLogGroup: this.ami.logGroup?.logGroupName, }, }; } /** * The network connections associated with this resource. */ public get connections(): ec2.Connections { return new ec2.Connections({ securityGroups: this.securityGroups }); } }