import * as fs from 'fs'; import * as os from 'node:os'; import * as path from 'path'; import * as cdk from 'aws-cdk-lib'; import { aws_lambda as lambda, aws_sqs as sqs, aws_stepfunctions as stepfunctions } from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { LambdaAccess } from './access'; import { PROVIDERS_PATH } from './lambda-common'; import { Secrets } from './secrets'; import { singletonLogGroup, SingletonLogType } from './utils'; import { WebhookHandlerFunction } from './webhook-handler-function'; /** * Input to the provider selector Lambda function. */ export interface ProviderSelectorInput { /** * Full GitHub webhook payload (workflow_job event structure with action="queued"). * * * Original labels requested by the workflow job can be found at `payload.workflow_job.labels`. * * Repository path (e.g. CloudSnorkel/cdk-github-runners) is at `payload.repository.full_name`. * * Commit hash is at `payload.workflow_job.head_sha`. * * @see https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=queued#workflow_job */ readonly payload: any; /** * Map of available provider node paths to their configured labels. * Example: { "MyStack/Small": ["linux", "small"], "MyStack/Large": ["linux", "large"] } */ readonly providers: Record; /** * Provider node path that would have been selected by default label matching. * Use this to easily return the default selection: `{ provider: input.defaultProvider, labels: input.defaultLabels }` * May be undefined if no provider matched by default. */ readonly defaultProvider?: string; /** * Labels that would have been used by default (the selected provider's labels). * May be undefined if no provider matched by default. */ readonly defaultLabels?: string[]; } /** * Result from the provider selector Lambda function. */ export interface ProviderSelectorResult { /** * Node path of the provider to use (e.g., "MyStack/MyProvider"). * Must match one of the configured provider node paths from the input. * If not provided, the job will be skipped (no runner created). */ readonly provider?: string; /** * Labels to use when registering the runner. * Must be returned when a provider is selected. * Can be used to add, remove, or modify labels. */ readonly labels?: string[]; } /** * Properties for GithubWebhookHandler * * @internal */ export interface GithubWebhookHandlerProps { /** * Step function in charge of handling the workflow job events and start the runners. */ readonly orchestrator: stepfunctions.StateMachine; /** * Secrets used to communicate with GitHub. */ readonly secrets: Secrets; /** * Configure access to webhook function. */ readonly access?: LambdaAccess; /** * Mapping of provider node paths to their supported labels. */ readonly providers: Record; /** * Optional Lambda function to customize provider selection. */ readonly providerSelector?: lambda.IFunction; /** * Whether to require the "self-hosted" label. */ readonly requireSelfHostedLabel: boolean; /** * Idle timeout for runners in seconds. */ readonly idleTimeoutSeconds?: number; /** * Stolen runner detector queue. */ readonly stolenRunnerQueue: sqs.IQueue; /** * Additional Lambda function options (VPC, security groups, layers, etc.). */ readonly extraLambdaProps?: lambda.FunctionOptions; /** * Additional environment variables for the Lambda function. */ readonly extraLambdaEnv?: { [key: string]: string }; } /** * Create a Lambda with a public URL to handle GitHub webhook events. After validating the event with the given secret, the orchestrator step function is called with information about the workflow job. * * @internal */ export class GithubWebhookHandler extends Construct { /** * Public URL of webhook to be used with GitHub. */ readonly url: string; /** * Webhook event handler. */ readonly handler: WebhookHandlerFunction; constructor(scope: Construct, id: string, props: GithubWebhookHandlerProps) { super(scope, id); const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'providers-layer-')); let providersLayer: lambda.LayerVersion; try { fs.writeFileSync(path.join(workdir, path.posix.basename(PROVIDERS_PATH)), JSON.stringify(props.providers)); providersLayer = new lambda.LayerVersion(this, 'Providers', { description: 'Runner providers and their labels', code: lambda.Code.fromAsset(workdir), }); } finally { // Calling `fromAsset()` has copied files to the assembly, so we can delete the temporary directory. fs.rmSync(path.join(workdir, path.posix.basename(PROVIDERS_PATH))); fs.rmdirSync(workdir); } this.handler = new WebhookHandlerFunction( this, 'webhook-handler', { description: 'Handle GitHub webhook and start runner orchestrator', environment: { STEP_FUNCTION_ARN: props.orchestrator.stateMachineArn, WEBHOOK_SECRET_ARN: props.secrets.webhook.secretArn, GITHUB_SECRET_ARN: props.secrets.github.secretArn, GITHUB_PRIVATE_KEY_SECRET_ARN: props.secrets.githubPrivateKey.secretArn, REQUIRE_SELF_HOSTED_LABEL: props.requireSelfHostedLabel ? '1' : '0', PROVIDER_SELECTOR_ARN: props.providerSelector?.functionArn ?? '', IDLE_TIMEOUT_SECONDS: props.idleTimeoutSeconds?.toString() ?? '300', // default 5 minutes JOB_ASSIGNMENT_QUEUE_URL: props.stolenRunnerQueue.queueUrl, ...props.extraLambdaEnv, }, timeout: cdk.Duration.seconds(31), logGroup: singletonLogGroup(this, SingletonLogType.ORCHESTRATOR), loggingFormat: lambda.LoggingFormat.JSON, ...props.extraLambdaProps, layers: [...props.extraLambdaProps?.layers ?? [], providersLayer], }, ); const access = props?.access ?? LambdaAccess.lambdaUrl(); this.url = access.bind(this, 'access', this.handler); props.secrets.webhook.grantRead(this.handler); props.secrets.github.grantRead(this.handler); props.secrets.githubPrivateKey.grantRead(this.handler); props.orchestrator.grantStartExecution(this.handler); props.providerSelector?.grantInvoke(this.handler); props.stolenRunnerQueue.grantSendMessages(this.handler); } }