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 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 only launch, tag and list instances. |
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 só lança, marca e lista máquinas. |
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 activeByLabel(c) {
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
- const out = {};
169
- for (const i of (r.Reservations ?? []).flatMap((x) => x.Instances ?? [])) {
170
- const label = i.Tags?.find((t) => t.Key === 'runner-label')?.Value ?? c.label;
171
- out[label] = (out[label] ?? 0) + 1;
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
- async function launch(c, jobId, onDemand, jit) {
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: `aws-${jobId}-${Date.now().toString(36)}`, runner_group_id: c.runnerGroupId, work_folder: '_work',
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 = await activeByLabel(c);
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)) {
@@ -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 */
@@ -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.2.1",
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",