Skip to content

Commit f10a155

Browse files
fix(compute-providers): replace unsupported MicroVM tags
1 parent 979c88d commit f10a155

18 files changed

Lines changed: 1202 additions & 382 deletions

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

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
2+
DeleteParameterCommand,
23
GetParameterCommand,
34
GetParameterCommandOutput,
5+
GetParametersByPathCommand,
46
GetParametersCommand,
57
PutParameterCommand,
68
PutParameterCommandOutput,
@@ -10,7 +12,16 @@ import 'aws-sdk-client-mock-jest/vitest';
1012
import { mockClient } from 'aws-sdk-client-mock';
1113
import nock from 'nock';
1214

13-
import { getParameter, getParameters, putParameter, resetSSMClient, ssmClient, SSM_ADVANCED_TIER_THRESHOLD } from '.';
15+
import {
16+
deleteParameter,
17+
getParameter,
18+
getParameters,
19+
getParametersByPath,
20+
putParameter,
21+
resetSSMClient,
22+
ssmClient,
23+
SSM_ADVANCED_TIER_THRESHOLD,
24+
} from '.';
1425
import { describe, it, expect, beforeEach, vi } from 'vitest';
1526

1627
const mockSSMClient = mockClient(SSMClient);
@@ -104,6 +115,30 @@ describe('Test getParameter and putParameter', () => {
104115
});
105116
});
106117

118+
it('overwrites a parameter only when explicitly requested', async () => {
119+
mockSSMClient.on(PutParameterCommand).resolves({});
120+
121+
await putParameter('testParam', 'updated', false, { overwrite: true });
122+
123+
expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
124+
Name: 'testParam',
125+
Value: 'updated',
126+
Type: 'String',
127+
Overwrite: true,
128+
});
129+
});
130+
131+
it('rejects tags when overwriting an existing parameter', async () => {
132+
mockSSMClient.resetHistory();
133+
await expect(
134+
putParameter('testParam', 'updated', false, {
135+
overwrite: true,
136+
tags: [{ Key: 'owner', Value: 'runner' }],
137+
} as never),
138+
).rejects.toThrow('tags cannot be supplied when overwriting');
139+
expect(mockSSMClient).not.toHaveReceivedCommand(PutParameterCommand);
140+
});
141+
107142
it('Puts parameters as SecureString', async () => {
108143
// Arrange
109144
const parameterValue = 'test';
@@ -256,6 +291,46 @@ describe('Test getParameters (batch)', () => {
256291
});
257292
});
258293

