Ctrl K
ComunidadGuías…

src/warm-runner-manager.lambda.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Warm Runner Manager Lambda
 *
 * Maintains a pool of pre-provisioned ("warm") GitHub self-hosted runners so jobs
 * start with near-zero latency instead of waiting for a fresh runner to provision.
 *
 * ## Lifecycle
 *
 * 1. **Fill** — An EventBridge cron rule (midnight UTC for always-on, or window
 *    start for scheduled) sends a fill payload to the shared SQS queue. For
 *    AlwaysOnWarmRunner only, a CloudFormation custom resource (on deploy)
 *    invokes this Lambda directly to fill immediately; the deadline is set to
 *    the next midnight UTC (not full 24h) so runners last until the next cron
 *    fill. ScheduledWarmRunner has no deployment-fill — first fill is at the
 *    next schedule occurrence.
 *
 * 2. **Keeper** — Each keeper message tracks one runner. The SQS queue delivers the
 *    message, this Lambda inspects the runner, and one of the following happens:
 *    - Runner is past its deadline → the keeper stops the Step Function and deletes
 *      the runner. No replacement is created.
 *    - Runner is idle and within deadline → message is returned to the queue
 *      (via batch item failure) to be checked again after the visibility timeout.
 *    - Runner is busy or its Step Function finished → a replacement runner is
 *      started with the same deadline, and a new keeper message is enqueued.
 *    - Config hash mismatch (config was changed/removed since this runner was
 *      created) → the runner is stopped and deleted. No replacement is created.
 *      This is how old runners are cleaned up quickly on config changes.
 *
 * 3. **Shutdown mechanisms** — The keeper is the primary mechanism for enforcing the
 *    absolute deadline: when a runner is past its deadline, the keeper stops the
 *    Step Function and deletes the runner. Fallbacks:
 *    - **Idle reaper**: Measures idle time from when it first sees the runner online and
 *      idle, not step function start. So it fires at deadline + provisioning delay.
 *      It's a fallback if the keeper misses a message; it does not enforce the
 *      absolute deadline precisely.
 *    - **Step Function idle timeout**: Each runner is started with `maxIdleSeconds`
 *      matching the deadline. If both keeper and idle reaper miss it, the Step
 *      Function will self-terminate when its idle timeout fires.
 *
 * ## Config hash
 *
 * Each warm runner config (provider, count, labels, owner, repo, duration) is
 * hashed at CDK synth time. All current hashes are stored in the WARM_CONFIG_HASHES
 * environment variable. Fill payloads and keeper messages carry the hash. When the
 * keeper processes a message whose hash is not in the current set, it knows the
 * config was changed or removed and stops the runner immediately. This helps quickly
 * get rid of stale runners while keeping over-provisioning to a minimum.
 *
 * ## Gotchas
 *
 * - Keeper messages rely on SQS redelivery (batch item failure) for periodic
 *   checking. The visibility timeout (1 min) determines how often runners are
 *   polled. Failed messages are retried until they succeed or the runner
 *   self-terminates at its idle timeout.
 * - Keeper messages enqueue their own replacement, which is a Lambda -> SQS -> Lambda
 *   loop by design. AWS recursive loop detection is disabled for this function, so
 *   the loop is rate limited here instead: new keeper messages are delayed by a
 *   minute, and that delay doubles (up to 15 minutes) for every runner in a row that
 *   failed within 5 minutes without taking a job. This keeps a broken provider from
 *   starting a new runner every few seconds until the deadline. A provider that fails
 *   slower than that is only limited by the one minute delay. That one minute delay
 *   doesn't delay the runner itself, but only the keeper message which checks if it's
 *   busy and needs to be replaced. A runner won't normally start much faster than a
 *   minute anyway so this delay doesn't impact the user much.
 * - Each fill unconditionally starts `count` runners — it does not check how many
 *   are already running. On cron fire, this creates a brief overlap with the
 *   previous cycle's runners (which are near their deadline).
 * - Removing all warm runners configurations may result in warm runners staying
 *   around until they expire. To remove all warm runners quickly, set count to 0
 *   and deploy. Only once all the warm runners are stopped, you can remove all
 *   configurations and deploy again.
 * - **Gaps in coverage**: The Step Function that provisions each warm runner uses
 *   increasing timeouts between retries for provider failures (CodeBuild timeout,
 *   Lambda timeout, capacity errors, etc.). While a warm runner slot is retrying,
 *   that slot has no runner. This may create gaps in coverage. An idle warm runner
 *   that fails to provision (or whose replacement fails) will be unavailable 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.
 */
