Skip to content

Commit 5fd2abd

Browse files
committed
runners: Add tests for scale-cycle
Signed-off-by: Eli Uriegas <[email protected]> ghstack-source-id: 35acb85 ghstack-comment-id: 3046661661 Pull-Request: #6899
1 parent 4c1be0b commit 5fd2abd

File tree

1 file changed

+383
-0
lines changed
  • terraform-aws-github-runner/modules/runners/lambdas/runners/src/scale-runners

1 file changed

+383
-0
lines changed
Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
import { Config } from './config';
2+
import { mocked } from 'ts-jest/utils';
3+
import { getRepo, getRepoKey, RunnerInfo } from './utils';
4+
import { getRunnerTypes } from './gh-runners';
5+
import { listRunners, tryReuseRunner, RunnerType } from './runners';
6+
import { scaleCycle } from './scale-cycle';
7+
import { createRunnerConfigArgument } from './scale-up';
8+
import * as MetricsModule from './metrics';
9+
import nock from 'nock';
10+
11+
jest.mock('./runners');
12+
jest.mock('./gh-runners');
13+
jest.mock('./utils');
14+
jest.mock('./scale-up');
15+
16+
const mockRunnerTypes = new Map([
17+
[
18+
'linux.2xlarge',
19+
{
20+
instance_type: 'm5.2xlarge',
21+
os: 'linux',
22+
max_available: 10,
23+
disk_size: 100,
24+
runnerTypeName: 'linux.2xlarge',
25+
is_ephemeral: true,
26+
} as RunnerType,
27+
],
28+
[
29+
'windows.large',
30+
{
31+
instance_type: 'm5.large',
32+
os: 'windows',
33+
max_available: 5,
34+
disk_size: 200,
35+
runnerTypeName: 'windows.large',
36+
is_ephemeral: false,
37+
} as RunnerType,
38+
],
39+
]);
40+
41+
const mockRunners: RunnerInfo[] = [
42+
{
43+
instanceId: 'i-1234567890abcdef0',
44+
runnerType: 'linux.2xlarge',
45+
org: 'pytorch',
46+
repo: 'pytorch',
47+
awsRegion: 'us-west-2',
48+
ghRunnerId: 'runner-1',
49+
environment: 'test',
50+
},
51+
{
52+
instanceId: 'i-0987654321fedcba0',
53+
runnerType: 'windows.large',
54+
org: 'pytorch',
55+
repo: 'vision',
56+
awsRegion: 'us-east-1',
57+
ghRunnerId: 'runner-2',
58+
environment: 'test',
59+
},
60+
];
61+
62+
const mockRunnersWithMissingTags: RunnerInfo[] = [
63+
{
64+
instanceId: 'i-missing-runner-type',
65+
runnerType: undefined, // Missing runnerType
66+
org: 'pytorch',
67+
repo: 'pytorch',
68+
awsRegion: 'us-west-2',
69+
},
70+
{
71+
instanceId: 'i-missing-org',
72+
runnerType: 'linux.2xlarge',
73+
org: undefined, // Missing org
74+
repo: 'pytorch',
75+
awsRegion: 'us-west-2',
76+
},
77+
{
78+
instanceId: 'i-missing-repo',
79+
runnerType: 'linux.2xlarge',
80+
org: 'pytorch',
81+
repo: undefined, // Missing repo
82+
awsRegion: 'us-west-2',
83+
},
84+
];
85+
86+
const baseCfg = {
87+
scaleConfigOrg: 'pytorch',
88+
scaleConfigRepo: 'test-infra',
89+
environment: 'test',
90+
enableOrganizationRunners: false,
91+
minimumRunningTimeInMinutes: 5,
92+
awsRegion: 'us-east-1',
93+
cantHaveIssuesLabels: [],
94+
mustHaveIssuesLabels: [],
95+
lambdaTimeout: 600,
96+
} as unknown as Config;
97+
98+
const metrics = new MetricsModule.ScaleCycleMetrics();
99+
100+
beforeEach(() => {
101+
jest.resetModules();
102+
jest.clearAllMocks();
103+
jest.restoreAllMocks();
104+
nock.disableNetConnect();
105+
106+
// Default mocks
107+
mocked(getRepo).mockReturnValue({ owner: 'pytorch', repo: 'test-infra' });
108+
mocked(getRepoKey).mockReturnValue('pytorch/pytorch');
109+
mocked(getRunnerTypes).mockResolvedValue(mockRunnerTypes);
110+
mocked(listRunners).mockResolvedValue([]);
111+
mocked(tryReuseRunner).mockResolvedValue(mockRunners[0]);
112+
mocked(createRunnerConfigArgument).mockResolvedValue(
113+
'--url https://github.com/pytorch/pytorch --token mock-token --labels linux.2xlarge',
114+
);
115+
116+
// Mock metrics methods
117+
jest.spyOn(metrics, 'scaleCycleRunnerReuseFoundOrg').mockImplementation(() => {
118+
// Mock implementation
119+
});
120+
jest.spyOn(metrics, 'scaleCycleRunnerReuseFoundRepo').mockImplementation(() => {
121+
// Mock implementation
122+
});
123+
});
124+
125+
describe('scaleCycle', () => {
126+
describe('basic functionality', () => {
127+
it('should successfully process runners with valid configuration', async () => {
128+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
129+
mocked(listRunners).mockResolvedValueOnce([mockRunners[0]]).mockResolvedValueOnce([mockRunners[1]]);
130+
131+
await scaleCycle(metrics);
132+
133+
// Verify getRunnerTypes was called correctly
134+
expect(getRunnerTypes).toHaveBeenCalledWith({ owner: 'pytorch', repo: 'test-infra' }, metrics);
135+
136+
// Verify listRunners was called for each runner type
137+
expect(listRunners).toHaveBeenCalledTimes(2);
138+
expect(listRunners).toHaveBeenCalledWith(metrics, {
139+
containsTags: ['GithubRunnerID', 'EphemeralRunnerFinished', 'RunnerType'],
140+
runnerType: 'linux.2xlarge',
141+
});
142+
expect(listRunners).toHaveBeenCalledWith(metrics, {
143+
containsTags: ['GithubRunnerID', 'EphemeralRunnerFinished', 'RunnerType'],
144+
runnerType: 'windows.large',
145+
});
146+
147+
// Verify tryReuseRunner was called for each valid runner
148+
expect(tryReuseRunner).toHaveBeenCalledTimes(2);
149+
});
150+
151+
it('should handle empty runner list', async () => {
152+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
153+
mocked(listRunners).mockResolvedValue([]);
154+
155+
await scaleCycle(metrics);
156+
157+
expect(getRunnerTypes).toHaveBeenCalledTimes(1);
158+
expect(listRunners).toHaveBeenCalledTimes(2);
159+
expect(tryReuseRunner).not.toHaveBeenCalled();
160+
});
161+
162+
it('should handle no runner types configured', async () => {
163+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
164+
mocked(getRunnerTypes).mockResolvedValue(new Map());
165+
166+
await scaleCycle(metrics);
167+
168+
expect(getRunnerTypes).toHaveBeenCalledTimes(1);
169+
expect(listRunners).not.toHaveBeenCalled();
170+
expect(tryReuseRunner).not.toHaveBeenCalled();
171+
});
172+
});
173+
174+
describe('runner filtering and validation', () => {
175+
it('should skip runners with missing required tags', async () => {
176+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
177+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
178+
mocked(listRunners).mockResolvedValue(mockRunnersWithMissingTags);
179+
180+
await scaleCycle(metrics);
181+
182+
expect(consoleSpy).toHaveBeenCalledWith('Skipping runner i-missing-runner-type due to missing required tags');
183+
expect(consoleSpy).toHaveBeenCalledWith('Skipping runner i-missing-org due to missing required tags');
184+
expect(consoleSpy).toHaveBeenCalledWith('Skipping runner i-missing-repo due to missing required tags');
185+
expect(tryReuseRunner).not.toHaveBeenCalled();
186+
187+
consoleSpy.mockRestore();
188+
});
189+
190+
it('should skip runners with unknown runner types', async () => {
191+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
192+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
193+
const runnerWithUnknownType: RunnerInfo[] = [
194+
{
195+
instanceId: 'i-unknown-type',
196+
runnerType: 'unknown.type',
197+
org: 'pytorch',
198+
repo: 'pytorch',
199+
awsRegion: 'us-west-2',
200+
},
201+
];
202+
mocked(listRunners).mockResolvedValue(runnerWithUnknownType);
203+
204+
await scaleCycle(metrics);
205+
206+
expect(consoleSpy).toHaveBeenCalledWith('Unknown runner type: unknown.type, skipping');
207+
expect(tryReuseRunner).not.toHaveBeenCalled();
208+
209+
consoleSpy.mockRestore();
210+
});
211+
});
212+
213+
describe('organization vs repository runners', () => {
214+
it('should handle organization runners correctly', async () => {
215+
const orgConfig = {
216+
...baseCfg,
217+
enableOrganizationRunners: true,
218+
} as unknown as Config;
219+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => orgConfig);
220+
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
221+
mocked(listRunners).mockResolvedValueOnce([mockRunners[0]]).mockResolvedValueOnce([]);
222+
// Only test with one runner from first runner type
223+
224+
await scaleCycle(metrics);
225+
226+
expect(tryReuseRunner).toHaveBeenCalledWith(
227+
expect.objectContaining({
228+
orgName: 'pytorch',
229+
runnerType: mockRunnerTypes.get('linux.2xlarge'),
230+
}),
231+
metrics,
232+
);
233+
234+
expect(metrics.scaleCycleRunnerReuseFoundOrg).toHaveBeenCalledWith('pytorch', 'linux.2xlarge');
235+
expect(consoleSpy).toHaveBeenCalledWith('Reusing runner i-1234567890abcdef0 for pytorch');
236+
237+
consoleSpy.mockRestore();
238+
});
239+
240+
it('should handle repository runners correctly', async () => {
241+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
242+
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
243+
mocked(listRunners).mockResolvedValue(mockRunners);
244+
245+
await scaleCycle(metrics);
246+
247+
expect(tryReuseRunner).toHaveBeenCalledWith(
248+
expect.objectContaining({
249+
repoName: 'pytorch/pytorch',
250+
runnerType: mockRunnerTypes.get('linux.2xlarge'),
251+
}),
252+
metrics,
253+
);
254+
255+
expect(metrics.scaleCycleRunnerReuseFoundRepo).toHaveBeenCalledWith('pytorch/pytorch', 'linux.2xlarge');
256+
expect(consoleSpy).toHaveBeenCalledWith('Reusing runner i-1234567890abcdef0 for pytorch/pytorch');
257+
258+
consoleSpy.mockRestore();
259+
});
260+
});
261+
262+
describe('runner configuration', () => {
263+
it('should create correct runner input parameters', async () => {
264+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
265+
mocked(listRunners).mockResolvedValue([mockRunners[0]]); // Use only the first runner
266+
mocked(getRepo).mockReturnValue({ owner: 'pytorch', repo: 'pytorch' }); // Mock to match the expected repo
267+
268+
await scaleCycle(metrics);
269+
270+
expect(tryReuseRunner).toHaveBeenCalledWith(
271+
expect.objectContaining({
272+
environment: 'test',
273+
runnerType: mockRunnerTypes.get('linux.2xlarge'),
274+
repoName: 'pytorch/pytorch',
275+
runnerConfig: expect.any(Function),
276+
}),
277+
metrics,
278+
);
279+
280+
// Test the runnerConfig function
281+
const callArgs = mocked(tryReuseRunner).mock.calls[0][0];
282+
const runnerConfigResult = await callArgs.runnerConfig('us-west-2', false);
283+
284+
expect(createRunnerConfigArgument).toHaveBeenCalledWith(
285+
mockRunnerTypes.get('linux.2xlarge'),
286+
{ owner: 'pytorch', repo: 'pytorch' },
287+
undefined,
288+
metrics,
289+
'us-west-2',
290+
false,
291+
);
292+
expect(runnerConfigResult).toBe(
293+
'--url https://github.com/pytorch/pytorch --token mock-token --labels linux.2xlarge',
294+
);
295+
});
296+
});
297+
298+
describe('error handling', () => {
299+
it('should handle getRunnerTypes failure', async () => {
300+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
301+
const error = new Error('Failed to get runner types');
302+
mocked(getRunnerTypes).mockRejectedValue(error);
303+
304+
await expect(scaleCycle(metrics)).rejects.toThrow('Failed to get runner types');
305+
expect(listRunners).not.toHaveBeenCalled();
306+
expect(tryReuseRunner).not.toHaveBeenCalled();
307+
});
308+
309+
it('should handle listRunners failure', async () => {
310+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
311+
const error = new Error('Failed to list runners');
312+
mocked(listRunners).mockRejectedValue(error);
313+
314+
await expect(scaleCycle(metrics)).rejects.toThrow('Failed to list runners');
315+
expect(tryReuseRunner).not.toHaveBeenCalled();
316+
});
317+
318+
it('should handle tryReuseRunner failure', async () => {
319+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
320+
const error = new Error('Failed to reuse runner');
321+
mocked(listRunners).mockResolvedValue(mockRunners);
322+
mocked(tryReuseRunner).mockRejectedValue(error);
323+
324+
await expect(scaleCycle(metrics)).rejects.toThrow('Failed to reuse runner');
325+
});
326+
});
327+
328+
describe('scale config repository', () => {
329+
it('should use custom scale config org and repo', async () => {
330+
const customConfig = {
331+
...baseCfg,
332+
scaleConfigOrg: 'custom-org',
333+
scaleConfigRepo: 'custom-repo',
334+
} as unknown as Config;
335+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => customConfig);
336+
mocked(getRepo).mockReturnValue({ owner: 'custom-org', repo: 'custom-repo' });
337+
338+
await scaleCycle(metrics);
339+
340+
expect(getRepo).toHaveBeenCalledWith('custom-org', 'custom-repo');
341+
expect(getRunnerTypes).toHaveBeenCalledWith({ owner: 'custom-org', repo: 'custom-repo' }, metrics);
342+
});
343+
344+
it('should handle missing scale config repo', async () => {
345+
const configWithoutRepo = {
346+
...baseCfg,
347+
scaleConfigRepo: '',
348+
} as unknown as Config;
349+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => configWithoutRepo);
350+
351+
await scaleCycle(metrics);
352+
353+
expect(getRepo).toHaveBeenCalledWith('pytorch', '');
354+
});
355+
});
356+
357+
describe('parallel processing', () => {
358+
it('should process multiple runner types in parallel', async () => {
359+
jest.spyOn(Config, 'Instance', 'get').mockImplementation(() => baseCfg);
360+
const multiTypeRunners = [
361+
[mockRunners[0]], // linux.2xlarge runners
362+
[mockRunners[1]], // windows.large runners
363+
];
364+
mocked(listRunners).mockResolvedValueOnce(multiTypeRunners[0]).mockResolvedValueOnce(multiTypeRunners[1]);
365+
366+
await scaleCycle(metrics);
367+
368+
// Verify both runner types were queried
369+
expect(listRunners).toHaveBeenCalledTimes(2);
370+
expect(listRunners).toHaveBeenNthCalledWith(1, metrics, {
371+
containsTags: ['GithubRunnerID', 'EphemeralRunnerFinished', 'RunnerType'],
372+
runnerType: 'linux.2xlarge',
373+
});
374+
expect(listRunners).toHaveBeenNthCalledWith(2, metrics, {
375+
containsTags: ['GithubRunnerID', 'EphemeralRunnerFinished', 'RunnerType'],
376+
runnerType: 'windows.large',
377+
});
378+
379+
// Verify both runners were processed
380+
expect(tryReuseRunner).toHaveBeenCalledTimes(2);
381+
});
382+
});
383+
});

0 commit comments

Comments
 (0)