Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7fddf8d
feat(compute-providers): add MicroVM API foundations
edersonbrilhante Aug 6, 2026
242c6ab
feat(compute-providers): add MicroVM control-plane provider
edersonbrilhante Aug 6, 2026
9a90243
feat(compute-providers): add MicroVM webhook routing
edersonbrilhante Aug 6, 2026
c70e6d1
docs(compute-providers): document Lambda MicroVM provider
edersonbrilhante Aug 6, 2026
624d551
fix(compute-providers): replace unsupported MicroVM tags
edersonbrilhante Aug 19, 2026
a724517
fix(compute-providers): make metadata cleanup idempotent
edersonbrilhante Aug 19, 2026
101f30a
fix(compute-providers): remove MicroVM duration label
edersonbrilhante Aug 19, 2026
6d831cc
fix(compute-providers): fix MicroVM lifetime at eight hours
edersonbrilhante Aug 20, 2026
cba5e4d
feat(microvm): tag runner metadata
edersonbrilhante Aug 21, 2026
ef0ab3c
feat(microvm): add runner config ARN to hook payload
edersonbrilhante Aug 21, 2026
584544f
fix(microvm): reuse runner configuration path
edersonbrilhante Aug 21, 2026
1990621
feat(microvm): extend runner lifecycle metadata
edersonbrilhante Aug 21, 2026
2458a00
fix(deps): align Lambda lockfile after rebase
edersonbrilhante Sep 2, 2026
59772ed
fix(scale-runners): log JIT setup after provider callback
edersonbrilhante Sep 3, 2026
d8fd51b
fix(scale-runners): restore provider callback ordering
edersonbrilhante Sep 3, 2026
2be9c8c
revert(scale-runners): restore JIT callback ordering
edersonbrilhante Sep 3, 2026
2f4916a
refactor(tests): keep MicroVM coverage in provider layer
edersonbrilhante Sep 3, 2026
2ced8fa
fix(microvm): use shared runner source type
edersonbrilhante Sep 3, 2026
dd08f6f
fix(scale-runners): preserve existing JIT config ordering
edersonbrilhante Sep 3, 2026
2066905
fix(microvm): read SSM settings from environment
edersonbrilhante Sep 3, 2026
6eb9ff8
chore(scale-runners): remove dummy comment
edersonbrilhante Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion lambdas/libs/aws-ssm-util/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
AddTagsToResourceCommand,
DeleteParameterCommand,
GetParameterCommand,
GetParameterCommandOutput,
GetParametersByPathCommand,
GetParametersCommand,
PutParameterCommand,
PutParameterCommandOutput,
Expand All @@ -10,7 +13,17 @@ import 'aws-sdk-client-mock-jest/vitest';
import { mockClient } from 'aws-sdk-client-mock';
import nock from 'nock';

import { getParameter, getParameters, putParameter, resetSSMClient, ssmClient, SSM_ADVANCED_TIER_THRESHOLD } from '.';
import {
addParameterTags,
deleteParameter,
getParameter,
getParameters,
getParametersByPath,
putParameter,
resetSSMClient,
ssmClient,
SSM_ADVANCED_TIER_THRESHOLD,
} from '.';
import { describe, it, expect, beforeEach, vi } from 'vitest';

const mockSSMClient = mockClient(SSMClient);
Expand Down Expand Up @@ -104,6 +117,30 @@ describe('Test getParameter and putParameter', () => {
});
});

it('overwrites a parameter only when explicitly requested', async () => {
mockSSMClient.on(PutParameterCommand).resolves({});

await putParameter('testParam', 'updated', false, { overwrite: true });

expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: 'testParam',
Value: 'updated',
Type: 'String',
Overwrite: true,
});
});

it('rejects tags when overwriting an existing parameter', async () => {
mockSSMClient.resetHistory();
await expect(
putParameter('testParam', 'updated', false, {
overwrite: true,
tags: [{ Key: 'owner', Value: 'runner' }],
} as never),
).rejects.toThrow('tags cannot be supplied when overwriting');
expect(mockSSMClient).not.toHaveReceivedCommand(PutParameterCommand);
});

it('Puts parameters as SecureString', async () => {
// Arrange
const parameterValue = 'test';
Expand Down Expand Up @@ -256,6 +293,70 @@ describe('Test getParameters (batch)', () => {
});
});

