Skip to content

Commit 755da9d

Browse files
committed
feat: adjust pool dynamically based on demand
1 parent 0c32047 commit 755da9d

File tree

2 files changed

+282
-70
lines changed

2 files changed

+282
-70
lines changed

lambdas/functions/control-plane/src/pool/pool.test.ts

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ import { createRunners } from '../scale-runners/scale-up';
99
import { adjust } from './pool';
1010

1111
const mockOctokit = {
12-
paginate: jest.fn(),
12+
paginate: (f: any, o: any) => f(o),
1313
checks: { get: jest.fn() },
1414
actions: {
1515
createRegistrationTokenForOrg: jest.fn(),
16+
listJobsForWorkflowRunAttempt: jest.fn(),
17+
listSelfHostedRunnersForOrg: jest.fn(),
18+
listSelfHostedRunnersForRepo: jest.fn(),
19+
listWorkflowRunsForRepo: jest.fn(),
1620
},
1721
apps: {
1822
getOrgInstallation: jest.fn(),
23+
listReposAccessibleToInstallation: jest.fn(),
1924
},
2025
};
2126

@@ -42,6 +47,7 @@ const cleanEnv = process.env;
4247

4348
const ORG = 'my-org';
4449
const MINIMUM_TIME_RUNNING = 15;
50+
const LABELS = ['label1', 'label2'];
4551

