src/webhook.ts
Rama main · réplica de lectura del repositorio original.
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<string, string[]>;
/**
* 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<string, string[]>;
/**
* 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);
}
}