import * as crypto from 'crypto';
import { DescribeExecutionCommand, ExecutionAlreadyExists, SFNClient, StartExecutionCommand, StopExecutionCommand } from '@aws-sdk/client-sfn';
import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs';
import type { Octokit } from '@octokit/rest' with { 'resolution-mode': 'import' };
import * as AWSLambda from 'aws-lambda';
import { OrchestratorInput, WARM_RUNNER_JOB_ID } from './lambda-common';
import { cachedGetOctokit, deleteRunner, getRunner, GitHubSecrets, resolveInstallationId } from './lambda-github';
import { customResourceRespond } from './lambda-helpers';

const sfn = new SFNClient();
const sqs = new SQSClient();

const SFN_EXECUTION_NAME_MAX_LENGTH = 80;
const KEEPER_DELAY_SECONDS = 60;
const KEEPER_MAX_DELAY_SECONDS = 900; // SQS maximum
const FAST_FAILURE_MS = 5 * 60 * 1000;

export interface WarmRunnerKeeperMessage {
  readonly executionArn: string;
  readonly runnerName: string;
  readonly owner: string;
  readonly repo: string;
  readonly installationId?: number;
  readonly providerPath: string;
  readonly providerLabels: string[];
  readonly absoluteDeadline: number; // Unix ms — inherited by replacements
  readonly configHash: string;
  readonly failures?: number; // consecutive failed runners in this slot (for back-off calculation)
}

/**
 * @internal
 */
export interface WarmRunnerFillPayload {
  readonly action: 'fill';
  readonly providerPath: string;
  readonly providerLabels: string[];
  readonly count: number;
  readonly duration: number;
  readonly owner: string;
  readonly repo: string;
  readonly configHash: string;
}

function isSqsEvent(event: unknown): event is AWSLambda.SQSEvent {
  return Array.isArray((event as AWSLambda.SQSEvent).Records);
}

function isFillInput(event: unknown): event is WarmRunnerFillPayload {
  return typeof event === 'object' && event !== null && (event as WarmRunnerFillPayload).action === 'fill';
}

function isCustomResourceEvent(event: unknown): event is AWSLambda.CloudFormationCustomResourceEvent {
  const e = event as AWSLambda.CloudFormationCustomResourceEvent;
  return typeof e?.RequestType === 'string' && typeof e?.ResponseURL === 'string';
}

