Skip to content

Terminate legacy runners #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 20, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions modules/runners/lambdas/runners/src/scale-runners/runners.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { EC2, SSM } from 'aws-sdk';

export interface RunnerList {
instanceId: string;
launchTime?: Date;
owner?: string;
type?: string;
Repo?: string;
Org?: string;
}

export interface RunnerInfo {
instanceId: string;
launchTime?: Date;
Expand All @@ -20,7 +29,7 @@ export interface RunnerInputParameters {
runnerOwner: string;
}

export async function listEC2Runners(filters: ListRunnerFilters | undefined = undefined): Promise<RunnerInfo[]> {
export async function listEC2Runners(filters: ListRunnerFilters | undefined = undefined): Promise<RunnerList[]> {
const ec2 = new EC2();
const ec2Filters = [
{ Name: 'tag:Application', Values: ['github-action-runner'] },
Expand All @@ -36,7 +45,7 @@ export async function listEC2Runners(filters: ListRunnerFilters | undefined = un
}
}
const runningInstances = await ec2.describeInstances({ Filters: ec2Filters }).promise();
const runners: RunnerInfo[] = [];
const runners: RunnerList[] = [];
if (runningInstances.Reservations) {
for (const r of runningInstances.Reservations) {
if (r.Instances) {
Expand All @@ -46,6 +55,8 @@ export async function listEC2Runners(filters: ListRunnerFilters | undefined = un
launchTime: i.LaunchTime,
owner: i.Tags?.find((e) => e.Key === 'Owner')?.Value as string,
type: i.Tags?.find((e) => e.Key === 'Type')?.Value as string,
Repo: i.Tags?.find((e) => e.Key === 'Repo')?.Value as string,
Org: i.Tags?.find((e) => e.Key === 'Org')?.Value as string,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import moment from 'moment';
import { mocked } from 'ts-jest/utils';
import { listEC2Runners, terminateRunner, RunnerInfo } from './runners';
import { listEC2Runners, terminateRunner, RunnerInfo, RunnerList } from './runners';
import { scaleDown } from './scale-down';
import * as ghAuth from './gh-auth';
import nock from 'nock';
Expand Down Expand Up @@ -48,7 +48,7 @@ const TEST_DATA: TestData = {
repositoryOwner: 'Codertocat',
};

let DEFAULT_RUNNERS: RunnerInfo[];
let DEFAULT_RUNNERS: RunnerList[];
let RUNNERS_ALL_REMOVED: RunnerInfo[];
let DEFAULT_RUNNERS_REPO_TO_BE_REMOVED: RunnerInfo[];
let RUNNERS_ORG_TO_BE_REMOVED_WITH_AUTO_SCALING_CONFIG: RunnerInfo[];
Expand All @@ -57,6 +57,9 @@ let RUNNERS_ORG_WITH_AUTO_SCALING_CONFIG: RunnerInfo[];
let DEFAULT_RUNNERS_REPO: RunnerInfo[];
let DEFAULT_RUNNERS_ORG: RunnerInfo[];
let DEFAULT_RUNNERS_ORG_TO_BE_REMOVED: RunnerInfo[];
let DEFAULT_RUNNERS_ORPHANED: RunnerInfo[];
let DEFAULT_REPO_RUNNERS_ORPHANED: RunnerInfo[];
let DEFAULT_ORG_RUNNERS_ORPHANED: RunnerInfo[];
const DEFAULT_RUNNERS_ORIGINAL = [
{
instanceId: 'i-idle-101',
Expand Down Expand Up @@ -126,6 +129,13 @@ const DEFAULT_RUNNERS_ORIGINAL = [
type: 'Org',
owner: TEST_DATA.repositoryOwner,
},
{
instanceId: 'i-legacy-110',
launchTime: moment(new Date())
.subtract(minimumRunningTimeInMinutes + 5, 'minutes')
.toDate(),
Repo: `${TEST_DATA.repositoryOwner}/${TEST_DATA.repositoryName}`,
},
];

const DEFAULT_REGISTERED_RUNNERS = [
Expand Down Expand Up @@ -215,8 +225,8 @@ describe('scaleDown', () => {
});
mockCreateClient.mockResolvedValue(new mocktokit());
DEFAULT_RUNNERS = JSON.parse(JSON.stringify(DEFAULT_RUNNERS_ORIGINAL));
DEFAULT_RUNNERS_REPO = DEFAULT_RUNNERS.filter((r) => r.type === 'Repo');
DEFAULT_RUNNERS_ORG = DEFAULT_RUNNERS.filter((r) => r.type === 'Org');
DEFAULT_RUNNERS_REPO = DEFAULT_RUNNERS.filter((r) => r.type === 'Repo') as RunnerInfo[];
DEFAULT_RUNNERS_ORG = DEFAULT_RUNNERS.filter((r) => r.type === 'Org') as RunnerInfo[];
DEFAULT_RUNNERS_REPO_TO_BE_REMOVED = DEFAULT_RUNNERS_REPO.filter(
(r) => r.instanceId.includes('idle') || r.instanceId.includes('orphan'),
);
Expand All @@ -239,6 +249,15 @@ describe('scaleDown', () => {
RUNNERS_ALL_REMOVED = DEFAULT_RUNNERS_ORG.filter(
(r) => !r.instanceId.includes('running') && !r.instanceId.includes('registered'),
);
DEFAULT_RUNNERS_ORPHANED = DEFAULT_RUNNERS_ORIGINAL.filter(
(r) => r.instanceId.includes('orphan') && !r.instanceId.includes('not-registered'),
) as RunnerInfo[];
DEFAULT_REPO_RUNNERS_ORPHANED = DEFAULT_RUNNERS_REPO.filter(
(r) => r.instanceId.includes('orphan') && !r.instanceId.includes('not-registered'),
);
DEFAULT_ORG_RUNNERS_ORPHANED = DEFAULT_RUNNERS_ORG.filter(
(r) => r.instanceId.includes('orphan') && !r.instanceId.includes('not-registered'),
);
});

describe('github.com', () => {
Expand All @@ -258,8 +277,7 @@ describe('scaleDown', () => {
});
});

it('Terminates 3 of 5 runners owned by repos.', async () => {
// This will not terminate the orphan runners that have not yet reached their minimum running time.
it('Terminates 3 of 5 runners owned by repos and all orphaned', async () => {
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS_REPO);
await scaleDown();
expect(listEC2Runners).toBeCalledWith({
Expand All @@ -268,25 +286,30 @@ describe('scaleDown', () => {

expect(mockOctokit.apps.getRepoInstallation).toBeCalled();

expect(terminateRunner).toBeCalledTimes(3);
expect(terminateRunner).toBeCalledTimes(4);
for (const toTerminate of DEFAULT_RUNNERS_REPO_TO_BE_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_REPO_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});

it('Terminates 2 of 3 runners owned by orgs.', async () => {
// This will not terminate the orphan runners that have not yet reached their minimum running time.
it('Terminates 2 of 3 runners owned by orgs and all orphaned', async () => {
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS_ORG);
await scaleDown();
expect(listEC2Runners).toBeCalledWith({
environment: environment,
});

expect(mockOctokit.apps.getOrgInstallation).toBeCalled();
expect(terminateRunner).toBeCalledTimes(2);
expect(terminateRunner).toBeCalledTimes(3);
for (const toTerminate of DEFAULT_RUNNERS_ORG_TO_BE_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_ORG_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});

describe('With idle config', () => {
Expand Down Expand Up @@ -329,7 +352,7 @@ describe('scaleDown', () => {
});
});

it('Terminates 6 runners amongst all owners', async () => {
it('Terminates 6 runners amongst all owners and all orphaned', async () => {
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS);
await scaleDown();

Expand All @@ -339,10 +362,13 @@ describe('scaleDown', () => {

expect(mockOctokit.apps.getRepoInstallation).toBeCalledTimes(2);
expect(mockOctokit.apps.getOrgInstallation).toBeCalledTimes(1);
expect(terminateRunner).toBeCalledTimes(5);
expect(terminateRunner).toBeCalledTimes(8);
for (const toTerminate of RUNNERS_ALL_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});
});

Expand All @@ -366,22 +392,24 @@ describe('scaleDown', () => {
});
});

it('Terminates 3 of 5 runners owned by repos.', async () => {
// This will not terminate the orphan runners that have not yet reached their minimum running time.
it('Terminates 3 of 5 runners owned by repos and all orphaned', async () => {
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS_REPO);
await scaleDown();
expect(listEC2Runners).toBeCalledWith({
environment: environment,
});

expect(mockOctokit.apps.getRepoInstallation).toBeCalled();
expect(terminateRunner).toBeCalledTimes(3);
expect(terminateRunner).toBeCalledTimes(4);
for (const toTerminate of DEFAULT_RUNNERS_REPO_TO_BE_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_REPO_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});

it('Terminates 2 of 3 runners owned by orgs.', async () => {
it('Terminates 2 of 3 runners owned by orgs and all orphaned', async () => {
// This will not terminate the orphan runners that have not yet reached their minimum running time.
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS_ORG);
await scaleDown();
Expand All @@ -390,10 +418,13 @@ describe('scaleDown', () => {
});

expect(mockOctokit.apps.getOrgInstallation).toBeCalled();
expect(terminateRunner).toBeCalledTimes(2);
expect(terminateRunner).toBeCalledTimes(3);
for (const toTerminate of DEFAULT_RUNNERS_ORG_TO_BE_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_ORG_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});

describe('With idle config', () => {
Expand Down Expand Up @@ -436,7 +467,7 @@ describe('scaleDown', () => {
});
});

it('Terminates 6 runners amongst all owners', async () => {
it('Terminates 6 runners amongst all owners and all orphaned', async () => {
mockListRunners.mockResolvedValue(DEFAULT_RUNNERS);
await scaleDown();

Expand All @@ -446,10 +477,13 @@ describe('scaleDown', () => {

expect(mockOctokit.apps.getRepoInstallation).toBeCalledTimes(2);
expect(mockOctokit.apps.getOrgInstallation).toBeCalledTimes(1);
expect(terminateRunner).toBeCalledTimes(5);
expect(terminateRunner).toBeCalledTimes(8);
for (const toTerminate of RUNNERS_ALL_REMOVED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
for (const toTerminate of DEFAULT_RUNNERS_ORPHANED) {
expect(terminateRunner).toHaveBeenCalledWith(toTerminate.instanceId);
}
});
});
});
25 changes: 23 additions & 2 deletions modules/runners/lambdas/runners/src/scale-runners/scale-down.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Octokit } from '@octokit/rest';
import moment from 'moment';
import { listEC2Runners, RunnerInfo, terminateRunner } from './runners';
import { listEC2Runners, RunnerInfo, RunnerList, terminateRunner } from './runners';
import { getIdleRunnerCount, ScalingDownConfig } from './scale-down-config';
import { createOctoClient, createGithubAppAuth, createGithubInstallationAuth } from './gh-auth';
import { githubCache, GhRunners } from './cache';
Expand Down Expand Up @@ -116,6 +116,8 @@ async function evaluateAndRemoveRunners(
if (idleCounter > 0) {
idleCounter--;
console.debug(`Runner '${ec2runner.instanceId}' will kept idle.`);
const instanceIndex = ec2Runners.findIndex((runner) => runner.instanceId === ec2runner.instanceId);
ec2Runners.splice(instanceIndex, 1);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we consolidate the gathering of instanceIndex and spliceing here to avoid code duplication?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't initially think of anything but I'll give it some more thought.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to consolidate findIndex.

} else {
await removeRunner(ec2runner, ghRunner.id);
const instanceIndex = ec2Runners.findIndex((runner) => runner.instanceId === ec2runner.instanceId);
Expand All @@ -130,6 +132,12 @@ async function evaluateAndRemoveRunners(
}
}
}
if (!(ec2Runners.length === 0)) {
console.info(`${ec2Runners.length} runners itentified as orphans.`);
ec2Runners.forEach((runner) => {
terminateOrphan(runner.instanceId);
});
}
}

async function terminateOrphan(instanceId: string): Promise<void> {
Expand All @@ -154,6 +162,14 @@ async function listAndSortRunners(environment: string) {
});
}

function filterLegacyRunners(ec2runners: RunnerList[]): RunnerList[] {
return ec2runners.filter((ec2Runner) => ec2Runner.Repo || ec2Runner.Org) as RunnerList[];
}

function filterNewRunners(ec2runners: RunnerList[]): RunnerInfo[] {
return ec2runners.filter((ec2Runner) => ec2Runner.type) as RunnerInfo[];
}

export async function scaleDown(): Promise<void> {
const scaleDownConfigs = JSON.parse(process.env.SCALE_DOWN_CONFIG) as [ScalingDownConfig];
const environment = process.env.ENVIRONMENT;
Expand All @@ -166,6 +182,11 @@ export async function scaleDown(): Promise<void> {
console.debug(`No active runners found for environment: '${environment}'`);
return;
}
const legacyRunners = filterLegacyRunners(ec2Runners);
const newRunners = filterNewRunners(ec2Runners);

await evaluateAndRemoveRunners(ec2Runners, scaleDownConfigs, minimumRunningTimeInMinutes);
await evaluateAndRemoveRunners(newRunners, scaleDownConfigs, minimumRunningTimeInMinutes);
legacyRunners.forEach((runner) => {
terminateOrphan(runner.instanceId);
});
}