Skip to content

Commit dbb63c8

Browse files
authored
feat(ec2): add tags option to Ec2RunnerProvider (#961)
## Summary Adds an `tags` prop to `Ec2RunnerProvider` that merges extra tags into the existing `RunInstances` `TagSpecifications` (instance + volume). **Why launch-time tags (not job hooks):** security monitoring that enrolls hosts by tag needs the tag present from the moment the instance exists. Tagging from `ACTIONS_RUNNER_HOOK_JOB_STARTED` (the workaround from #524) runs only after the job starts, which is too late for short-lived ephemeral runners — the instance may terminate before the agent is installed. `cdk.Tags.of()` also does not help, because these instances are created dynamically by Step Functions, not as CloudFormation resources. This builds on the tags already applied in #909 (`Name`, `GitHubRunners:*`) and makes the same mechanism extensible. ## API ```ts new Ec2RunnerProvider(this, 'EC2', { labels: ['ec2'], tags: { SecurityMonitoring: 'enabled', }, }); ``` ## Testing - Unit tests cover TagSpecifications merge for instance + volume, and reserved-key rejection - `pnpm exec jest test/providers.test.ts` passes - `API.md` regenerated via `pnpm docgen` Related: #524 (custom EC2 tags at launch time). Made with [Cursor](https://cursor.com)
1 parent 5ef725b commit dbb63c8

4 files changed

Lines changed: 110 additions & 42 deletions

File tree

API.md

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/providers/ec2.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,16 @@ export interface Ec2RunnerProviderProps extends RunnerProviderProps {
329329
* @default no max price (you will pay current spot price)
330330
*/
331331
readonly spotMaxPrice?: string;
332+
333+
/**
334+
* Additional tags to apply to launched runner instances and their volumes.
335+
*
336+
* These additional tags are set on top of `Name`, `GitHubRunners:Provider`, `GitHubRunners:Repo`, and `GitHubRunners:Labels`.
337+
* You may override the built-in tags.
338+
*
339+
* @default no additional tags
340+
*/
341+
readonly tags?: { [key: string]: string };
332342
}
333343

334344
/**
@@ -410,6 +420,7 @@ export class Ec2RunnerProvider extends BaseProvider implements IRunnerProvider {
410420
private readonly subnets: ec2.ISubnet[];
411421
private readonly securityGroups: ec2.ISecurityGroup[];
412422
private readonly defaultLabels: boolean;
423+
private readonly tags: { [key: string]: string };
413424

414425
constructor(scope: Construct, id: string, props?: Ec2RunnerProviderProps) {
415426
super(scope, id, props);
@@ -425,6 +436,7 @@ export class Ec2RunnerProvider extends BaseProvider implements IRunnerProvider {
425436
this.spot = props?.spot ?? false;
426437
this.spotMaxPrice = props?.spotMaxPrice;
427438
this.defaultLabels = props?.defaultLabels ?? true;
439+
this.tags = props?.tags ?? {};
428440

429441
if (this.subnets.length === 0) {
430442
cdk.Annotations.of(this).addError('At least one subnet is required');
@@ -517,6 +529,15 @@ export class Ec2RunnerProvider extends BaseProvider implements IRunnerProvider {
517529
// we build a complicated chain of states here because ec2:RunInstances can only try one subnet at a time
518530
// if someone can figure out a good way to use Map for this, please open a PR
519531

532+
// calculate tags
533+
const tags = {
534+
'Name': parameters.runnerNamePath,
535+
'GitHubRunners:Provider': this.node.path,
536+
'GitHubRunners:Repo': stepfunctions.JsonPath.format('{}/{}', parameters.ownerPath, parameters.repoPath),
537+
'GitHubRunners:Labels': parameters.labelsPath,
538+
...this.tags,
539+
};
540+
520541
// build a state for each subnet we want to try
521542
const instanceProfile = new iam.CfnInstanceProfile(this, 'Instance Profile', {
522543
roles: [this.role.roleName],
@@ -577,24 +598,7 @@ export class Ec2RunnerProvider extends BaseProvider implements IRunnerProvider {
577598
TagSpecifications: ['instance', 'volume'].map(resType => { // manually propagate tags
578599
return {
579600
ResourceType: resType,
580-
Tags: [
581-
{
582-
Key: 'Name',
583-
Value: parameters.runnerNamePath,
584-
},
585-
{
586-
Key: 'GitHubRunners:Provider',
587-
Value: this.node.path,
588-
},
589-
{
590-
Key: 'GitHubRunners:Repo',
591-
Value: stepfunctions.JsonPath.format('{}/{}', parameters.ownerPath, parameters.repoPath),
592-
},
593-
{
594-
Key: 'GitHubRunners:Labels',
595-
Value: parameters.labelsPath,
596-
},
597-
],
601+
Tags: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })),
598602
};
599603
}),
600604
},

test/providers.test.ts

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Os,
1515
RunnerVersion,
1616
} from '../src';
17+
import { stateMachineDefinition } from './sfn-helpers';
1718

1819
describe('Providers', () => {
1920
let app: cdk.App;
@@ -300,18 +301,7 @@ describe('Providers', () => {
300301

301302
const template = Template.fromStack(stack);
302303

303-
function extractStateMachineDefinition(tmpl: Template): string {
304-
const sms = tmpl.findResources('AWS::StepFunctions::StateMachine');
305-
const smKeys = Object.keys(sms);
306-
if (smKeys.length !== 1) throw new Error(`expected 1 SM, got ${smKeys.length}`);
307-
const sm = sms[smKeys[0]];
308-
const def = sm.Properties.DefinitionString;
309-
const parts = def?.['Fn::Join']?.[1];
310-
if (!Array.isArray(parts)) throw new Error('unexpected DefinitionString shape');
311-
return parts.map((p: any) => (typeof p === 'string' ? p : '')).join('');
312-
}
313-
314-
const def = JSON.parse(extractStateMachineDefinition(template));
304+
const def = stateMachineDefinition(template);
315305
const ecsPlacement = def?.States?.providerPlacement;
316306
expect(ecsPlacement?.Type).toBe('Task');
317307
const ps = ecsPlacement?.Parameters?.PlacementStrategy;
@@ -353,18 +343,7 @@ describe('Providers', () => {
353343

354344
const template = Template.fromStack(stack);
355345

356-
function extractStateMachineDefinition(tmpl: Template): string {
357-
const sms = tmpl.findResources('AWS::StepFunctions::StateMachine');
358-
const smKeys = Object.keys(sms);
359-
if (smKeys.length !== 1) throw new Error(`expected 1 SM, got ${smKeys.length}`);
360-
const sm = sms[smKeys[0]];
361-
const def = sm.Properties.DefinitionString;
362-
const parts = def?.['Fn::Join']?.[1];
363-
if (!Array.isArray(parts)) throw new Error('unexpected DefinitionString shape');
364-
return parts.map((p: any) => (typeof p === 'string' ? p : '')).join('');
365-
}
366-
367-
const def = JSON.parse(extractStateMachineDefinition(template));
346+
const def = stateMachineDefinition(template);
368347
const ecsTask = def?.States?.providerPlacementConstraints;
369348
expect(ecsTask?.Type).toBe('Task');
370349
const pc = ecsTask?.Parameters?.PlacementConstraints;
@@ -428,6 +407,58 @@ describe('Providers', () => {
428407
InstanceTypes: ['m6g.large'],
429408
}));
430409
});
410+
411+
test('tags are merged into RunInstances TagSpecifications', () => {
412+
const vpc = new ec2.Vpc(stack, 'vpc');
413+
const sg = new ec2.SecurityGroup(stack, 'sg', { vpc });
414+
415+
const provider = new Ec2RunnerProvider(stack, 'provider tags', {
416+
vpc,
417+
securityGroups: [sg],
418+
labels: ['ec2-tags'],
419+
tags: {
420+
SecurityMonitoring: 'enabled',
421+
Name: 'test',
422+
},
423+
});
424+
425+
const runtimeParams = {
426+
runnerTokenPath: '$.runner.token',
427+
runnerNamePath: '$$.Execution.Name',
428+
ownerPath: '$.owner',
429+
repoPath: '$.repo',
430+
registrationUrl: 'https://github.com',
431+
githubDomainPath: 'github.com',
432+
labelsPath: '$.labels',
433+
addCatchAndCleanUp: (state: sfn.State | sfn.StateMachineFragment | sfn.Parallel, next?: sfn.IChainable) => {
434+
(state as sfn.TaskStateBase | sfn.Parallel).addCatch(next ?? new sfn.Pass(stack, 'CleanupStubTags'), {
435+
errors: [sfn.Errors.ALL],
436+
resultPath: '$.error',
437+
});
438+
},
439+
};
440+
const task = provider.getStepFunctionTask(runtimeParams);
441+
442+
new sfn.StateMachine(stack, 'sm', {
443+
definitionBody: sfn.DefinitionBody.fromChainable(task),
444+
});
445+
446+
const template = Template.fromStack(stack);
447+
448+
const def = stateMachineDefinition(template);
449+
const runInstances = Object.values(def.States).find((state: any) =>
450+
state?.Parameters?.TagSpecifications,
451+
) as any;
452+
expect(runInstances).toBeDefined();
453+
454+
for (const spec of runInstances.Parameters.TagSpecifications) {
455+
expect(['instance', 'volume']).toContain(spec.ResourceType);
456+
const tags = Object.fromEntries(spec.Tags.map((t: any) => [t.Key, t.Value]));
457+
expect(tags.SecurityMonitoring).toBe('enabled');
458+
expect(tags.Name).toBe('test');
459+
expect(tags['GitHubRunners:Provider']).toBeDefined();
460+
}
461+
});
431462
});
432463

433464
test('root device resolution re-runs when the AMI recipe changes (issue #962)', () => {

test/sfn-helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// test/sfn-helpers.ts
2+
import { Template } from 'aws-cdk-lib/assertions';
3+
4+
/** Resolve a state machine's DefinitionString (literals + tokens) into its parsed JSON. */
5+
export function stateMachineDefinition(template: Template, logicalId?: string): any {
6+
const sms = template.findResources('AWS::StepFunctions::StateMachine');
7+
const keys = logicalId ? [logicalId] : Object.keys(sms);
8+
if (keys.length !== 1) throw new Error(`expected 1 state machine, got ${keys.length}`);
9+
10+
const def = sms[keys[0]].Properties.DefinitionString;
11+
const json = typeof def === 'string'
12+
? def
13+
: (def['Fn::Join'][1] as unknown[]).map(p => (typeof p === 'string' ? p : '')).join('');
14+
15+
return JSON.parse(json);
16+
}

0 commit comments

Comments
 (0)