4652
const ec2InstancesRegistered = [
4753
{
@@ -79,31 +85,46 @@ const githubRunnersRegistered = [
7985
os: 'linux',
8086
status: 'online',
8187
busy: false,
82-
labels: [],
88+
labels: LABELS,
8389
},
8490
{
8591
id: 2,
8692
name: 'i-2-busy',
8793
os: 'linux',
8894
status: 'online',
8995
busy: true,
90-
labels: [],
96+
labels: LABELS,
9197
},
9298
{
9399
id: 3,
94100
name: 'i-3-offline',
95101
os: 'linux',
96102
status: 'offline',
97103
busy: false,
98-
labels: [],
104+
labels: LABELS,
99105
},
100106
{
101107
id: 3,
102108
name: 'i-4-idle-older-than-minimum-time-running',
103109
os: 'linux',
104110
status: 'online',
105111
busy: false,
106-
labels: [],
112+
labels: LABELS,
113+
},
114+
];
115+
116+
const githubReposAccessibleToInstallation = [
117+
{
118+
owner: {
119+
login: ORG,
120+
},
121+
name: 'my-repo-1',
122+
},
123+
{
124+
owner: {
125+
login: ORG,
126+
},
127+
name: 'my-repo-2',
107128
},
108129
];
109130

@@ -126,6 +147,7 @@ beforeEach(() => {
126147
process.env.INSTANCE_TARGET_CAPACITY_TYPE = 'spot';
127148
process.env.RUNNER_OWNER = ORG;
128149
process.env.RUNNER_BOOT_TIME_IN_MINUTES = MINIMUM_TIME_RUNNING.toString();
150+
process.env.RUNNER_LABELS = LABELS.join(',');
129151

130152
const mockTokenReturnValue = {
131153
data: {
@@ -134,7 +156,15 @@ beforeEach(() => {
134156
};
135157
mockOctokit.actions.createRegistrationTokenForOrg.mockImplementation(() => mockTokenReturnValue);
136158

137-
mockOctokit.paginate.mockImplementation(() => githubRunnersRegistered);
159+
mockOctokit.actions.listSelfHostedRunnersForOrg.mockImplementation(() => githubRunnersRegistered);
160+
161+
mockOctokit.actions.listSelfHostedRunnersForRepo.mockImplementation(() => githubRunnersRegistered);
162+
163+
mockOctokit.apps.listReposAccessibleToInstallation.mockImplementation(() => githubReposAccessibleToInstallation);
164+
165+
mockOctokit.actions.listWorkflowRunsForRepo.mockImplementation(async () => []);
166+
167+
mockOctokit.actions.listJobsForWorkflowRunAttempt.mockImplementation(async () => []);
138168

139169
mockListRunners.mockImplementation(async () => ec2InstancesRegistered);
140170

@@ -171,7 +201,7 @@ describe('Test simple pool.', () => {
171201
await expect(await adjust({ poolSize: 3 })).resolves;
172202
expect(createRunners).toHaveBeenCalledTimes(1);
173203
expect(createRunners).toHaveBeenCalledWith(
174-
expect.anything(),
204+
expect.objectContaining({ runnerOwner: ORG, runnerType: 'Org' }),
175205
expect.objectContaining({ numberOfRunners: 1 }),
176206
expect.anything(),
177207
);
@@ -206,7 +236,7 @@ describe('Test simple pool.', () => {
206236
// 2 idle + 1 booting = 3, top up with 2 to match a pool of 5
207237
await expect(await adjust({ poolSize: 5 })).resolves;
208238
expect(createRunners).toHaveBeenCalledWith(
209-
expect.anything(),
239+
expect.objectContaining({ runnerOwner: ORG, runnerType: 'Org' }),
210240
expect.objectContaining({ numberOfRunners: 2 }),
211241
expect.anything(),
212242
);
@@ -247,7 +277,7 @@ describe('Test simple pool.', () => {
247277
await expect(await adjust({ poolSize: 5 })).resolves;
248278
// 2 idle, top up with 3 to match a pool of 5
249279
expect(createRunners).toHaveBeenCalledWith(
250-
expect.anything(),
280+
expect.objectContaining({ runnerOwner: ORG, runnerType: 'Org' }),
251281
expect.objectContaining({ numberOfRunners: 3 }),
252282
expect.anything(),
253283
);
@@ -261,7 +291,7 @@ describe('Test simple pool.', () => {
261291

262292
it('Should top up with fewer runners when there are idle prefixed runners', async () => {
263293
// Add prefixed runners to github
264-
mockOctokit.paginate.mockImplementation(async () => [
294+
mockOctokit.actions.listSelfHostedRunnersForOrg.mockImplementation(async () => [
265295
...githubRunnersRegistered,
266296
{
267297
id: 5,
@@ -301,10 +331,110 @@ describe('Test simple pool.', () => {
301331
await expect(await adjust({ poolSize: 5 })).resolves;
302332
// 2 idle, 2 prefixed idle top up with 1 to match a pool of 5
303333
expect(createRunners).toHaveBeenCalledWith(
334+
expect.objectContaining({ runnerOwner: ORG, runnerType: 'Org' }),
335+
expect.objectContaining({ numberOfRunners: 1 }),
336+
expect.anything(),
337+
);
338+
});
339+
});
340+
341+
describe('With Negative Pool Size', () => {
342+
// effective pool size is 2 (1 queued job with matching labels x 1 workflows x 2 accessible repositories)
343+
it('Should not top up if there are fewer queued jobs than idle runners.', async () => {
344+
mockOctokit.actions.listWorkflowRunsForRepo.mockImplementation(async ({ owner, repo }) => [
345+
{
346+
repository: {
347+
owner: { login: owner },
348+
name: repo,
349+
},
350+
id: 1,
351+
attempt_number: 1,
352+
},
353+
]);
354+
mockOctokit.actions.listJobsForWorkflowRunAttempt.mockImplementation(async () => [
355+
{
356+
status: 'completed',
357+
labels: LABELS,
358+
},
359+
{
360+
status: 'queued',
361+
labels: LABELS,
362+
},
363+
{
364+
status: 'queued',
365+
labels: [...LABELS, 'label3'],
366+
},
367+
]);
368+
await expect(await adjust({ poolSize: -1 })).resolves;
369+
expect(createRunners).not.toHaveBeenCalled();
370+
});
371+
// effective pool size is 8 (2 queued job with matching labels x 2 workflows x 2 accessible repositories)
372+
it('Should top up if there are more queued jobs with matching labels than idle runners.', async () => {
373+
mockOctokit.actions.listWorkflowRunsForRepo.mockImplementation(async ({ owner, repo }) => [
374+
{
375+
repository: {
376+
owner: { login: owner },
377+
name: repo,
378+
},
379+
id: 1,
380+
attempt_number: 1,
381+
},
382+
{
383+
repository: {
384+
owner: { login: owner },
385+
name: repo,
386+
},
387+
id: 2,
388+
attempt_number: 1,
389+
},
390+
]);
391+
mockOctokit.actions.listJobsForWorkflowRunAttempt.mockImplementation(async () => [
392+
{
393+
status: 'queued',
394+
labels: LABELS,
395+
},
396+
{
397+
status: 'queued',
398+
labels: LABELS,
399+
},
400+
]);
401+
await expect(await adjust({ poolSize: -1 })).resolves;
402+
expect(createRunners).toHaveBeenCalledTimes(1);
403+
expect(createRunners).toHaveBeenCalledWith(
404+
expect.objectContaining({ runnerOwner: ORG, runnerType: 'Org' }),
405+
expect.objectContaining({ numberOfRunners: 6 }),
304406
expect.anything(),
407+
);
408+
});
409+
});
410+
411+
describe('With Runner Type Repo', () => {
412+
it('Should top up the repository runners pool', async () => {
413+
const runnerOwner = `${ORG}/my-repo-1`;
414+
process.env.RUNNER_OWNER = runnerOwner;
415+
await expect(await adjust({ poolSize: 3 })).resolves;
416+
expect(createRunners).toHaveBeenCalledTimes(1);
417+
expect(createRunners).toHaveBeenCalledWith(
418+
expect.objectContaining({ runnerOwner, runnerType: 'Repo' }),
305419
expect.objectContaining({ numberOfRunners: 1 }),
306420
expect.anything(),
307421
);
308422
});
309423
});
424+
425+
describe('With Multiple Runner Owners', () => {
426+
it('Should top up pools for all runner owners', async () => {
427+
const runnerOwners = [`${ORG}/my-repo-1`, `${ORG}/my-repo-2`];
428+
process.env.RUNNER_OWNER = runnerOwners.join(',');
429+
await expect(await adjust({ poolSize: 3 })).resolves;
430+
expect(createRunners).toHaveBeenCalledTimes(2);
431+
for (const runnerOwner of runnerOwners) {
432+
expect(createRunners).toHaveBeenCalledWith(
433+
expect.objectContaining({ runnerOwner, runnerType: 'Repo' }),
434+
expect.objectContaining({ numberOfRunners: 1 }),
435+
expect.anything(),
436+
);
437+
}
438+
});
439+
});
310440
});

0 commit comments

Comments
 (0)