src/warm-runner.ts
Rama main · réplica de lectura del repositorio original.
import * as crypto from 'crypto';
import * as cdk from 'aws-cdk-lib';
import {
aws_events as events,
aws_events_targets as events_targets,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CronExpressionParser } from 'cron-parser';
import { ICompositeProvider, IRunnerProvider } from './providers';
import { GitHubRunners } from './runner';
import { WarmRunnerFillPayload } from './warm-runner-manager.lambda';
/**
* Common properties for warm runner constructs.
*
* @internal
*/
export interface WarmRunnerBaseProps {
/**
* The GitHubRunners construct that owns the shared warm runner infrastructure.
*/
readonly runners: GitHubRunners;
/**
* Provider to use. Warm runners bypass the provider selector — they always use
* this provider, regardless of job characteristics. Labels cannot be modified.
*/
readonly provider: IRunnerProvider | ICompositeProvider;
/**
* Number of warm runners to maintain.
*/
readonly count: number;
/**
* GitHub owner where runners will be registered (org or user login).
*/
readonly owner: string;
/**
* Registration level — must match how your runners are set up in GitHub. Choose
* 'org' for org-wide runners, 'repo' for repo-level. See the setup wizard for choosing repo vs org.
*
* @see https://github.com/CloudSnorkel/cdk-github-runners/blob/main/SETUP_GITHUB.md
*
* @default 'repo'
*/
readonly registrationLevel?: 'org' | 'repo';
/**
* Repository name (without owner) where runners will be registered. Required when `registrationLevel` is 'repo'.
*/
readonly repo?: string;
}
/**
* Properties for always on warm runners.
*/
export interface AlwaysOnWarmRunnerProps extends WarmRunnerBaseProps { }
/**
* Properties for scheduled warm runners.
*/
export interface ScheduledWarmRunnerProps extends WarmRunnerBaseProps {
/**
* When to start filling the pool (e.g. start of business hours).
*/
readonly schedule: events.Schedule;
/**
* How long the warm runners should be maintained from the fill time (schedule). Defines the end of the
* window (schedule time + duration).
*/
readonly duration: cdk.Duration;
}
function buildWarmRunner(scope: Construct, props: WarmRunnerBaseProps, schedule: events.Schedule, duration: number, createInitialFill: boolean) {
const registrationLevel = props.registrationLevel ?? 'repo';
if (registrationLevel === 'org' && props.repo) {
cdk.Annotations.of(scope).addError('Do not specify repo when registrationLevel is \'org\'');
}
if (registrationLevel === 'repo' && !props.repo) {
cdk.Annotations.of(scope).addError('repo is required when registrationLevel is \'repo\'');
}
const providerPath = props.provider.node.path;
if (!props.runners.providers.some(p => p.node.path === providerPath)) {
cdk.Annotations.of(scope).addError(`Provider ${providerPath} is not in the providers list of the GitHubRunners construct`);
}
const labels = props.provider.labels;
const repo = registrationLevel === 'repo' ? (props.repo ?? '') : '';
const configHash = crypto.createHash('sha256')
.update(JSON.stringify({ providerPath, providerLabels: labels, count: props.count, duration, owner: props.owner, repo }))
.digest('hex')
.slice(0, 16);
const fillPayload: WarmRunnerFillPayload = {
action: 'fill' as const,
providerPath,
providerLabels: labels,
count: props.count,
duration,
owner: props.owner,
repo,
configHash,
};
const { lambda: managerFn, queue } = props.runners._ensureWarmRunnerInfra();
props.runners._registerWarmConfigHash(configHash, duration);
// Schedule to fill the warm pool (usually daily). Sends to SQS so we get stable messageId for idempotent fills.
new events.Rule(scope, 'Schedule', {
schedule,
targets: [new events_targets.SqsQueue(queue, {
message: events.RuleTargetInput.fromObject(fillPayload),
})],
});
// Fill the warm pool immediately on deploy (AlwaysOnWarmRunner only).
// ScheduledWarmRunner does not get deployment-fill. First fill happens at the next schedule fire.
if (createInitialFill) {
new cdk.CustomResource(scope, 'Initial Fill', {
serviceToken: managerFn.functionArn,
resourceType: 'Custom::WarmRunnerFill',
properties: fillPayload,
});
}
return fillPayload;
}
/**
* Warm runners that run 24/7. Fills at midnight UTC and each runner stays alive for 24 hours.
*
* Runners will be provisioned using the specified provider and registered in the specified repository or organization.
*
* Registration level must match the one selected during setup.
*
* @see https://github.com/CloudSnorkel/cdk-github-runners/blob/main/SETUP_GITHUB.md
*
* **Limitations**
*
* - Jobs will still trigger provisioning of on-demand runners, even if a warm runner ends up being used.
* - You may briefly see more than `count` runners when changing config or at rotation.
* - To remove: set `count` to 0, deploy, wait for warm runners to stop, then remove and deploy again.
* If you don't follow this procedure, warm runners may linger until they expire.
* - Provider failures or timeouts (like Lambda provider timing out after 15 minutes) will result in a
* gap in coverage until the retry succeeds. Current retry mechanism has built-in back-off rate and
* can be tweaked using `retryOptions`. This will be improved in the future.
*
* ```typescript
* new AlwaysOnWarmRunner(stack, 'AlwaysOnLinux', {
* runners,
* provider: myProvider,
* count: 3,
* owner: 'my-org',
* repo: 'my-repo',
* });
* ```
*/
export class AlwaysOnWarmRunner extends Construct {
/**
* The fill payload for this warm runner configuration.
* @internal
*/
public readonly _fillPayload: WarmRunnerFillPayload;
constructor(scope: Construct, id: string, props: AlwaysOnWarmRunnerProps) {
super(scope, id);
this._fillPayload = buildWarmRunner(this, props, events.Schedule.cron({ hour: '0', minute: '0' }), cdk.Duration.days(1).toSeconds(), true);
}
}
/**
* Convert AWS EventBridge cron format to cron-parser format.
* AWS: cron(min hour dom month dow year), cron-parser: sec min hour dom month dow
*/
function awsCronToParserFormat(expressionString: string): string {
const match = expressionString.match(/^cron\((.+)\)$/);
if (!match) return expressionString;
const [, inner] = match;
const parts = inner.trim().split(/\s+/);
if (parts.length !== 6) return expressionString;
const [minute, hour, dom, month, dow] = parts;
return `0 ${minute} ${hour} ${dom} ${month} ${dow}`;
}
/**
* Parse AWS EventBridge rate expression and return interval in seconds.
* Format: rate(value unit) e.g. rate(2 hours), rate(5 minutes), rate(1 day)
*/
function parseRateInterval(expressionString: string): number | undefined {
const match = expressionString.match(/^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/i);
if (!match) return undefined;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
if (value <= 0) return undefined;
const secondsPerUnit: Record<string, number> = {
minute: 60,
minutes: 60,
hour: 3600,
hours: 3600,
day: 86400,
days: 86400,
};
return value * secondsPerUnit[unit];
}
/**
* Get the interval between schedule occurrences in seconds.
* Supports both cron and rate expressions.
*/
function getScheduleIntervalSeconds(expressionString: string): number | undefined {
const rateInterval = parseRateInterval(expressionString);
if (rateInterval !== undefined) return rateInterval;
try {
const cronExpression = CronExpressionParser.parse(awsCronToParserFormat(expressionString));
const next = cronExpression.take(2);
return (next[1].getTime() - next[0].getTime()) / 1000;
} catch {
return undefined;
}
}
/**
* Warm runners active during a time window specified by start time (`schedule`) and duration (`duration`).
*
* Runners will be provisioned using the specified provider and registered in the specified repository or organization.
*
* Registration level must match the one selected during setup.
*
* @see https://github.com/CloudSnorkel/cdk-github-runners/blob/main/SETUP_GITHUB.md
*
* **Limitations**
*
* - **No deployment-fill**: Unlike `AlwaysOnWarmRunner`, scheduled warm runners do not get an initial
* fill on deploy. The first fill happens at the next schedule occurrence. If you deploy at 1pm for
* a 2pm schedule, runners will not appear until 2pm.
* - Jobs will still trigger provisioning of on-demand runners, even if a warm runner ends up being used.
* - You may briefly see more than `count` runners when changing config or at rotation.
* - To remove: set `count` to 0, deploy, wait for warm runners to stop, then remove and deploy again.
* If you don't follow this procedure, warm runners may linger until they expire.
* - Provider failures or timeouts (like Lambda provider timing out after 15 minutes) will result in a
* gap in coverage until the retry succeeds. Current retry mechanism has built-in back-off rate and
* can be tweaked using `retryOptions`. This will be improved in the future.
*
* ```typescript
* // Cron: fill at 1pm on weekdays
* new ScheduledWarmRunner(stack, 'Business Hours', {
* runners,
* provider: myProvider,
* count: 3,
* owner: 'my-org',
* repo: 'my-repo',
* schedule: events.Schedule.cron({ hour: '13', minute: '0', weekDay: 'MON-FRI' }),
* duration: cdk.Duration.hours(2),
* });
* ```
*
* ```typescript
* // Rate: fill every 12 hours
* new ScheduledWarmRunner(stack, 'Every 12 Hours', {
* runners,
* provider: myProvider,
* count: 2,
* owner: 'my-org',
* repo: 'my-repo',
* schedule: events.Schedule.rate(cdk.Duration.hours(5)),
* duration: cdk.Duration.hours(12),
* });
* ```
*/
export class ScheduledWarmRunner extends Construct {
/**
* The fill payload for this warm runner configuration.
* @internal
*/
public readonly _fillPayload: WarmRunnerFillPayload;
constructor(scope: Construct, id: string, props: ScheduledWarmRunnerProps) {
super(scope, id);
// make sure the duration is not longer than the interval between next two schedule occurrences
const interval = getScheduleIntervalSeconds(props.schedule.expressionString);
if (interval !== undefined && interval < props.duration.toSeconds()) {
cdk.Annotations.of(this).addError(`ScheduledWarmRunner duration ${props.duration.toHumanString()} is longer than the interval ${cdk.Duration.seconds(interval).toHumanString()} between next two schedule occurrences. This will result in overlapping warm runners at the start of the next schedule occurrence.`);
}
// warn for short interval
if (interval !== undefined && interval < cdk.Duration.hours(1).toSeconds()) {
cdk.Annotations.of(this).addWarningV2(
'@cloudsnorkel/cdk-github-runners:ScheduledWarmRunner.intervalTooShort',
`ScheduledWarmRunner interval ${cdk.Duration.seconds(interval).toHumanString()} is less than 1 hour, which may result in more warm runners than expected`,
);
}
this._fillPayload = buildWarmRunner(this, props, props.schedule, props.duration.toSeconds(), false);
}
}