Ctrl K
ComunidadGuías…

src/stolen-runners.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import * as cdk from 'aws-cdk-lib';
import {
  aws_dynamodb as dynamodb,
  aws_iam as iam,
  aws_lambda as lambda,
  aws_lambda_event_sources as lambda_event_sources,
  aws_logs as logs,
  aws_logs_destinations as logs_destinations,
  aws_sqs as sqs,
  aws_stepfunctions as stepfunctions,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Secrets } from './secrets';
import { StolenRunnerHandlerFunction } from './stolen-runner-handler-function';
import { singletonLogGroup, SingletonLogType } from './utils';

/**
 * Properties for StolenRunnerDetector.
 *
 * @internal
 */
export interface StolenRunnerDetectorProps {
  /**
   * Secrets used to communicate with GitHub.
   */
  readonly secrets: Secrets;

  /**
   * Step function that starts runners. Used to start replacement runners and to know when runners are done.
   */
  readonly orchestrator: stepfunctions.StateMachine;

  /**
   * Log groups the runners write to. Runners report which job landed on them, and this is where those reports come out.
   */
  readonly runnerLogGroups: logs.ILogGroup[];

  /**
   * 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 };
}

/**
 * Remembers which jobs we start runners for, listens to runners reporting the jobs they pick up, and starts
 * another runner whenever one of ours runs a job we didn't start a runner for.
 *
 * We collect a set of jobs we started runners for.
 * Then for each job a runner picks up, we check if it was in that set.
 * If it wasn't in that set, the runner was stolen.
 * We start a new runner of the exact same type if it was stolen.
 *
 * @internal
 */
export class StolenRunnerDetector extends Construct {
  /**
   * DynamoDB table holding the jobs we started runners for. Has TTL so data doesn't live here forever.
   */
  readonly table: dynamodb.Table;

  /**
   * Queue of runner reports waiting to be checked.
   */
  readonly queue: sqs.Queue;

  /**
   * Function that checks assignments and starts replacement runners.
   */
  readonly handler: StolenRunnerHandlerFunction;

  private readonly ttlSeconds: number;

  constructor(scope: Construct, id: string, props: StolenRunnerDetectorProps) {
    super(scope, id);

    // leave record long enough for github's 24-hour job timeout + our step function 24 hour timeout
    this.ttlSeconds = cdk.Duration.days(3).toSeconds();

    this.table = new dynamodb.Table(this, 'Jobs', {
      partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      timeToLiveAttribute: 'ttl',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    this.queue = new sqs.Queue(this, 'Queue', {
      // FIFO for the deduplication, not the ordering. a runner runs one job, so it has exactly one thing to tell
      // us, but we hear it twice: once from the workflow_job.in_progress webhook and once from the runner's log.
      // sending both with MessageDeduplicationId set to the runner name will only allow the first message.
      // this saves us on extra work and protects from buggy or malicious runners reporting too much.
      // deduplication is a fixed five minute window.
      fifo: true,
      // delay testing runner reports by a minute to give workflow_job.queued webhook a chance to arrive and be recorded
      deliveryDelay: cdk.Duration.minutes(1),
      visibilityTimeout: cdk.Duration.minutes(3),
      // a report is only worth acting on while the abandoned job is still waiting for a runner, so there is no
      // point keeping one for days. this also bounds anything that fails forever without needing a dead letter
      // queue full of reports nobody will ever look at.
      retentionPeriod: cdk.Duration.hours(1),
    });

    this.handler = new StolenRunnerHandlerFunction(this, 'Handler', {
      description: 'Detect GitHub Actions runners stolen by other jobs and start a new runner for the abandoned job',
      environment: {
        GITHUB_SECRET_ARN: props.secrets.github.secretArn,
        GITHUB_PRIVATE_KEY_SECRET_ARN: props.secrets.githubPrivateKey.secretArn,
        STEP_FUNCTION_ARN: props.orchestrator.stateMachineArn,
        RUNNER_TRACKER_TABLE: this.table.tableName,
        RUNNER_TRACKER_TTL_SECONDS: `${this.ttlSeconds}`,
        JOB_ASSIGNMENT_QUEUE_URL: this.queue.queueUrl,
        ...props.extraLambdaEnv,
      },
      timeout: cdk.Duration.minutes(2),
      logGroup: singletonLogGroup(this, SingletonLogType.ORCHESTRATOR),
      loggingFormat: lambda.LoggingFormat.JSON,
      ...props.extraLambdaProps,
    });

    this.handler.addEventSource(new lambda_event_sources.SqsEventSource(this.queue, {
      reportBatchItemFailures: true,
      batchSize: 10,
    }));

    this.table.grantReadWriteData(this.handler);
    this.queue.grantSendMessages(this.handler);
    props.secrets.github.grantRead(this.handler);
    props.secrets.githubPrivateKey.grantRead(this.handler);
    props.orchestrator.grantStartExecution(this.handler);
    props.orchestrator.grantExecution(this.handler, 'states:DescribeExecution');

    // runners report which job they were handed the moment it's assigned, by printing a line to their own log.
    // that's the only signal we can safely get from a runner that might be running in an isolated subnet or without permissions.
    // runners also technically run untrusted code so we don't want to give them credentials to call API or touch a queue or anything like that.
    const destination = new logs_destinations.LambdaDestination(this.handler, { addPermissions: false /* too many resources */ });
    this.handler.addPermission('Runner Reports', {
      principal: new iam.ServicePrincipal('logs.amazonaws.com'),
      sourceArn: cdk.Stack.of(this).formatArn({
        service: 'logs',
        resource: 'log-group',
        resourceName: '*',
        arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
      }),
      sourceAccount: cdk.Stack.of(this).account,
    });
    const permNode = this.handler.node.findChild('Runner Reports');

    for (const logGroup of props.runnerLogGroups) {
      const sub = new logs.SubscriptionFilter(this, `Runner Reports ${logGroup.node.addr.slice(0, 8)}`, {
        logGroup,
        destination,
        filterPattern: logs.FilterPattern.literal('"CDKGHR JOB RUNNER="'),
      });
      sub.node.addDependency(permNode);
    }
  }

  public grantRecordRunners(func: lambda.Function) {
    func.addEnvironment('RUNNER_TRACKER_TABLE', this.table.tableName);
    func.addEnvironment('RUNNER_TRACKER_TTL_SECONDS', `${this.ttlSeconds}`);
    this.table.grantReadWriteData(func);
  }
}