Ctrl K
ComunidadGuías…

src/delete-failed-runner.lambda.ts

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

Archivo original ↗Preguntar a KiBot
import { terminateRunnerInstances } from './lambda-ec2';
import { deleteRunner, getOctokit, getRunner } from './lambda-github';
import { StepFunctionLambdaInput } from './lambda-helpers';

/**
 * Runner families that can leave an EC2 instance behind. Everything else is managed by AWS for us, so there is
 * nothing to look for and no reason to spend an `ec2:DescribeInstances` call on a quota shared with every runner in
 * the account.
 */
const EC2_FAMILIES = ['ec2'];

interface DeleteFailedRunnerInput extends StepFunctionLambdaInput {
  /**
   * Family of the provider config that was tried, straight out of `$.providerParams.family`. A fallback chain hits
   * this Lambda once per attempt, so this is the family of that attempt and not of the whole chain.
   */
  readonly family: string;

  /**
   * The error that failed this attempt, straight out of the catch in `$.error`. We only log it, so failure reasons can be seen without state machine
   * logging enabled.
   */
  readonly error: { readonly Error?: string; readonly Cause?: string };
}

/**
 * Outcome of the clean-up, stored in `$.delete` by the step function so the execution history says what happened.
 *
 * The original error that got us here is re-raised by a separate `Rethrow Error` state, so a red `Delete Failed Runner`
 * state always means the clean-up itself had a problem. We only throw when we can't be sure the runner is gone.
 */
interface DeleteFailedRunnerResult {
  /**
   * Was a runner still registered on GitHub Actions when we looked for it?
   */
  readonly runnerFound: boolean;

  /**
   * Did we delete the runner? Always false when no runner was found, as there is nothing to delete.
   */
  readonly runnerDeleted: boolean;

  /**
   * Instances we terminated because they outlived the runner. Usually empty: an EC2 runner should power itself off so any instance listed here failed
   * to do that for some reason. Other providers are not affected because AWS handles turning them off for us.
   */
  readonly instancesTerminated: string[];
}

/**
 * Terminate leftover instances, but only for families that can have them.
 */
async function terminateInstancesIfNeeded(event: DeleteFailedRunnerInput): Promise<string[]> {
  if (!EC2_FAMILIES.includes(event.family)) {
    return [];
  }

  // the runner is already gone, so this can't block the next attempt. we don't throw as the step function would retry
  // and eventually give up on the whole execution. the idle reaper will try again once this execution is done.
  try {
    return await terminateRunnerInstances(event.runnerName);
  } catch (e) {
    console.error({
      notice: 'Unable to terminate leftover instances',
      runnerName: event.runnerName,
      error: e,
    });
    return [];
  }
}

export async function handler(event: DeleteFailedRunnerInput): Promise<DeleteFailedRunnerResult> {
  const result = await cleanUp(event);

  // logged only after a successful clean-up, so the step function retrying this Lambda doesn't log the same failure more than once. a clean-up that
  // keeps failing ends the execution with `RunnerNotDeleted` and shows up in "Recent errors" instead.
  console.log({
    notice: 'Runner failed',
    runnerName: event.runnerName,
    owner: event.owner,
    repo: event.repo,
    family: event.family,
    error: event.error,
    ...result,
  });

  return result;
}

async function cleanUp(event: DeleteFailedRunnerInput): Promise<DeleteFailedRunnerResult> {
  // any error before we know the runner is gone is thrown as-is. the step function retries all of them for an hour and
  // then fails the whole execution without retrying it. see `Runner Not Deleted` in runner.ts for why.
  const { octokit, githubSecrets } = await getOctokit(event.installationId);

  // find runner id
  const runner = await getRunner(octokit, githubSecrets.runnerLevel, event.owner, event.repo, event.runnerName);
  if (!runner) {
    console.warn({
      notice: 'Unable to find runner id (usually fine, as the runner may have never registered or already removed itself)',
      owner: event.owner,
      repo: event.repo,
      runnerName: event.runnerName,
    });
    return {
      runnerFound: false,
      runnerDeleted: false,
      // terminate any instances that didn't properly power-off due to some extreme failure
      instancesTerminated: await terminateInstancesIfNeeded(event),
    };
  }

  console.log({
    notice: 'Found runner id',
    runnerName: event.runnerName,
    runnerId: runner.id,
    owner: event.owner,
    repo: event.repo,
  });

  // delete runner (it usually gets deleted by ./run.sh, but it stopped prematurely if we're here).
  // it seems like runners are automatically removed after a timeout, if they first accepted a job.
  // we try removing it anyway for cases where a job wasn't accepted, and just in case it wasn't removed.
  // repos have a limited number of self-hosted runners, so we can't leave dead ones behind.
  //
  // any error here means the runner is still registered, usually because it's still running a job. we don't try to
  // parse the error as GitHub changed the busy message before (#1007) and older GHES versions may still use the old one.
  // deleting a runner that's already gone returns 204, so trying to delete a missing runner doesn't throw and just succeeds.
  try {
    await deleteRunner(octokit, githubSecrets.runnerLevel, event.owner, event.repo, runner.id);
  } catch (e) {
    console.error({
      notice: 'Unable to delete runner',
      owner: event.owner,
      repo: event.repo,
      runnerId: runner.id,
      runnerName: event.runnerName,
      error: e,
    });
    // ideally we would stop the job that's hanging on this failed runner, but GitHub Actions only has API to stop the entire workflow
    //
    // we deliberately don't terminate the instance here. the task token is dead, but GitHub says a job might still be running on this runner, and
    // that job's instance is the one we would be killing. the step function retries this state for an hour, which is long enough for the job to
    // finish and the runner to remove itself. if it's still there after that, the idle reaper keeps watching it and will hard delete the instance
    // once it's done.
    throw e;
  }

  // don't terminate here to let the runner logs flush to CloudWatch. the runner should power itself off, but if it doesn't we will terminate it later
  // in the idle reaper.
  return { runnerFound: true, runnerDeleted: true, instancesTerminated: [] };
}