src/providers/common.ts
Rama main · réplica de lectura del repositorio original.
import * as cdk from 'aws-cdk-lib';
import { aws_ec2 as ec2, aws_ecr as ecr, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, CustomResource, Duration } from 'aws-cdk-lib';
import { EbsDeviceVolumeType } from 'aws-cdk-lib/aws-ec2';
import { Construct, IConstruct, IDependable } from 'constructs';
import { AmiRootDeviceFunction } from './ami-root-device-function';
import { singletonLambda, singletonLogGroup, SingletonLogType } from '../utils';
/**
* Defines desired GitHub Actions runner version.
*/
export class RunnerVersion {
/**
* Use the latest version available at the time the runner provider image is built.
*/
public static latest(): RunnerVersion {
return new RunnerVersion('latest');
}
/**
* Use a specific version.
*
* @see https://github.com/actions/runner/releases
*
* @param version GitHub Runner version
*/
public static specific(version: string) {
return new RunnerVersion(version);
}
protected constructor(readonly version: string) {
}
/**
* Check if two versions are the same.
*
* @param other version to compare
*/
public is(other: RunnerVersion) {
return this.version == other.version;
}
}
/**
* CPU architecture enum for an image.
*/
export class Architecture {
/**
* ARM64
*/
public static readonly ARM64 = Architecture.of('ARM64');
/**
* X86_64
*/
public static readonly X86_64 = Architecture.of('X86_64');
private static of(architecture: string) {
return new Architecture(architecture);
}
private constructor(public readonly name: string) {
}
/**
* Checks if the given architecture is the same as this one.
*
* @param arch architecture to compare
*/
public is(arch: Architecture): boolean {
return arch.name == this.name;
}
/**
* Checks if this architecture is in a given list.
*
* @param arches architectures to check
*/
public isIn(arches: Architecture[]): boolean {
for (const arch of arches) {
if (this.is(arch)) {
return true;
}
}
return false;
}
/**
* Checks if a given EC2 instance type matches this architecture.
*
* @param instanceType instance type to check
*/
public instanceTypeMatch(instanceType: ec2.InstanceType): boolean {
if (instanceType.architecture == ec2.InstanceArchitecture.X86_64) {
return this.is(Architecture.X86_64);
}
if (instanceType.architecture == ec2.InstanceArchitecture.ARM_64) {
return this.is(Architecture.ARM64);
}
throw new Error('Unknown instance type architecture');
}
}
/**
* OS enum for an image.
*/
export class Os {
/**
* Ubuntu Linux
*/
public static readonly LINUX_UBUNTU = Os.of('Ubuntu Linux');
/**
* Ubuntu Linux 22.04
*/
public static readonly LINUX_UBUNTU_2204 = Os.of('Ubuntu Linux 22.04');
/**
* Ubuntu Linux 24.04
*/
public static readonly LINUX_UBUNTU_2404 = Os.of('Ubuntu Linux 24.04');
/**
* Amazon Linux 2
*/
public static readonly LINUX_AMAZON_2 = Os.of('Amazon Linux 2');
/**
* Amazon Linux 2023
*/
public static readonly LINUX_AMAZON_2023 = Os.of('Amazon Linux 2023');
/**
* @internal
*/
public static readonly _ALL_LINUX_VERSIONS =
[Os.LINUX_UBUNTU, Os.LINUX_UBUNTU_2204, Os.LINUX_UBUNTU_2404, Os.LINUX_AMAZON_2, Os.LINUX_AMAZON_2023];
/**
* @internal
*/
public static readonly _ALL_LINUX_AMAZON_VERSIONS = [Os.LINUX_AMAZON_2, Os.LINUX_AMAZON_2023];
/**
* @internal
*/
public static readonly _ALL_LINUX_UBUNTU_VERSIONS = [Os.LINUX_UBUNTU, Os.LINUX_UBUNTU_2204, Os.LINUX_UBUNTU_2404];
/**
* Windows
*/
public static readonly WINDOWS = Os.of('Windows');
private static of(os: string) {
return new Os(os);
}
private constructor(public readonly name: string) {
}
/**
* Checks if the given OS is the same as this one.
*
* @param os OS to compare
*/
public is(os: Os) {
return os.name == this.name;
}
/**
* Checks if this OS is in a given list.
*
* @param oses list of OS to check
*/
public isIn(oses: Os[]): boolean {
for (const os of oses) {
if (this.is(os)) {
return true;
}
}
return false;
}
}
/**
* Description of a Docker image built by {@link RunnerImageBuilder}.
*/
export interface RunnerImage {
/**
* ECR repository containing the image.
*/
readonly imageRepository: ecr.IRepository;
/**
* Static image tag where the image will be pushed.
*/
readonly imageTag: string;
/**
* Architecture of the image.
*/
readonly architecture: Architecture;
/**
* OS type of the image.
*/
readonly os: Os;
/**
* Log group where image builds are logged.
*/
readonly logGroup?: logs.LogGroup;
/**
* A dependable that can be waited on to ensure the image is ready.
*
* @internal
*/
readonly _dependable?: IDependable;
}
/**
* Description of a AMI built by {@link RunnerImageBuilder}.
*/
export interface RunnerAmi {
/**
* Launch template pointing to the latest AMI.
*/
readonly launchTemplate: ec2.ILaunchTemplate;
/**
* Architecture of the image.
*/
readonly architecture: Architecture;
/**
* OS type of the image.
*/
readonly os: Os;
/**
* Log group where image builds are logged.
*/
readonly logGroup?: logs.LogGroup;
/**
* Set this to a value that changes whenever the AMI changes (the AMI id or any version string works).
*
* It's used to know when the AMI's root device name needs to be looked up again. If left empty, the root
* device name is looked up once and reused. That's fine as long as the AMI's root device never changes.
*
* This value may be used for other things in the future that require knowing when the AMI changed.
*/
readonly cacheKey?: string;
}
/**
* Retry options for providers. The default is to retry 210 times for a bit over 24 hours with increasing interval.
*
* Retries use full jitter, so every wait is a random time between zero and the calculated interval. This spreads out
* runners that all failed at the same time, so they don't hit the same missing capacity or API quota together again.
* It also means the average wait is half the calculated interval, and that's what the 24 hours are calculated from.
*/
export interface ProviderRetryOptions {
/**
* Set to true to retry provider on supported failures. Which failures generate a retry depends on the specific provider.
*
* @default true
*/
readonly retry?: boolean;
/**
* How much time to wait after first retryable failure. This interval will be multiplied by {@link backoffRate} each retry, up to {@link maxDelay}.
*
* @default 1 minute
*/
readonly interval?: Duration;
/**
* Maximum wait between retries. Without it, exponential backoff quickly grows to hours between attempts, so a job
* can end up waiting hours for a runner even though capacity came back minutes after it failed.
*
* Don't go too low either. A lower maximum needs more attempts to cover the same 24 hours, and every attempt adds
* to the execution history that Step Functions caps at 25,000 events.
*
* @default 15 minutes
*/
readonly maxDelay?: Duration;
/**
* How many times to retry.
*
* @default 210
*/
readonly maxAttempts?: number;
/**
* Multiplication for how much longer the wait interval gets on every retry.
*
* @default 2
*/
readonly backoffRate?: number;
}
/**
* Common properties for all runner providers.
*/
export interface RunnerProviderProps {
/**
* The number of days log events are kept in CloudWatch Logs. When updating
* this property, unsetting it doesn't remove the log retention policy. To
* remove the retention policy, set the value to `INFINITE`.
*
* @default logs.RetentionDays.ONE_MONTH
*/
readonly logRetention?: logs.RetentionDays;
/**
* Add default labels based on OS and architecture of the runner. This will tell GitHub Runner to add default labels like `self-hosted`, `linux`, `x64`, and `arm64`.
*
* @default true
*/
readonly defaultLabels?: boolean;
}
/**
* Image status returned from runner providers to be displayed in status.json.
*/
export interface IRunnerImageStatus {
/**
* Image repository where image builder pushes runner images.
*/
readonly imageRepository: string;
/**
* Tag of image that should be used.
*/
readonly imageTag: string;
/**
* Log group name for the image builder where history of image builds can be analyzed.
*/
readonly imageBuilderLogGroup?: string;
}
/**
* AMI status returned from runner providers to be displayed as output of status function.
*/
export interface IRunnerAmiStatus {
/**
* Id of launch template pointing to the latest AMI built by the AMI builder.
*/
readonly launchTemplate: string;
/**
* Log group name for the AMI builder where history of builds can be analyzed.
*/
readonly amiBuilderLogGroup?: string;
}
/**
* Interface for runner image status used by status.json.
*/
export interface IRunnerProviderStatus {
/**
* Runner provider type.
*/
readonly type: string;
/**
* Labels associated with provider.
*/
readonly labels: string[];
/**
* CDK construct node path for this provider.
*/
readonly constructPath?: string;
/**
* VPC where runners will be launched.
*/
readonly vpcArn?: string;
/**
* Security groups attached to runners.
*/
readonly securityGroups?: string[];
/**
* Role attached to runners.
*/
readonly roleArn?: string;
/**
* Details about Docker image used by this runner provider.
*/
readonly image?: IRunnerImageStatus;
/**
* Details about AMI used by this runner provider.
*/
readonly ami?: IRunnerAmiStatus;
/**
* Log group for runners.
*/
readonly logGroup?: string;
}
/**
* Interface for all runner providers.
*
* This interface cannot be implemented by external code. If the built-in providers don't cover your use case, open an issue so we can discuss it.
*/
export interface IRunnerProvider extends ec2.IConnectable, iam.IGrantable, IConstruct {
/**
* 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 use 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.
*/
readonly labels: string[];
/**
* 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;
}
/**
* Contract between GitHubRunners and its providers, both normal and composite. It's hidden from the public API
* because the state machine has one shared fragment per provider family, and we only implement the families in
* this library.
*
* @internal
*/
export interface IParameterizedRunnerProvider extends IConstruct {
/**
* GitHub Actions labels used for this provider.
*/
readonly labels: string[];
/**
* Runtime configuration for this provider. We embed it in the state machine definition and the family fragments
* read it from `$.providerParams`. Must be JSON-serializable, but can contain CloudFormation tokens.
*
* A config can chain another one at `fallback` to try when it fails, or hold a `distribute` list of weighted
* configs to pick from.
*/
_runnerConfig(): AnyRunnerConfig;
/**
* Grant the state machine role whatever the family fragment needs to run this particular provider.
*/
_grantStateMachine(stateMachineRole: iam.IGrantable): void;
/**
* Return status of the runner provider to be used in the main status function. Also gives the status function any
* needed permissions to query the Docker image or AMI. Composite providers return one status per sub-provider.
*/
_status(statusFunctionRole: iam.IGrantable): IRunnerProviderStatus | IRunnerProviderStatus[];
}
/**
* Check whether a provider implements the internal contract.
*
* instanceof doesn't really work in CDK so duck-type instead.
*
* @internal
*/
export function isParameterizedRunnerProvider(provider: IConstruct): provider is IParameterizedRunnerProvider {
return '_runnerConfig' in provider;
}
/**
* Reference paths to the runner values the execution input carries. Written once here so a rename can't quietly
* miss a fragment.
*
* @internal
*/
export const RUNNER_INPUT = {
jitConfig: '$.runner.jitConfig',
name: '$$.Execution.Name',
labels: '$.labels',
domain: '$.runner.domain',
owner: '$.owner',
repo: '$.repo',
} as const;
/**
* Reference path to a field of a provider's runner config. Typed on the family's config so renaming a field breaks every fragment that reads it.
*
* @internal
*/
export function providerParam<C>(key: keyof C & string): string {
return `$.providerParams.${key}`;
}
/**
* Fields every runner config carries.
*
* @internal
*/
export interface RunnerConfig {
/** family whose fragment runs this config */
readonly family: string;
/** config to try when this one fails */
readonly fallback?: RunnerConfig;
/** provider that actually runs the job, for tagging, when it isn't `$.provider` */
readonly provider: string;
/** runner group this config registers with, or an empty string */
readonly runnerGroup: string;
/** labels config.sh would have added on its own, or nothing when the provider turned them off. see {@link runnerDefaultLabels} */
readonly defaultLabels: string[];
/** tags the provider sets on whatever it creates, before the standard runner tags get merged in */
readonly tags?: { readonly Key: string; readonly Value: string }[];
/** ECS won't take the labels as they come in -- no commas, no parenthesis -- so its configs ask for a cleaned up labels tag */
readonly cleanLabels?: boolean;
}
/**
* A config that picks one of several weighted configs at runtime. Composite distribution providers return this
* instead of a runner config of their own.
*
* @internal
*/
export interface DistributedRunnerConfig {
/** sum of every weight, so a random number in [0, totalWeight) can be compared against the thresholds */
readonly totalWeight: number;
/** running weight sums, paired with the config to use below each one */
readonly distribute: {
readonly threshold: number;
readonly config: AnyRunnerConfig;
}[];
}
/**
* Either kind of config the state machine can select.
*
* @internal
*/
export type AnyRunnerConfig = RunnerConfig | DistributedRunnerConfig;
/**
* Environment variables we pass to the runner. `format` renders one, so
* each family can use whatever shape its API wants.
*
* @internal
*/
export function runnerEnvironment(format: (name: string, value: string) => any): any[] {
return [
format('RUNNER_JIT_CONFIG', RUNNER_INPUT.jitConfig),
format('RUNNER_NAME', RUNNER_INPUT.name),
format('RUNNER_LABEL', RUNNER_INPUT.labels),
format('GITHUB_DOMAIN', RUNNER_INPUT.domain),
format('OWNER', RUNNER_INPUT.owner),
format('REPO', RUNNER_INPUT.repo),
];
}
/**
* Labels config.sh adds on its own unless it gets `--no-default-labels`. JIT runners don't run config.sh, and whoever creates the JIT config
* doesn't know the OS or architecture, so we have to pass them.
*
* @internal
*/
export function runnerDefaultLabels(os: Os, architecture: Architecture, enabled: boolean): string[] {
return enabled ? ['self-hosted', os.is(Os.WINDOWS) ? 'Windows' : 'Linux', architecture.is(Architecture.ARM64) ? 'ARM64' : 'X64'] : [];
}
/**
* Interface for composite runner providers that combine multiple sub-providers.
* Unlike IRunnerProvider, composite providers do not have connections, grant capabilities,
* or log groups as they delegate to their sub-providers.
*
* Note that this interface cannot be implemented by external code. Use {@link CompositeProvider} factory methods.
*/
export interface ICompositeProvider extends IConstruct {
/**
* 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 use 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.
*/
readonly labels: string[];
/**
* All sub-providers contained in this composite provider.
* This is used to extract providers for metric filters and other operations.
*/
readonly providers: IRunnerProvider[];
}
/**
* Storage options for the runner instance.
*/
export interface StorageOptions {
/**
* The EBS volume type
* @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSVolumeTypes.html
*
* @default `EbsDeviceVolumeType.GP2`
*/
readonly volumeType?: EbsDeviceVolumeType;
/**
* The number of I/O operations per second (IOPS) to provision for the volume.
*
* Must only be set for `volumeType`: `EbsDeviceVolumeType.IO1`
*
* The maximum ratio of IOPS to volume size (in GiB) is 50:1, so for 5,000 provisioned IOPS,
* you need at least 100 GiB storage on the volume.
*
* @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSVolumeTypes.html
*
* @default - none, required for `EbsDeviceVolumeType.IO1`
*/
readonly iops?: number;
/**
* The throughput that the volume supports, in MiB/s
* Takes a minimum of 125 and maximum of 1000.
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-volume.html#cfn-ec2-volume-throughput
* @default - 125 MiB/s. Only valid on gp3 volumes.
*/
readonly throughput?: number;
}
/**
* Base class for all providers with common methods used by all providers.
*
* @internal
*/
export abstract class BaseProvider extends Construct {
protected constructor(scope: Construct, id: string, _props?: RunnerProviderProps) {
super(scope, id);
cdk.Tags.of(this).add('GitHubRunners:Provider', this.node.path);
}
}
/**
* Use custom resource to determine the root device name of a given AMI, Launch Template, or SSM parameter pointing to AMI.
*
* TODO move somewhere more common as it's used by both providers and AMI builder now
*
* @internal
*/
export function amiRootDevice(scope: Construct, ami?: string, cacheKey?: string) {
const crHandler = singletonLambda(AmiRootDeviceFunction, scope, 'AMI Root Device Reader', {
description: 'Custom resource handler that discovers the boot drive device name for a given AMI',
timeout: cdk.Duration.minutes(1),
logGroup: singletonLogGroup(scope, SingletonLogType.RUNNER_IMAGE_BUILD),
loggingFormat: lambda.LoggingFormat.JSON,
initialPolicy: [
new iam.PolicyStatement({
actions: [
'ssm:GetParameter',
'ec2:DescribeImages',
'ec2:DescribeLaunchTemplateVersions',
'imagebuilder:GetImage',
],
resources: ['*'],
}),
],
});
return new CustomResource(scope, 'AMI Root Device', {
serviceToken: crHandler.functionArn,
resourceType: 'Custom::AmiRootDevice',
properties: {
Ami: ami ?? '',
CacheKey: cacheKey,
},
});
}