function requireEnv(name: string) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing environment variable ${name}`);
  }
  return value;
}

interface StartWarmRunnerInput {
  readonly providerPath: string;
  readonly providerLabels: string[];
  readonly owner: string;
  readonly repo: string;
  readonly installationId?: number;
  readonly absoluteDeadline: number; // Unix ms
  readonly configHash: string;
  readonly executionName: string;
  readonly slot?: number; // 0-based index when filling multiple slots; helps correlate logs
  readonly failures?: number; // consecutive failed runners in this slot (for back-off calculation)
}

/**
 * Deterministic execution name for idempotent fills. Same seed → same name, so retries
 * (custom resource, SQS redelivery) don't create duplicate runners.
 */
function deterministicExecutionName(providerPath: string, seed: string) {
  const pathWithoutStack = providerPath.split('/').slice(1).join('/') || providerPath;
  const sanitized = `warm-${pathWithoutStack.replace(/[^a-zA-Z0-9-]/g, '-')}`;
  const hash = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 16);
  const maxPrefixLen = SFN_EXECUTION_NAME_MAX_LENGTH - hash.length - 1;
  return `${sanitized.slice(0, maxPrefixLen)}-${hash}`;
}

/** Returns Unix ms of the next midnight UTC. Used for AlwaysOn deployment-fill deadline. */
function getNextMidnightUtcMs(): number {
  const now = new Date();
  const nextMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0, 0));
  return nextMidnight.getTime();
}

/** Start a warm runner and enqueue a keeper message using SQS. */
async function startWarmRunnerAndEnqueueKeeper(input: StartWarmRunnerInput) {
  const stepFunctionArn = requireEnv('STEP_FUNCTION_ARN');
  const queueUrl = requireEnv('WARM_RUNNER_QUEUE_URL');

  const remainingSeconds = Math.floor((input.absoluteDeadline - Date.now()) / 1000);
  if (remainingSeconds <= 0) {
    console.log({
      notice: 'Absolute deadline already passed; not starting replacement',
      configHash: input.configHash,
      runnerName: input.executionName,
      input,
    });
    return;
  }

  let executionArn: string;
  try {
    const result = await sfn.send(new StartExecutionCommand({
      stateMachineArn: stepFunctionArn,
      name: input.executionName,
      input: JSON.stringify(<OrchestratorInput>{
        owner: input.owner,
        repo: input.repo || '',
        jobId: WARM_RUNNER_JOB_ID,
        jobUrl: '',
        installationId: input.installationId ?? -1,
        jobLabels: input.providerLabels.join(','),
        provider: input.providerPath,
        labels: [...input.providerLabels, 'cdkghr:warm'].join(','),
        maxIdleSeconds: remainingSeconds,
      }),
    }));
    executionArn = result.executionArn!;
  } catch (e) {
    if (e instanceof ExecutionAlreadyExists) {
      // Idempotent retry: execution was already started (e.g. Lambda timed out mid-fill).
      // Don't enqueue. The first attempt already did, so duplicate keeper messages would cause duplicate replacements.
      console.log({
        notice: 'ExecutionAlreadyExists — idempotent retry, skipping enqueue',
        configHash: input.configHash,
        slot: input.slot,
        runnerName: input.executionName,
      });
      return;
    } else {
      throw e;
    }
  }

  const message: WarmRunnerKeeperMessage = {
    executionArn,
    runnerName: input.executionName,
    owner: input.owner,
    repo: input.repo,
    installationId: input.installationId,
    providerPath: input.providerPath,
    providerLabels: input.providerLabels,
    absoluteDeadline: input.absoluteDeadline,
    configHash: input.configHash,
    failures: input.failures,
  };

  // delay the first check so a runner that keeps failing can't spin in a tight loop of replacements
  // back off exponentially while the slot keeps failing (a new runner can't register in less than a minute anyway)
  const delaySeconds = Math.min(KEEPER_DELAY_SECONDS * 2 ** (input.failures ?? 0), KEEPER_MAX_DELAY_SECONDS);

  await sqs.send(new SendMessageCommand({
    QueueUrl: queueUrl,
    MessageBody: JSON.stringify(message),
    DelaySeconds: delaySeconds,
  }));

  console.log({
    notice: 'Started warm runner and enqueued keeper message',
    configHash: input.configHash,
    slot: input.slot,
    runnerName: input.executionName,
    executionArn,
    remainingSeconds,
    failures: input.failures,
    delaySeconds,
  });
}

/**
 * Unconditionally starts `count` warm runners for the given config and enqueues keeper messages.
 *
 * @param input the fill payload
 * @param getNameForSlot a function to generate a unique and stable execution name for each slot
 * @param source the source of the fill for debugging purposes
 * @param absoluteDeadlineOverride if provided, use this instead of now + duration (for AlwaysOn deployment-fill)
 */
async function runFiller(input: WarmRunnerFillPayload, getNameForSlot: (slot: number) => string, source: string, absoluteDeadlineOverride?: number) {
  const installationId = await resolveInstallationId(input.owner, input.repo);
  const absoluteDeadline = absoluteDeadlineOverride ?? Date.now() + input.duration * 1000;

  for (let i = 0; i < input.count; i++) {
    await startWarmRunnerAndEnqueueKeeper({
      providerPath: input.providerPath,
      providerLabels: input.providerLabels,
      owner: input.owner,
      repo: input.repo,
      installationId,
      absoluteDeadline,
      configHash: input.configHash,
      executionName: getNameForSlot(i),
      slot: i,
    });
  }

  console.log({
    notice: 'Fill complete - started warm runners',
    source,
    configHash: input.configHash,
    providerPath: input.providerPath,
    started: input.count,
  });
}

/** Stop the step function and delete the runner from GitHub. */
async function stopAndDeleteRunner(input: WarmRunnerKeeperMessage, octokit: Octokit, secrets: GitHubSecrets, reason: string) {
  try {
    await sfn.send(new StopExecutionCommand({
      executionArn: input.executionArn,
      error: reason,
      cause: 'Warm runner stopped by keeper',
    }));
    console.log({
      notice: 'Stopped step function',
      metric: 'RunnerStopped',
      reason,
      configHash: input.configHash,
      runnerName: input.runnerName,
      executionArn: input.executionArn,
      provider: input.providerPath,
    });
  } catch (e) {
    console.error({
      notice: 'Failed to stop step function',
      configHash: input.configHash,
      runnerName: input.runnerName,
      executionArn: input.executionArn,
      error: e,
      input,
    });
  }

  const runner = await getRunner(octokit, secrets.runnerLevel, input.owner, input.repo, input.runnerName);
  if (runner) {
    try {
      await deleteRunner(octokit, secrets.runnerLevel, input.owner, input.repo, runner.id);
    } catch (e) {
      console.error({
        notice: 'Failed to delete runner',
        configHash: input.configHash,
        runnerName: input.runnerName,
        runnerId: runner.id,
        error: e,
        input,
      });
    }
  }
}

/**
 * Warm runner manager Lambda - handles three invocation modes:
 *
 * 1. CloudFormation Custom Resource - triggered on stack deploy (Create/Update) for AlwaysOnWarmRunner only. Runs
 *    runFiller with deadline = next midnight UTC so runners last until the next cron fill. Delete is a no-op.
 * 2. SQS messages - fill or keeper
 *    - Fill - from EventBridge cron. Uses messageId for deterministic execution names (idempotent on redelivery).
 *    - Keeper - tracks one runner. Uses SQS message cycling for periodic checks.
 *      Each message tracks one warm runner. The keeper checks:
 *      - Past deadline - stop the Step Function and delete the runner.
 *      - Config hash - if the message's `configHash` doesn't match the current `WARM_CONFIG_HASHES` env var, the runner is from a stale config - stop it and discard the message without replacement.
 *      - Busy/finished - if the Step Function ended or the GitHub runner is busy (took a job), start a replacement runner (inheriting the same deadline and config hash).
 *      - Not found yet (runner/infrastructure still starting) - retry later (message goes back to queue).
 *      - Still idle - retry later to check again.
 */
export async function handler(event: AWSLambda.SQSEvent | AWSLambda.CloudFormationCustomResourceEvent) {
  if (isCustomResourceEvent(event)) {
    const physicalId = ('PhysicalResourceId' in event ? event.PhysicalResourceId : undefined) ?? event.LogicalResourceId;
    try {
      const props = event.ResourceProperties as unknown as WarmRunnerFillPayload;
      console.log({
        notice: 'Custom resource fill',
        requestType: event.RequestType,
        logicalResourceId: event.LogicalResourceId,
        configHash: props.configHash,
        providerPath: props.providerPath,
        count: props.count,
      });
      if (event.RequestType === 'Create' || event.RequestType === 'Update') {
        const getNameForSlot = (slot: number) =>
          deterministicExecutionName(props.providerPath, `${event.LogicalResourceId}:${event.RequestType}:${props.configHash}:${slot}`);
        const deadline = getNextMidnightUtcMs();
        await runFiller(props, getNameForSlot, 'customResource', deadline);
      }
      await customResourceRespond(event, 'SUCCESS', 'OK', physicalId, {});
    } catch (e) {
      console.error({ notice: 'Custom resource handler failed', error: e });
      await customResourceRespond(event, 'FAILED', (e as Error).message || 'Internal Error', physicalId, {});
    }
    return;
  }

  if (!isSqsEvent(event)) {
    console.error({ notice: 'Unknown event type; ignoring', event });
    return;
  }

  const validHashes = new Set((process.env.WARM_CONFIG_HASHES ?? '').split(',').filter(Boolean));
  const maxDurationMs = parseInt(process.env.WARM_MAX_DURATION_SECONDS ?? '0', 10) * 1000;
  const result: AWSLambda.SQSBatchResponse = { batchItemFailures: [] };
  const getOctokitCached = cachedGetOctokit();

  for (const record of event.Records) {
    let body: WarmRunnerKeeperMessage | WarmRunnerFillPayload;
    try {
      body = JSON.parse(record.body) as WarmRunnerKeeperMessage | WarmRunnerFillPayload;
    } catch (e) {
      console.error({
        notice: 'Failed to parse message body',
        requestId: record.messageId,
        error: e,
      });
      continue;
    }

    const retryLater = () => result.batchItemFailures.push({ itemIdentifier: record.messageId });

    const isFill = isFillInput(body);
    const configHash = (body as WarmRunnerFillPayload & WarmRunnerKeeperMessage).configHash;
    const runnerName = isFill ? undefined : (body as WarmRunnerKeeperMessage).runnerName;
    console.log({
      notice: 'Processing SQS message',
      messageId: record.messageId,
      configHash,
      runnerName,
    });

    // scheduled fill from EventBridge via SQS
    if (isFill) {
      const fillPayload = body as WarmRunnerFillPayload;
      try {
        console.log({
          notice: 'Scheduled fill',
          configHash,
          providerPath: fillPayload.providerPath,
          count: fillPayload.count,
        });
        const getNameForSlot = (slot: number) =>
          deterministicExecutionName(fillPayload.providerPath, `${record.messageId}:${slot}`);
        await runFiller(fillPayload, getNameForSlot, 'scheduled', undefined);
      } catch (e) {
        console.error({
          notice: 'Fill failed',
          messageId: record.messageId,
          configHash: fillPayload.configHash,
          error: e,
        });
        retryLater();
      }
      continue;
    }

    // keeper message
    const input = body as WarmRunnerKeeperMessage;
    console.log({
      notice: 'Checking warm runner',
      configHash: input.configHash,
      runnerName: input.runnerName,
    });

    // get github access
    const { octokit, githubSecrets: secrets } = await getOctokitCached(input.installationId);

    // stale config - best-effort stop, then discard message (runner will self-terminate at its idle timeout)
    if (!validHashes.has(input.configHash)) {
      console.log({
        notice: 'Config hash mismatch (new CDK deployment, old runner) - stopping stale warm runner',
        configHash: input.configHash,
        runnerName: input.runnerName,
        validHashes: validHashes,
      });

      try {
        await stopAndDeleteRunner(input, octokit, secrets, 'StaleWarmRunner');
      } catch (e) {
        console.error({
          notice: 'Best-effort cleanup of stale warm runner failed; it will self-terminate at idle timeout',
          configHash: input.configHash,
          runnerName: input.runnerName,
          error: e,
        });
      }
      continue;
    }

    // reject bad deadlines to avoid spinning up too many warm runners.
    // missing deadline or further than any config allows - the deadline is all that ends a chain, so don't trust it and stop the runner.
    // a minute of slack covers clock differences between invocations.
    if (!Number.isFinite(input.absoluteDeadline) || (maxDurationMs > 0 && input.absoluteDeadline > Date.now() + maxDurationMs + 60_000)) {
      console.error({
        notice: 'Warm runner deadline is invalid or further than the longest configured duration, stopping and deleting',
        configHash: input.configHash,
        runnerName: input.runnerName,
        absoluteDeadline: input.absoluteDeadline,
        maxDurationMs,
      });
      try {
        await stopAndDeleteRunner(input, octokit, secrets, 'WarmRunnerBadDeadline');
      } catch (e) {
        console.error({
          notice: 'Failed to stop warm runner with bad deadline; it will self-terminate at idle timeout',
          configHash: input.configHash,
          runnerName: input.runnerName,
          error: e,
        });
      }
      continue;
    }

    // past deadline - keeper must stop and delete the runner
    if (Date.now() >= input.absoluteDeadline) {
      console.log({
        notice: 'Warm runner past deadline, stopping and deleting',
        configHash: input.configHash,
        runnerName: input.runnerName,
      });
      try {
        await stopAndDeleteRunner(input, octokit, secrets, 'WarmRunnerExpired');
      } catch (e) {
        console.error({
          notice: 'Failed to stop expired warm runner',
          configHash: input.configHash,
          runnerName: input.runnerName,
          error: e,
        });
        // don't retry to not accidentally create a new runner
        // idle reaper will take care of it soon enough
      }
      continue;
    }

    // check if step function is still running
    const execution = await sfn.send(new DescribeExecutionCommand({ executionArn: input.executionArn }));
    const stillRunning = execution.status === 'RUNNING';

    // find runner
    const runner = await getRunner(octokit, secrets.runnerLevel, input.owner, input.repo, input.runnerName);

    // need replacement: step function finished (not running) or runner took a job (busy)
    if (!stillRunning || runner?.busy) {
      // a runner that failed quickly without taking a job (e.g. broken provider config) will likely fail again, so back off.
      // anything else resets the back-off, including an idle runner that died after a while (e.g. spot interruption).
      const ranMs = (execution.stopDate ?? new Date()).getTime() - (execution.startDate ?? new Date()).getTime();
      const failed = !stillRunning && !runner?.busy && execution.status !== 'SUCCEEDED' && ranMs < FAST_FAILURE_MS;
      const previousFailures = Number.isInteger(input.failures) && input.failures! > 0 ? input.failures! : 0;
      const failures = failed ? previousFailures + 1 : 0;
      console.log({
        notice: 'Warm runner finished or busy; starting replacement',
        configHash: input.configHash,
        runnerName: input.runnerName,
        stillRunning,
        runnerBusy: runner?.busy ?? false,
        executionStatus: execution.status,
        failures,
      });
      try {
        await startWarmRunnerAndEnqueueKeeper({
          providerPath: input.providerPath,
          providerLabels: input.providerLabels,
          owner: input.owner,
          repo: input.repo,
          installationId: input.installationId,
          absoluteDeadline: input.absoluteDeadline,
          configHash: input.configHash,
          executionName: deterministicExecutionName(input.providerPath, record.messageId),
          failures,
        });
      } catch (e) {
        console.error({
          notice: 'Failed to start replacement warm runner',
          configHash: input.configHash,
          runnerName: input.runnerName,
          error: e,
        });
        retryLater();
      }
      continue;
    }

    // step function still running but runner not found yet
    if (!runner) {
      console.log({
        notice: 'Runner not running yet',
        configHash: input.configHash,
        runnerName: input.runnerName,
      });
      retryLater();
      continue;
    }

    // still idle - check again later
    console.log({
      notice: 'Runner still idle - will check again later',
      configHash: input.configHash,
      runnerName: input.runnerName,
    });
    retryLater();
  }

  return result;
}