describe('Test direct parameter path operations', () => {
beforeEach(() => {
mockSSMClient.reset();
});

it('paginates direct, non-secret children of a parameter path', async () => {
mockSSMClient
.on(GetParametersByPathCommand, {
Path: '/metadata',
Recursive: false,
WithDecryption: false,
NextToken: undefined,
})
.resolves({ Parameters: [{ Name: '/metadata/one', Value: '1' }], NextToken: 'page-2' })
.on(GetParametersByPathCommand, {
Path: '/metadata',
Recursive: false,
WithDecryption: false,
NextToken: 'page-2',
})
.resolves({ Parameters: [{ Name: '/metadata/two', Value: '2' }] });

await expect(getParametersByPath('/metadata')).resolves.toEqual(
new Map([
['/metadata/one', '1'],
['/metadata/two', '2'],
]),
);
expect(mockSSMClient).toHaveReceivedCommandTimes(GetParametersByPathCommand, 2);
});

it('deletes an exact parameter name', async () => {
mockSSMClient.on(DeleteParameterCommand).resolves({});

await deleteParameter('/metadata/one');

expect(mockSSMClient).toHaveReceivedCommandWith(DeleteParameterCommand, { Name: '/metadata/one' });
});

it('adds tags to an exact parameter name', async () => {
mockSSMClient.on(AddTagsToResourceCommand).resolves({});

await addParameterTags('/metadata/one', [{ Key: 'ghr:environment', Value: 'unit-test' }]);

expect(mockSSMClient).toHaveReceivedCommandWith(AddTagsToResourceCommand, {
ResourceType: 'Parameter',
ResourceId: '/metadata/one',
Tags: [{ Key: 'ghr:environment', Value: 'unit-test' }],
});
});

it('does not call SSM when there are no parameter tags to add', async () => {
await addParameterTags('/metadata/one', []);

expect(mockSSMClient).not.toHaveReceivedCommand(AddTagsToResourceCommand);
});

it('propagates failures when adding parameter tags', async () => {
mockSSMClient.on(AddTagsToResourceCommand).rejects(new Error('AccessDenied'));

await expect(addParameterTags('/metadata/one', [{ Key: 'Name', Value: 'runner' }])).rejects.toThrow('AccessDenied');
});
});