294+
describe('Test direct parameter path operations', () => {
295+
beforeEach(() => {
296+
mockSSMClient.reset();
297+
});
298+
299+
it('paginates direct, non-secret children of a parameter path', async () => {
300+
mockSSMClient
301+
.on(GetParametersByPathCommand, {
302+
Path: '/metadata',
303+
Recursive: false,
304+
WithDecryption: false,
305+
NextToken: undefined,
306+
})
307+
.resolves({ Parameters: [{ Name: '/metadata/one', Value: '1' }], NextToken: 'page-2' })
308+
.on(GetParametersByPathCommand, {
309+
Path: '/metadata',
310+
Recursive: false,
311+
WithDecryption: false,
312+
NextToken: 'page-2',
313+
})
314+
.resolves({ Parameters: [{ Name: '/metadata/two', Value: '2' }] });
315+
316+
await expect(getParametersByPath('/metadata')).resolves.toEqual(
317+
new Map([
318+
['/metadata/one', '1'],
319+
['/metadata/two', '2'],
320+
]),
321+
);
322+
expect(mockSSMClient).toHaveReceivedCommandTimes(GetParametersByPathCommand, 2);
323+
});
324+
325+
it('deletes an exact parameter name', async () => {
326+
mockSSMClient.on(DeleteParameterCommand).resolves({});
327+
328+
await deleteParameter('/metadata/one');
329+
330+
expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: '/metadata/one' });
331+
});
332+
});
333+
259334
describe('SSM client configuration', () => {
260335
it('configures adaptive retry with a raised attempt cap', async () => {
261336
const config = ssmClient().config;

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

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { GetParametersCommand, PutParameterCommand, SSMClient, Tag } from '@aws-sdk/client-ssm';
1+
import {
2+
DeleteParameterCommand,
3+
GetParametersByPathCommand,
4+
GetParametersCommand,
5+
PutParameterCommand,
6+
SSMClient,
7+
Tag,
8+
} from '@aws-sdk/client-ssm';
29
import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util';
310
import { SSMProvider } from '@aws-lambda-powertools/parameters/ssm';
411

@@ -103,14 +110,56 @@ export async function getParameters(parameter_names: string[]): Promise<Map<stri
103110
return result;
104111
}
105112

113+
/**
114+
* Retrieves every direct child of an SSM Parameter Store path.
115+
*
116+
* Values are returned without decryption because this helper is intended for
117+
* non-secret provider metadata. API failures are propagated so callers do not
118+
* mistake an authorization or throttling failure for an empty path.
119+
*/
120+
export async function getParametersByPath(parameter_path: string): Promise<Map<string, string>> {
121+
const result = new Map<string, string>();
122+
let nextToken: string | undefined;
123+
124+
do {
125+
const response = await ssmClient().send(
126+
new GetParametersByPathCommand({
127+
Path: parameter_path,
128+
Recursive: false,
129+
WithDecryption: false,
130+
NextToken: nextToken,
131+
}),
132+
);
133+
134+
for (const parameter of response.Parameters ?? []) {
135+
if (parameter.Name && parameter.Value) {
136+
result.set(parameter.Name, parameter.Value);
137+
}
138+
}
139+
nextToken = response.NextToken;
140+
} while (nextToken);
141+
142+
return result;
143+
}
144+
145+
export async function deleteParameter(parameter_name: string): Promise<void> {
146+
await ssmClient().send(new DeleteParameterCommand({ Name: parameter_name }));
147+
}
148+
106149
export const SSM_ADVANCED_TIER_THRESHOLD = 4000;
107150

151+
type PutParameterOptions = { overwrite: true; tags?: never } | { overwrite?: false | undefined; tags?: Tag[] };
152+
108153
export async function putParameter(
109154
parameter_name: string,
110155
parameter_value: string,
111156
secure: boolean,
112-
options: { tags?: Tag[] } = {},
157+
options: PutParameterOptions = {},
113158
): Promise<void> {
159+
if (options.overwrite && options.tags !== undefined) {
160+
throw new Error('SSM parameter tags cannot be supplied when overwriting an existing parameter');
161+
}
162+
114163
const client = ssmClient();
115164

116165
// Determine tier based on parameter_value size
@@ -121,6 +170,7 @@ export async function putParameter(
121170
Name: parameter_name,
122171
Value: parameter_value,
123172
Type: secure ? 'SecureString' : 'String',
173+
Overwrite: options.overwrite,
124174
Tags: options.tags,
125175
Tier: valueSizeBytes >= SSM_ADVANCED_TIER_THRESHOLD ? 'Advanced' : 'Standard',
126176
}),

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

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ The MicroVM image `/run` hook receives this `runHookPayload`:
1313

1414
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.
1515

16+
Runner ownership and lifecycle state are stored separately as non-secret `String`
17+
parameters under `<MICROVM_METADATA_SSM_PATH>/<microvmId>`. The immutable base
18+
record and independent state parameters prevent concurrent GitHub ID, orphan,
19+
and cleanup updates from overwriting one another. Deleting the JIT SecureString
20+
does not delete this metadata. Use a dedicated metadata prefix that does not
21+
overlap the JIT path, and do not grant the MicroVM execution role access to it.
22+
The control plane retries pending cleanup, removes metadata after termination,
23+
and reconciles expired records during inventory.
24+
1625
The control-plane Lambda requires these provider environment variables:
1726

1827
- `MICROVM_IMAGE_ARN`
@@ -21,8 +30,31 @@ The control-plane Lambda requires these provider environment variables:
2130
- `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
2231
- `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
2332
- `MICROVM_MAXIMUM_DURATION_IN_SECONDS` (optional, defaults to 3600)
33+
- `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata)
2434
- `MICROVM_LOG_GROUP` (optional)
2535

36+
The control-plane role requires `ssm:GetParametersByPath`, `ssm:PutParameter`,
37+
and `ssm:DeleteParameter` on the dedicated metadata prefix, plus
38+
`lambda:ListMicrovms`, `lambda:RunMicrovm`, and `lambda:TerminateMicrovm` for
39+
inventory and lifecycle reconciliation. Restrict `lambda:RunMicrovm` and
40+
`lambda:TerminateMicrovm` to approved image resources; `lambda:ListMicrovms`
41+
does not support resource-level permissions.
42+
43+
The MicroVM execution role must trust `lambda.amazonaws.com` for both
44+
`sts:AssumeRole` and `sts:TagSession`. Restrict `iam:PassRole` to that exact role
45+
with `iam:PassedToService=lambda.amazonaws.com`. Egress connectors also require
46+
`lambda:PassNetworkConnector`; because that action does not currently support
47+
resource-level permissions, enforce the connector boundary with the explicit
48+
dynamic-label allowlist described below.
49+
50+
All MicroVMs using one execution role and JIT prefix share a trust boundary.
51+
Grant that role only `ssm:GetParameter` and `ssm:DeleteParameter` on the JIT
52+
prefix; do not grant parameter-listing APIs or access to the metadata prefix.
53+
The `MicrovmId` tag on each JIT parameter supports operations but is not a
54+
documented binding to the calling MicroVM's session identity. Only allow trusted
55+
images and workloads within a shared role, or isolate trust domains with
56+
separate roles, prefixes, and provider deployments.
57+
2658
## Dynamic labels
2759

2860
When a runner matcher enables dynamic labels, workflow jobs can override the
@@ -45,9 +77,10 @@ image and version with the required resources instead. Labels such as
4577
`ghr-microvm-memory` are rejected.
4678

4779
Execution roles, ingress network connectors, logging, idle policy, run hook
48-
payloads, and client tokens remain deployment-controlled. Egress connector
49-
overrides change the runner's network boundary and should be restricted to
50-
approved connector ARNs with `awsDynamicLabelsPolicy`.
80+
payloads, and client tokens remain deployment-controlled. Image ARN, image
81+
version, and egress connector overrides change executable code or the network
82+
boundary, so they are rejected unless `awsDynamicLabelsPolicy` supplies an
83+
explicit `allowed` list for the corresponding key.
5184

5285
Use the matcher's `awsDynamicLabelsPolicy` to restrict values accepted from
5386
workflow jobs. The MicroVM policy keys are `egress-network-connectors`,
@@ -62,6 +95,9 @@ workflow jobs. The MicroVM policy keys are `egress-network-connectors`,
6295
"image-arn": {
6396
"allowed": ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-*"]
6497
},
98+
"image-version": {
99+
"allowed": ["3.*"]
100+
},
65101
"maximum-duration-in-seconds": {
66102
"max": 3600
67103
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ beforeEach(() => {
88
process.env = { ...cleanEnv };
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';
11+
process.env.MICROVM_METADATA_SSM_PATH = '/github-action-runners/unit-test/microvm-metadata/';
1112
delete process.env.MICROVM_IMAGE_VERSION;
1213
delete process.env.MICROVM_INGRESS_NETWORK_CONNECTORS;
1314
delete process.env.MICROVM_EGRESS_NETWORK_CONNECTORS;
@@ -24,6 +25,7 @@ describe('loadMicrovmProviderConfig', () => {
2425
ingressNetworkConnectors: undefined,
2526
egressNetworkConnectors: undefined,
2627
maximumDurationInSeconds: 3600,
28+
metadataSsmPath: '/github-action-runners/unit-test/microvm-metadata',
2729
logging: undefined,
2830
});
2931
});
@@ -47,6 +49,7 @@ describe('loadMicrovmProviderConfig', () => {
4749
it.each([
4850
['MICROVM_IMAGE_ARN', 'MICROVM_IMAGE_ARN'],
4951
['MICROVM_EXECUTION_ROLE_ARN', 'MICROVM_EXECUTION_ROLE_ARN'],
52+
['MICROVM_METADATA_SSM_PATH', 'MICROVM_METADATA_SSM_PATH'],
5053
])('requires %s', (environmentVariable, expectedName) => {
5154
delete process.env[environmentVariable];
5255

@@ -68,4 +71,15 @@ describe('loadMicrovmProviderConfig', () => {
6871

6972
expect(() => loadMicrovmProviderConfig()).toThrow(/MICROVM_EGRESS_NETWORK_CONNECTORS must/);
7073
});
74+
75+
it.each(['metadata', '/', '/metadata//nested', '/metadata/has space'])(
76+
'rejects malformed metadata SSM path %s',
77+
(metadataPath) => {
78+
process.env.MICROVM_METADATA_SSM_PATH = metadataPath;
79+
80+
expect(() => loadMicrovmProviderConfig()).toThrow(
81+
'MICROVM_METADATA_SSM_PATH must be a valid absolute SSM parameter path',
82+
);
83+
},
84+
);
7185
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface MicrovmProviderConfig {
1111
ingressNetworkConnectors?: string[];
1212
logging?: Logging;
1313
maximumDurationInSeconds: number;
14+
metadataSsmPath: string;
1415
}
1516

1617
function requiredEnvironmentValue(name: string, value: string | undefined): string {
@@ -26,6 +27,14 @@ function optionalEnvironmentValue(value: string | undefined): string | undefined
2627
return trimmed ? trimmed : undefined;
2728
}
2829

30+
function parseMetadataSsmPath(value: string | undefined): string {
31+
const path = requiredEnvironmentValue('MICROVM_METADATA_SSM_PATH', value).replace(/\/+$/, '');
32+
if (path === '' || !/^\/[A-Za-z0-9_.\-/]+$/.test(path) || path.includes('//')) {
33+
throw new Error('MICROVM_METADATA_SSM_PATH must be a valid absolute SSM parameter path');
34+
}
35+
return path;
36+
}
37+
2938
function parseNetworkConnectors(name: string, value: string | undefined): string[] | undefined {
3039
const configuredValue = optionalEnvironmentValue(value);
3140
if (!configuredValue) return undefined;
@@ -83,6 +92,7 @@ export function loadMicrovmProviderConfig(): MicrovmProviderConfig {
8392
process.env.MICROVM_EGRESS_NETWORK_CONNECTORS,
8493
),
8594
maximumDurationInSeconds: parseMaximumDuration(process.env.MICROVM_MAXIMUM_DURATION_IN_SECONDS),
95+
metadataSsmPath: parseMetadataSsmPath(process.env.MICROVM_METADATA_SSM_PATH),
8696
logging: logGroup ? ({ cloudWatch: { logGroup } } satisfies RunMicrovmCommandInput['logging']) : undefined,
8797
};
8898
}

0 commit comments

Comments
 (0)