src/providers/fargate.ts
Rama main · réplica de lectura del repositorio original.
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 } from 'aws-cdk-lib';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import {
Architecture,
BaseProvider,
IRunnerProvider,
IRunnerProviderStatus,
Os,
providerParam,
RunnerConfig,
runnerDefaultLabels,
runnerEnvironment,
RunnerImage,
RunnerProviderProps,
RunnerVersion,
} from './common';
import { IRunnerImageBuilder, RunnerImageBuilder, RunnerImageBuilderProps, RunnerImageComponent } from '../image-builders';
import { ReservedTags } from '../lambda-common';
import { MINIMAL_SSM_SESSION_MANAGER_POLICY_STATEMENT } from '../utils';
/**
* Properties for FargateRunnerProvider.
*/
export interface FargateRunnerProviderProps 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 FargateRunnerProvider.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 ['fargate']
*/
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 Fargate default
*/
readonly subnetSelection?: ec2.SubnetSelection;
/**
* Security groups to assign to the task.
*
* @default a new security group
*/
readonly securityGroups?: ec2.ISecurityGroup[];
/**
* Existing Fargate cluster to use.
*
* @default a new cluster
*/
readonly cluster?: ecs.Cluster;
/**
* Assign public IP to the runner task.
*
* Make sure the task will have access to GitHub. A public IP might be required unless you have NAT gateway.
*
* @default true
*/
readonly assignPublicIp?: boolean;
/**
* The number of cpu units used by the task. For tasks using the Fargate launch type,
* this field is required and you must use one of the following values,
* which determines your range of valid values for the memory parameter:
*
* 256 (.25 vCPU) - Available memory values: 512 (0.5 GB), 1024 (1 GB), 2048 (2 GB)
*
* 512 (.5 vCPU) - Available memory values: 1024 (1 GB), 2048 (2 GB), 3072 (3 GB), 4096 (4 GB)
*
* 1024 (1 vCPU) - Available memory values: 2048 (2 GB), 3072 (3 GB), 4096 (4 GB), 5120 (5 GB), 6144 (6 GB), 7168 (7 GB), 8192 (8 GB)
*
* 2048 (2 vCPU) - Available memory values: Between 4096 (4 GB) and 16384 (16 GB) in increments of 1024 (1 GB)
*
* 4096 (4 vCPU) - Available memory values: Between 8192 (8 GB) and 30720 (30 GB) in increments of 1024 (1 GB)
*
* @default 1024
*/
readonly cpu?: number;
/**
* The amount (in MiB) of memory used by the task. For tasks using the Fargate launch type,
* this field is required and you must use one of the following values, which determines your range of valid values for the cpu parameter:
*
* 512 (0.5 GB), 1024 (1 GB), 2048 (2 GB) - Available cpu values: 256 (.25 vCPU)
*
* 1024 (1 GB), 2048 (2 GB), 3072 (3 GB), 4096 (4 GB) - Available cpu values: 512 (.5 vCPU)
*
* 2048 (2 GB), 3072 (3 GB), 4096 (4 GB), 5120 (5 GB), 6144 (6 GB), 7168 (7 GB), 8192 (8 GB) - Available cpu values: 1024 (1 vCPU)
*
* Between 4096 (4 GB) and 16384 (16 GB) in increments of 1024 (1 GB) - Available cpu values: 2048 (2 vCPU)
*
* Between 8192 (8 GB) and 30720 (30 GB) in increments of 1024 (1 GB) - Available cpu values: 4096 (4 vCPU)
*
* @default 2048
*/
readonly memoryLimitMiB?: number;
/**
* The amount (in GiB) of ephemeral storage to be allocated to the task. The maximum supported value is 200 GiB.
*
* NOTE: This parameter is only supported for tasks hosted on AWS Fargate using platform version 1.4.0 or later.
*
* @default 20
*/
readonly ephemeralStorageGiB?: number;
/**
* Use Fargate spot capacity provider to save money.
*
* * Runners may fail to start due to missing capacity.
* * Runners might be stopped prematurely with spot pricing.
*
* @default false
*/
readonly spot?: boolean;
/**
* 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 };
}
/**
* @internal
*/
export function ecsRunCommand(os: Os, dind: boolean): string[] {
if (os.isIn(Os._ALL_LINUX_VERSIONS)) {
let dindCommand = '';
if (dind) {
dindCommand = 'nohup sudo dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 & ' +
'timeout 15 sh -c "until docker info; do echo .; sleep 1; done"';
}
return [
'sh', '-c',
`${dindCommand}
cd /home/runner &&
./job-reporter.sh "$RUNNER_NAME" &&
./run.sh --jitconfig "$RUNNER_JIT_CONFIG" &&
STATUS=$(grep -Phors "finish job request for job [0-9a-f-]+ with result: .*" _diag | tail -n1 | awk '{print $NF}') &&
if [ -n "$STATUS" ]; then echo CDKGHA JOB DONE "$RUNNER_LABEL" "$STATUS"; fi`,
];
} else if (os.is(Os.WINDOWS)) {
return [
'powershell', '-Command',
`cd \\actions ;
& ./job-reporter.ps1 "$Env:RUNNER_NAME" ;
./run.cmd --jitconfig "$Env:RUNNER_JIT_CONFIG" ;
$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 $Env:RUNNER_LABEL $STATUS" }`,
];
} else {
throw new Error(`Fargate runner doesn't support ${os.name}`);
}
}
/**
* Grant the state machine role whatever ecs:runTask.sync needs to run and track a task definition. Same
* statements stepfunctions_tasks.EcsRunTask would have generated for us.
*
* @internal
*/
export function grantEcsRunTask(scope: Construct, stateMachineRole: iam.IGrantable, task: ecs.TaskDefinition) {
const stack = cdk.Stack.of(scope);
// grant on the unversioned task definition arn so we can always run the latest revision
const arnComponents = stack.splitArn(task.taskDefinitionArn, cdk.ArnFormat.SLASH_RESOURCE_NAME);
let resourceName = arnComponents.resourceName;
if (resourceName) {
resourceName = resourceName.split(':')[0];
}
const familyArn = stack.formatArn({
partition: arnComponents.partition,
service: arnComponents.service,
account: arnComponents.account,
region: arnComponents.region,
resource: arnComponents.resource,
arnFormat: arnComponents.arnFormat,
resourceName,
});
stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['ecs:RunTask'],
resources: [`${familyArn}:*`],
}));
stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['ecs:StopTask', 'ecs:DescribeTasks'],
resources: ['*'],
}));
// tagging the task on creation needs its own permission
stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['ecs:TagResource'],
resources: ['*'],
conditions: {
StringEquals: {
'ecs:CreateAction': 'RunTask',
},
},
}));
const passedRoles = [task.taskRole.roleArn];
if (task.executionRole) {
passedRoles.push(task.executionRole.roleArn);
}
stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['iam:PassRole'],
resources: passedRoles,
}));
// managed rule for the runTask.sync integration
// every ECS and Fargate provider emits the same statement so policy minimization keeps just one
stateMachineRole.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['events:PutTargets', 'events:PutRule', 'events:DescribeRule'],
resources: [stack.formatArn({
service: 'events',
resource: 'rule',
resourceName: 'StepFunctionsGetEventsForECSTaskRule',
})],
}));
}
/**
* @internal
*/
export function cleanEcsTag(scope: Construct, description: string, value: string) {
const cleaned = value.replace(/[^\p{L}\p{Z}\p{N}_.:/=+\-@]/gu, '_').slice(0, 256);
if (cleaned !== value) {
cdk.Annotations.of(scope).addWarning(
`ECS tags can only contain up to 256 letters, numbers, spaces, and _ . : / = + - @, so the ${description} will be tagged as ` +
`${JSON.stringify(cleaned.slice(0, 100))}`,
);
}
return cleaned;
}
/**
* Tags for a runner task, on top of the standard runner tags the orchestrator merges in at runtime.
*
* ECS is a lot pickier about tags than EC2. It doesn't allow commas which we use in labels, no parenthesis, no brackets, and others. For data that
* used to allow those characters, we clean up the tag and warn the user. For tags manually set on the provider (new feature), we instead error out.
*
* `Name`, `GitHubRunners:Repo` and `GitHubRunners:Labels` are left to the orchestrator. The first two are built out of the repository name and the
* webhook delivery id, and GitHub doesn't allow anything ECS would reject. Labels are only known when a job comes in, so the orchestrator cleans
* them up instead (see `cleanLabels` in selectProviderParams). Unlike EC2 tags, they end up separated with spaces. Which honestly is quite annoying.
*
* @internal
*/
export function ecsTags(scope: Construct, tags: { [key: string]: string }): { [key: string]: string } {
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Tag.html
const allowed = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u;
const check = (description: string, value: string, maxLength: number) => {
if (!allowed.test(value)) {
cdk.Annotations.of(scope).addError(
`Bad character in ${description}. ECS tags can only contain letters, numbers, spaces, and _ . : / = + - @: ${JSON.stringify(value.slice(0, 100))}`,
);
}
if (value.length > maxLength) {
cdk.Annotations.of(scope).addError(
`Too many characters in ${description}. ECS tags are limited to ${maxLength} characters: ${JSON.stringify(value.slice(0, 100))}`,
);
}
};
if (Object.keys(tags).length > 45) {
cdk.Annotations.of(scope).addError('Too many tags. ECS tags are limited to 50 tags, and 5 are already used by the orchestrator.' );
}
for (const [key, value] of Object.entries(tags)) {
if (!key) {
cdk.Annotations.of(scope).addError('Tag names cannot be empty');
}
if (key.toLowerCase().startsWith('aws:')) {
cdk.Annotations.of(scope).addError(`Tag names cannot start with "aws:": ${JSON.stringify(key)}`);
}
if (key.startsWith(ReservedTags.PREFIX)) {
cdk.Annotations.of(scope).addError(`Tag names cannot start with "${ReservedTags.PREFIX}": ${JSON.stringify(key)}`);
}
check('tag name', key, 128);
check('tag value', value, 256);
}
// the user's tags come last so they can override ours, just like they override the orchestrator's standard tags
return {
'GitHubRunners:Provider': cleanEcsTag(scope, 'provider construct path', scope.node.path),
...tags,
};
}
/**
* Runner config for the Fargate family fragment.
*
* @internal
*/
interface FargateRunnerConfig extends RunnerConfig {
readonly clusterArn: string;
readonly taskDefinitionFamily: string;
readonly containerName: string;
readonly capacityProvider: string;
readonly enableExecuteCommand: boolean;
readonly subnets: string[];
readonly securityGroups: string[];
readonly assignPublicIp: string;
}
/**
* GitHub Actions runner provider using Fargate to execute jobs.
*
* Creates a task definition with a single container that gets started for each job.
*
* This construct is not meant to be used by itself. It should be passed in the providers property for GitHubRunners.
*/
export class FargateRunnerProvider extends BaseProvider implements IRunnerProvider {
/** @internal */
public static readonly _FAMILY = 'fargate';
/**
* The fragment that runs any Fargate provider. Reads the config {@link _runnerConfig} generated for it from
* `$.providerParams`. Renders what EcsRunTask used to render per provider.
*
* @internal
*/
public static _stateMachineFragment(scope: Construct): stepfunctions.IChainable {
const p = providerParam<FargateRunnerConfig>;
return new stepfunctions.CustomState(scope, 'Fargate Runner', {
stateJson: {
Type: 'Task',
Resource: `arn:${cdk.Aws.PARTITION}:states:::ecs:runTask.sync`,
Parameters: {
'Cluster.$': p('clusterArn'),
'TaskDefinition.$': p('taskDefinitionFamily'),
'NetworkConfiguration': {
AwsvpcConfiguration: {
'AssignPublicIp.$': p('assignPublicIp'),
'Subnets.$': p('subnets'),
'SecurityGroups.$': p('securityGroups'),
},
},
'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('capacityProvider'),
}],
'PlatformVersion': 'LATEST',
'EnableExecuteCommand.$': p('enableExecuteCommand'),
},
},
});
}
/**
* Create new image builder that builds Fargate 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.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.githubRunner(props?.runnerVersion ?? RunnerVersion.latest()),
],
...props,
});
}
/**
* Cluster hosting the task hosting the runner.
*/
readonly cluster: ecs.Cluster;
/**
* Fargate task hosting the runner.
*/
private readonly task: ecs.FargateTaskDefinition;
/**
* 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;
/**
* Whether runner task will have a public IP.
*/
private readonly assignPublicIp: boolean;
/**
* 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;
/**
* Use spot pricing for Fargate tasks.
*/
private readonly spot: boolean;
/**
* Docker image loaded with GitHub Actions Runner and its prerequisites. The image is built by an image builder and is specific to Fargate 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;
private readonly group?: string;
private readonly defaultLabels: boolean;
private readonly securityGroups: ec2.ISecurityGroup[];
private readonly tags: { [key: string]: string };
constructor(scope: Construct, id: string, props?: FargateRunnerProviderProps) {
super(scope, id, props);
this.labels = props?.labels ?? ['fargate'];
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.assignPublicIp = props?.assignPublicIp ?? true;
this.cluster = props?.cluster ? props.cluster : new ecs.Cluster(
this,
'cluster',
{
vpc: this.vpc,
enableFargateCapacityProviders: true,
},
);
this.spot = props?.spot ?? false;
this.tags = ecsTags(this, props?.tags ?? {});
// 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));
const imageBuilder = props?.imageBuilder ?? FargateRunnerProvider.imageBuilder(this, 'Image Builder');
const image = this.image = imageBuilder.bindDockerImage();
let arch: ecs.CpuArchitecture;
if (image.architecture.is(Architecture.ARM64)) {
arch = ecs.CpuArchitecture.ARM64;
} else if (image.architecture.is(Architecture.X86_64)) {
arch = ecs.CpuArchitecture.X86_64;
} else {
cdk.Annotations.of(this).addError(`${image.architecture.name} is not supported on Fargate`);
arch = ecs.CpuArchitecture.X86_64; // so the code below doesn't throw an exception
}
let fargateRunnerCommandOs = image.os;
let os: ecs.OperatingSystemFamily;
if (image.os.isIn(Os._ALL_LINUX_VERSIONS)) {
os = ecs.OperatingSystemFamily.LINUX;
} else if (image.os.is(Os.WINDOWS)) {
os = ecs.OperatingSystemFamily.WINDOWS_SERVER_2019_CORE;
if (props?.ephemeralStorageGiB) {
cdk.Annotations.of(this).addError('Ephemeral storage is not supported on Fargate Windows');
}
} else {
cdk.Annotations.of(this).addError(`${image.os.name} is not supported on Fargate`);
os = ecs.OperatingSystemFamily.LINUX;
fargateRunnerCommandOs = Os.LINUX_UBUNTU;
}
this.logGroup = new logs.LogGroup(this, 'logs', {
retention: props?.logRetention ?? RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
});
this.task = new ecs.FargateTaskDefinition(
this,
'task',
{
cpu: props?.cpu ?? 1024,
memoryLimitMiB: props?.memoryLimitMiB ?? 2048,
ephemeralStorageGiB: image.os.is(Os.WINDOWS) ? undefined : props?.ephemeralStorageGiB ?? 25,
runtimePlatform: {
operatingSystemFamily: os,
cpuArchitecture: arch,
},
},
);
this.container = this.task.addContainer(
'runner',
{
image: ecs.AssetImage.fromEcrRepository(image.imageRepository, image.imageTag),
logging: ecs.AwsLogDriver.awsLogs({
logGroup: this.logGroup,
streamPrefix: 'runner',
}),
command: ecsRunCommand(fargateRunnerCommandOs, false),
user: image.os.is(Os.WINDOWS) ? undefined : 'runner',
},
);
this.grantPrincipal = this.task.taskRole;
// allow SSM Session Manager
this.task.taskRole.addToPrincipalPolicy(MINIMAL_SSM_SESSION_MANAGER_POLICY_STATEMENT);
}
/**
* Config for the shared Fargate fragment.
*
* @internal
*/
_runnerConfig(): FargateRunnerConfig {
// same subnet selection EcsRunTask defaults to: whatever was asked for, or public/private per assignPublicIp
const subnetSelection = this.subnetSelection ??
{ subnetType: this.assignPublicIp ? ec2.SubnetType.PUBLIC : ec2.SubnetType.PRIVATE_WITH_EGRESS };
return {
family: FargateRunnerProvider._FAMILY,
provider: this.node.path,
clusterArn: this.cluster.clusterArn,
taskDefinitionFamily: this.task.family,
containerName: this.container.containerName,
capacityProvider: this.spot ? 'FARGATE_SPOT' : 'FARGATE',
enableExecuteCommand: this.image.os.isIn(Os._ALL_LINUX_VERSIONS),
subnets: this.cluster.vpc.selectSubnets(subnetSelection).subnetIds,
securityGroups: this.securityGroups.map(sg => sg.securityGroupId),
assignPublicIp: this.assignPublicIp ? 'ENABLED' : 'DISABLED',
// 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,
},
};
}
}