Skip to content

Commit 2b086a8

Browse files
feat(microvm): tag runner metadata
1 parent fbf3556 commit 2b086a8

15 files changed

Lines changed: 523 additions & 46 deletions

File tree

lambdas/functions/control-plane/src/scale-runners/github-runner.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,15 @@ function addDelay(runnerIds: string[]) {
264264
return { isDelay, delay };
265265
}
266266

267+
function mergeSsmParameterTags(
268+
configuredTags: CreateGitHubRunnerConfig['ssmParameterStoreTags'],
269+
providerTags: CreateGitHubRunnerConfig['ssmParameterStoreTags'],
270+
): CreateGitHubRunnerConfig['ssmParameterStoreTags'] {
271+
const tagsByKey = new Map(configuredTags.map(({ Key, Value }) => [Key, Value]));
272+
for (const { Key, Value } of providerTags) tagsByKey.set(Key, Value);
273+
return [...tagsByKey].map(([Key, Value]) => ({ Key, Value }));
274+
}
275+
267276
/**
268277
* Creates registration token configuration for non-ephemeral runners.
269278
*
@@ -285,7 +294,10 @@ async function createRegistrationTokenConfig(
285294

286295
for (const runnerId of runnerIds) {
287296
await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerServiceConfig.join(' '), true, {
288-
tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags],
297+
tags: mergeSsmParameterTags(
298+
githubRunnerConfig.ssmParameterStoreTags,
299+
options.getSsmParameterTags?.(runnerId) ?? [],
300+
),
289301
});
290302
if (isDelay) {
291303
// Delay to prevent AWS ssm rate limits by being within the max throughput limit
@@ -352,7 +364,10 @@ async function createJitConfig(
352364
instance: runnerId,
353365
});
354366
await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerConfig.data.encoded_jit_config, true, {
355-
tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags],
367+
tags: mergeSsmParameterTags(
368+
githubRunnerConfig.ssmParameterStoreTags,
369+
options.getSsmParameterTags?.(runnerId) ?? [],
370+
),
356371
});
357372
if (isDelay) {
358373
// Delay to prevent AWS ssm rate limits by being within the max throughput limit

lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,25 @@ describe('scaleUp with GHES', () => {
409409
});
410410
});
411411

412+
it.each([true, false])(
413+
'keeps the provider runner identity tag authoritative for ephemeral=%s',
414+
async (ephemeral) => {
415+
process.env.ENABLE_EPHEMERAL_RUNNERS = String(ephemeral);
416+
process.env.RUNNERS_MAXIMUM_COUNT = '2';
417+
process.env.SSM_PARAMETER_STORE_TAGS = JSON.stringify([
418+
{ Key: 'RunnerId', Value: 'configured-value-cannot-win' },
419+
{ Key: 'CostCenter', Value: '1234' },
420+
]);
421+
422+
await scaleUpModule.scaleUp(TEST_DATA);
423+
424+
expect(mockSSMClient.commandCalls(PutParameterCommand)[0].args[0].input.Tags).toEqual([
425+
{ Key: 'RunnerId', Value: 'i-12345' },
426+
{ Key: 'CostCenter', Value: '1234' },
427+
]);
428+
},
429+
);
430+
412431
it('quotes runner labels with semicolon separators in non-ephemeral runner config', async () => {
413432
process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
414433
process.env.RUNNERS_MAXIMUM_COUNT = '2';

lambdas/libs/aws-ssm-util/src/index.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AddTagsToResourceCommand,
23
DeleteParameterCommand,
34
GetParameterCommand,
45
GetParameterCommandOutput,
@@ -13,6 +14,7 @@ import { mockClient } from 'aws-sdk-client-mock';
1314
import nock from 'nock';
1415

1516
import {
17+
addParameterTags,
1618
deleteParameter,
1719
getParameter,
1820
getParameters,
@@ -329,6 +331,30 @@ describe('Test direct parameter path operations', () => {
329331

330332
expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: '/metadata/one' });
331333
});
334+
335+
it('adds tags to an exact parameter name', async () => {
336+
mockSSMClient.on(AddTagsToResourceCommand).resolves({});
337+
338+
await addParameterTags('/metadata/one', [{ Key: 'ghr:environment', Value: 'unit-test' }]);
339+
340+
expect(mockSSMClient).toHaveReceivedCommandWith(AddTagsToResourceCommand, {
341+
ResourceType: 'Parameter',
342+
ResourceId: '/metadata/one',
343+
Tags: [{ Key: 'ghr:environment', Value: 'unit-test' }],
344+
});
345+
});
346+
347+
it('does not call SSM when there are no parameter tags to add', async () => {
348+
await addParameterTags('/metadata/one', []);
349+
350+
expect(mockSSMClient).not.toHaveReceivedCommand(AddTagsToResourceCommand);
351+
});
352+
353+
it('propagates failures when adding parameter tags', async () => {
354+
mockSSMClient.on(AddTagsToResourceCommand).rejects(new Error('AccessDenied'));
355+
356+
await expect(addParameterTags('/metadata/one', [{ Key: 'Name', Value: 'runner' }])).rejects.toThrow('AccessDenied');
357+
});
332358
});
333359

334360
describe('SSM client configuration', () => {

lambdas/libs/aws-ssm-util/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AddTagsToResourceCommand,
23
DeleteParameterCommand,
34
GetParametersByPathCommand,
45
GetParametersCommand,
@@ -146,6 +147,18 @@ export async function deleteParameter(parameter_name: string): Promise<void> {
146147
await ssmClient().send(new DeleteParameterCommand({ Name: parameter_name }));
147148
}
148149

150+
export async function addParameterTags(parameter_name: string, tags: Tag[]): Promise<void> {
151+
if (tags.length === 0) return;
152+
153+
await ssmClient().send(
154+
new AddTagsToResourceCommand({
155+
ResourceType: 'Parameter',
156+
ResourceId: parameter_name,
157+
Tags: tags,
158+
}),
159+
);
160+
}
161+
149162
export const SSM_ADVANCED_TIER_THRESHOLD = 4000;
150163

151164
type PutParameterOptions = { overwrite: true; tags?: never } | { overwrite?: false | undefined; tags?: Tag[] };

lambdas/libs/compute-providers/aws/microvm/README.md

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The MicroVM image `/run` hook receives this `runHookPayload`:
1111
}
1212
```
1313

14-
Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `<runnerConfigSsmPath>/<microvmId>`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and terminate the MicroVM after the job completes.
14+
Lambda adds `microvmId` beside that payload. The image must poll the SecureString parameter at `<runnerConfigSsmPath>/<microvmId>`, start the GitHub runner with its encoded JIT configuration, delete the parameter after reading it, and exit its lifecycle entrypoint after the job completes. Trusted control-plane cleanup and the fixed lifetime remain termination backstops.
1515

1616
Runner ownership and lifecycle state are stored separately as non-secret `String`
1717
parameters under `<MICROVM_METADATA_SSM_PATH>/<microvmId>`. The immutable base
@@ -22,6 +22,19 @@ overlap the JIT path, and do not grant the MicroVM execution role access to it.
2222
The control plane retries pending cleanup, removes metadata after termination,
2323
and reconciles expired records during inventory.
2424

25+
The immutable base metadata parameter is also the canonical tag surface for a
26+
runner. It merges `SSM_PARAMETER_STORE_TAGS` with the Terraform-generated
27+
`MICROVM_METADATA_TAGS`. Terraform supplies `Name`, `ghr:environment`,
28+
`ghr:ssm_config_path`, and `ghr:runner_name_prefix`; the Lambda then adds
29+
authoritative runtime tags:
30+
`ghr:Application`, `ghr:created_by`, `ghr:environment`, `ghr:Owner`,
31+
`ghr:Type`, `ghr:microvm_id`, `ghr:microvm_image_arn`, and, when available,
32+
`ghr:microvm_image_version`. After JIT registration, the control plane adds
33+
`ghr:github_runner_id` and base64url-encoded runner-label groups under
34+
`ghr:runner_labels` through `ghr:runner_labels:5`. Runtime-owned values override
35+
configured collisions. The `aws:` tag prefix is reserved and cannot be used for
36+
these SSM parameters.
37+
2538
The control-plane Lambda requires these provider environment variables:
2639

2740
- `MICROVM_IMAGE_ARN`
@@ -30,31 +43,33 @@ The control-plane Lambda requires these provider environment variables:
3043
- `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
3144
- `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
3245
- `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata)
46+
- `MICROVM_METADATA_TAGS` (optional JSON array of base tags for the canonical metadata parameter)
3347
- `MICROVM_LOG_GROUP` (optional)
3448

3549
Each runner is launched with a fixed lifetime of 28,800 seconds (8 hours).
3650

3751
The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`,
38-
and `ssm:DeleteParameter` on the dedicated metadata prefix, plus
39-
`lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for
40-
inventory and lifecycle reconciliation. Restrict `lambda:RunMicrovm` and
41-
`lambda:TerminateMicrovm` to approved image resources; `lambda:ListMicrovms`
42-
does not support resource-level permissions.
52+
`ssm:AddTagsToResource`, and `ssm:DeleteParameter` on the dedicated metadata
53+
prefix, plus `lambda:ListMicrovms`, `lambda:RunMicrovm`, and
54+
`lambda:TerminateMicrovm` for inventory and lifecycle reconciliation. Restrict
55+
`lambda:RunMicrovm` and `lambda:TerminateMicrovm` to approved image resources;
56+
`lambda:ListMicrovms` does not support resource-level permissions.
4357

4458
The MicroVM execution role must trust `lambda.amazonaws.com` for both
4559
`sts:AssumeRole` and `sts:TagSession`. Restrict `iam:PassRole` to that exact role
46-
with `iam:PassedToService=lambda.amazonaws.com`. Egress connectors also require
47-
`lambda:PassNetworkConnector`; because that action does not currently support
48-
resource-level permissions, enforce the connector boundary with the explicit
49-
dynamic-label allowlist described below.
60+
ARN. Network connectors also require `lambda:PassNetworkConnector`; because
61+
that action does not currently support resource-level permissions, enforce the
62+
connector boundary with the explicit dynamic-label allowlist described below.
5063

5164
All MicroVMs using one execution role and JIT prefix share a trust boundary.
52-
Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the JIT
53-
prefix; do not grant parameter-listing APIs or access to the metadata prefix.
54-
The `MicrovmId` tag on each JIT parameter supports operations but is not a
55-
documented binding to the calling MicroVM's session identity. Only allow trusted
56-
images and workloads within a shared role, or isolate trust domains with
57-
separate roles, prefixes, and provider deployments.
65+
Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the
66+
lane-scoped JIT prefix. The image must use the
67+
exact `<runnerConfigSsmPath>/<microvmId>` parameter name and must not receive
68+
access to the metadata prefix or path-listing APIs. The `MicrovmId` tag on each
69+
JIT parameter supports operations but is not a documented binding to the
70+
calling MicroVM's session identity. Only allow trusted images and workloads
71+
within a shared role, or isolate trust domains with separate roles, prefixes,
72+
and provider deployments.
5873

5974
## Dynamic labels
6075

lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ beforeEach(() => {
99
process.env.MICROVM_IMAGE_ARN = 'arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner';
1010
process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/microvm-runner';
1111
process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/';
12+
delete process.env.MICROVM_METADATA_TAGS;
1213
delete process.env.MICROVM_IMAGE_VERSION;
1314
delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS;
1415
delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS;
@@ -24,6 +25,7 @@ describe('loadMicrovmProviderConfig', () => {
2425
ingressNetworkConnectors: undefined,
2526
egressNetworkConnectors: undefined,
2627
metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata',
28+
metadataTags: [],
2729
logging: undefined,
2830
});
2931
});
@@ -33,11 +35,19 @@ describe('loadMicrovmProviderConfig', () => {
3335
process.env.MICROVM_INGRESS_NETWORK_CONNECTORS = '["arn:ingress:one","arn:ingress:two"]';
3436
process.env.MICROVM_EGRESS_NETWORK_CONNECTORS = 'arn:egress:one, arn:egress:two';
3537
process.env.MICROVM_LOG_GROUP = ' /aws/lambda-microvms/runner ';
38+
process.env.MICROVM_METADATA_TAGS = JSON.stringify([
39+
{ Key: 'Name', Value: 'unit-test-runner' },
40+
{ Key: 'ghr:environment', Value: 'unit-test' },
41+
]);
3642

3743
expect(loadMicrovmProviderConfig()).toMatchObject({
3844
imageVersion: '3.0',
3945
ingressNetworkConnectors: ['arn:ingress:one', 'arn:ingress:two'],
4046
egressNetworkConnectors: ['arn:egress:one', 'arn:egress:two'],
47+
metadataTags: [
48+
{ Key: 'Name', Value: 'unit-test-runner' },
49+
{ Key: 'ghr:environment', Value: 'unit-test' },
50+
],
4151
logging: { cloudWatch: { logGroup: '/aws/lambda-microvms/runner' } },
4252
});
4353
});
@@ -70,4 +80,17 @@ describe('loadMicrovmProviderConfig', () => {
7080
);
7181
},
7282
);
83+
84+
it.each([
85+
'[not-json',
86+
'{}',
87+
'[{"Key":"Name"}]',
88+
'[{"Key":"","Value":"runner"}]',
89+
'[{"Key":"Name","Value":1}]',
90+
'[{"Key":"Name","Value":"one"},{"Key":"Name","Value":"two"}]',
91+
])('rejects malformed metadata tags %s', (tags) => {
92+
process.env.MICROVM_METADATA_TAGS = tags;
93+
94+
expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_METADATA_TAGS must/);
95+
});
7396
});

lambdas/libs/compute-providers/aws/microvm/src/control-plane/config.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { Logging, RunMicrovmCommandInput } from '@aws-sdk/client-lambda-microvms';
22

3+
export interface MicrovmMetadataTag {
4+
Key: string;
5+
Value: string;
6+
}
7+
38
export interface MicrovmProviderConfig {
49
egressNetworkConnectors?: string[];
510
executionRoleArn: string;
@@ -8,6 +13,7 @@ export interface MicrovmProviderConfig {
813
ingressNetworkConnectors?: string[];
914
logging?: Logging;
1015
metadataSsmPath: string;
16+
metadataTags: MicrovmMetadataTag[];
1117
}
1218

1319
function requiredEnvironmentValue(name: string, value: string | undefined): string {
@@ -55,6 +61,41 @@ function parseNetworkConnectors(name: string, value: string | undefined): string
5561
return connectors.map((connector) => connector.trim());
5662
}
5763

64+
function parseMetadataTags(value: string | undefined): MicrovmMetadataTag[] {
65+
const configuredValue = optionalEnvironmentValue(value);
66+
if (!configuredValue) return [];
67+
68+
let tags: unknown;
69+
try {
70+
tags = JSON.parse(configuredValue);
71+
} catch (error) {
72+
throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects', { cause: error });
73+
}
74+
75+
if (
76+
!Array.isArray(tags) ||
77+
tags.some(
78+
(tag) =>
79+
typeof tag !== 'object' ||
80+
tag === null ||
81+
!('Key' in tag) ||
82+
typeof tag.Key !== 'string' ||
83+
tag.Key.length === 0 ||
84+
!('Value' in tag) ||
85+
typeof tag.Value !== 'string',
86+
)
87+
) {
88+
throw new Error('MICROVM_METADATA_TAGS must be a JSON array of SSM tag objects');
89+
}
90+
91+
const typedTags = tags as MicrovmMetadataTag[];
92+
if (new Set(typedTags.map((tag) => tag.Key)).size !== typedTags.length) {
93+
throw new Error('MICROVM_METADATA_TAGS must not contain duplicate tag keys');
94+
}
95+
96+
return typedTags;
97+
}
98+
5899
export function loadMicrovmProviderConfig(): MicrovmProviderConfig {
59100
const logGroup = optionalEnvironmentValue(process.env.MICROVM_LOG_GROUP);
60101

@@ -71,6 +112,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig {
71112
process.env.MICROVM_EGRESS_NETWORK_CONNECTORS,
72113
),
73114
metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH),
115+
metadataTags: parseMetadataTags(process.env.MICROVM_METADATA_TAGS),
74116
logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined,
75117
};
76118
}

0 commit comments

Comments
 (0)