cdk-github-runner-switch 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.pt-BR.md +2 -2
- package/lambda/index.mjs +71 -13
- package/lib/runner-switch.d.ts +5 -0
- package/lib/runner-switch.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,10 +42,10 @@ flowchart LR
|
|
|
42
42
|
|---|---|
|
|
43
43
|
| Webhook (Lambda Function URL) | Checks the GitHub signature. Each queued job that asks for the AWS label gets one machine: Spot on every instance type × subnet first, then on-demand. |
|
|
44
44
|
| Switch (every 30 min) | Reads the Actions minutes used this month and flips `RUNNER` to `aws` at the threshold (default 1,900 of 2,000). It flips back to `github` when the month resets. When it moves to AWS, it re-runs the runs that failed because the minutes ran out. |
|
|
45
|
-
| Reconciliation (every 5 min) | Queued jobs without a machine get one. This covers a lost webhook and jobs that hit the machine cap. |
|
|
45
|
+
| Reconciliation (every 5 min) | Queued jobs without a machine get one. This covers a lost webhook and jobs that hit the machine cap. A machine left without a job (its job was cancelled, or another runner took it) is terminated after 5 minutes. |
|
|
46
46
|
| On-demand label | Jobs labeled `aws-ondemand` (for example, deploys) skip Spot, so an interruption never cuts a deploy in half. |
|
|
47
47
|
| Runner image | Ubuntu 24.04 arm64 from Canonical with Docker, Node.js, Python, git, GitHub CLI, AWS CLI and the runner. EC2 Image Builder rebuilds it monthly. You can add cached container images and extra commands. |
|
|
48
|
-
| Safety | Security group with no inbound rules, IMDSv2, encrypted disk, shutdown → terminate, a 2-hour guard against hung jobs. The function may
|
|
48
|
+
| Safety | Security group with no inbound rules, IMDSv2, encrypted disk, shutdown → terminate, a 2-hour guard against hung jobs. The function may launch, tag and list instances, and terminate only the ones it launched (tag condition). |
|
|
49
49
|
|
|
50
50
|
## Quick start
|
|
51
51
|
|
|
@@ -98,6 +98,7 @@ To flip the switch by hand: `gh variable set RUNNER --body aws` (or `github`).
|
|
|
98
98
|
| `maxInstances` | `6` | Machine cap. Jobs above it wait and are launched by the reconciliation. |
|
|
99
99
|
| `switchVariable` | `RUNNER` | Repository variable that the construct flips. |
|
|
100
100
|
| `maxJobDuration` | 2 hours | The machine shuts down after this, even if a job hangs. |
|
|
101
|
+
| `idleGrace` | 5 min | A machine without a job older than this is terminated by the reconciliation. |
|
|
101
102
|
| `rootVolumeSize` | `40` GiB | |
|
|
102
103
|
| `nodeMajorVersion` | `24` | Node.js installed in the image. |
|
|
103
104
|
| `cachedImages` | `[]` | Container images pulled into the AMI, for faster `services:`. |
|
package/README.pt-BR.md
CHANGED
|
@@ -23,10 +23,10 @@ Ninguém precisa lembrar de nada, a pipeline não trava no fim do mês, e você
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| Webhook (Function URL da Lambda) | Confere a assinatura do GitHub. Cada job na fila que pede o rótulo da AWS ganha uma máquina: primeiro Spot, em cada tipo de máquina × sub-rede, depois sob demanda. |
|
|
25
25
|
| Chave (a cada 30 min) | Lê os minutos do Actions usados no mês e vira `RUNNER` para `aws` no limite (padrão: 1.900 de 2.000). Volta para `github` quando o mês vira. Ao ir para a AWS, refaz as execuções que falharam por falta de minutos. |
|
|
26
|
-
| Reconciliação (a cada 5 min) | Job na fila sem máquina ganha uma. Cobre aviso do GitHub que se perdeu e o teto de máquinas. |
|
|
26
|
+
| Reconciliação (a cada 5 min) | Job na fila sem máquina ganha uma. Cobre aviso do GitHub que se perdeu e o teto de máquinas. Máquina que ficou sem job (o job foi cancelado ou outra máquina o pegou) é desligada depois de 5 minutos. |
|
|
27
27
|
| Rótulo sob demanda | Jobs com `aws-ondemand` (ex.: deploy) não usam Spot, então uma interrupção nunca corta um deploy no meio. |
|
|
28
28
|
| Imagem | Ubuntu 24.04 arm64 da Canonical com Docker, Node.js, Python, git, GitHub CLI, AWS CLI e o runner. O EC2 Image Builder refaz a imagem todo mês. Dá para incluir imagens de contêiner em cache e comandos extras. |
|
|
29
|
-
| Segurança | Sem porta de entrada, IMDSv2, disco criptografado, desligar = encerrar, limite de 2 horas para job travado. A função
|
|
29
|
+
| Segurança | Sem porta de entrada, IMDSv2, disco criptografado, desligar = encerrar, limite de 2 horas para job travado. A função lança, marca e lista máquinas, e só desliga as que ela mesma lançou (condição pela etiqueta). |
|
|
30
30
|
|
|
31
31
|
## Começando
|
|
32
32
|
|
package/lambda/index.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 1. Webhook `workflow_job` from the GitHub App → one disposable EC2 machine (Spot first) with a single-use (JIT)
|
|
4
4
|
// runner registration for the queued job.
|
|
5
|
-
// 2. Every few minutes (`reconcile`) → queued jobs that have no machine (lost webhook, cap reached) get one
|
|
5
|
+
// 2. Every few minutes (`reconcile`) → queued jobs that have no machine (lost webhook, cap reached) get one, and
|
|
6
|
+
// machines left without a job (job cancelled, or taken by another machine) are terminated after a grace period.
|
|
6
7
|
// 3. Every half hour (`check-usage`) → flips the repository variable (default `RUNNER`) between `github` and `aws`
|
|
7
8
|
// according to the GitHub Actions minutes used this month, and re-runs the runs that failed for lack of minutes.
|
|
8
9
|
//
|
|
@@ -30,6 +31,7 @@ export function config(env = process.env) {
|
|
|
30
31
|
billing: env.BILLING === 'user' ? 'user' : 'organization',
|
|
31
32
|
runnerGroupId: Number(env.RUNNER_GROUP_ID || 1),
|
|
32
33
|
maxJobMinutes: Number(env.MAX_JOB_MINUTES || 120),
|
|
34
|
+
idleGraceMinutes: Number(env.IDLE_GRACE_MINUTES || 5),
|
|
33
35
|
switchId: env.SWITCH_ID || 'default',
|
|
34
36
|
repository: env.REPOSITORY || '',
|
|
35
37
|
};
|
|
@@ -93,6 +95,38 @@ export function missingRunners({ queued, active, busy }, c = config()) {
|
|
|
93
95
|
return out;
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Machines without a job. A JIT runner is not tied to the job that launched it: when that job is cancelled, or another
|
|
100
|
+
* idle runner takes it first, the machine would wait until `maxJobMinutes`. Per label, the machines whose runner is not
|
|
101
|
+
* busy (idle, still offline, or not registered) beyond the queued jobs are surplus; only those launched more than
|
|
102
|
+
* `idleGraceMinutes` ago are terminated (a young machine is still booting or about to take a job), oldest first.
|
|
103
|
+
* Also returns offline registrations of our labels with no machine behind them (left by terminated machines).
|
|
104
|
+
*
|
|
105
|
+
* `instances`: `{ id, label, launchTime, runnerName?, jobId? }`; `runners`: GitHub runners (`id`, `name`, `status`, `busy`, `labels`).
|
|
106
|
+
*/
|
|
107
|
+
export function surplusRunners({ queued, instances, runners }, c = config(), now = new Date()) {
|
|
108
|
+
const ours = (r) => (r.labels ?? []).some((l) => [c.label, c.onDemandLabel].includes(l.name ?? l));
|
|
109
|
+
const runnerOf = (i) => runners.find((r) => (i.runnerName ? r.name === i.runnerName : i.jobId && r.name.startsWith(`aws-${i.jobId}-`)));
|
|
110
|
+
const terminate = [];
|
|
111
|
+
for (const label of [c.onDemandLabel, c.label]) {
|
|
112
|
+
const free = instances
|
|
113
|
+
.filter((i) => i.label === label)
|
|
114
|
+
.map((i) => ({ ...i, runner: runnerOf(i) }))
|
|
115
|
+
.filter((i) => !i.runner?.busy)
|
|
116
|
+
.sort((a, b) => new Date(a.launchTime) - new Date(b.launchTime));
|
|
117
|
+
let extra = free.length - (queued[label] ?? 0);
|
|
118
|
+
for (const i of free) {
|
|
119
|
+
if (extra <= 0) break;
|
|
120
|
+
if (now - new Date(i.launchTime) < c.idleGraceMinutes * 60_000) continue;
|
|
121
|
+
terminate.push({ instanceId: i.id, runnerId: i.runner?.id ?? null });
|
|
122
|
+
extra--;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const matched = new Set(instances.map(runnerOf).filter(Boolean).map((r) => r.id));
|
|
126
|
+
const orphans = runners.filter((r) => ours(r) && r.status === 'offline' && !r.busy && !matched.has(r.id)).map((r) => r.id);
|
|
127
|
+
return { terminate, orphans };
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
/** Boot script: runs exactly one job (JIT) and shuts down — the launch template turns shutdown into terminate. */
|
|
97
131
|
export function userData(jitConfig, c = config()) {
|
|
98
132
|
if (!/^[A-Za-z0-9+/=]+$/.test(jitConfig)) throw new Error('invalid JIT config');
|
|
@@ -160,20 +194,24 @@ async function ec2() {
|
|
|
160
194
|
return { client: ec2Client, ...m };
|
|
161
195
|
}
|
|
162
196
|
|
|
163
|
-
async function
|
|
197
|
+
async function instancesOf(c) {
|
|
164
198
|
const { client, DescribeInstancesCommand } = await ec2();
|
|
165
199
|
const r = await client.send(new DescribeInstancesCommand({
|
|
166
200
|
Filters: [{ Name: 'tag:runner-switch', Values: [c.switchId] }, { Name: 'instance-state-name', Values: ['pending', 'running'] }],
|
|
167
201
|
}));
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
202
|
+
return (r.Reservations ?? []).flatMap((x) => x.Instances ?? []).map((i) => {
|
|
203
|
+
const tag = (k) => i.Tags?.find((t) => t.Key === k)?.Value;
|
|
204
|
+
return { id: i.InstanceId, label: tag('runner-label') ?? c.label, launchTime: i.LaunchTime, runnerName: tag('runner-name'), jobId: tag('github-job') };
|
|
205
|
+
});
|
|
174
206
|
}
|
|
175
207
|
|
|
176
|
-
|
|
208
|
+
const countByLabel = (instances) => instances.reduce((out, i) => ({ ...out, [i.label]: (out[i.label] ?? 0) + 1 }), {});
|
|
209
|
+
|
|
210
|
+
async function activeByLabel(c) {
|
|
211
|
+
return countByLabel(await instancesOf(c));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function launch(c, jobId, onDemand, jit, runnerName) {
|
|
177
215
|
const { SSMClient, GetParameterCommand } = await sdk.ssm();
|
|
178
216
|
const ami = (await new SSMClient({}).send(new GetParameterCommand({ Name: process.env.AMI_PARAMETER }))).Parameter.Value;
|
|
179
217
|
const { client, RunInstancesCommand } = await ec2();
|
|
@@ -191,7 +229,7 @@ async function launch(c, jobId, onDemand, jit) {
|
|
|
191
229
|
ResourceType: 'instance',
|
|
192
230
|
Tags: [
|
|
193
231
|
{ Key: 'Name', Value: `github-runner-${c.switchId}` }, { Key: 'runner-switch', Value: c.switchId }, { Key: 'github-job', Value: String(jobId) },
|
|
194
|
-
{ Key: 'market', Value: p.spot ? 'spot' : 'on-demand' }, { Key: 'runner-label', Value: label },
|
|
232
|
+
{ Key: 'market', Value: p.spot ? 'spot' : 'on-demand' }, { Key: 'runner-label', Value: label }, { Key: 'runner-name', Value: runnerName },
|
|
195
233
|
],
|
|
196
234
|
}],
|
|
197
235
|
}));
|
|
@@ -208,14 +246,15 @@ async function launch(c, jobId, onDemand, jit) {
|
|
|
208
246
|
}
|
|
209
247
|
|
|
210
248
|
async function launchFor(c, token, jobId, onDemand) {
|
|
249
|
+
const name = `aws-${jobId}-${Date.now().toString(36)}`;
|
|
211
250
|
const jit = await gh(`/repos/${c.repository}/actions/runners/generate-jitconfig`, {
|
|
212
251
|
token, method: 'POST',
|
|
213
252
|
body: {
|
|
214
|
-
name
|
|
253
|
+
name, runner_group_id: c.runnerGroupId, work_folder: '_work',
|
|
215
254
|
labels: ['self-hosted', 'linux', 'arm64', onDemand ? c.onDemandLabel : c.label],
|
|
216
255
|
},
|
|
217
256
|
});
|
|
218
|
-
return launch(c, jobId, onDemand, jit.json.encoded_jit_config);
|
|
257
|
+
return launch(c, jobId, onDemand, jit.json.encoded_jit_config, name);
|
|
219
258
|
}
|
|
220
259
|
|
|
221
260
|
// ---------------------------------------------------------------- tasks
|
|
@@ -254,6 +293,25 @@ export async function reconcile(c = config()) {
|
|
|
254
293
|
}
|
|
255
294
|
}
|
|
256
295
|
const runners = await gh(`/repos/${c.repository}/actions/runners?per_page=100`, { token });
|
|
296
|
+
const instances = await instancesOf(c);
|
|
297
|
+
const { terminate, orphans } = surplusRunners({ queued, instances, runners: runners.json?.runners ?? [] }, c);
|
|
298
|
+
const ended = new Set();
|
|
299
|
+
for (const t of terminate) {
|
|
300
|
+
// Remove the registration first: GitHub refuses it while the runner is busy, so a job taken meanwhile is safe.
|
|
301
|
+
if (t.runnerId) {
|
|
302
|
+
try {
|
|
303
|
+
await gh(`/repos/${c.repository}/actions/runners/${t.runnerId}`, { token, method: 'DELETE' });
|
|
304
|
+
} catch (e) {
|
|
305
|
+
console.log(JSON.stringify({ keep: t.instanceId, reason: e.message }));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const { client, TerminateInstancesCommand } = await ec2();
|
|
310
|
+
await client.send(new TerminateInstancesCommand({ InstanceIds: [t.instanceId] }));
|
|
311
|
+
ended.add(t.instanceId);
|
|
312
|
+
}
|
|
313
|
+
for (const id of orphans) await gh(`/repos/${c.repository}/actions/runners/${id}`, { token, method: 'DELETE' }).catch((e) => console.log(e.message));
|
|
314
|
+
if (ended.size || orphans.length) console.log(JSON.stringify({ surplus: { terminated: [...ended], orphans } }));
|
|
257
315
|
const busy = {};
|
|
258
316
|
for (const r of runners.json?.runners ?? []) {
|
|
259
317
|
if (!r.busy) continue;
|
|
@@ -261,7 +319,7 @@ export async function reconcile(c = config()) {
|
|
|
261
319
|
const l = names.includes(c.onDemandLabel) ? c.onDemandLabel : names.includes(c.label) ? c.label : null;
|
|
262
320
|
if (l) busy[l] = (busy[l] ?? 0) + 1;
|
|
263
321
|
}
|
|
264
|
-
const active =
|
|
322
|
+
const active = countByLabel(instances.filter((i) => !ended.has(i.id)));
|
|
265
323
|
const missing = missingRunners({ queued, active, busy }, c);
|
|
266
324
|
console.log(JSON.stringify({ reconcile: { queued, active, busy, missing } }));
|
|
267
325
|
for (const [label, n] of Object.entries(missing)) {
|
package/lib/runner-switch.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface GitHubRunnerSwitchProps {
|
|
|
35
35
|
readonly runnerGroupId?: number;
|
|
36
36
|
/** Safety net: the machine shuts down after this long even if the job hangs. @default Duration.hours(2) */
|
|
37
37
|
readonly maxJobDuration?: Duration;
|
|
38
|
+
/**
|
|
39
|
+
* A machine left without a job (its job was cancelled, or another runner took it) is terminated by the
|
|
40
|
+
* reconciliation once it is older than this. @default Duration.minutes(5)
|
|
41
|
+
*/
|
|
42
|
+
readonly idleGrace?: Duration;
|
|
38
43
|
/** Root disk in GiB. @default 40 */
|
|
39
44
|
readonly rootVolumeSize?: number;
|
|
40
45
|
/** Node.js major version installed in the image. @default 24 */
|
package/lib/runner-switch.js
CHANGED
|
@@ -179,6 +179,7 @@ class GitHubRunnerSwitch extends constructs_1.Construct {
|
|
|
179
179
|
SWITCH_VARIABLE: props.switchVariable ?? 'RUNNER',
|
|
180
180
|
RUNNER_GROUP_ID: String(props.runnerGroupId ?? 1),
|
|
181
181
|
MAX_JOB_MINUTES: String((props.maxJobDuration ?? aws_cdk_lib_1.Duration.hours(2)).toMinutes()),
|
|
182
|
+
IDLE_GRACE_MINUTES: String((props.idleGrace ?? aws_cdk_lib_1.Duration.minutes(5)).toMinutes()),
|
|
182
183
|
SWITCH_ID: name,
|
|
183
184
|
AMI_PARAMETER: this.amiParameterName,
|
|
184
185
|
LAUNCH_TEMPLATE_ID: template.launchTemplateId,
|
|
@@ -192,6 +193,11 @@ class GitHubRunnerSwitch extends constructs_1.Construct {
|
|
|
192
193
|
fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['ssm:GetParameter'], resources: [`arn:aws:ssm:${region}:${account}:parameter${this.amiParameterName}`] }));
|
|
193
194
|
fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['ec2:RunInstances', 'ec2:CreateTags', 'ec2:DescribeInstances'], resources: ['*'] }));
|
|
194
195
|
fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['iam:PassRole'], resources: [instanceRole.roleArn] }));
|
|
196
|
+
// Only its own machines (tagged at launch) can be terminated: the ones left without a job.
|
|
197
|
+
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
198
|
+
actions: ['ec2:TerminateInstances'], resources: [`arn:aws:ec2:${region}:${account}:instance/*`],
|
|
199
|
+
conditions: { StringEquals: { 'aws:ResourceTag/runner-switch': name } },
|
|
200
|
+
}));
|
|
195
201
|
this.webhookUrl = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE }).url;
|
|
196
202
|
new events.Rule(this, 'UsageCheck', {
|
|
197
203
|
schedule: events.Schedule.rate(props.usageCheckInterval ?? aws_cdk_lib_1.Duration.minutes(30)),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdk-github-runner-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AWS CDK construct: GitHub Actions runners that switch from GitHub-hosted free minutes to disposable EC2 Spot (Graviton) machines when the monthly minutes run out — and back when the month resets.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "uarch-solutions",
|