describe('SSM client configuration', () => {
it('configures adaptive retry with a raised attempt cap', async () => {
const config = ssmClient().config;
Expand Down
67 changes: 65 additions & 2 deletions lambdas/libs/aws-ssm-util/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { GetParametersCommand, PutParameterCommand, SSMClient, Tag } from '@aws-sdk/client-ssm';
import {
AddTagsToResourceCommand,
DeleteParameterCommand,
GetParametersByPathCommand,
GetParametersCommand,
PutParameterCommand,
SSMClient,
Tag,
} from '@aws-sdk/client-ssm';
import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util';
import { SSMProvider } from '@aws-lambda-powertools/parameters/ssm';

Expand Down Expand Up @@ -103,14 +111,68 @@ export async function getParameters(parameter_names: string[]): Promise<Map<stri
return result;
}

/**
* Retrieves every direct child of an SSM Parameter Store path.
*
* Values are returned without decryption because this helper is intended for
* non-secret provider metadata. API failures are propagated so callers do not
* mistake an authorization or throttling failure for an empty path.
*/
export async function getParametersByPath(parameter_path: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
let nextToken: string | undefined;

do {
const response = await ssmClient().send(
new GetParametersByPathCommand({
Path: parameter_path,
Recursive: false,
WithDecryption: false,
NextToken: nextToken,
}),
);

for (const parameter of response.Parameters ?? []) {
if (parameter.Name && parameter.Value) {
result.set(parameter.Name, parameter.Value);
}
}
nextToken = response.NextToken;
} while (nextToken);

return result;
}

export async function deleteParameter(parameter_name: string): Promise<void> {
await ssmClient().send(new DeleteParameterCommand({ Name: parameter_name }));
}

export async function addParameterTags(parameter_name: string, tags: Tag[]): Promise<void> {
if (tags.length === 0) return;

await ssmClient().send(
new AddTagsToResourceCommand({
ResourceType: 'Parameter',
ResourceId: parameter_name,
Tags: tags,
}),
);
}

export const SSM_ADVANCED_TIER_THRESHOLD = 4000;

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

export async function putParameter(
parameter_name: string,
parameter_value: string,
secure: boolean,
options: { tags?: Tag[] } = {},
options: PutParameterOptions = {},
): Promise<void> {
if (options.overwrite && options.tags !== undefined) {
throw new Error('SSM parameter tags cannot be supplied when overwriting an existing parameter');
}

const client = ssmClient();

// Determine tier based on parameter_value size
Expand All @@ -121,6 +183,7 @@ export async function putParameter(
Name: parameter_name,
Value: parameter_value,
Type: secure ? 'SecureString' : 'String',
Overwrite: options.overwrite,
Tags: options.tags,
Tier: valueSizeBytes >= SSM_ADVANCED_TIER_THRESHOLD ? 'Advanced' : 'Standard',
}),
Expand Down
149 changes: 149 additions & 0 deletions lambdas/libs/compute-providers/aws/microvm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Lambda MicroVM compute provider

This provider manages a compatible AWS Lambda MicroVM image through the control-plane Lambda. It currently supports ephemeral JIT runners only.

The MicroVM image `/run` hook receives this `runHookPayload`:

```json
{
"version": 1,
"imageArn": "arn:aws:lambda:eu-west-1:123456789012:microvm-image:runner",
"imageVersion": "12.0",
"runnerConfigSsmPath": "/github-action-runners/example/runners/config",
"runnerTokenSsmPath": "/github-action-runners/example/runners/tokens"
}
```

Lambda adds `microvmId` beside that payload. `imageArn` and `imageVersion` are the requested launch values and are included together when an explicit image version is selected. The image must poll the SecureString parameter at `<runnerTokenSsmPath>/<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. The image separately polls its complete non-secret tag map at `<runnerConfigSsmPath>/microvm-metadata/<microvmId>.tags`. The control plane stores the JIT parameter before the provider callback writes the tag map, preventing cleanup from deleting an absent JIT that could otherwise be recreated later. Neither metadata record contains the JIT configuration value. Trusted control-plane cleanup and the fixed lifetime remain termination backstops.

Runner ownership and lifecycle state are stored separately as non-secret `String`
parameters under `<MICROVM_METADATA_SSM_PATH>/<microvmId>`. The immutable base
record and independent state parameters prevent concurrent GitHub ID, orphan,
and cleanup updates from overwriting one another. Deleting the JIT SecureString
does not delete this metadata. Use a dedicated metadata prefix that does not
overlap the JIT path, and grant the MicroVM execution role only the exact
value-read access described below, without path-listing permissions. The control
plane retries pending cleanup, removes metadata after termination, and reconciles
expired records during inventory.

The immutable base metadata parameter carries the same AWS resource tags that
are serialized as a JSON object in the `<microvmId>.tags` parameter. The tag
set starts with `SSM_PARAMETER_STORE_TAGS`, omits `Name`, and derives
`ghr:environment`, `ghr:ssm_config_path`, and `ghr:runner_name_prefix` from
the existing `ENVIRONMENT`, `SSM_CONFIG_PATH`, and `RUNNER_NAME_PREFIX`
settings. The Lambda then adds authoritative runtime tags:
`ghr:Application`, `ghr:created_by`, `ghr:environment`, `ghr:Owner`,
`ghr:Type`, `ghr:microvm_id`, `ghr:microvm_image_arn`, and, when available,
`ghr:microvm_image_version`. After JIT registration, the control plane adds
`ghr:github_runner_id` and base64url-encoded runner-label groups under
`ghr:runner_labels` through `ghr:runner_labels:5`. Runtime-owned values override
configured collisions. The `aws:` tag prefix is reserved and cannot be used for
these SSM parameters. The `.tags` value may use the Parameter Store advanced
tier when its UTF-8 representation is at least 4,000 bytes and is rejected if
the complete value could exceed the 8 KiB Parameter Store limit.

Final cleanup deletes `<runnerTokenSsmPath>/<microvmId>`, the
`.github-runner-id`, `.orphan`, and `.tags` companions, the base ownership
record, and `.cleanup-requested-at` last. The tombstone keeps its original
timestamp through a five-minute grace window so cleanup can repeatedly revoke a
late JIT write before removing every record. Missing parameters are treated as
already cleaned.

The runner configuration publishes `<runnerConfigSsmPath>/enable_cloudwatch`
and, when enabled, `<runnerConfigSsmPath>/cloudwatch_agent_config_runner`.
The generated agent configuration reads these image-owned files by default:

- `/var/log/microvm/internal-services.log`
- `/var/log/microvm/run.log`
- `/opt/actions-runner/_diag/Runner_**.log`

Their default log-group suffixes are `internal_service`, `run`, and `runner`,
and `{microvm_id}` is an image-expanded log-stream placeholder. The first two
files are part of the MicroVM image contract; the portable lifecycle hook does
not create CloudWatch-specific files. Native RunMicrovm stdout and stderr stay
enabled independently as the early-startup and failure backstop.

The control-plane Lambda requires these provider environment variables:

- `MICROVM_IMAGE_ARN`
- `MICROVM_EXECUTION_ROLE_ARN`
- `MICROVM_IMAGE_VERSION` (optional)
- `MICROVM_INGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
- `MICROVM_EGRESS_NETWORK_CONNECTORS` (optional JSON array or comma-separated list)
- `MICROVM_METADATA_SSM_PATH` (dedicated SSM path for control-plane metadata)
- `MICROVM_LOG_GROUP` (optional)
- `SSM_TOKEN_PATH` (lane-scoped JIT parameter path)

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

The control-plane role requires `ssm:GetParametersByPath`, `ssm:GetParameters`,
`ssm:PutParameter`, `ssm:AddTagsToResource`, and `ssm:DeleteParameter` on the
dedicated metadata prefix, plus a separate `ssm:DeleteParameter` grant on the
lane-scoped JIT prefix, and `lambda:ListMicrovms`, `lambda:RunMicrovm`, and
`lambda:TerminateMicrovm` for inventory and lifecycle reconciliation. Restrict
`lambda:RunMicrovm` and `lambda:TerminateMicrovm` to approved image resources;
`lambda:ListMicrovms` does not support resource-level permissions.

The MicroVM execution role must trust `lambda.amazonaws.com` for both
`sts:AssumeRole` and `sts:TagSession`. Restrict `iam:PassRole` to that exact role
ARN. Network connectors also require `lambda:PassNetworkConnector`; because
that action does not currently support resource-level permissions, enforce the
connector boundary with the explicit dynamic-label allowlist described below.

All MicroVMs using one execution role, JIT prefix, and metadata prefix share a
trust boundary. Grant that role only `ssm:GetParameter` on the Parameter Store
ARN corresponding to `<runnerConfigSsmPath>/microvm-metadata/*` and the exact
CloudWatch configuration parameters, `ssm:GetParameter` and
`ssm:DeleteParameter` on the lane-scoped JIT prefix, and stream-write access to
the provider-managed log groups. The image must address its own metadata with
its AWS-provided `microvmId` and must not receive path-listing access. IAM cannot
bind that ID to the calling MicroVM session, so a MicroVM can read other
metadata records in the same lane if it learns their IDs. Only allow trusted
images and workloads within a shared role, or isolate trust domains with
separate roles, prefixes, and provider deployments.

## Dynamic labels

When a runner matcher enables dynamic labels, workflow jobs can override the
following `RunMicrovm` inputs:

| Label | Override |
| --------------------------------------------- | -------------------------------- |
| `ghr-microvm-egress-network-connectors:<arn>` | One egress network connector ARN |
| `ghr-microvm-image-arn:<arn>` | MicroVM image ARN |
| `ghr-microvm-image-version:<version>` | MicroVM image version |

Repeat `ghr-microvm-egress-network-connectors:<arn>` to attach multiple
connectors. Specify one ARN per label; `RunMicrovm` accepts at most 10. These
labels replace the compute provider's configured
`MICROVM_EGRESS_NETWORK_CONNECTORS` value for that job.

Lambda MicroVM does not expose CPU or memory as `RunMicrovm` inputs. Select an
image and version with the required resources instead. Labels such as
`ghr-microvm-memory` are rejected.

Execution roles, ingress network connectors, logging, idle policy, run hook
payloads, and client tokens remain deployment-controlled. Image ARN, image
version, and egress connector overrides change executable code or the network
boundary, so they are rejected unless `awsDynamicLabelsPolicy` supplies an
explicit `allowed` list for the corresponding key.

Use the matcher's `awsDynamicLabelsPolicy` to restrict values accepted from
workflow jobs. The MicroVM policy keys are `egress-network-connectors`,
`image-arn`, and `image-version`. For example:

```json
{
"restricted_keys": {
"egress-network-connectors": {
"allowed": ["arn:aws:lambda:eu-west-1:123456789012:network-connector:github-runner-*"]
},
"image-arn": {
"allowed": ["arn:aws:lambda:eu-west-1:123456789012:microvm-image:github-runner-*"]
},
"image-version": {
"allowed": ["3.*"]
}
}
}
```
25 changes: 25 additions & 0 deletions lambdas/libs/compute-providers/aws/microvm/control-plane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ComputeProviderPlugin, CreateStartRunnerConfig } from '../../core';

import type { ControlPlaneProviderCapabilities, ControlPlaneProviderModule } from '../../contracts';
import type {} from './src/environment';
import { createMicrovmPoolProvider } from './src/control-plane/pool';
import { createMicrovmScaleDownProvider } from './src/control-plane/scale-down';
import { createMicrovmScaleUpProvider } from './src/control-plane/scale-up';

export function createMicrovmControlPlanePlugin(
createStartRunnerConfig: CreateStartRunnerConfig,
): ComputeProviderPlugin<ControlPlaneProviderCapabilities, 'microvm'> {
return {
type: 'microvm',
capabilities: {
pool: () => createMicrovmPoolProvider(createStartRunnerConfig),
scaleUp: () => createMicrovmScaleUpProvider(createStartRunnerConfig),
scaleDown: createMicrovmScaleDownProvider,
},
};
}

export const provider = {
type: 'microvm',
createPlugin: createMicrovmControlPlanePlugin,
} satisfies ControlPlaneProviderModule<'microvm'>;
Loading