This is the full developer documentation for cdk-github-microvm-runners # cdk-github-microvm-runners > A CDK construct library that runs GitHub Actions jobs on AWS Lambda MicroVMs, one ephemeral runner per job. A CDK construct library that runs GitHub Actions jobs on [AWS Lambda MicroVMs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html). Each job runs on its own MicroVM in your AWS account, created when the job starts and removed once it finishes. A runner set is one `GithubMicrovmRunners` in a CDK stack, serving a GitHub organization or a list of repositories. It receives GitHub’s `workflow_job` events, launches a MicroVM for each job, registers a single-use runner, and reconciles the result once the job is done. Within a runner set, a runner class pairs a `runs-on` label with the MicroVM size and image that jobs carrying that label run on, and a set can define more than one. [Getting started](/guides/getting-started/) covers the GitHub App, the secrets it needs in AWS Secrets Manager, deploying a runner set, and running a first job. The [API reference](/api/) carries every construct, property, and default. # API reference > Every construct, property, and default in cdk-github-microvm-runners, generated from the source. ## Constructs[]() [Section titled “Constructs ”](#constructs-) ### GithubMicrovmRunners[]() [Section titled “GithubMicrovmRunners ”](#githubmicrovmrunners-) A runner set: one deployment of GitHub Actions runners that run on AWS Lambda MicroVMs, with a fresh VM per job that is thrown away when the job ends. The construct deploys a webhook handler for GitHub’s `workflow_job` deliveries, a queue those deliveries become launch and terminate intents on, a launcher that starts a VM and registers it with GitHub for each queued job, and a janitor that sweeps on a schedule for VMs and runners that outlived their job. Every runner class registered through `addRunnerClass` adds an image build of its own, and a runner set needs at least one class to synthesize. Deploying it takes two props: how to authenticate to GitHub, and which GitHub scope the runners register into. The VMs themselves carry no AWS identity unless `GithubMicrovmRunnersProps.vmExecutionRole` gives them one. *Example* ```typescript const runnerSet = new GithubMicrovmRunners(stack, 'Runners', { github: GithubAuth.app({ appId: GithubAppId.fromSecret( Secret.fromSecretNameV2(stack, 'AppId', 'microvm-runner/dev/app-id'), ), privateKey: GithubAppKey.fromSecret( Secret.fromSecretNameV2(stack, 'AppKey', 'microvm-runner/dev/app-private-key'), ), webhookSecret: Secret.fromSecretNameV2( stack, 'WebhookSecret', 'microvm-runner/dev/webhook-secret', ), }), scope: RunnerScope.org('my-org'), }); runnerSet.addRunnerClass('microvm', { size: MicrovmSize.GB4 }); ``` #### Initializers[]() [Section titled “Initializers ”](#initializers-) ```typescript import { GithubMicrovmRunners } from 'cdk-github-microvm-runners' new GithubMicrovmRunners(scope: Construct, id: string, props: GithubMicrovmRunnersProps) ``` | **Name** | **Type** | **Description** | | -------- | --------------------------- | ----------------- | | `scope` | `constructs.Construct` | *No description.* | | `id` | `string` | *No description.* | | `props` | `GithubMicrovmRunnersProps` | *No description.* | *** ##### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired-) * *Type:* constructs.Construct *** ##### `id`Required[]() [Section titled “idRequired ”](#idrequired-) * *Type:* string *** ##### `props`Required[]() [Section titled “propsRequired ”](#propsrequired-) * *Type:* [GithubMicrovmRunnersProps](#cdk-github-microvm-runners.GithubMicrovmRunnersProps) *** #### Methods[]() [Section titled “Methods ”](#methods-) | **Name** | **Description** | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `toString` | Returns a string representation of this construct. | | `with` | Applies one or more mixins to this construct. | | `addRunnerClass` | Register a runner class: the `runs-on` label a workflow targets, paired with the VM size, and optionally the image, that jobs carrying that label run on. | | `runnerClass` | The registered `RunnerClass` carrying `label`. | *** ##### `toString`[]() [Section titled “toString ”](#tostring-) ```typescript public toString(): string ``` Returns a string representation of this construct. ##### `with`[]() [Section titled “with ”](#with-) ```typescript public with(mixins: ...IMixin[]): IConstruct ``` Applies one or more mixins to this construct. Mixins are applied in order. The list of constructs is captured at the start of the call, so constructs added by a mixin will not be visited. Use multiple `with()` calls if subsequent mixins should apply to added constructs. ###### `mixins`Required[]() [Section titled “mixinsRequired ”](#mixinsrequired-) * *Type:* …constructs.IMixin\[] The mixins to apply. *** ##### `addRunnerClass`[]() [Section titled “addRunnerClass ”](#addrunnerclass-) ```typescript public addRunnerClass(label: string, props: RunnerClassProps): RunnerClass ``` Register a runner class: the `runs-on` label a workflow targets, paired with the VM size, and optionally the image, that jobs carrying that label run on. Each class builds its own image. A runner set needs at least one class, and one that reaches synth with none fails. Classes can be registered at any point before synth. Everything that depends on the full set of them — which labels the webhook accepts, which image each label launches, and the janitor’s access to each class’s image — is resolved once, after the last call. Setting `warmPoolSize` on a class keeps that many pre-booted VMs ready for it. The first class to do so creates the warm-pool handler and its schedule, which every later warm class then shares. ###### `label`Required[]() [Section titled “labelRequired ”](#labelrequired-) * *Type:* string the `runs-on` label workflows use to target this class. *** ###### `props`Required[]() [Section titled “propsRequired ”](#propsrequired--1) * *Type:* [RunnerClassProps](#cdk-github-microvm-runners.RunnerClassProps) the VM size, and optionally the image, warm pool size, and idle policy for this class. *** ##### `runnerClass`[]() [Section titled “runnerClass ”](#runnerclass-) ```typescript public runnerClass(label: string): RunnerClass ``` The registered `RunnerClass` carrying `label`. Throws when no class with that label has been registered. ###### `label`Required[]() [Section titled “labelRequired ”](#labelrequired--1) * *Type:* string the `runs-on` label the class was registered under. *** #### Static Functions[]() [Section titled “Static Functions ”](#static-functions-) | **Name** | **Description** | | ------------- | ----------------------------- | | `isConstruct` | Checks if `x` is a construct. | *** ##### `isConstruct`[]() [Section titled “isConstruct ”](#isconstruct-) ```typescript import { GithubMicrovmRunners } from 'cdk-github-microvm-runners' GithubMicrovmRunners.isConstruct(x: any) ``` Checks if `x` is a construct. Use this method instead of `instanceof` to properly detect `Construct` instances, even when the construct library is symlinked. Explanation: in JavaScript, multiple copies of the `constructs` library on disk are seen as independent, completely different libraries. As a consequence, the class `Construct` in each copy of the `constructs` library is seen as a different class, and an instance of one class will not test as `instanceof` the other class. `npm install` will not create installations like this, but users may manually symlink construct libraries together or use a monorepo tool: in those cases, multiple copies of the `constructs` library can be accidentally installed, and `instanceof` will behave unpredictably. It is safest to avoid using `instanceof`, and using this type-testing method instead. ###### `x`Required[]() [Section titled “xRequired ”](#xrequired-) * *Type:* any Any object. *** #### Properties[]() [Section titled “Properties ”](#properties-) | **Name** | **Type** | **Description** | | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `node` | `constructs.Node` | The tree node. | | `deadLetterQueue` | `aws-cdk-lib.aws_sqs.IQueue` | Dead-letter queue holding job-queue messages that ran out of redrives. | | `defaultImageArn` | `string` | The image a job whose labels match no registered runner class launches on: the class labelled `microvm` if one is registered, otherwise the first class registered. | | `janitorFunction` | `aws-cdk-lib.aws_lambda.IFunction` | The janitor Lambda, which runs the scheduled sweep. | | `jobQueue` | `aws-cdk-lib.aws_sqs.IQueue` | Queue carrying launch and terminate intents from the webhook handler to the launcher. | | `launcherFunction` | `aws-cdk-lib.aws_lambda.IFunction` | The launcher Lambda, which reads the job queue and starts and terminates MicroVMs. | | `metrics` | `GithubMicrovmRunnersMetrics` | This runner set’s CloudWatch metrics and the ready-made alarms over them. | | `runnerClasses` | `RunnerClass[]` | Every runner class registered through `addRunnerClass`, in the order they were registered. | | `runnerTable` | `aws-cdk-lib.aws_dynamodb.ITable` | DynamoDB table mapping each runner’s name to its MicroVM, and holding the janitor’s record of which VMs it already suspects. | | `setupCommand` | `string` | The command that creates this runner set’s GitHub App and writes its three secrets. | | `webhookFunction` | `aws-cdk-lib.aws_lambda.IFunction` | The webhook Lambda, which GitHub’s deliveries reach through `webhookUrl`. | | `webhookUrl` | `string` | The webhook handler’s public Function URL, which is the payload URL to configure on the GitHub App or webhook. | | `vmConsoleLogGroup` | `aws-cdk-lib.aws_logs.ILogGroup` | Where a VM’s runtime console goes when console capture is on: the group the construct created, or the one you supplied. | | `vmExecutionRole` | `aws-cdk-lib.aws_iam.IRole` | The AWS identity launched MicroVMs run with, passed in as `GithubMicrovmRunnersProps.vmExecutionRole`, or `undefined` when the VMs carry no AWS identity. Console capture runs on this role and requires it. A runner set identifies its own VMs by the image they booted from, not by this role. | | `warmPoolFunction` | `aws-cdk-lib.aws_lambda.IFunction` | The Lambda that refills the warm pool, or `undefined` when no registered runner class sets `RunnerClassProps.warmPoolSize`. It is created by the first class that does, so a runner set with no warm class deploys neither this function nor its schedule. | *** ##### `node`Required[]() [Section titled “nodeRequired ”](#noderequired-) ```typescript public readonly node: Node; ``` * *Type:* constructs.Node The tree node. *** ##### `deadLetterQueue`Required[]() [Section titled “deadLetterQueueRequired ”](#deadletterqueuerequired-) ```typescript public readonly deadLetterQueue: IQueue; ``` * *Type:* aws-cdk-lib.aws\_sqs.IQueue Dead-letter queue holding job-queue messages that ran out of redrives. *** ##### `defaultImageArn`Required[]() [Section titled “defaultImageArnRequired ”](#defaultimagearnrequired-) ```typescript public readonly defaultImageArn: string; ``` * *Type:* string The image a job whose labels match no registered runner class launches on: the class labelled `microvm` if one is registered, otherwise the first class registered. Runner classes can be added right up until synth, so this is a token that resolves once the set of them is final. *** ##### `janitorFunction`Required[]() [Section titled “janitorFunctionRequired ”](#janitorfunctionrequired-) ```typescript public readonly janitorFunction: IFunction; ``` * *Type:* aws-cdk-lib.aws\_lambda.IFunction The janitor Lambda, which runs the scheduled sweep. *** ##### `jobQueue`Required[]() [Section titled “jobQueueRequired ”](#jobqueuerequired-) ```typescript public readonly jobQueue: IQueue; ``` * *Type:* aws-cdk-lib.aws\_sqs.IQueue Queue carrying launch and terminate intents from the webhook handler to the launcher. *** ##### `launcherFunction`Required[]() [Section titled “launcherFunctionRequired ”](#launcherfunctionrequired-) ```typescript public readonly launcherFunction: IFunction; ``` * *Type:* aws-cdk-lib.aws\_lambda.IFunction The launcher Lambda, which reads the job queue and starts and terminates MicroVMs. *** ##### `metrics`Required[]() [Section titled “metricsRequired ”](#metricsrequired-) ```typescript public readonly metrics: GithubMicrovmRunnersMetrics; ``` * *Type:* [GithubMicrovmRunnersMetrics](#cdk-github-microvm-runners.GithubMicrovmRunnersMetrics) This runner set’s CloudWatch metrics and the ready-made alarms over them. *** ##### `runnerClasses`Required[]() [Section titled “runnerClassesRequired ”](#runnerclassesrequired-) ```typescript public readonly runnerClasses: RunnerClass[]; ``` * *Type:* [RunnerClass](#cdk-github-microvm-runners.RunnerClass)\[] Every runner class registered through `addRunnerClass`, in the order they were registered. It is empty until the first class is added, and a runner set that reaches synth with none fails. Each call returns a copy, so changing the returned array does not change the runner set. *** ##### `runnerTable`Required[]() [Section titled “runnerTableRequired ”](#runnertablerequired-) ```typescript public readonly runnerTable: ITable; ``` * *Type:* aws-cdk-lib.aws\_dynamodb.ITable DynamoDB table mapping each runner’s name to its MicroVM, and holding the janitor’s record of which VMs it already suspects. *** ##### `setupCommand`Required[]() [Section titled “setupCommandRequired ”](#setupcommandrequired-) ```typescript public readonly setupCommand: string; ``` * *Type:* string The command that creates this runner set’s GitHub App and writes its three secrets. It carries the scope, the stack name, and the region this runner set was built with, and is pinned to the version of this library that produced it, so the helper and the construct agree about secret names and stack outputs. Surface it as a stack output and the deploy ends by printing the line to paste. On a stack built without an explicit `env`, the region is a token that reads as `${Token[AWS.Region.N]}` here and resolves to the real region in the deployed output. *** *Example* ```typescript new cdk.CfnOutput(stack, 'SetupCommand', { value: runners.setupCommand }); ``` ##### `webhookFunction`Required[]() [Section titled “webhookFunctionRequired ”](#webhookfunctionrequired-) ```typescript public readonly webhookFunction: IFunction; ``` * *Type:* aws-cdk-lib.aws\_lambda.IFunction The webhook Lambda, which GitHub’s deliveries reach through `webhookUrl`. *** ##### `webhookUrl`Required[]() [Section titled “webhookUrlRequired ”](#webhookurlrequired-) ```typescript public readonly webhookUrl: string; ``` * *Type:* string The webhook handler’s public Function URL, which is the payload URL to configure on the GitHub App or webhook. *** ##### `vmConsoleLogGroup`Optional[]() [Section titled “vmConsoleLogGroupOptional ”](#vmconsoleloggroupoptional-) ```typescript public readonly vmConsoleLogGroup: ILogGroup; ``` * *Type:* aws-cdk-lib.aws\_logs.ILogGroup Where a VM’s runtime console goes when console capture is on: the group the construct created, or the one you supplied. `undefined` when console capture is off. *** ##### `vmExecutionRole`Optional[]() [Section titled “vmExecutionRoleOptional ”](#vmexecutionroleoptional-) ```typescript public readonly vmExecutionRole: IRole; ``` * *Type:* aws-cdk-lib.aws\_iam.IRole The AWS identity launched MicroVMs run with, passed in as `GithubMicrovmRunnersProps.vmExecutionRole`, or `undefined` when the VMs carry no AWS identity. Console capture runs on this role and requires it. A runner set identifies its own VMs by the image they booted from, not by this role. *** ##### `warmPoolFunction`Optional[]() [Section titled “warmPoolFunctionOptional ”](#warmpoolfunctionoptional-) ```typescript public readonly warmPoolFunction: IFunction; ``` * *Type:* aws-cdk-lib.aws\_lambda.IFunction The Lambda that refills the warm pool, or `undefined` when no registered runner class sets `RunnerClassProps.warmPoolSize`. It is created by the first class that does, so a runner set with no warm class deploys neither this function nor its schedule. *** ### ImagePipeline[]() [Section titled “ImagePipeline ”](#imagepipeline-) The build behind one runner class’s MicroVM image. `addRunnerClass` creates one for each class it registers and returns it as `RunnerClass.imagePipeline`, so this is a handle you read rather than a construct you instantiate. It stages the class’s Dockerfile and build context as a CDK asset, declares the `AWS::Lambda::MicrovmImage` resource that CloudFormation builds from it, and creates the IAM role that build runs as. Reading it is how you reach the built image’s name and ARN, and the role the build runs as. *Example* ```typescript const buildClass = runners.addRunnerClass('build', { size: MicrovmSize.GB4, image: RunnerImage.fromOptions({ systemPackages: ['jq'] }), }); new cdk.CfnOutput(stack, 'BuildImageName', { value: buildClass.imagePipeline.imageName, }); ``` #### Initializers[]() [Section titled “Initializers ”](#initializers--1) ```typescript import { ImagePipeline } from 'cdk-github-microvm-runners' new ImagePipeline(scope: Construct, id: string, props: ImagePipelineProps) ``` | **Name** | **Type** | **Description** | | -------- | ---------------------- | ----------------- | | `scope` | `constructs.Construct` | *No description.* | | `id` | `string` | *No description.* | | `props` | `ImagePipelineProps` | *No description.* | *** ##### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--1) * *Type:* constructs.Construct *** ##### `id`Required[]() [Section titled “idRequired ”](#idrequired--1) * *Type:* string *** ##### `props`Required[]() [Section titled “propsRequired ”](#propsrequired--2) * *Type:* [ImagePipelineProps](#cdk-github-microvm-runners.ImagePipelineProps) *** #### Methods[]() [Section titled “Methods ”](#methods--1) | **Name** | **Description** | | ---------- | -------------------------------------------------- | | `toString` | Returns a string representation of this construct. | | `with` | Applies one or more mixins to this construct. | *** ##### `toString`[]() [Section titled “toString ”](#tostring--1) ```typescript public toString(): string ``` Returns a string representation of this construct. ##### `with`[]() [Section titled “with ”](#with--1) ```typescript public with(mixins: ...IMixin[]): IConstruct ``` Applies one or more mixins to this construct. Mixins are applied in order. The list of constructs is captured at the start of the call, so constructs added by a mixin will not be visited. Use multiple `with()` calls if subsequent mixins should apply to added constructs. ###### `mixins`Required[]() [Section titled “mixinsRequired ”](#mixinsrequired--1) * *Type:* …constructs.IMixin\[] The mixins to apply. *** #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--1) | **Name** | **Description** | | ------------- | ----------------------------- | | `isConstruct` | Checks if `x` is a construct. | *** ##### `isConstruct`[]() [Section titled “isConstruct ”](#isconstruct--1) ```typescript import { ImagePipeline } from 'cdk-github-microvm-runners' ImagePipeline.isConstruct(x: any) ``` Checks if `x` is a construct. Use this method instead of `instanceof` to properly detect `Construct` instances, even when the construct library is symlinked. Explanation: in JavaScript, multiple copies of the `constructs` library on disk are seen as independent, completely different libraries. As a consequence, the class `Construct` in each copy of the `constructs` library is seen as a different class, and an instance of one class will not test as `instanceof` the other class. `npm install` will not create installations like this, but users may manually symlink construct libraries together or use a monorepo tool: in those cases, multiple copies of the `constructs` library can be accidentally installed, and `instanceof` will behave unpredictably. It is safest to avoid using `instanceof`, and using this type-testing method instead. ###### `x`Required[]() [Section titled “xRequired ”](#xrequired--1) * *Type:* any Any object. *** #### Properties[]() [Section titled “Properties ”](#properties--1) | **Name** | **Type** | **Description** | | --------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `node` | `constructs.Node` | The tree node. | | `buildRole` | `aws-cdk-lib.aws_iam.IRole` | IAM role the image build runs as, able to read the staged build context and pull any private container base image. | | `imageArn` | `string` | ARN of the built MicroVM image. | | `imageName` | `string` | Name of the built MicroVM image — the per-class `runnerSetId`, stable for the life of the runner class. | | `imageResource` | `aws-cdk-lib.aws_lambda.CfnMicrovmImage` | The underlying `AWS::Lambda::MicrovmImage` resource. | | `imageVersion` | `string` | The image’s latest active version, which advances every time the image is rebuilt in place. | *** ##### `node`Required[]() [Section titled “nodeRequired ”](#noderequired--1) ```typescript public readonly node: Node; ``` * *Type:* constructs.Node The tree node. *** ##### `buildRole`Required[]() [Section titled “buildRoleRequired ”](#buildrolerequired-) ```typescript public readonly buildRole: IRole; ``` * *Type:* aws-cdk-lib.aws\_iam.IRole IAM role the image build runs as, able to read the staged build context and pull any private container base image. *** ##### `imageArn`Required[]() [Section titled “imageArnRequired ”](#imagearnrequired-) ```typescript public readonly imageArn: string; ``` * *Type:* string ARN of the built MicroVM image. *** ##### `imageName`Required[]() [Section titled “imageNameRequired ”](#imagenamerequired-) ```typescript public readonly imageName: string; ``` * *Type:* string Name of the built MicroVM image — the per-class `runnerSetId`, stable for the life of the runner class. *** ##### `imageResource`Required[]() [Section titled “imageResourceRequired ”](#imageresourcerequired-) ```typescript public readonly imageResource: CfnMicrovmImage; ``` * *Type:* aws-cdk-lib.aws\_lambda.CfnMicrovmImage The underlying `AWS::Lambda::MicrovmImage` resource. *** ##### `imageVersion`Required[]() [Section titled “imageVersionRequired ”](#imageversionrequired-) ```typescript public readonly imageVersion: string; ``` * *Type:* string The image’s latest active version, which advances every time the image is rebuilt in place. *** ## Structs[]() [Section titled “Structs ”](#structs-) ### GithubAppAuthProps[]() [Section titled “GithubAppAuthProps ”](#githubappauthprops-) Props for `GithubAuth.app`. #### Initializer[]() [Section titled “Initializer ”](#initializer-) ```typescript import { GithubAppAuthProps } from 'cdk-github-microvm-runners' const githubAppAuthProps: GithubAppAuthProps = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--2) | **Name** | **Type** | **Description** | | --------------- | ---------------------------------------- | ------------------------------------------------------------------------- | | `appId` | `GithubAppId` | The GitHub App’s numeric ID, as a literal or a Secrets Manager reference. | | `privateKey` | `GithubAppKey` | The App’s private key, backed by a secret or a KMS key. | | `webhookSecret` | `aws-cdk-lib.aws_secretsmanager.ISecret` | Secret holding the webhook secret used to validate inbound deliveries. | *** ##### `appId`Required[]() [Section titled “appIdRequired ”](#appidrequired-) ```typescript public readonly appId: GithubAppId; ``` * *Type:* [GithubAppId](#cdk-github-microvm-runners.GithubAppId) The GitHub App’s numeric ID, as a literal or a Secrets Manager reference. *** ##### `privateKey`Required[]() [Section titled “privateKeyRequired ”](#privatekeyrequired-) ```typescript public readonly privateKey: GithubAppKey; ``` * *Type:* [GithubAppKey](#cdk-github-microvm-runners.GithubAppKey) The App’s private key, backed by a secret or a KMS key. *** ##### `webhookSecret`Required[]() [Section titled “webhookSecretRequired ”](#webhooksecretrequired-) ```typescript public readonly webhookSecret: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret Secret holding the webhook secret used to validate inbound deliveries. *** ### GithubMicrovmRunnersProps[]() [Section titled “GithubMicrovmRunnersProps ”](#githubmicrovmrunnersprops-) Props for `GithubMicrovmRunners`. #### Initializer[]() [Section titled “Initializer ”](#initializer--1) ```typescript import { GithubMicrovmRunnersProps } from 'cdk-github-microvm-runners' const githubMicrovmRunnersProps: GithubMicrovmRunnersProps = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--3) | **Name** | **Type** | **Description** | | ---------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `github` | `GithubAuth` | How the runner set authenticates to GitHub, as an App or with a personal access token. | | `scope` | `RunnerScope` | Which GitHub scope, one organization, several organizations, or a list of repositories, registered runners are visible to. | | `additionalRegions` | `string[]` | Additional regions to accept as Lambda MicroVMs regions, beyond the ones this library already knows about. | | `consoleLogs` | `ConsoleLogs` | Where a VM’s runtime console goes: everything it prints while it boots, runs the runner agent, and runs the job. | | `deadLetterRetention` | `aws-cdk-lib.Duration` | How long the dead-letter queue retains a failed launch or terminate intent. | | `emitMetrics` | `boolean` | Report this runner set’s CloudWatch custom metrics. | | `encryptionKey` | `aws-cdk-lib.aws_kms.IKey` | Customer-managed KMS key for this runner set’s data at rest: the DynamoDB runner table, the SQS job queue and dead-letter queue, and any log group this construct creates. | | `idleRunnerGraceSeconds` | `number` | How many seconds a registered runner may sit idle before the janitor’s two-strike sweep treats it as stuck. | | `imageLogs` | `ImageLogs` | Where build-time image logs go: the Docker build layers and the ready-probe banner from each image build. | | `janitorInterval` | `aws-cdk-lib.Duration` | How often the janitor sweep runs. | | `keepImageVersions` | `number` | How many MicroVM image versions to keep per runner class. | | `lambdaMemorySize` | `number` | Memory, in MiB, for the handler Lambdas: the webhook, the launcher, the janitor, and the warm pool. | | `logRetention` | `aws-cdk-lib.aws_logs.RetentionDays` | Retention for the CloudWatch log groups this construct creates: the handler Lambda log groups and, when console capture is on, the VM console group. | | `maxConcurrentVms` | `number` | Maximum number of MicroVMs this runner set runs at once. | | `maxJobDuration` | `aws-cdk-lib.Duration` | How long a job may run before its MicroVM is terminated. | | `maxReceiveCount` | `number` | How many times a launch intent is redriven before it dead-letters. | | `network` | `RunnerNetwork` | How launched MicroVMs and image builds reach the network. | | `permissionsBoundary` | `aws-cdk-lib.aws_iam.IManagedPolicy` | Permissions boundary applied to every IAM role this construct creates: the handler execution roles, the per-class image build roles, and the network-connector operator role. | | `pointInTimeRecovery` | `boolean` | Turn on DynamoDB point-in-time recovery for the runner table. | | `recoverStuckLaunches` | `boolean` | Re-launch jobs that are still waiting for a runner they never got. | | `removalPolicy` | `aws-cdk-lib.RemovalPolicy` | Removal policy for this runner set’s stateful resources: the runner table and any log group the construct creates. | | `vmExecutionRole` | `aws-cdk-lib.aws_iam.IRole` | An AWS identity for this runner set’s runner VMs. | | `warmPoolInterval` | `aws-cdk-lib.Duration` | How often the warm-pool sweep refills pre-booted VMs. | | `webhook` | `WebhookEndpoint` | How the webhook handler is exposed to GitHub. | | `webhookReservedConcurrency` | `number` | Reserved concurrency for the webhook Lambda, which caps how many webhook deliveries the runner set processes at once. | *** ##### `github`Required[]() [Section titled “githubRequired ”](#githubrequired-) ```typescript public readonly github: GithubAuth; ``` * *Type:* [GithubAuth](#cdk-github-microvm-runners.GithubAuth) How the runner set authenticates to GitHub, as an App or with a personal access token. This also carries the webhook secret. *** ##### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--2) ```typescript public readonly scope: RunnerScope; ``` * *Type:* [RunnerScope](#cdk-github-microvm-runners.RunnerScope) Which GitHub scope, one organization, several organizations, or a list of repositories, registered runners are visible to. *** ##### `additionalRegions`Optional[]() [Section titled “additionalRegionsOptional ”](#additionalregionsoptional-) ```typescript public readonly additionalRegions: string[]; ``` * *Type:* string\[] * *Default:* \[] (only the regions this library knows about) Additional regions to accept as Lambda MicroVMs regions, beyond the ones this library already knows about. Deploying into a region on neither list fails at synth. *** ##### `consoleLogs`Optional[]() [Section titled “consoleLogsOptional ”](#consolelogsoptional-) ```typescript public readonly consoleLogs: ConsoleLogs; ``` * *Type:* [ConsoleLogs](#cdk-github-microvm-runners.ConsoleLogs) * *Default:* undefined (no runtime console capture) Where a VM’s runtime console goes: everything it prints while it boots, runs the runner agent, and runs the job. `ConsoleLogs.enabled()` has the construct create the group and expose it as `vmConsoleLogGroup`, and `ConsoleLogs.enabled(logGroup)` uses one you control. The platform writes these logs with the VM’s own role, so this requires `vmExecutionRole`; see `ConsoleLogs` for what that role means for job code. Independent of `imageLogs`. *** ##### `deadLetterRetention`Optional[]() [Section titled “deadLetterRetentionOptional ”](#deadletterretentionoptional-) ```typescript public readonly deadLetterRetention: Duration; ``` * *Type:* aws-cdk-lib.Duration * *Default:* Duration.days(4) (SQS default) How long the dead-letter queue retains a failed launch or terminate intent. SQS allows up to 14 days, which is also how long `recoverStuckLaunches` has to re-drive a message before SQS drops it. *** ##### `emitMetrics`Optional[]() [Section titled “emitMetricsOptional ”](#emitmetricsoptional-) ```typescript public readonly emitMetrics: boolean; ``` * *Type:* boolean * *Default:* false (no metrics emitted) Report this runner set’s CloudWatch custom metrics. Those are the janitor’s per-sweep counters, the launcher’s per-launch outcomes and spin-up timings, and the warm pool’s fill numbers — everything `GithubMicrovmRunnersMetrics` names. With this off the handlers report none of them. CloudWatch bills custom metrics per metric per month, and this runner set’s bill is not a fixed number: the launcher and warm-pool metrics carry a runner-class dimension, so each one becomes a separate billable metric per registered runner class. The two alarms backed by these metrics, `sweepErrorsAlarm` and `stuckLaunchesRecoveredAlarm`, throw at synth unless this is on, since the metric they watch would never report. `deadLetterQueueNotEmptyAlarm` watches an SQS metric and works either way. The metric accessors on `GithubMicrovmRunnersMetrics` return a `Metric` regardless, so a dashboard can be built ahead of turning metrics on. *** ##### `encryptionKey`Optional[]() [Section titled “encryptionKeyOptional ”](#encryptionkeyoptional-) ```typescript public readonly encryptionKey: IKey; ``` * *Type:* aws-cdk-lib.aws\_kms.IKey * *Default:* undefined (AWS-managed keys) Customer-managed KMS key for this runner set’s data at rest: the DynamoDB runner table, the SQS job queue and dead-letter queue, and any log group this construct creates. Log groups you bring yourself, and the GitHub secrets you pass in, keep their own keys. *** ##### `idleRunnerGraceSeconds`Optional[]() [Section titled “idleRunnerGraceSecondsOptional ”](#idlerunnergracesecondsoptional-) ```typescript public readonly idleRunnerGraceSeconds: number; ``` * *Type:* number * *Default:* 600 How many seconds a registered runner may sit idle before the janitor’s two-strike sweep treats it as stuck. *** ##### `imageLogs`Optional[]() [Section titled “imageLogsOptional ”](#imagelogsoptional-) ```typescript public readonly imageLogs: ImageLogs; ``` * *Type:* [ImageLogs](#cdk-github-microvm-runners.ImageLogs) * *Default:* undefined (no image logs) Where build-time image logs go: the Docker build layers and the ready-probe banner from each image build. `ImageLogs.enabled()` sends them to the platform’s own CloudWatch group, and `ImageLogs.enabled(logGroup)` to a group whose retention and KMS key you control. These are written by the image build role rather than by a VM, so they need no VM execution role. Independent of `consoleLogs`. *** ##### `janitorInterval`Optional[]() [Section titled “janitorIntervalOptional ”](#janitorintervaloptional-) ```typescript public readonly janitorInterval: Duration; ``` * *Type:* aws-cdk-lib.Duration * *Default:* Duration.minutes(5) How often the janitor sweep runs. *** ##### `keepImageVersions`Optional[]() [Section titled “keepImageVersionsOptional ”](#keepimageversionsoptional-) ```typescript public readonly keepImageVersions: number; ``` * *Type:* number * *Default:* 5 How many MicroVM image versions to keep per runner class. The janitor prunes inactive versions past this count. *** ##### `lambdaMemorySize`Optional[]() [Section titled “lambdaMemorySizeOptional ”](#lambdamemorysizeoptional-) ```typescript public readonly lambdaMemorySize: number; ``` * *Type:* number * *Default:* 128 Memory, in MiB, for the handler Lambdas: the webhook, the launcher, the janitor, and the warm pool. The janitor’s sweep scans the runner table and reconciles every running VM, so it is the handler most sensitive to this on a busy runner set. *** ##### `logRetention`Optional[]() [Section titled “logRetentionOptional ”](#logretentionoptional-) ```typescript public readonly logRetention: RetentionDays; ``` * *Type:* aws-cdk-lib.aws\_logs.RetentionDays * *Default:* logs.RetentionDays.TWO\_WEEKS Retention for the CloudWatch log groups this construct creates: the handler Lambda log groups and, when console capture is on, the VM console group. *** ##### `maxConcurrentVms`Optional[]() [Section titled “maxConcurrentVmsOptional ”](#maxconcurrentvmsoptional-) ```typescript public readonly maxConcurrentVms: number; ``` * *Type:* number * *Default:* 10 Maximum number of MicroVMs this runner set runs at once. *** ##### `maxJobDuration`Optional[]() [Section titled “maxJobDurationOptional ”](#maxjobdurationoptional-) ```typescript public readonly maxJobDuration: Duration; ``` * *Type:* aws-cdk-lib.Duration * *Default:* Duration.hours(6) How long a job may run before its MicroVM is terminated. The VM is killed five minutes after this value, not at it. The runner set asks the platform for `maxJobDuration + 5 minutes`, so that a job which reaches its own limit is stopped by the runner — which reports the timeout to GitHub and lets the VM come down cleanly — rather than by the platform removing the machine underneath it. Treat the five minutes as headroom for that shutdown rather than as extra running time. *** ##### `maxReceiveCount`Optional[]() [Section titled “maxReceiveCountOptional ”](#maxreceivecountoptional-) ```typescript public readonly maxReceiveCount: number; ``` * *Type:* number * *Default:* 20 How many times a launch intent is redriven before it dead-letters. A runner set already at `maxConcurrentVms` redrives capacity-rejected launches through this same budget, so on a runner set that regularly runs at capacity this count is how long a queued job waits before its launch is dropped. See docs/service-quotas.md. *** ##### `network`Optional[]() [Section titled “networkOptional ”](#networkoptional-) ```typescript public readonly network: RunnerNetwork; ``` * *Type:* [RunnerNetwork](#cdk-github-microvm-runners.RunnerNetwork) * *Default:* RunnerNetwork.internetEgress() How launched MicroVMs and image builds reach the network. *** ##### `permissionsBoundary`Optional[]() [Section titled “permissionsBoundaryOptional ”](#permissionsboundaryoptional-) ```typescript public readonly permissionsBoundary: IManagedPolicy; ``` * *Type:* aws-cdk-lib.aws\_iam.IManagedPolicy * *Default:* undefined (no boundary) Permissions boundary applied to every IAM role this construct creates: the handler execution roles, the per-class image build roles, and the network-connector operator role. It is applied once at construct scope, so roles created later also carry it — the warm-pool handler’s role, and the build role of any runner class registered after construction. *** ##### `pointInTimeRecovery`Optional[]() [Section titled “pointInTimeRecoveryOptional ”](#pointintimerecoveryoptional-) ```typescript public readonly pointInTimeRecovery: boolean; ``` * *Type:* boolean * *Default:* false Turn on DynamoDB point-in-time recovery for the runner table. *** ##### `recoverStuckLaunches`Optional[]() [Section titled “recoverStuckLaunchesOptional ”](#recoverstucklaunchesoptional-) ```typescript public readonly recoverStuckLaunches: boolean; ``` * *Type:* boolean * *Default:* true Re-launch jobs that are still waiting for a runner they never got. This is the floor under an event-driven plane, and it is on by default. GitHub announces a job once. If the launch that announcement triggered doesn’t end with the job being served, nothing else ever asks again, and the job waits for as long as the workflow allows with no error anywhere — the plane looks healthy because by its own bookkeeping it did its work. Each janitor sweep closes that hole from three directions: it re-drives dead-lettered launch messages back onto the job queue, it re-launches claims whose VM is gone while the job is still queued, and it re-launches a job the instant its runner is reaped as `unregistered` (a VM that launched but never registered with GitHub). All three check with GitHub first, so a job that has since completed or been cancelled is discarded rather than booting a VM for work nobody is waiting on. The two relaunch paths (the committed-claim reconciliation and the `unregistered` reap) share ONE per-job attempt counter, threaded through the launch message onto the fresh claim, so a job that repeatedly fails to register is relaunched only a bounded number of times in total; at the bound its claim is released (deleted, so nothing resurrects it) and the exhaustion is raised via a metric and an error log rather than looping. The job then stays queued for the operator to re-run once the cause is fixed. Turn it off only if you want a job that slips through to stay stuck. The cost of leaving it on is one extra GitHub read per sweep per candidate, bounded per sweep so it cannot exhaust the installation’s rate limit. The janitor counts recoveries under the `stuckLaunchesRecovered`, `stuckClaimsRelaunched`, and `unregisteredRelaunched` metrics, and jobs it released at the shared relaunch bound under `unregisteredRelaunchExhausted` (emitted from whichever recovery path hit the bound) — all report when `emitMetrics` is on. A count that stays high means launches are failing for some ongoing reason and the recovery is masking it — alarm on it rather than ignoring it. *** ##### `removalPolicy`Optional[]() [Section titled “removalPolicyOptional ”](#removalpolicyoptional-) ```typescript public readonly removalPolicy: RemovalPolicy; ``` * *Type:* aws-cdk-lib.RemovalPolicy * *Default:* RemovalPolicy.DESTROY Removal policy for this runner set’s stateful resources: the runner table and any log group the construct creates. The table holds correlation data for VMs that are currently running, all of which the janitor can rebuild from the MicroVM and GitHub APIs. *** ##### `vmExecutionRole`Optional[]() [Section titled “vmExecutionRoleOptional ”](#vmexecutionroleoptional--1) ```typescript public readonly vmExecutionRole: IRole; ``` * *Type:* aws-cdk-lib.aws\_iam.IRole * *Default:* undefined (the VMs carry no AWS identity) An AWS identity for this runner set’s runner VMs. By default the VMs carry no AWS identity at all: the runner agent talks outbound to GitHub, the just-in-time registration is pushed to the VM over a platform-authenticated channel, and a job that needs AWS assumes its own role through GitHub OIDC. With a role attached, the MicroVM’s instance metadata service serves that role’s credentials to arbitrary job code, so every job running on this runner set can do whatever the role can do. `consoleLogs` requires a role, because the platform writes a VM’s console output using it. *** ##### `warmPoolInterval`Optional[]() [Section titled “warmPoolIntervalOptional ”](#warmpoolintervaloptional-) ```typescript public readonly warmPoolInterval: Duration; ``` * *Type:* aws-cdk-lib.Duration * *Default:* Duration.minutes(2) How often the warm-pool sweep refills pre-booted VMs. It applies only to runner classes that set `RunnerClassProps.warmPoolSize`, and the warm-pool handler and its schedule are only created once such a class is registered. A runner set with no warm class never runs this sweep, and never reads this value. *** ##### `webhook`Optional[]() [Section titled “webhookOptional ”](#webhookoptional-) ```typescript public readonly webhook: WebhookEndpoint; ``` * *Type:* [WebhookEndpoint](#cdk-github-microvm-runners.WebhookEndpoint) * *Default:* WebhookEndpoint.functionUrl() How the webhook handler is exposed to GitHub. *** ##### `webhookReservedConcurrency`Optional[]() [Section titled “webhookReservedConcurrencyOptional ”](#webhookreservedconcurrencyoptional-) ```typescript public readonly webhookReservedConcurrency: number; ``` * *Type:* number * *Default:* undefined (no reservation; the webhook draws from the shared pool) Reserved concurrency for the webhook Lambda, which caps how many webhook deliveries the runner set processes at once. Reserved concurrency is carved out of the account’s shared pool of unreserved concurrency, so a runner set that sets it takes that capacity away from every other function in the account. Must be a positive integer when set, since `0` would disable the webhook entirely. *** ### GithubPatAuthProps[]() [Section titled “GithubPatAuthProps ”](#githubpatauthprops-) Props for `GithubAuth.pat`. #### Initializer[]() [Section titled “Initializer ”](#initializer--2) ```typescript import { GithubPatAuthProps } from 'cdk-github-microvm-runners' const githubPatAuthProps: GithubPatAuthProps = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--4) | **Name** | **Type** | **Description** | | --------------- | ---------------------------------------- | ---------------------------------------------------------------------- | | `token` | `aws-cdk-lib.aws_secretsmanager.ISecret` | Secret holding a GitHub personal access token. | | `webhookSecret` | `aws-cdk-lib.aws_secretsmanager.ISecret` | Secret holding the webhook secret used to validate inbound deliveries. | *** ##### `token`Required[]() [Section titled “tokenRequired ”](#tokenrequired-) ```typescript public readonly token: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret Secret holding a GitHub personal access token. *** ##### `webhookSecret`Required[]() [Section titled “webhookSecretRequired ”](#webhooksecretrequired--1) ```typescript public readonly webhookSecret: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret Secret holding the webhook secret used to validate inbound deliveries. *** ### ImageAsset[]() [Section titled “ImageAsset ”](#imageasset-) One extra file or directory baked into the image. `source` is a path on the machine running `cdk synth`. The image pipeline reads it off disk when it stages the Docker build context, and the rendered Dockerfile copies it to `target` inside the image. #### Initializer[]() [Section titled “Initializer ”](#initializer--3) ```typescript import { ImageAsset } from 'cdk-github-microvm-runners' const imageAsset: ImageAsset = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--5) | **Name** | **Type** | **Description** | | -------- | -------- | --------------------------------------------------------------------- | | `source` | `string` | Path (file or directory) on the build machine to copy into the image. | | `target` | `string` | Absolute path inside the image to copy `source` to. | *** ##### `source`Required[]() [Section titled “sourceRequired ”](#sourcerequired-) ```typescript public readonly source: string; ``` * *Type:* string Path (file or directory) on the build machine to copy into the image. *** ##### `target`Required[]() [Section titled “targetRequired ”](#targetrequired-) ```typescript public readonly target: string; ``` * *Type:* string Absolute path inside the image to copy `source` to. *** ### ImagePipelineProps[]() [Section titled “ImagePipelineProps ”](#imagepipelineprops-) Props for `ImagePipeline`. #### Initializer[]() [Section titled “Initializer ”](#initializer--4) ```typescript import { ImagePipelineProps } from 'cdk-github-microvm-runners' const imagePipelineProps: ImagePipelineProps = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--6) | **Name** | **Type** | **Description** | | --------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `image` | `RunnerImage` | The runner image to build: `RunnerImage.fromOptions()` for a synthesized Dockerfile, `RunnerImage.fromInline(text)` for Dockerfile text, or `RunnerImage.fromDockerfile(dir)` for a Dockerfile and build context on disk. | | `network` | `RunnerNetwork` | How the build and the VMs reach the network. | | `runnerSetId` | `string` | Identifier for the runner set this image belongs to, used verbatim as the image’s name. | | `size` | `MicrovmSize` | The size the built image runs at, which becomes its memory floor. | | `baseImageVersion` | `string` | Version of the managed `al2023-1` base image to build from. | | `imageLogs` | `ImageLogs` | Where the build’s logs go. | | `readyTimeoutSeconds` | `number` | Seconds the service waits for the in-VM agent’s `/ready` hook before failing the image build. | | `runTimeoutSeconds` | `number` | Seconds the service waits for the in-VM agent’s `/run` hook to accept a launch. | *** ##### `image`Required[]() [Section titled “imageRequired ”](#imagerequired-) ```typescript public readonly image: RunnerImage; ``` * *Type:* [RunnerImage](#cdk-github-microvm-runners.RunnerImage) The runner image to build: `RunnerImage.fromOptions()` for a synthesized Dockerfile, `RunnerImage.fromInline(text)` for Dockerfile text, or `RunnerImage.fromDockerfile(dir)` for a Dockerfile and build context on disk. *** ##### `network`Required[]() [Section titled “networkRequired ”](#networkrequired-) ```typescript public readonly network: RunnerNetwork; ``` * *Type:* [RunnerNetwork](#cdk-github-microvm-runners.RunnerNetwork) How the build and the VMs reach the network. *** ##### `runnerSetId`Required[]() [Section titled “runnerSetIdRequired ”](#runnersetidrequired-) ```typescript public readonly runnerSetId: string; ``` * *Type:* string Identifier for the runner set this image belongs to, used verbatim as the image’s name. It is deliberately stable: the name is the only property whose change forces CloudFormation to replace the image, so holding it still keeps every content change an in-place update that adds a version. A content change is still picked up — the build context is a content-hashed CDK asset, so it moves `codeArtifact` — and an unchanged one is still a no-op. Must match `^[a-zA-Z0-9-_]+$` and be 64 characters or fewer, the service’s name limit. *** ##### `size`Required[]() [Section titled “sizeRequired ”](#sizerequired-) ```typescript public readonly size: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) The size the built image runs at, which becomes its memory floor. *** ##### `baseImageVersion`Optional[]() [Section titled “baseImageVersionOptional ”](#baseimageversionoptional-) ```typescript public readonly baseImageVersion: string; ``` * *Type:* string * *Default:* ‘0’ Version of the managed `al2023-1` base image to build from. *** ##### `imageLogs`Optional[]() [Section titled “imageLogsOptional ”](#imagelogsoptional--1) ```typescript public readonly imageLogs: ImageLogs; ``` * *Type:* [ImageLogs](#cdk-github-microvm-runners.ImageLogs) Where the build’s logs go. Left unset, the build emits no logs; `ImageLogs.enabled()` sends them to the platform’s group, and `ImageLogs.enabled(logGroup)` to a group you supply. *** ##### `readyTimeoutSeconds`Optional[]() [Section titled “readyTimeoutSecondsOptional ”](#readytimeoutsecondsoptional-) ```typescript public readonly readyTimeoutSeconds: number; ``` * *Type:* number * *Default:* 300 Seconds the service waits for the in-VM agent’s `/ready` hook before failing the image build. *** ##### `runTimeoutSeconds`Optional[]() [Section titled “runTimeoutSecondsOptional ”](#runtimeoutsecondsoptional-) ```typescript public readonly runTimeoutSeconds: number; ``` * *Type:* number * *Default:* 60 Seconds the service waits for the in-VM agent’s `/run` hook to accept a launch. Service maximum: 60. *** ### MicrovmIdlePolicy[]() [Section titled “MicrovmIdlePolicy ”](#microvmidlepolicy-) When the platform suspends and resumes a runner class’s cold-launched VMs. This mirrors the MicroVM service’s own `idlePolicy` shape, expressed as `Duration`s rather than raw seconds. Set it on a runner class through `RunnerClassProps.idlePolicy`. #### Initializer[]() [Section titled “Initializer ”](#initializer--5) ```typescript import { MicrovmIdlePolicy } from 'cdk-github-microvm-runners' const microvmIdlePolicy: MicrovmIdlePolicy = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--7) | **Name** | **Type** | **Description** | | ------------------- | ---------------------- | ------------------------------------------------------------------ | | `maxIdleDuration` | `aws-cdk-lib.Duration` | Idle time before the platform auto-suspends the VM. | | `suspendedDuration` | `aws-cdk-lib.Duration` | How long a suspended VM is kept before the platform terminates it. | | `autoResume` | `boolean` | Auto-resume the VM on activity. | *** ##### `maxIdleDuration`Required[]() [Section titled “maxIdleDurationRequired ”](#maxidledurationrequired-) ```typescript public readonly maxIdleDuration: Duration; ``` * *Type:* aws-cdk-lib.Duration Idle time before the platform auto-suspends the VM. *** ##### `suspendedDuration`Required[]() [Section titled “suspendedDurationRequired ”](#suspendeddurationrequired-) ```typescript public readonly suspendedDuration: Duration; ``` * *Type:* aws-cdk-lib.Duration How long a suspended VM is kept before the platform terminates it. Required. The MicroVM service rejects a launch whose idle policy omits this value, and it offers no value meaning “keep the suspended VM indefinitely”, so every idle policy names a duration. *** ##### `autoResume`Optional[]() [Section titled “autoResumeOptional ”](#autoresumeoptional-) ```typescript public readonly autoResume: boolean; ``` * *Type:* boolean * *Default:* false Auto-resume the VM on activity. *** ### RunnerAlarmOptions[]() [Section titled “RunnerAlarmOptions ”](#runneralarmoptions-) Tuning for the ready-made alarms on `GithubMicrovmRunnersMetrics`. #### Initializer[]() [Section titled “Initializer ”](#initializer--6) ```typescript import { RunnerAlarmOptions } from 'cdk-github-microvm-runners' const runnerAlarmOptions: RunnerAlarmOptions = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--8) | **Name** | **Type** | **Description** | | ------------------- | ---------------------- | ----------------------------------------------------- | | `evaluationPeriods` | `number` | Consecutive breaching periods before the alarm fires. | | `period` | `aws-cdk-lib.Duration` | Aggregation period for the metric. | | `threshold` | `number` | Value at or above which the alarm fires. | *** ##### `evaluationPeriods`Optional[]() [Section titled “evaluationPeriodsOptional ”](#evaluationperiodsoptional-) ```typescript public readonly evaluationPeriods: number; ``` * *Type:* number * *Default:* 1 (3 for the stuck-launch alarm) Consecutive breaching periods before the alarm fires. *** ##### `period`Optional[]() [Section titled “periodOptional ”](#periodoptional-) ```typescript public readonly period: Duration; ``` * *Type:* aws-cdk-lib.Duration * *Default:* Duration.minutes(5) Aggregation period for the metric. *** ##### `threshold`Optional[]() [Section titled “thresholdOptional ”](#thresholdoptional-) ```typescript public readonly threshold: number; ``` * *Type:* number * *Default:* 1 Value at or above which the alarm fires. *** ### RunnerClass[]() [Section titled “RunnerClass ”](#runnerclass--1) Handle returned by `GithubMicrovmRunners.addRunnerClass`. #### Initializer[]() [Section titled “Initializer ”](#initializer--7) ```typescript import { RunnerClass } from 'cdk-github-microvm-runners' const runnerClass: RunnerClass = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--9) | **Name** | **Type** | **Description** | | --------------- | --------------- | -------------------------------------------------------------------------- | | `imageArn` | `string` | ARN of this class’s built MicroVM image (a CloudFormation token at synth). | | `imagePipeline` | `ImagePipeline` | The image pipeline that builds and publishes this class’s MicroVM image. | | `label` | `string` | The `runs-on` label workflows target to run on this class. | | `size` | `MicrovmSize` | The VM memory floor this class launches at. | *** ##### `imageArn`Required[]() [Section titled “imageArnRequired ”](#imagearnrequired--1) ```typescript public readonly imageArn: string; ``` * *Type:* string ARN of this class’s built MicroVM image (a CloudFormation token at synth). *** ##### `imagePipeline`Required[]() [Section titled “imagePipelineRequired ”](#imagepipelinerequired-) ```typescript public readonly imagePipeline: ImagePipeline; ``` * *Type:* [ImagePipeline](#cdk-github-microvm-runners.ImagePipeline) The image pipeline that builds and publishes this class’s MicroVM image. *** ##### `label`Required[]() [Section titled “labelRequired ”](#labelrequired--2) ```typescript public readonly label: string; ``` * *Type:* string The `runs-on` label workflows target to run on this class. *** ##### `size`Required[]() [Section titled “sizeRequired ”](#sizerequired--1) ```typescript public readonly size: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) The VM memory floor this class launches at. *** ### RunnerClassProps[]() [Section titled “RunnerClassProps ”](#runnerclassprops-) Props for `GithubMicrovmRunners.addRunnerClass`. #### Initializer[]() [Section titled “Initializer ”](#initializer--8) ```typescript import { RunnerClassProps } from 'cdk-github-microvm-runners' const runnerClassProps: RunnerClassProps = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--10) | **Name** | **Type** | **Description** | | -------------- | ------------------- | ----------------------------------------------------------------------- | | `size` | `MicrovmSize` | VM memory floor for this class. | | `idlePolicy` | `MicrovmIdlePolicy` | Auto-suspend and auto-resume policy for this class’s cold-launched VMs. | | `image` | `RunnerImage` | Image this class builds from. | | `warmPoolSize` | `number` | How many pre-booted, suspended VMs to keep ready for this class. | *** ##### `size`Required[]() [Section titled “sizeRequired ”](#sizerequired--2) ```typescript public readonly size: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) VM memory floor for this class. *** ##### `idlePolicy`Optional[]() [Section titled “idlePolicyOptional ”](#idlepolicyoptional-) ```typescript public readonly idlePolicy: MicrovmIdlePolicy; ``` * *Type:* [MicrovmIdlePolicy](#cdk-github-microvm-runners.MicrovmIdlePolicy) * *Default:* undefined (no idle policy; the platform never auto-suspends) Auto-suspend and auto-resume policy for this class’s cold-launched VMs. Mutually exclusive with `warmPoolSize` on the same class, since both drive the VM’s suspended state; setting both throws at `addRunnerClass` time. *** ##### `image`Optional[]() [Section titled “imageOptional ”](#imageoptional-) ```typescript public readonly image: RunnerImage; ``` * *Type:* [RunnerImage](#cdk-github-microvm-runners.RunnerImage) * *Default:* RunnerImage.fromOptions() Image this class builds from. *** ##### `warmPoolSize`Optional[]() [Section titled “warmPoolSizeOptional ”](#warmpoolsizeoptional-) ```typescript public readonly warmPoolSize: number; ``` * *Type:* number * *Default:* undefined (no warm pool; every job cold-launches) How many pre-booted, suspended VMs to keep ready for this class. A job that matches this class resumes one of them instead of cold-launching a new VM, and falls back to a cold launch when none is available. This is a count, not a flag: `warmPoolSize: 3` keeps three VMs ready. The runner set refills the pool on the `warmPoolInterval` schedule. *** ### RunnerImageOptions[]() [Section titled “RunnerImageOptions ”](#runnerimageoptions-) Options for `RunnerImage.fromOptions`. #### Initializer[]() [Section titled “Initializer ”](#initializer--9) ```typescript import { RunnerImageOptions } from 'cdk-github-microvm-runners' const runnerImageOptions: RunnerImageOptions = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--11) | **Name** | **Type** | **Description** | | -------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `additionalOsCapabilities` | `string[]` | Extra Linux capabilities granted to the MicroVM’s operating system. | | `assets` | `ImageAsset[]` | Extra files and directories to copy into the image. | | `environment` | `{[ key: string ]: string}` | Extra environment variables baked into the image. | | `runnerVersion` | `RunnerVersion` | `actions/runner` release to install. | | `setupCommands` | `string[]` | Extra `RUN` commands, executed in order after packages, assets, and environment variables are laid down. | | `systemPackages` | `string[]` | Extra `dnf` packages to install alongside the fixed base set. | | `toolchains` | `RunnerToolchain[]` | Language runtimes to bake into the hosted tool cache, so `actions/setup-*` finds them without downloading anything. | *** ##### `additionalOsCapabilities`Optional[]() [Section titled “additionalOsCapabilitiesOptional ”](#additionaloscapabilitiesoptional-) ```typescript public readonly additionalOsCapabilities: string[]; ``` * *Type:* string\[] * *Default:* \[‘ALL’] Extra Linux capabilities granted to the MicroVM’s operating system. *** ##### `assets`Optional[]() [Section titled “assetsOptional ”](#assetsoptional-) ```typescript public readonly assets: ImageAsset[]; ``` * *Type:* [ImageAsset](#cdk-github-microvm-runners.ImageAsset)\[] Extra files and directories to copy into the image. *** ##### `environment`Optional[]() [Section titled “environmentOptional ”](#environmentoptional-) ```typescript public readonly environment: {[ key: string ]: string}; ``` * *Type:* {\[ key: string ]: string} Extra environment variables baked into the image. *** ##### `runnerVersion`Optional[]() [Section titled “runnerVersionOptional ”](#runnerversionoptional-) ```typescript public readonly runnerVersion: RunnerVersion; ``` * *Type:* [RunnerVersion](#cdk-github-microvm-runners.RunnerVersion) * *Default:* RunnerVersion.libraryDefault() `actions/runner` release to install. *** ##### `setupCommands`Optional[]() [Section titled “setupCommandsOptional ”](#setupcommandsoptional-) ```typescript public readonly setupCommands: string[]; ``` * *Type:* string\[] Extra `RUN` commands, executed in order after packages, assets, and environment variables are laid down. *** ##### `systemPackages`Optional[]() [Section titled “systemPackagesOptional ”](#systempackagesoptional-) ```typescript public readonly systemPackages: string[]; ``` * *Type:* string\[] Extra `dnf` packages to install alongside the fixed base set. *** ##### `toolchains`Optional[]() [Section titled “toolchainsOptional ”](#toolchainsoptional-) ```typescript public readonly toolchains: RunnerToolchain[]; ``` * *Type:* [RunnerToolchain](#cdk-github-microvm-runners.RunnerToolchain)\[] * *Default:* \[] Language runtimes to bake into the hosted tool cache, so `actions/setup-*` finds them without downloading anything. An image with none of these is smaller; one toolchain entry is needed per version your workflows ask for. *** ### RunnerNetworkVpcOptions[]() [Section titled “RunnerNetworkVpcOptions ”](#runnernetworkvpcoptions-) Options for `RunnerNetwork.vpc`. #### Initializer[]() [Section titled “Initializer ”](#initializer--10) ```typescript import { RunnerNetworkVpcOptions } from 'cdk-github-microvm-runners' const runnerNetworkVpcOptions: RunnerNetworkVpcOptions = { ... } ``` #### Properties[]() [Section titled “Properties ”](#properties--12) | **Name** | **Type** | **Description** | | ---------------- | -------------------------------------- | -------------------------------------------------------- | | `securityGroups` | `aws-cdk-lib.aws_ec2.ISecurityGroup[]` | Security groups attached to the connector’s ENIs. | | `subnets` | `aws-cdk-lib.aws_ec2.SubnetSelection` | Which of the VPC’s subnets the connector’s ENIs land in. | *** ##### `securityGroups`Optional[]() [Section titled “securityGroupsOptional ”](#securitygroupsoptional-) ```typescript public readonly securityGroups: ISecurityGroup[]; ``` * *Type:* aws-cdk-lib.aws\_ec2.ISecurityGroup\[] * *Default:* a new security group is created on the VPC Security groups attached to the connector’s ENIs. *** ##### `subnets`Optional[]() [Section titled “subnetsOptional ”](#subnetsoptional-) ```typescript public readonly subnets: SubnetSelection; ``` * *Type:* aws-cdk-lib.aws\_ec2.SubnetSelection * *Default:* the VPC’s private-with-egress subnets (CDK’s `selectSubnets()` default; falls back to isolated, then public, subnets if the VPC has none of the preceding kind) Which of the VPC’s subnets the connector’s ENIs land in. *** ## Classes[]() [Section titled “Classes ”](#classes-) ### ConsoleLogs[]() [Section titled “ConsoleLogs ”](#consolelogs-) Runtime console capture for a runner set: everything a VM prints while it boots, runs the runner agent, and runs the job. Off unless you add it. Console capture needs a VM execution role. The platform writes these logs with the VM’s own role, and the construct never creates a VM identity on your behalf, so a runner set that turns console capture on without `vmExecutionRole` fails at synth. The two console-write actions on the group are granted by you as well, since the construct does not add policy to a role it did not create: ```ts runners.vmConsoleLogGroup!.grant( role, 'logs:CreateLogStream', 'logs:PutLogEvents', ); ``` A MicroVM’s instance metadata service serves the execution role’s credentials to arbitrary job code, so whatever that role can do, every job running on this runner set can do. Console capture on its own needs nothing beyond the two log-write actions above. Job code can also write whatever it likes into the console group, so the contents are as trustworthy as the jobs that produced them. `ImageLogs` covers the build-time counterpart and needs no role. The two are independent and can both be on. *Example* ```typescript new GithubMicrovmRunners(stack, 'Runners', { github, scope, vmExecutionRole: role, consoleLogs: ConsoleLogs.enabled(), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--2) | **Name** | **Description** | | --------- | ---------------------------- | | `enabled` | Capture the runtime console. | *** ##### `enabled`[]() [Section titled “enabled ”](#enabled-) ```typescript import { ConsoleLogs } from 'cdk-github-microvm-runners' ConsoleLogs.enabled(logGroup?: ILogGroup) ``` Capture the runtime console. With no argument the construct creates a log group and exposes it as `runners.vmConsoleLogGroup`. Pass an `ILogGroup` to use one whose retention and KMS key you control. Either way the runner set needs `vmExecutionRole`, and that role needs the two console-write actions on the group. *Example* ```typescript const consoleCapture = ConsoleLogs.enabled(myConsoleLogGroup); ``` ###### `logGroup`Optional[]() [Section titled “logGroupOptional ”](#loggroupoptional-) * *Type:* aws-cdk-lib.aws\_logs.ILogGroup destination group. Omitted, the construct creates one with the runner set’s `logRetention` (two weeks by default). *** #### Properties[]() [Section titled “Properties ”](#properties--13) | **Name** | **Type** | **Description** | | ---------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `logGroup` | `aws-cdk-lib.aws_logs.ILogGroup` | The group console output goes to, when one was passed to `ConsoleLogs.enabled()`. `undefined` means the construct creates one. | *** ##### `logGroup`Optional[]() [Section titled “logGroupOptional ”](#loggroupoptional--1) ```typescript public readonly logGroup: ILogGroup; ``` * *Type:* aws-cdk-lib.aws\_logs.ILogGroup The group console output goes to, when one was passed to `ConsoleLogs.enabled()`. `undefined` means the construct creates one. *** ### GithubAppId[]() [Section titled “GithubAppId ”](#githubappid-) Where a GitHub App’s numeric ID comes from: a literal known at synth time, or a Secrets Manager secret read at runtime. The secret form makes setup single-pass. A GitHub App can only be created once the runner set’s webhook URL exists, so an App ID that has to be known at synth means deploying twice. Referencing the ID by secret, the way the private key and webhook secret already are, lets you deploy, then create the App and write its ID into the secret, with no redeploy. Build one with the static factories below; the constructor is private. *Example* ```typescript const appId = GithubAppId.fromSecret( Secret.fromSecretNameV2(stack, 'AppId', 'microvm-runner/dev/app-id'), ); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--3) | **Name** | **Description** | | ------------ | ------------------------------------------------------------------------------------------ | | `fromSecret` | The App ID is read at runtime from a Secrets Manager secret whose value is the numeric ID. | | `fromValue` | The App ID is a literal string known at synth time. | *** ##### `fromSecret`[]() [Section titled “fromSecret ”](#fromsecret-) ```typescript import { GithubAppId } from 'cdk-github-microvm-runners' GithubAppId.fromSecret(secret: ISecret) ``` The App ID is read at runtime from a Secrets Manager secret whose value is the numeric ID. The secret need not exist at deploy time. *Example* ```typescript const appId = GithubAppId.fromSecret( Secret.fromSecretNameV2(stack, 'AppId', 'microvm-runner/dev/app-id'), ); ``` ###### `secret`Required[]() [Section titled “secretRequired ”](#secretrequired-) * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret *** ##### `fromValue`[]() [Section titled “fromValue ”](#fromvalue-) ```typescript import { GithubAppId } from 'cdk-github-microvm-runners' GithubAppId.fromValue(value: string) ``` The App ID is a literal string known at synth time. *Example* ```typescript const appId = GithubAppId.fromValue('123456'); ``` ###### `value`Required[]() [Section titled “valueRequired ”](#valuerequired-) * *Type:* string *** #### Properties[]() [Section titled “Properties ”](#properties--14) | **Name** | **Type** | **Description** | | -------- | ---------------------------------------- | --------------------------------------------------------------- | | `secret` | `aws-cdk-lib.aws_secretsmanager.ISecret` | The secret holding the ID, for an ID built with `fromSecret()`. | | `value` | `string` | The literal ID, for an ID built with `fromValue()`. | *** ##### `secret`Optional[]() [Section titled “secretOptional ”](#secretoptional-) ```typescript public readonly secret: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret The secret holding the ID, for an ID built with `fromSecret()`. *** ##### `value`Optional[]() [Section titled “valueOptional ”](#valueoptional-) ```typescript public readonly value: string; ``` * *Type:* string The literal ID, for an ID built with `fromValue()`. *** ### GithubAppKey[]() [Section titled “GithubAppKey ”](#githubappkey-) Where a GitHub App’s private key lives: a Secrets Manager secret holding the PEM, or a KMS key that signs the App’s JWTs directly. Build one with the static factories below; the constructor is private. *Example* ```typescript const privateKey = GithubAppKey.fromSecret( Secret.fromSecretNameV2(stack, 'AppKey', 'microvm-runner/dev/app-private-key'), ); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--4) | **Name** | **Description** | | ------------ | -------------------------------------------------------------- | | `fromKmsKey` | The App’s private key lives in KMS and is used via `kms:Sign`. | | `fromSecret` | The App’s private key is stored as a PEM in Secrets Manager. | *** ##### `fromKmsKey`[]() [Section titled “fromKmsKey ”](#fromkmskey-) ```typescript import { GithubAppKey } from 'cdk-github-microvm-runners' GithubAppKey.fromKmsKey(key: IKey) ``` The App’s private key lives in KMS and is used via `kms:Sign`. *Example* ```typescript import { Key } from 'aws-cdk-lib/aws-kms'; const privateKey = GithubAppKey.fromKmsKey( Key.fromKeyArn( stack, 'AppKey', 'arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab', ), ); ``` ###### `key`Required[]() [Section titled “keyRequired ”](#keyrequired-) * *Type:* aws-cdk-lib.aws\_kms.IKey *** ##### `fromSecret`[]() [Section titled “fromSecret ”](#fromsecret--1) ```typescript import { GithubAppKey } from 'cdk-github-microvm-runners' GithubAppKey.fromSecret(secret: ISecret) ``` The App’s private key is stored as a PEM in Secrets Manager. *Example* ```typescript const privateKey = GithubAppKey.fromSecret( Secret.fromSecretNameV2(stack, 'AppKey', 'microvm-runner/dev/app-private-key'), ); ``` ###### `secret`Required[]() [Section titled “secretRequired ”](#secretrequired--1) * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret *** #### Properties[]() [Section titled “Properties ”](#properties--15) | **Name** | **Type** | **Description** | | -------- | ---------------------------------------- | ---------------------------------------------------------------- | | `kmsKey` | `aws-cdk-lib.aws_kms.IKey` | The signing key, for a key built with `fromKmsKey()`. | | `secret` | `aws-cdk-lib.aws_secretsmanager.ISecret` | The secret holding the PEM, for a key built with `fromSecret()`. | *** ##### `kmsKey`Optional[]() [Section titled “kmsKeyOptional ”](#kmskeyoptional-) ```typescript public readonly kmsKey: IKey; ``` * *Type:* aws-cdk-lib.aws\_kms.IKey The signing key, for a key built with `fromKmsKey()`. *** ##### `secret`Optional[]() [Section titled “secretOptional ”](#secretoptional--1) ```typescript public readonly secret: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret The secret holding the PEM, for a key built with `fromSecret()`. *** ### GithubAuth[]() [Section titled “GithubAuth ”](#githubauth-) How a runner set authenticates to GitHub: as a GitHub App, whose private key is backed by a secret or a KMS key, or with a personal access token. Either form also carries the webhook secret that inbound GitHub deliveries are validated against. Build one with the static factories below; the constructor is private. *Example* ```typescript const auth = GithubAuth.app({ appId: GithubAppId.fromSecret( Secret.fromSecretNameV2(stack, 'AppId', 'microvm-runner/dev/app-id'), ), privateKey: GithubAppKey.fromSecret( Secret.fromSecretNameV2(stack, 'AppKey', 'microvm-runner/dev/app-private-key'), ), webhookSecret: Secret.fromSecretNameV2( stack, 'WebhookSecret', 'microvm-runner/dev/webhook-secret', ), }); ``` #### Methods[]() [Section titled “Methods ”](#methods--2) | **Name** | **Description** | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bindEnv` | Serialize to the environment variables the runner set’s handlers read: `GH_AUTH_KIND`, `GH_APP_ID` or `GH_APP_ID_SECRET_ARN`, `GH_KEY_SECRET_ARN` or `GH_KEY_KMS_ARN`, `GH_PAT_SECRET_ARN`, and `GH_WEBHOOK_SECRET_ARN`. | | `grantRead` | Grant `grantee` read access to whichever credentials this auth carries: the App’s secret-backed key and/or `kms:Sign` on its KMS key, plus its secret-backed App ID when one is used, or the PAT secret — plus, in every case, read access to the webhook secret. | | `grantReadWebhookSecret` | Grant `grantee` read access to the webhook secret, and nothing else. | *** ##### `bindEnv`[]() [Section titled “bindEnv ”](#bindenv-) ```typescript public bindEnv(): {[ key: string ]: string} ``` Serialize to the environment variables the runner set’s handlers read: `GH_AUTH_KIND`, `GH_APP_ID` or `GH_APP_ID_SECRET_ARN`, `GH_KEY_SECRET_ARN` or `GH_KEY_KMS_ARN`, `GH_PAT_SECRET_ARN`, and `GH_WEBHOOK_SECRET_ARN`. Entries that do not apply are left out. ##### `grantRead`[]() [Section titled “grantRead ”](#grantread-) ```typescript public grantRead(grantee: IGrantable): void ``` Grant `grantee` read access to whichever credentials this auth carries: the App’s secret-backed key and/or `kms:Sign` on its KMS key, plus its secret-backed App ID when one is used, or the PAT secret — plus, in every case, read access to the webhook secret. This is the full set, for a handler that has to act as the App. A handler that only verifies signatures wants {@link grantReadWebhookSecret}. ###### `grantee`Required[]() [Section titled “granteeRequired ”](#granteerequired-) * *Type:* aws-cdk-lib.aws\_iam.IGrantable *** ##### `grantReadWebhookSecret`[]() [Section titled “grantReadWebhookSecret ”](#grantreadwebhooksecret-) ```typescript public grantReadWebhookSecret(grantee: IGrantable): void ``` Grant `grantee` read access to the webhook secret, and nothing else. This is all a handler needs to verify the HMAC signature GitHub sends with every delivery. It is deliberately separate from {@link grantRead}, which also hands over the credentials that can act AS the App — minting installation tokens, registering runners. A component that only checks signatures and enqueues has no use for those, and the webhook handler is the one component reachable from the public internet. *Example* ```typescript github.grantReadWebhookSecret(role); ``` ###### `grantee`Required[]() [Section titled “granteeRequired ”](#granteerequired--1) * *Type:* aws-cdk-lib.aws\_iam.IGrantable *** #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--5) | **Name** | **Description** | | -------- | ------------------------------------------ | | `app` | Authenticate as a GitHub App. | | `pat` | Authenticate with a personal access token. | *** ##### `app`[]() [Section titled “app ”](#app-) ```typescript import { GithubAuth } from 'cdk-github-microvm-runners' GithubAuth.app(props: GithubAppAuthProps) ``` Authenticate as a GitHub App. *Example* ```typescript const auth = GithubAuth.app({ appId: GithubAppId.fromSecret( Secret.fromSecretNameV2(stack, 'AppId', 'microvm-runner/dev/app-id'), ), privateKey: GithubAppKey.fromSecret( Secret.fromSecretNameV2(stack, 'AppKey', 'microvm-runner/dev/app-private-key'), ), webhookSecret: Secret.fromSecretNameV2( stack, 'WebhookSecret', 'microvm-runner/dev/webhook-secret', ), }); ``` ###### `props`Required[]() [Section titled “propsRequired ”](#propsrequired--3) * *Type:* [GithubAppAuthProps](#cdk-github-microvm-runners.GithubAppAuthProps) *** ##### `pat`[]() [Section titled “pat ”](#pat-) ```typescript import { GithubAuth } from 'cdk-github-microvm-runners' GithubAuth.pat(props: GithubPatAuthProps) ``` Authenticate with a personal access token. *Example* ```typescript const auth = GithubAuth.pat({ token: Secret.fromSecretNameV2(stack, 'Pat', 'microvm-runner/dev/token'), webhookSecret: Secret.fromSecretNameV2( stack, 'WebhookSecret', 'microvm-runner/dev/webhook-secret', ), }); ``` ###### `props`Required[]() [Section titled “propsRequired ”](#propsrequired--4) * *Type:* [GithubPatAuthProps](#cdk-github-microvm-runners.GithubPatAuthProps) *** #### Properties[]() [Section titled “Properties ”](#properties--16) | **Name** | **Type** | **Description** | | --------------- | ---------------------------------------- | --------------------------------------------------------------------------- | | `kind` | `GithubAuthKind` | Whether this is App or personal-access-token authentication. | | `webhookSecret` | `aws-cdk-lib.aws_secretsmanager.ISecret` | Secret holding the webhook secret inbound deliveries are validated against. | | `appId` | `GithubAppId` | The App’s ID, for App authentication. | | `privateKey` | `GithubAppKey` | The App’s private key, for App authentication. | | `token` | `aws-cdk-lib.aws_secretsmanager.ISecret` | The personal access token secret, for token authentication. | *** ##### `kind`Required[]() [Section titled “kindRequired ”](#kindrequired-) ```typescript public readonly kind: GithubAuthKind; ``` * *Type:* [GithubAuthKind](#cdk-github-microvm-runners.GithubAuthKind) Whether this is App or personal-access-token authentication. *** ##### `webhookSecret`Required[]() [Section titled “webhookSecretRequired ”](#webhooksecretrequired--2) ```typescript public readonly webhookSecret: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret Secret holding the webhook secret inbound deliveries are validated against. *** ##### `appId`Optional[]() [Section titled “appIdOptional ”](#appidoptional-) ```typescript public readonly appId: GithubAppId; ``` * *Type:* [GithubAppId](#cdk-github-microvm-runners.GithubAppId) The App’s ID, for App authentication. *** ##### `privateKey`Optional[]() [Section titled “privateKeyOptional ”](#privatekeyoptional-) ```typescript public readonly privateKey: GithubAppKey; ``` * *Type:* [GithubAppKey](#cdk-github-microvm-runners.GithubAppKey) The App’s private key, for App authentication. *** ##### `token`Optional[]() [Section titled “tokenOptional ”](#tokenoptional-) ```typescript public readonly token: ISecret; ``` * *Type:* aws-cdk-lib.aws\_secretsmanager.ISecret The personal access token secret, for token authentication. *** ### GithubMicrovmRunnersMetrics[]() [Section titled “GithubMicrovmRunnersMetrics ”](#githubmicrovmrunnersmetrics-) The CloudWatch metrics a runner set reports, and ready-made alarms over them. A `GithubMicrovmRunners` exposes its own as `runners.metrics`. The metrics live in the `MicrovmRunners` namespace, tagged with the runner set’s id. The janitor reports one set of counters per sweep, the launcher one per launch, and the warm pool one per refill sweep. Launcher and warm-pool metrics are also tagged with the runner class the launch belongs to, which is why those accessors take a runner-class label. Every method here names one of those metrics, or the dead-letter queue’s own SQS metric; the class carries no data of its own. Everything except `deadLetterQueueDepth` reports only when `GithubMicrovmRunnersProps.emitMetrics` is on. The accessors return a `Metric` either way, so a dashboard can be built ahead of turning metrics on. The two alarms over those metrics throw at synth instead, rather than synthesizing an alarm that could never fire. *Example* ```typescript new cw.Alarm(stack, 'SweepErrors', { metric: runners.metrics.errors(), threshold: 1, evaluationPeriods: 1, }); ``` #### Initializers[]() [Section titled “Initializers ”](#initializers--2) ```typescript import { GithubMicrovmRunnersMetrics } from 'cdk-github-microvm-runners' new GithubMicrovmRunnersMetrics(runnerSetId: string, deadLetterQueue: IQueue, emitMetrics?: boolean, janitorInterval?: Duration, queuedJobScanUnavailable?: boolean) ``` | **Name** | **Type** | **Description** | | -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `runnerSetId` | `string` | *No description.* | | `deadLetterQueue` | `aws-cdk-lib.aws_sqs.IQueue` | *No description.* | | `emitMetrics` | `boolean` | Whether the runner set reports the metrics this class names, which is `GithubMicrovmRunnersProps.emitMetrics`. Every accessor except `deadLetterQueueDepth` depends on it, though they all return a `Metric` either way; only the two alarms over those metrics refuse to synthesize. | | `janitorInterval` | `aws-cdk-lib.Duration` | The runner set’s `GithubMicrovmRunnersProps.janitorInterval`, which sets how often the queued-job-age metric is emitted. {@link queuedJobAgeAlarm} derives its aggregation period from this so the alarm never evaluates a period the janitor could not have written a datapoint into (which would make missing-data-breaching page permanently). Optional and trailing to keep this constructor backward compatible and jsii-clean. | | `queuedJobScanUnavailable` | `boolean` | Whether the queued-job scan is unavailable for this runner set’s auth + scope combination \u2014 true only under PAT auth with org scope, where `listInstallationRepos` cannot enumerate the org’s repos (GitHub 403s a PAT there). | *** ##### `runnerSetId`Required[]() [Section titled “runnerSetIdRequired ”](#runnersetidrequired--1) * *Type:* string *** ##### `deadLetterQueue`Required[]() [Section titled “deadLetterQueueRequired ”](#deadletterqueuerequired--1) * *Type:* aws-cdk-lib.aws\_sqs.IQueue *** ##### `emitMetrics`Optional[]() [Section titled “emitMetricsOptional ”](#emitmetricsoptional--1) * *Type:* boolean Whether the runner set reports the metrics this class names, which is `GithubMicrovmRunnersProps.emitMetrics`. Every accessor except `deadLetterQueueDepth` depends on it, though they all return a `Metric` either way; only the two alarms over those metrics refuse to synthesize. *** ##### `janitorInterval`Optional[]() [Section titled “janitorIntervalOptional ”](#janitorintervaloptional--1) * *Type:* aws-cdk-lib.Duration The runner set’s `GithubMicrovmRunnersProps.janitorInterval`, which sets how often the queued-job-age metric is emitted. {@link queuedJobAgeAlarm} derives its aggregation period from this so the alarm never evaluates a period the janitor could not have written a datapoint into (which would make missing-data-breaching page permanently). Optional and trailing to keep this constructor backward compatible and jsii-clean. *** ##### `queuedJobScanUnavailable`Optional[]() [Section titled “queuedJobScanUnavailableOptional ”](#queuedjobscanunavailableoptional-) * *Type:* boolean Whether the queued-job scan is unavailable for this runner set’s auth + scope combination \u2014 true only under PAT auth with org scope, where `listInstallationRepos` cannot enumerate the org’s repos (GitHub 403s a PAT there). The janitor skips the scan in that configuration, so `oldestQueuedJobSeconds`/`queuedJobs` never report and {@link queuedJobAgeAlarm} throws at synth rather than synthesizing an alarm that could only ever page on missing data. *** #### Methods[]() [Section titled “Methods ”](#methods--3) | **Name** | **Description** | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cancelledBeforeLaunch` | Launches skipped because the job had already stopped waiting for a runner by the time the launch was processed — cancelled, or its run deleted. | | `capacityRejected` | Launches the MicroVM service rejected for capacity. | | `capacityRejectedAlarm` | Alarm when the MicroVM service is rejecting a runner class’s launches for capacity — the class’s quota signal. | | `coldBoot` | Launches served by booting a new VM, because no warm VM was available or the class keeps no warm pool. | | `coldSpinUpMs` | Milliseconds to spin up a cold launch: starting the VM, waiting for it to boot, and pushing the runner’s registration. | | `deadLetterQueueDepth` | Messages sitting in the dead-letter queue: a launch or terminate intent SQS gave up redriving. | | `deadLetterQueueNotEmptyAlarm` | Alarm when the dead-letter queue is not empty, meaning SQS gave up redriving a launch or terminate intent. | | `errors` | Janitor sweep count: failures on individual VMs, rows, or image versions during a sweep. | | `imageVersionsPruned` | Janitor sweep count: inactive MicroVM image versions pruned past `keepImageVersions`. | | `lifetimeKills` | Janitor sweep count: VMs terminated for having run longer than `maxJobDuration` plus the platform’s own grace. | | `oldestQueuedJobSeconds` | Janitor per-class gauge (seconds): how long the oldest queued job this class owns has waited, measured read-only each sweep, or 0 when the class has nothing queued. | | `orphansReaped` | Janitor sweep count: running VMs that belong to this runner set but have no row in the runner table, reaped once a second sweep has seen the same VM unaccounted for. | | `poolCurrent` | Warm VMs suspended and available for this class as of the last warm-pool sweep. | | `poolLaunched` | Warm VMs the last warm-pool sweep launched to reach `warmPoolSize`. | | `poolLaunchFailed` | Warm-VM launches a warm-pool sweep attempted and failed. | | `poolTarget` | This class’s `warmPoolSize`, as the last warm-pool sweep read it. | | `queuedJobAgeAlarm` | Alarm when a runner class has a job that has waited queued far longer than a runner should take to appear — the signal that NO launch happened at all, which the reap-based alarms structurally cannot catch. | | `queuedJobs` | Janitor per-class gauge: queued jobs this class owned as of the last sweep (see {@link oldestQueuedJobSeconds} for ownership). | | `queuedJobScanTruncated` | Janitor per-sweep flag: 1 when a sweep hit its GitHub-listing cap and did not scan every queued job, so `oldestQueuedJobSeconds`/`queuedJobs` may under-report that sweep. | | `stuckClaimsRelaunched` | Janitor sweep count: launches that were claimed but never served, re-launched from the orphaned claim. | | `stuckLaunchesRecovered` | Janitor sweep count: dead-lettered launches re-driven onto the job queue, which is 0 unless `recoverStuckLaunches` is on. | | `stuckLaunchesRecoveredAlarm` | Alarm on stuck-launch recoveries, the dead-lettered launches the janitor re-drove, which only happens with `recoverStuckLaunches` on. | | `stuckRunnersReaped` | Janitor sweep count: runners that registered with GitHub and then sat idle past `idleRunnerGraceSeconds`, reaped once a second sweep has seen them the same way. | | `stuckRunnersReapedAlarm` | Alarm when the janitor is reaping stuck runners in volume, the signal a refused runner version leaves. | | `suspectsCleared` | Janitor sweep count: VMs an earlier sweep had marked as suspect, cleared because this sweep found them accounted for or working again. | | `sweepErrorsAlarm` | Alarm on janitor sweep errors, the per-item failures a sweep isolates and continues past. | | `tableRowsCleaned` | Janitor sweep count: runner table rows deleted, either because the VM they name is confirmed gone or because a real row superseded an orphaned one. | | `unregisteredRelaunched` | Janitor sweep count: still-queued jobs re-launched the moment their runner was reaped as `unregistered` (a VM that launched but never registered with GitHub). | | `unregisteredRelaunchExhausted` | Janitor sweep count: jobs RELEASED (claim deleted) at the shared relaunch bound because they had already been re-launched the maximum number of times across BOTH recovery paths (the `unregistered` reap and the committed-claim reconciliation) and still never registered. | | `warmHit` | Launches served from the warm pool: a pre-booted VM claimed and resumed rather than a new one launched. | | `warmSpinUpMs` | Milliseconds to spin up a warm launch: claiming the VM, resuming it, and pushing the runner’s registration. | | `warmThrottled` | Warm-pool claims that were throttled and fell back to booting a new VM. | *** ##### `cancelledBeforeLaunch`[]() [Section titled “cancelledBeforeLaunch ”](#cancelledbeforelaunch-) ```typescript public cancelledBeforeLaunch(runnerClassLabel: string): Metric ``` Launches skipped because the job had already stopped waiting for a runner by the time the launch was processed — cancelled, or its run deleted. No VM is booted for these, so a rising count is work avoided rather than work lost. It tracks how often jobs are cancelled while still queued, which is routine on a repository using concurrency groups: every re-push cancels the run it superseded. A count that dwarfs `ColdBoot` suggests the workflows feeding this runner set are cancelled more often than they finish, which is usually a question about their triggers rather than about the runner set. *Example* ```typescript new cw.Alarm(stack, 'MostlyCancelled', { metric: runners.metrics.cancelledBeforeLaunch('microvm'), threshold: 50, evaluationPeriods: 3, }); ``` ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired-) * *Type:* string *** ##### `capacityRejected`[]() [Section titled “capacityRejected ”](#capacityrejected-) ```typescript public capacityRejected(runnerClassLabel: string): Metric ``` Launches the MicroVM service rejected for capacity. This is the runner set’s quota signal: a value that stays above zero means jobs are queueing behind a MicroVM quota, or behind `maxConcurrentVms`, rather than running, and each rejected launch spends one of its `maxReceiveCount` redrives on the way to the dead-letter queue. See docs/service-quotas.md. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--1) * *Type:* string *** ##### `capacityRejectedAlarm`[]() [Section titled “capacityRejectedAlarm ”](#capacityrejectedalarm-) ```typescript public capacityRejectedAlarm(scope: Construct, runnerClassLabel: string, options?: RunnerAlarmOptions): Alarm ``` Alarm when the MicroVM service is rejecting a runner class’s launches for capacity — the class’s quota signal. Each rejection is a job queueing behind the account’s MicroVM memory quota or the set’s `maxConcurrentVms` rather than running, and spends one of its `maxReceiveCount` redrives on the way to the dead-letter queue. It fires on one rejection in each of three consecutive 5-minute periods, so a lone burst that the next launch clears does not page; pass `RunnerAlarmOptions` to change that. See docs/service-quotas.md for raising the ceiling. `capacityRejected` is dimensioned per runner class, so this builds one alarm per `runnerClassLabel` under an id unique to that label — call it once per class you want watched, in the same scope, without collision. Requires `GithubMicrovmRunnersProps.emitMetrics`, and throws at synth without it. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--3) * *Type:* constructs.Construct *** ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--2) * *Type:* string *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional-) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `coldBoot`[]() [Section titled “coldBoot ”](#coldboot-) ```typescript public coldBoot(runnerClassLabel: string): Metric ``` Launches served by booting a new VM, because no warm VM was available or the class keeps no warm pool. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--3) * *Type:* string *** ##### `coldSpinUpMs`[]() [Section titled “coldSpinUpMs ”](#coldspinupms-) ```typescript public coldSpinUpMs(runnerClassLabel: string): Metric ``` Milliseconds to spin up a cold launch: starting the VM, waiting for it to boot, and pushing the runner’s registration. Reported as an average rather than a sum. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--4) * *Type:* string *** ##### `deadLetterQueueDepth`[]() [Section titled “deadLetterQueueDepth ”](#deadletterqueuedepth-) ```typescript public deadLetterQueueDepth(): Metric ``` Messages sitting in the dead-letter queue: a launch or terminate intent SQS gave up redriving. ##### `deadLetterQueueNotEmptyAlarm`[]() [Section titled “deadLetterQueueNotEmptyAlarm ”](#deadletterqueuenotemptyalarm-) ```typescript public deadLetterQueueNotEmptyAlarm(scope: Construct, options?: RunnerAlarmOptions): Alarm ``` Alarm when the dead-letter queue is not empty, meaning SQS gave up redriving a launch or terminate intent. A runner set that is keeping up holds this at 0, so any sustained depth means jobs are being dropped, unless `recoverStuckLaunches` is draining them. It fires on one message over a single 5-minute period; pass `RunnerAlarmOptions` to change that, and `alarm.addAlarmAction()` to route it. This is the one alarm here that works without `GithubMicrovmRunnersProps.emitMetrics`, because it watches the dead-letter queue’s own SQS metric rather than one the handlers report. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--4) * *Type:* constructs.Construct *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional--1) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `errors`[]() [Section titled “errors ”](#errors-) ```typescript public errors(): Metric ``` Janitor sweep count: failures on individual VMs, rows, or image versions during a sweep. The sweep isolates each one and still completes. ##### `imageVersionsPruned`[]() [Section titled “imageVersionsPruned ”](#imageversionspruned-) ```typescript public imageVersionsPruned(): Metric ``` Janitor sweep count: inactive MicroVM image versions pruned past `keepImageVersions`. ##### `lifetimeKills`[]() [Section titled “lifetimeKills ”](#lifetimekills-) ```typescript public lifetimeKills(): Metric ``` Janitor sweep count: VMs terminated for having run longer than `maxJobDuration` plus the platform’s own grace. ##### `oldestQueuedJobSeconds`[]() [Section titled “oldestQueuedJobSeconds ”](#oldestqueuedjobseconds-) ```typescript public oldestQueuedJobSeconds(runnerClassLabel: string): Metric ``` Janitor per-class gauge (seconds): how long the oldest queued job this class owns has waited, measured read-only each sweep, or 0 when the class has nothing queued. A job is owned by a class when every runner label it requests beyond `self-hosted` is that class’s label — GitHub’s own matching — so another runner set’s jobs are excluded. Emitted every sweep for every class, which is what lets {@link queuedJobAgeAlarm} treat missing data as breaching. Reported as an average across a period; the alarm reads it as a maximum. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--5) * *Type:* string *** ##### `orphansReaped`[]() [Section titled “orphansReaped ”](#orphansreaped-) ```typescript public orphansReaped(): Metric ``` Janitor sweep count: running VMs that belong to this runner set but have no row in the runner table, reaped once a second sweep has seen the same VM unaccounted for. ##### `poolCurrent`[]() [Section titled “poolCurrent ”](#poolcurrent-) ```typescript public poolCurrent(runnerClassLabel: string): Metric ``` Warm VMs suspended and available for this class as of the last warm-pool sweep. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--6) * *Type:* string *** ##### `poolLaunched`[]() [Section titled “poolLaunched ”](#poollaunched-) ```typescript public poolLaunched(runnerClassLabel: string): Metric ``` Warm VMs the last warm-pool sweep launched to reach `warmPoolSize`. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--7) * *Type:* string *** ##### `poolLaunchFailed`[]() [Section titled “poolLaunchFailed ”](#poollaunchfailed-) ```typescript public poolLaunchFailed(runnerClassLabel: string): Metric ``` Warm-VM launches a warm-pool sweep attempted and failed. A value that stays above zero means the pool is not reaching `warmPoolSize`, so jobs keep booting new VMs instead of resuming warm ones. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--8) * *Type:* string *** ##### `poolTarget`[]() [Section titled “poolTarget ”](#pooltarget-) ```typescript public poolTarget(runnerClassLabel: string): Metric ``` This class’s `warmPoolSize`, as the last warm-pool sweep read it. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--9) * *Type:* string *** ##### `queuedJobAgeAlarm`[]() [Section titled “queuedJobAgeAlarm ”](#queuedjobagealarm-) ```typescript public queuedJobAgeAlarm(scope: Construct, runnerClassLabel: string, options?: RunnerAlarmOptions): Alarm ``` Alarm when a runner class has a job that has waited queued far longer than a runner should take to appear — the signal that NO launch happened at all, which the reap-based alarms structurally cannot catch. A misrouted or dropped webhook, a GitHub App failure, a launch bug, or a MicroVM quota wall all leave the same fingerprint: a job sits `queued` and no VM is ever created for it, so there is no stuck runner to reap and no error to count. The janitor measures the oldest queued job per class every sweep, so this fires when a class’s oldest queued job crosses the threshold (20 minutes by default) over its aggregation period, read as a maximum. It also **treats missing data as breaching**: the metric is emitted every sweep, so its absence means the janitor itself has gone silent — itself a failure worth paging on — rather than “nothing queued”. A sweep that overruns its Lambda timeout emits no datapoint for that period, so a persistently slow or crashing janitor reads exactly like a silent one and pages: that is the intended “janitor went silent” signal, not a false alarm. Because absence pages, the aggregation period must be long enough that the janitor writes at least one datapoint into every period it evaluates. A 5-minute period against a 10-minute `janitorInterval` would leave every other period empty and page forever. So the default period is `max(5 min, 2 × janitorInterval)` rounded up to a CloudWatch-valid whole minute (two sweeps per period, so a single missed sweep does not trip it): 10 minutes at the default 5-minute interval, 20 minutes at a 10-minute interval. A caller-supplied `period` shorter than `janitorInterval` is rejected at synth — it would guarantee empty periods and permanent false pages. `oldestQueuedJobSeconds` is dimensioned per runner class, so this builds one alarm per `runnerClassLabel` under an id unique to that label — call it once per class you want watched, in the same scope, without collision. Requires `GithubMicrovmRunnersProps.emitMetrics`, and throws at synth without it. Also throws at synth under PAT auth with org scope, where the queued-job scan cannot enumerate the org’s repos and never emits the metric this alarm watches. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--5) * *Type:* constructs.Construct *** ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--10) * *Type:* string *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional--2) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `queuedJobs`[]() [Section titled “queuedJobs ”](#queuedjobs-) ```typescript public queuedJobs(runnerClassLabel: string): Metric ``` Janitor per-class gauge: queued jobs this class owned as of the last sweep (see {@link oldestQueuedJobSeconds} for ownership). ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--11) * *Type:* string *** ##### `queuedJobScanTruncated`[]() [Section titled “queuedJobScanTruncated ”](#queuedjobscantruncated-) ```typescript public queuedJobScanTruncated(): Metric ``` Janitor per-sweep flag: 1 when a sweep hit its GitHub-listing cap and did not scan every queued job, so `oldestQueuedJobSeconds`/`queuedJobs` may under-report that sweep. ##### `stuckClaimsRelaunched`[]() [Section titled “stuckClaimsRelaunched ”](#stuckclaimsrelaunched-) ```typescript public stuckClaimsRelaunched(): Metric ``` Janitor sweep count: launches that were claimed but never served, re-launched from the orphaned claim. This is 0 unless `recoverStuckLaunches` is on. ##### `stuckLaunchesRecovered`[]() [Section titled “stuckLaunchesRecovered ”](#stucklaunchesrecovered-) ```typescript public stuckLaunchesRecovered(): Metric ``` Janitor sweep count: dead-lettered launches re-driven onto the job queue, which is 0 unless `recoverStuckLaunches` is on. A value that stays high means launches are failing for some reason other than a GitHub outage. ##### `stuckLaunchesRecoveredAlarm`[]() [Section titled “stuckLaunchesRecoveredAlarm ”](#stucklaunchesrecoveredalarm-) ```typescript public stuckLaunchesRecoveredAlarm(scope: Construct, options?: RunnerAlarmOptions): Alarm ``` Alarm on stuck-launch recoveries, the dead-lettered launches the janitor re-drove, which only happens with `recoverStuckLaunches` on. Recoveries that keep coming mean launches are failing for some reason other than a GitHub outage. It fires on one recovery in each of three consecutive 5-minute periods, which rides out a real outage; pass `RunnerAlarmOptions` to change that. Requires `GithubMicrovmRunnersProps.emitMetrics`, and throws at synth without it. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--6) * *Type:* constructs.Construct *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional--3) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `stuckRunnersReaped`[]() [Section titled “stuckRunnersReaped ”](#stuckrunnersreaped-) ```typescript public stuckRunnersReaped(): Metric ``` Janitor sweep count: runners that registered with GitHub and then sat idle past `idleRunnerGraceSeconds`, reaped once a second sweep has seen them the same way. ##### `stuckRunnersReapedAlarm`[]() [Section titled “stuckRunnersReapedAlarm ”](#stuckrunnersreapedalarm-) ```typescript public stuckRunnersReapedAlarm(scope: Construct, options?: RunnerAlarmOptions): Alarm ``` Alarm when the janitor is reaping stuck runners in volume, the signal a refused runner version leaves. A stuck runner is one that registered with GitHub and then sat idle past `idleRunnerGraceSeconds`, reaped once a second sweep saw it the same way; a healthy runner set reaps the odd one. The threshold comes from an incident. On 2026-09-24 GitHub refused a stale runner version, so every launched runner registered and then went nowhere; `stuckRunnersReaped` ran 14–28 an hour for about six hours, peaking at 8–10 in a single five-minute sweep, against a month-long baseline that never exceeded 3 in any hour. The three ready-made alarms already on this class stayed OK throughout — nothing else caught it. A sum of 6 or more over one fifteen-minute period sits well above that baseline (≤0.75 per quarter-hour) and well below the incident’s rate, so it fires about fifteen minutes into a refusal and stays quiet otherwise; pass `RunnerAlarmOptions` to change the threshold, window, or count. Requires `GithubMicrovmRunnersProps.emitMetrics`, and throws at synth without it. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--7) * *Type:* constructs.Construct *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional--4) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `suspectsCleared`[]() [Section titled “suspectsCleared ”](#suspectscleared-) ```typescript public suspectsCleared(): Metric ``` Janitor sweep count: VMs an earlier sweep had marked as suspect, cleared because this sweep found them accounted for or working again. ##### `sweepErrorsAlarm`[]() [Section titled “sweepErrorsAlarm ”](#sweeperrorsalarm-) ```typescript public sweepErrorsAlarm(scope: Construct, options?: RunnerAlarmOptions): Alarm ``` Alarm on janitor sweep errors, the per-item failures a sweep isolates and continues past. A value that stays above zero means the runner set is failing to reconcile — VMs left running, runners left unreaped — even though each sweep completes. It fires on one error in each of three consecutive 5-minute periods; pass `RunnerAlarmOptions` to change that. The three periods are the point. A single sweep error is usually a transient fault the next sweep sails past — a GitHub API call that lost its connection, a throttled describe — and the sweep is convergent, so the work is retried five minutes later either way. Alarming on one such blip pages an operator for something already fixed by the time they read it. A genuine reconciliation failure (expired credentials, a broken table, a revoked App installation) fails every sweep, so it still announces itself within fifteen minutes. Requires `GithubMicrovmRunnersProps.emitMetrics`, and throws at synth without it. ###### `scope`Required[]() [Section titled “scopeRequired ”](#scoperequired--8) * *Type:* constructs.Construct *** ###### `options`Optional[]() [Section titled “optionsOptional ”](#optionsoptional--5) * *Type:* [RunnerAlarmOptions](#cdk-github-microvm-runners.RunnerAlarmOptions) *** ##### `tableRowsCleaned`[]() [Section titled “tableRowsCleaned ”](#tablerowscleaned-) ```typescript public tableRowsCleaned(): Metric ``` Janitor sweep count: runner table rows deleted, either because the VM they name is confirmed gone or because a real row superseded an orphaned one. ##### `unregisteredRelaunched`[]() [Section titled “unregisteredRelaunched ”](#unregisteredrelaunched-) ```typescript public unregisteredRelaunched(): Metric ``` Janitor sweep count: still-queued jobs re-launched the moment their runner was reaped as `unregistered` (a VM that launched but never registered with GitHub). This is 0 unless `recoverStuckLaunches` is on. A value that stays high means VMs are routinely failing to register — alarm on it. ##### `unregisteredRelaunchExhausted`[]() [Section titled “unregisteredRelaunchExhausted ”](#unregisteredrelaunchexhausted-) ```typescript public unregisteredRelaunchExhausted(): Metric ``` Janitor sweep count: jobs RELEASED (claim deleted) at the shared relaunch bound because they had already been re-launched the maximum number of times across BOTH recovery paths (the `unregistered` reap and the committed-claim reconciliation) and still never registered. The released job stays `queued` for the operator to re-run once the cause is fixed. Any non-zero value is a job that stranded despite recovery — alarm on it. This is 0 unless `recoverStuckLaunches` is on. ##### `warmHit`[]() [Section titled “warmHit ”](#warmhit-) ```typescript public warmHit(runnerClassLabel: string): Metric ``` Launches served from the warm pool: a pre-booted VM claimed and resumed rather than a new one launched. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--12) * *Type:* string *** ##### `warmSpinUpMs`[]() [Section titled “warmSpinUpMs ”](#warmspinupms-) ```typescript public warmSpinUpMs(runnerClassLabel: string): Metric ``` Milliseconds to spin up a warm launch: claiming the VM, resuming it, and pushing the runner’s registration. Reported as an average rather than a sum. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--13) * *Type:* string *** ##### `warmThrottled`[]() [Section titled “warmThrottled ”](#warmthrottled-) ```typescript public warmThrottled(runnerClassLabel: string): Metric ``` Warm-pool claims that were throttled and fell back to booting a new VM. The same launch can also count under `ColdBoot` or `CapacityRejected`. ###### `runnerClassLabel`Required[]() [Section titled “runnerClassLabelRequired ”](#runnerclasslabelrequired--14) * *Type:* string *** ### ImageLogs[]() [Section titled “ImageLogs ”](#imagelogs-) Build-time image logs for a runner set: the Docker build layers and the ready-probe banner an image emits while it is built. Off unless you add it. These logs are written while the image builds, by the image build role rather than by a VM, so image logging needs no VM execution role and puts no credentials on a runner. `ConsoleLogs` covers the runtime counterpart, which does need a role. The two are independent and can both be on. *Example* ```typescript new GithubMicrovmRunners(stack, 'Runners', { github, scope, imageLogs: ImageLogs.enabled(), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--6) | **Name** | **Description** | | --------- | ------------------------------------ | | `enabled` | Send image-build logs to CloudWatch. | *** ##### `enabled`[]() [Section titled “enabled ”](#enabled--1) ```typescript import { ImageLogs } from 'cdk-github-microvm-runners' ImageLogs.enabled(logGroup?: ILogGroup) ``` Send image-build logs to CloudWatch. With no argument they go to the platform’s own group (`/aws/lambda-microvms/…`). Pass an `ILogGroup` to send them to a group whose retention and KMS key you control. *Example* ```typescript const buildLogs = ImageLogs.enabled(myBuildLogGroup); ``` ###### `logGroup`Optional[]() [Section titled “logGroupOptional ”](#loggroupoptional--2) * *Type:* aws-cdk-lib.aws\_logs.ILogGroup destination group. Omitted, the platform’s own group. *** #### Properties[]() [Section titled “Properties ”](#properties--17) | **Name** | **Type** | **Description** | | ---------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `logGroup` | `aws-cdk-lib.aws_logs.ILogGroup` | The group build logs go to, when one was passed to `ImageLogs.enabled()`. `undefined` means the platform’s own group. | *** ##### `logGroup`Optional[]() [Section titled “logGroupOptional ”](#loggroupoptional--3) ```typescript public readonly logGroup: ILogGroup; ``` * *Type:* aws-cdk-lib.aws\_logs.ILogGroup The group build logs go to, when one was passed to `ImageLogs.enabled()`. `undefined` means the platform’s own group. *** ### MicrovmSize[]() [Section titled “MicrovmSize ”](#microvmsize-) The memory a MicroVM runs with. Each runner class picks one preset, and that preset becomes the memory floor of the image the class builds. Pick from the static presets below; the constructor is private. A preset is a **floor, not an allocation**. It is the minimum the image is built with, and the platform provisions above it — measured at roughly four times the request, so a class on `GB1` has been observed booting with about 4 GB and 2 vCPU, and one on `GB4` with about 16 GB and 8 vCPU. Two things follow: a workload usually fits a smaller preset than its memory figure suggests, and the account’s memory quota is charged the measured allocation rather than the floor. The service quotas guide carries the arithmetic. vCPU and disk follow from the preset and are not separately settable — the image resource takes a memory floor and nothing else. *Example* ```typescript runners.addRunnerClass('microvm-8gb', { size: MicrovmSize.GB8 }); ``` #### Properties[]() [Section titled “Properties ”](#properties--18) | **Name** | **Type** | **Description** | | ----------- | -------- | ----------------------------------------------------------------------- | | `memoryGb` | `number` | Memory floor in GB. | | `memoryMib` | `number` | Memory in MiB, the unit the MicroVM image’s `minimumMemoryInMiB` takes. | *** ##### `memoryGb`Required[]() [Section titled “memoryGbRequired ”](#memorygbrequired-) ```typescript public readonly memoryGb: number; ``` * *Type:* number Memory floor in GB. *** ##### `memoryMib`Required[]() [Section titled “memoryMibRequired ”](#memorymibrequired-) ```typescript public readonly memoryMib: number; ``` * *Type:* number Memory in MiB, the unit the MicroVM image’s `minimumMemoryInMiB` takes. *** #### Constants[]() [Section titled “Constants ”](#constants-) | **Name** | **Type** | **Description** | | -------- | ------------- | ----------------------- | | `GB0_5` | `MicrovmSize` | Memory floor of 0.5 GB. | | `GB1` | `MicrovmSize` | Memory floor of 1 GB. | | `GB2` | `MicrovmSize` | Memory floor of 2 GB. | | `GB4` | `MicrovmSize` | Memory floor of 4 GB. | | `GB8` | `MicrovmSize` | Memory floor of 8 GB. | *** ##### `GB0_5`Required[]() [Section titled “GB0\_5Required ”](#gb0_5required-) ```typescript public readonly GB0_5: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) Memory floor of 0.5 GB. *** ##### `GB1`Required[]() [Section titled “GB1Required ”](#gb1required-) ```typescript public readonly GB1: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) Memory floor of 1 GB. *** ##### `GB2`Required[]() [Section titled “GB2Required ”](#gb2required-) ```typescript public readonly GB2: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) Memory floor of 2 GB. *** ##### `GB4`Required[]() [Section titled “GB4Required ”](#gb4required-) ```typescript public readonly GB4: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) Memory floor of 4 GB. *** ##### `GB8`Required[]() [Section titled “GB8Required ”](#gb8required-) ```typescript public readonly GB8: MicrovmSize; ``` * *Type:* [MicrovmSize](#cdk-github-microvm-runners.MicrovmSize) Memory floor of 8 GB. *** ### RunnerImage[]() [Section titled “RunnerImage ”](#runnerimage-) The image a runner class’s VMs boot from: one this library synthesizes, or one you author yourself. Build one with the static factories below; the constructor is private. *Example* ```typescript runners.addRunnerClass('build', { size: MicrovmSize.GB4, image: RunnerImage.fromOptions({ systemPackages: ['jq', 'ripgrep'], }), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--7) | **Name** | **Description** | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fromDockerfile` | Use your own Dockerfile, and the build context around it, from the directory `dir`. | | `fromInline` | Use your own Dockerfile, supplied as text. | | `fromOptions` | Synthesize a Dockerfile from `opts` — extra packages, setup commands, assets, environment variables, toolchains, and the `actions/runner` release to install. | *** ##### `fromDockerfile`[]() [Section titled “fromDockerfile ”](#fromdockerfile-) ```typescript import { RunnerImage } from 'cdk-github-microvm-runners' RunnerImage.fromDockerfile(dir: string) ``` Use your own Dockerfile, and the build context around it, from the directory `dir`. The whole directory is staged as the Docker build context, so a Dockerfile that needs to `COPY` files of its own belongs here rather than in `RunnerImage.fromInline`. The `contentHash` recorded on the returned instance is derived from the path string. The directory’s actual contents are read and hashed later, when the image pipeline stages them as a CDK asset. A relative `dir` is resolved against the process working directory, which is wherever `cdk` was invoked. Anchor it to the file that declares the runner class instead by passing `path.join(__dirname, 'runner-image')`. *Example* ```typescript const customImage = RunnerImage.fromDockerfile('runner-image'); ``` ###### `dir`Required[]() [Section titled “dirRequired ”](#dirrequired-) * *Type:* string *** ##### `fromInline`[]() [Section titled “fromInline ”](#frominline-) ```typescript import { RunnerImage } from 'cdk-github-microvm-runners' RunnerImage.fromInline(dockerfile: string) ``` Use your own Dockerfile, supplied as text. The text is staged verbatim as the build context’s `Dockerfile`, alongside the `microvm-runner/` directory the image pipeline injects and nothing else. A Dockerfile that needs to `COPY` files of its own belongs with `RunnerImage.fromDockerfile`, which stages a whole directory. The text must `COPY microvm-runner/agent.mjs` and start the staged entrypoint, because that agent is what serves the MicroVM lifecycle hooks the platform calls. This is checked here, and a Dockerfile that does not copy the agent throws. The `contentHash` recorded on the returned instance is a sha256 over the supplied text. *Example* ```typescript const inlineImage = RunnerImage.fromInline(` FROM public.ecr.aws/lambda/microvms:al2023-minimal RUN dnf install -y git jq COPY microvm-runner/agent.mjs /opt/microvm-runner/agent.mjs COPY microvm-runner/entrypoint.sh /opt/microvm-runner/entrypoint.sh ENTRYPOINT ["/opt/microvm-runner/entrypoint.sh"] `); ``` ###### `dockerfile`Required[]() [Section titled “dockerfileRequired ”](#dockerfilerequired-) * *Type:* string *** ##### `fromOptions`[]() [Section titled “fromOptions ”](#fromoptions-) ```typescript import { RunnerImage } from 'cdk-github-microvm-runners' RunnerImage.fromOptions(opts?: RunnerImageOptions) ``` Synthesize a Dockerfile from `opts` — extra packages, setup commands, assets, environment variables, toolchains, and the `actions/runner` release to install. The image’s `contentHash` is computed here, over the rendered Dockerfile text and the list of assets it copies. *Example* ```typescript const buildImage = RunnerImage.fromOptions({ systemPackages: ['jq', 'ripgrep'], setupCommands: ['npm install -g pnpm@10'], environment: { LANG: 'C.UTF-8' }, toolchains: [RunnerToolchain.python('3.12.7')], }); ``` ###### `opts`Optional[]() [Section titled “optsOptional ”](#optsoptional-) * *Type:* [RunnerImageOptions](#cdk-github-microvm-runners.RunnerImageOptions) *** #### Properties[]() [Section titled “Properties ”](#properties--19) | **Name** | **Type** | **Description** | | -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `additionalOsCapabilities` | `string[]` | Extra Linux capabilities granted to the MicroVM’s operating system. | | `contentHash` | `string` | sha256 content hash, part of the built image’s name. | | `assets` | `ImageAsset[]` | The `{source, target}` pairs from `RunnerImageOptions.assets`. Set for `fromOptions()`, whose rendered Dockerfile copies each one into the image. `undefined` for `fromDockerfile()`, which stages your whole directory instead, and for `fromInline()`, whose build context holds the Dockerfile and the injected agent and nothing else. | | `dockerfile` | `string` | Dockerfile text: rendered for `fromOptions()`, supplied by you for `fromInline()`. | | `dockerfileDir` | `string` | The directory holding your own Dockerfile and build context. | *** ##### `additionalOsCapabilities`Required[]() [Section titled “additionalOsCapabilitiesRequired ”](#additionaloscapabilitiesrequired-) ```typescript public readonly additionalOsCapabilities: string[]; ``` * *Type:* string\[] Extra Linux capabilities granted to the MicroVM’s operating system. `fromOptions()` takes this from `RunnerImageOptions`; `fromDockerfile()` and `fromInline()` always carry `['ALL']`. *** ##### `contentHash`Required[]() [Section titled “contentHashRequired ”](#contenthashrequired-) ```typescript public readonly contentHash: string; ``` * *Type:* string sha256 content hash, part of the built image’s name. For `fromOptions()` it covers the rendered Dockerfile and the options that produced it; for `fromInline()`, the supplied Dockerfile text; for `fromDockerfile()`, the directory path. *** ##### `assets`Optional[]() [Section titled “assetsOptional ”](#assetsoptional--1) ```typescript public readonly assets: ImageAsset[]; ``` * *Type:* [ImageAsset](#cdk-github-microvm-runners.ImageAsset)\[] The `{source, target}` pairs from `RunnerImageOptions.assets`. Set for `fromOptions()`, whose rendered Dockerfile copies each one into the image. `undefined` for `fromDockerfile()`, which stages your whole directory instead, and for `fromInline()`, whose build context holds the Dockerfile and the injected agent and nothing else. *** ##### `dockerfile`Optional[]() [Section titled “dockerfileOptional ”](#dockerfileoptional-) ```typescript public readonly dockerfile: string; ``` * *Type:* string Dockerfile text: rendered for `fromOptions()`, supplied by you for `fromInline()`. `undefined` for `fromDockerfile()`, whose Dockerfile lives on disk under `dockerfileDir`. *** ##### `dockerfileDir`Optional[]() [Section titled “dockerfileDirOptional ”](#dockerfilediroptional-) ```typescript public readonly dockerfileDir: string; ``` * *Type:* string The directory holding your own Dockerfile and build context. Set for `fromDockerfile()`, `undefined` for `fromOptions()` and `fromInline()`, which both carry their Dockerfile as `dockerfile` text. *** ### RunnerNetwork[]() [Section titled “RunnerNetwork ”](#runnernetwork-) How a runner set’s MicroVMs reach the network: direct internet egress, Lambda VPC runtime connectors you already have, or a connector the construct builds from a CDK VPC. Build one with the static factories below; the constructor is private. *Example* ```typescript new GithubMicrovmRunners(stack, 'Runners', { github, scope, network: RunnerNetwork.vpc(vpc), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--8) | **Name** | **Description** | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `internetEgress` | Runners egress directly to the internet (no VPC connector). | | `vpc` | Runners egress through a network connector the construct builds from the given CDK VPC, along with the security group and the ENI-management operator role that connector needs. | | `vpcConnector` | Runners are attached to the given Lambda runtime connector ARNs. | *** ##### `internetEgress`[]() [Section titled “internetEgress ”](#internetegress-) ```typescript import { RunnerNetwork } from 'cdk-github-microvm-runners' RunnerNetwork.internetEgress() ``` Runners egress directly to the internet (no VPC connector). *Example* ```typescript const network = RunnerNetwork.internetEgress(); ``` ##### `vpc`[]() [Section titled “vpc ”](#vpc-) ```typescript import { RunnerNetwork } from 'cdk-github-microvm-runners' RunnerNetwork.vpc(vpc: IVpc, opts?: RunnerNetworkVpcOptions) ``` Runners egress through a network connector the construct builds from the given CDK VPC, along with the security group and the ENI-management operator role that connector needs. You supply the VPC; no connector ARN is required. `connectorArns` is empty on the returned instance, and the construct fills in the connector’s real ARN at synth. *Example* ```typescript const network = RunnerNetwork.vpc(vpc, { subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, securityGroups: [mySecurityGroup], }); ``` ###### `vpc`Required[]() [Section titled “vpcRequired ”](#vpcrequired-) * *Type:* aws-cdk-lib.aws\_ec2.IVpc *** ###### `opts`Optional[]() [Section titled “optsOptional ”](#optsoptional--1) * *Type:* [RunnerNetworkVpcOptions](#cdk-github-microvm-runners.RunnerNetworkVpcOptions) *** ##### `vpcConnector`[]() [Section titled “vpcConnector ”](#vpcconnector-) ```typescript import { RunnerNetwork } from 'cdk-github-microvm-runners' RunnerNetwork.vpcConnector(connectorArns: string[]) ``` Runners are attached to the given Lambda runtime connector ARNs. *Example* ```typescript const network = RunnerNetwork.vpcConnector([ 'arn:aws:lambda:us-east-1:111122223333:network-connector:my-connector', ]); ``` ###### `connectorArns`Required[]() [Section titled “connectorArnsRequired ”](#connectorarnsrequired-) * *Type:* string\[] *** #### Properties[]() [Section titled “Properties ”](#properties--20) | **Name** | **Type** | **Description** | | ---------------- | -------------------------------------- | ------------------------------------------------------------------------- | | `connectorArns` | `string[]` | Runtime connector ARNs. | | `kind` | `RunnerNetworkKind` | Which networking mode this instance carries. | | `securityGroups` | `aws-cdk-lib.aws_ec2.ISecurityGroup[]` | Security groups for the built connector, set only for a `vpc()` network. | | `sourceVpc` | `aws-cdk-lib.aws_ec2.IVpc` | The VPC to build a connector from, set only for a `vpc()` network. | | `subnets` | `aws-cdk-lib.aws_ec2.SubnetSelection` | Subnet selection for the built connector, set only for a `vpc()` network. | *** ##### `connectorArns`Required[]() [Section titled “connectorArnsRequired ”](#connectorarnsrequired--1) ```typescript public readonly connectorArns: string[]; ``` * *Type:* string\[] Runtime connector ARNs. Empty for direct internet egress, and empty for a `vpc()` network until the construct builds its connector at synth. *** ##### `kind`Required[]() [Section titled “kindRequired ”](#kindrequired--1) ```typescript public readonly kind: RunnerNetworkKind; ``` * *Type:* [RunnerNetworkKind](#cdk-github-microvm-runners.RunnerNetworkKind) Which networking mode this instance carries. *** ##### `securityGroups`Optional[]() [Section titled “securityGroupsOptional ”](#securitygroupsoptional--1) ```typescript public readonly securityGroups: ISecurityGroup[]; ``` * *Type:* aws-cdk-lib.aws\_ec2.ISecurityGroup\[] Security groups for the built connector, set only for a `vpc()` network. *** ##### `sourceVpc`Optional[]() [Section titled “sourceVpcOptional ”](#sourcevpcoptional-) ```typescript public readonly sourceVpc: IVpc; ``` * *Type:* aws-cdk-lib.aws\_ec2.IVpc The VPC to build a connector from, set only for a `vpc()` network. *** ##### `subnets`Optional[]() [Section titled “subnetsOptional ”](#subnetsoptional--1) ```typescript public readonly subnets: SubnetSelection; ``` * *Type:* aws-cdk-lib.aws\_ec2.SubnetSelection Subnet selection for the built connector, set only for a `vpc()` network. *** ### RunnerScope[]() [Section titled “RunnerScope ”](#runnerscope-) Which GitHub scope registered runners are visible to: an entire organization, several organizations, or an explicit list of `owner/repo` repositories. Build one with the static factories below; the constructor is private. *Example* ```typescript const orgScope = RunnerScope.org('my-org'); ``` #### Methods[]() [Section titled “Methods ”](#methods--4) | **Name** | **Description** | | -------- | -------------------------------------------------------------------------------- | | `toJson` | Serialize this scope to the JSON form the runner set’s handlers read at runtime. | *** ##### `toJson`[]() [Section titled “toJson ”](#tojson-) ```typescript public toJson(): string ``` Serialize this scope to the JSON form the runner set’s handlers read at runtime. #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--9) | **Name** | **Description** | | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `org` | Runners are registered at the organization level. | | `orgs` | Runners are registered at the organization level in each listed organization: one runner set serving several organizations. | | `repos` | Runners are registered against an explicit list of `owner/repo` repos. | *** ##### `org`[]() [Section titled “org ”](#org-) ```typescript import { RunnerScope } from 'cdk-github-microvm-runners' RunnerScope.org(org: string) ``` Runners are registered at the organization level. *Example* ```typescript const orgScope = RunnerScope.org('my-org'); ``` ###### `org`Required[]() [Section titled “orgRequired ”](#orgrequired-) * *Type:* string *** ##### `orgs`[]() [Section titled “orgs ”](#orgs-) ```typescript import { RunnerScope } from 'cdk-github-microvm-runners' RunnerScope.orgs(orgs: string[]) ``` Runners are registered at the organization level in each listed organization: one runner set serving several organizations. A job’s runner registers in the organization that owns the job’s repository. Install the runner set’s GitHub App on every listed organization. *Example* ```typescript const multiOrgScope = RunnerScope.orgs(['my-org', 'my-other-org']); ``` ###### `orgs`Required[]() [Section titled “orgsRequired ”](#orgsrequired-) * *Type:* string\[] *** ##### `repos`[]() [Section titled “repos ”](#repos-) ```typescript import { RunnerScope } from 'cdk-github-microvm-runners' RunnerScope.repos(repos: string[]) ``` Runners are registered against an explicit list of `owner/repo` repos. *Example* ```typescript const repoScope = RunnerScope.repos(['my-org/api', 'my-org/web']); ``` ###### `repos`Required[]() [Section titled “reposRequired ”](#reposrequired-) * *Type:* string\[] *** #### Properties[]() [Section titled “Properties ”](#properties--21) | **Name** | **Type** | **Description** | | --------------- | ----------------- | ---------------------------------------------------------------------------------------- | | `kind` | `RunnerScopeKind` | Whether this scope is an organization, several organizations, or a list of repositories. | | `organization` | `string` | The organization, for a scope built with `RunnerScope.org()`. | | `organizations` | `string[]` | The organizations, for a scope built with `RunnerScope.orgs()`. | | `repositories` | `string[]` | The `owner/repo` list, for a scope built with `RunnerScope.repos()`. | *** ##### `kind`Required[]() [Section titled “kindRequired ”](#kindrequired--2) ```typescript public readonly kind: RunnerScopeKind; ``` * *Type:* [RunnerScopeKind](#cdk-github-microvm-runners.RunnerScopeKind) Whether this scope is an organization, several organizations, or a list of repositories. *** ##### `organization`Optional[]() [Section titled “organizationOptional ”](#organizationoptional-) ```typescript public readonly organization: string; ``` * *Type:* string The organization, for a scope built with `RunnerScope.org()`. *** ##### `organizations`Optional[]() [Section titled “organizationsOptional ”](#organizationsoptional-) ```typescript public readonly organizations: string[]; ``` * *Type:* string\[] The organizations, for a scope built with `RunnerScope.orgs()`. *** ##### `repositories`Optional[]() [Section titled “repositoriesOptional ”](#repositoriesoptional-) ```typescript public readonly repositories: string[]; ``` * *Type:* string\[] The `owner/repo` list, for a scope built with `RunnerScope.repos()`. *** ### RunnerToolchain[]() [Section titled “RunnerToolchain ”](#runnertoolchain-) A language runtime baked into the runner image’s hosted tool cache at `/opt/hostedtoolcache`, where `actions/setup-python` and `actions/setup-node` find it without downloading anything. Those actions otherwise fetch OS-specific prebuilt runtimes, which are not published for the AL2023 image these runners use. Several versions can be baked in at once, and a workflow asking for `python-version: "3.12"` matches a baked `3.12.7`. Build one with the static factories below; the constructor is private. *Example* ```typescript const testImage = RunnerImage.fromOptions({ toolchains: [ RunnerToolchain.python('3.12.7'), RunnerToolchain.node('22.11.0'), ], }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--10) | **Name** | **Description** | | -------- | ------------------------------------------------------------------------ | | `node` | Node.js, from the official arm64 tarball. Full semver, e.g. `'22.11.0'`. | | `python` | CPython, built from source. | *** ##### `node`[]() [Section titled “node ”](#node-) ```typescript import { RunnerToolchain } from 'cdk-github-microvm-runners' RunnerToolchain.node(version: string) ``` Node.js, from the official arm64 tarball. Full semver, e.g. `'22.11.0'`. *Example* ```typescript const node = RunnerToolchain.node('22.11.0'); ``` ###### `version`Required[]() [Section titled “versionRequired ”](#versionrequired-) * *Type:* string *** ##### `python`[]() [Section titled “python ”](#python-) ```typescript import { RunnerToolchain } from 'cdk-github-microvm-runners' RunnerToolchain.python(version: string) ``` CPython, built from source. `version` is a full semver, e.g. `'3.12.7'`. *Example* ```typescript const python = RunnerToolchain.python('3.12.7'); ``` ###### `version`Required[]() [Section titled “versionRequired ”](#versionrequired--1) * *Type:* string *** #### Properties[]() [Section titled “Properties ”](#properties--22) | **Name** | **Type** | **Description** | | --------- | --------------- | -------------------------------------------------- | | `kind` | `ToolchainKind` | Which runtime this is. | | `version` | `string` | The full semver release baked in, e.g. `'3.12.7'`. | *** ##### `kind`Required[]() [Section titled “kindRequired ”](#kindrequired--3) ```typescript public readonly kind: ToolchainKind; ``` * *Type:* [ToolchainKind](#cdk-github-microvm-runners.ToolchainKind) Which runtime this is. *** ##### `version`Required[]() [Section titled “versionRequired ”](#versionrequired--2) ```typescript public readonly version: string; ``` * *Type:* string The full semver release baked in, e.g. `'3.12.7'`. *** ### RunnerVersion[]() [Section titled “RunnerVersion ”](#runnerversion-) Which `actions/runner` release to install on the MicroVM image. Build one with the static factories below; the constructor is private. *Example* ```typescript const pinnedImage = RunnerImage.fromOptions({ runnerVersion: RunnerVersion.of('2.328.0'), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--11) | **Name** | **Description** | | ---------------- | ---------------------------------------------------------------------------------------- | | `latest` | Use the `actions/runner` release this library currently pins (`DEFAULT_RUNNER_VERSION`). | | `libraryDefault` | Use the `actions/runner` release this library currently pins (`DEFAULT_RUNNER_VERSION`). | | `of` | Pin an explicit `actions/runner` release, e.g. `"2.319.1"`. | *** ##### ~~`latest`~~[]() [Section titled “latest ”](#latest-) ```typescript import { RunnerVersion } from 'cdk-github-microvm-runners' RunnerVersion.latest() ``` Use the `actions/runner` release this library currently pins (`DEFAULT_RUNNER_VERSION`). *Example* ```typescript const runnerVersion = RunnerVersion.latest(); ``` ##### `libraryDefault`[]() [Section titled “libraryDefault ”](#librarydefault-) ```typescript import { RunnerVersion } from 'cdk-github-microvm-runners' RunnerVersion.libraryDefault() ``` Use the `actions/runner` release this library currently pins (`DEFAULT_RUNNER_VERSION`). No version is carried on the instance; the image build fills the pinned value in at synth. This is the library’s pinned default, not GitHub’s latest release. The library bumps the pin as releases ship, because GitHub stops queuing jobs to a runner more than 30 days behind the newest release; see “Keeping the runner current” in `docs/images.md`. *Example* ```typescript const runnerVersion = RunnerVersion.libraryDefault(); ``` ##### `of`[]() [Section titled “of ”](#of-) ```typescript import { RunnerVersion } from 'cdk-github-microvm-runners' RunnerVersion.of(version: string) ``` Pin an explicit `actions/runner` release, e.g. `"2.319.1"`. *Example* ```typescript const pinnedRunner = RunnerVersion.of('2.328.0'); ``` ###### `version`Required[]() [Section titled “versionRequired ”](#versionrequired--3) * *Type:* string *** #### Properties[]() [Section titled “Properties ”](#properties--23) | **Name** | **Type** | **Description** | | --------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | `version` | `string` | The pinned release, for a version built with `RunnerVersion.of()`. `undefined` for `RunnerVersion.libraryDefault()`. | *** ##### `version`Optional[]() [Section titled “versionOptional ”](#versionoptional-) ```typescript public readonly version: string; ``` * *Type:* string The pinned release, for a version built with `RunnerVersion.of()`. `undefined` for `RunnerVersion.libraryDefault()`. *** ### WebhookEndpoint[]() [Section titled “WebhookEndpoint ”](#webhookendpoint-) How the webhook handler is exposed to GitHub’s `workflow_job` deliveries. The one form today is a Lambda Function URL. It is created with `authType: NONE`; the auth boundary is the HMAC-SHA256 signature GitHub sends with every delivery, which the handler verifies against the webhook secret before it does anything else. *Example* ```typescript new GithubMicrovmRunners(stack, 'Runners', { github, scope, webhook: WebhookEndpoint.functionUrl(), }); ``` #### Static Functions[]() [Section titled “Static Functions ”](#static-functions--12) | **Name** | **Description** | | ------------- | ---------------------------------------------------- | | `functionUrl` | Expose the webhook handler on a Lambda Function URL. | *** ##### `functionUrl`[]() [Section titled “functionUrl ”](#functionurl-) ```typescript import { WebhookEndpoint } from 'cdk-github-microvm-runners' WebhookEndpoint.functionUrl() ``` Expose the webhook handler on a Lambda Function URL. *Example* ```typescript const webhook = WebhookEndpoint.functionUrl(); ``` #### Properties[]() [Section titled “Properties ”](#properties--24) | **Name** | **Type** | **Description** | | -------- | --------------------- | ------------------------------------------------ | | `kind` | `WebhookEndpointKind` | Which form of endpoint this instance represents. | *** ##### `kind`Required[]() [Section titled “kindRequired ”](#kindrequired--4) ```typescript public readonly kind: WebhookEndpointKind; ``` * *Type:* [WebhookEndpointKind](#cdk-github-microvm-runners.WebhookEndpointKind) Which form of endpoint this instance represents. *** ## Enums[]() [Section titled “Enums ”](#enums-) ### GithubAuthKind[]() [Section titled “GithubAuthKind ”](#githubauthkind-) Which credential flow a `GithubAuth` represents. #### Members[]() [Section titled “Members ”](#members-) | **Name** | **Description** | | -------- | ------------------------ | | `APP` | A GitHub App. | | `PAT` | A personal access token. | *** ##### `APP`[]() [Section titled “APP ”](#app--1) A GitHub App. *** ##### `PAT`[]() [Section titled “PAT ”](#pat--1) A personal access token. *** ### RunnerNetworkKind[]() [Section titled “RunnerNetworkKind ”](#runnernetworkkind-) Which networking mode a `RunnerNetwork` carries. #### Members[]() [Section titled “Members ”](#members--1) | **Name** | **Description** | | ------------ | -------------------------------------------------------------------- | | `INTERNET` | Direct internet egress, with no Lambda VPC runtime connector. | | `CONNECTORS` | Runners attached to caller-supplied Lambda runtime connector ARNs. | | `VPC` | Runners attached to a connector the construct builds from a CDK VPC. | *** ##### `INTERNET`[]() [Section titled “INTERNET ”](#internet-) Direct internet egress, with no Lambda VPC runtime connector. *** ##### `CONNECTORS`[]() [Section titled “CONNECTORS ”](#connectors-) Runners attached to caller-supplied Lambda runtime connector ARNs. *** ##### `VPC`[]() [Section titled “VPC ”](#vpc--1) Runners attached to a connector the construct builds from a CDK VPC. *** ### RunnerScopeKind[]() [Section titled “RunnerScopeKind ”](#runnerscopekind-) Which GitHub scope a `RunnerScope` represents. #### Members[]() [Section titled “Members ”](#members--2) | **Name** | **Description** | | -------- | ----------------------------------------------------------------------------------- | | `ORG` | Runners are registered at the organization level. | | `REPOS` | Runners are registered against an explicit list of repositories. | | `ORGS` | Runners are registered at the organization level, in each of several organizations. | *** ##### `ORG`[]() [Section titled “ORG ”](#org--1) Runners are registered at the organization level. *** ##### `REPOS`[]() [Section titled “REPOS ”](#repos--1) Runners are registered against an explicit list of repositories. *** ##### `ORGS`[]() [Section titled “ORGS ”](#orgs--1) Runners are registered at the organization level, in each of several organizations. *** ### ToolchainKind[]() [Section titled “ToolchainKind ”](#toolchainkind-) How a toolchain is installed into the image’s hosted tool cache. #### Members[]() [Section titled “Members ”](#members--3) | **Name** | **Description** | | -------- | ------------------------------------------------------------------------------ | | `PYTHON` | CPython, built from source (`configure --prefix … --enable-shared`) on AL2023. | | `NODE` | Node.js, unpacked from the official nodejs.org linux-arm64 tarball. | *** ##### `PYTHON`[]() [Section titled “PYTHON ”](#python--1) CPython, built from source (`configure --prefix … --enable-shared`) on AL2023. *** ##### `NODE`[]() [Section titled “NODE ”](#node--1) Node.js, unpacked from the official nodejs.org linux-arm64 tarball. *** ### WebhookEndpointKind[]() [Section titled “WebhookEndpointKind ”](#webhookendpointkind-) Which form of endpoint a `WebhookEndpoint` represents. #### Members[]() [Section titled “Members ”](#members--4) | **Name** | **Description** | | -------------- | ---------------------- | | `FUNCTION_URL` | A Lambda Function URL. | *** ##### `FUNCTION_URL`[]() [Section titled “FUNCTION\_URL ”](#function_url-) A Lambda Function URL. *** # Architecture A runner set is one deployment of this construct. It is made up of a webhook handler, an SQS job queue and its dead-letter queue, a launcher, a scheduled janitor, a DynamoDB table, a warm-pool sweeper for any class that keeps one, and one MicroVM image per runner class. A runner class pairs a `runs-on` label with the MicroVM size and image contents that jobs carrying that label run on, and a runner set can define several of them. Each class builds its own image, because a VM’s size is fixed by the image at build time rather than chosen at launch. [Runner images](/guides/images/) covers what an image contains, when it is built, and the disk each size allows. ## The lifecycle [Section titled “The lifecycle”](#the-lifecycle) ``` flowchart TD GH["GitHub Actions — workflow_job event"] --> WH["Webhook Lambda — verifies the signature"] WH --> Q["SQS job queue — launch and terminate intents"] Q --> LN["Launcher Lambda — claims the job, obtains a MicroVM"] LN --> VM["MicroVM — runs one job"] LN -.->|registers a single-use runner| GH LN <--> DB[("DynamoDB runner table")] JAN["Janitor Lambda — scheduled sweep"] -.->|reaps stranded VMs, reconciles state| VM JAN <--> DB ``` 1. The **webhook Lambda**, behind a public Function URL, verifies GitHub’s HMAC-SHA256 signature against the raw request body before parsing it, using a timing-safe comparison, and answers a malformed payload with a 400. A `queued` `workflow_job` whose labels include one of the runner set’s class labels becomes a `launch` intent on the job queue, and a `completed` event for a runner whose name carries the `microvm-runner` prefix becomes a `terminate` intent; anything else is acknowledged and ignored. 2. The **SQS job queue**, with its dead-letter queue, buffers that work and provides at-least-once delivery with partial-batch failure reporting, so a single failing message is redriven on its own. 3. The **launcher Lambda** handles a `launch` intent by first asking GitHub whether the job is still waiting for a runner, and dropping the launch if it is not. A job cancelled while it was still queued never produces a `terminate` intent — its `completed` event names no runner, because none ever claimed it — so without this check a VM would boot for a job that no longer exists and hold a concurrency slot until the janitor reclaimed it. The check fails open: anything short of a definite answer proceeds with the launch, because dropping a live job is far worse than booting a VM nobody needs. It then counts the runner set’s running VMs and defers the message when the set is already at its concurrency cap, and claims the job with a conditional DynamoDB write keyed on `job##`, so that duplicate webhook deliveries, concurrent invocations, and same-batch duplicates all resolve to a single VM. Holding that claim, it registers a single-use runner with GitHub, obtains a VM — resuming a pre-booted warm-pool VM when the matched class keeps one, and otherwise cold-launching one with `RunMicrovm`, with no execution role attached by default — and pushes the runner’s configuration to the VM’s ingress endpoint. On the warm path the VM is resumed first and the runner registered against it; on the cold path the registration exists before the VM does, and the launcher releases it if the launch then fails. The runner-to-VM mapping row and the claim’s VM id are written last and together, in a single transaction. If any step after the VM exists fails, the launcher terminates the VM and releases the claim once the VM is confirmed gone. 4. The launcher handles a `terminate` intent by looking up the runner’s mapping row, terminating the VM that row names, and deleting the row. A `terminate` for a runner name the table does not know is a no-op. 5. The **MicroVM** runs exactly one job — GitHub’s just-in-time runners are single-use. Its in-VM agent takes the pushed configuration and starts the runner; on a Docker-capable image it first starts `dockerd` and waits for it to answer, so a job never starts against a daemon that has not finished coming up (see [Runner images](/guides/images/#containers-in-a-job)). The runner deregisters itself from GitHub when the job ends, and the VM itself is terminated from outside, either by the `terminate` intent above or by the janitor. 6. The **janitor Lambda** runs on a schedule and reconciles the runner set. It terminates VMs that GitHub has lost track of or that never received a job, prunes old image versions, removes stale table rows, and re-launches jobs still queued that never got the runner they were promised. That last duty is the floor under the whole plane: GitHub announces a job once, so without it a launch that goes astray leaves the job waiting with no error anywhere. It is on unless you set `recoverStuckLaunches` to false. When `emitMetrics` is on, each sweep also does one **read-only** pass over GitHub — measuring, per runner class, how long jobs have sat queued — so a job for which no launch ever happened surfaces as a metric even though there is nothing to reconcile. It writes nothing to GitHub or the table for that pass. See [Monitoring](/guides/monitoring/) for the `queuedJobAgeAlarm` this feeds. A runner class that sets `warmPoolSize` adds a seventh piece: the **warm-pool Lambda**, on its own schedule, which keeps that class’s pool at its target by pre-booting VMs and suspending them until a launch claims one. ## Start latency [Section titled “Start latency”](#start-latency) A job waits from the moment GitHub queues it until a runner picks it up. On a GitHub-hosted runner that is two or three seconds, because GitHub keeps machines booted and already registered. Here it is the time to build a machine, and it is worth knowing where it goes before trying to shorten it. Measured on a bench runner, a cold `GB1` launch of a queued job: | Segment | Time | What happens | | ---------------------------------------- | -------- | ---------------------------------------------------- | | Queued → the VM is running | 6.8 s | webhook delivery, the queue, the launcher, VM boot | | VM running → the runner’s first log line | 8.2 s | `run.sh`, the .NET host starting, assemblies loading | | Runner starting → connected to GitHub | 9.0 s | reading its configuration, registering | | Connected → the job begins | 1.0 s | GitHub assigns the queued job to it | | **Total** | **25 s** | | Three roughly equal thirds rather than one dominant cost. Queue latency is also load-dependent: the same measurement ranges from about 25 s on a quiet set to 45 s with fifteen jobs launching at once. **The last two segments are a floor.** A runner configured just-in-time has to start and register for every job, because that is what makes it single-use — the credential it registers with is minted for one job and is useless afterwards. Roughly 17 seconds of the total is that property being enforced. A warm pool removes the first segment, and nothing removes the other two. That is the trade the design makes. GitHub’s fleet is fast to start because its machines are already registered and shared; a runner here is registered for your job alone and destroyed after it. If start latency matters more than isolation for a particular workflow, the honest answer is to leave that job on a GitHub-hosted runner — the two fleets coexist in one workflow, and [Onboarding](/guides/onboarding/) covers routing jobs between them. Start latency is also billed. A MicroVM starts charging when it reaches the running state, which happens early in that chain, so a job pays for roughly thirty seconds of a VM that has no job on it yet. That is a fixed cost per job rather than a per-minute one, which is why short jobs cost proportionally more here and long ones proportionally less. ## State: the runner table [Section titled “State: the runner table”](#state-the-runner-table) The MicroVM service’s VM list says which VMs a runner set is running, and a set picks its own out of the account’s list by image ARN: running MicroVMs cannot be tagged, so each set names its images after itself — `-<8 hex characters>`, hashed over the image’s contents and the packaged runner agent — and the launcher and janitor filter on those ARNs. A single DynamoDB table, partitioned on `runnerName`, holds the rest of the bookkeeping — which GitHub runner a given VM is serving, whether a job has already been claimed, and how long a VM has looked stranded — in four kinds of short-lived rows, distinguished by key prefix: * `microvm-runner-…` maps a runner to its VM and carries the janitor’s strike memory. * `job#…` records a launch idempotency claim. * `warmvm#…` records a warm-VM claim, resolved first-writer-wins. * `orphan-vm-…` carries the janitor’s strike memory for a VM that has no mapping row. Rows expire through a TTL. The service’s own VM list and GitHub’s own runner list are what actually exists, and the janitor re-reads both before it acts. ## Correctness guards [Section titled “Correctness guards”](#correctness-guards) Three guards keep the launcher and the janitor acting on current information. * A launch claim is created with a conditional write that succeeds only when no claim for that job exists yet. A claim whose attempt died mid-flight can be taken over once it is old enough, and that takeover — along with every later release or update of the claim — is keyed on a random `attemptToken`, so exactly one attempt’s write succeeds. A warm-VM claim needs no such handoff: it is a single conditional write on `warmvm#`, and losing it means another launch reached that VM first. A committed launch writes its mapping row and stamps its claim in a single DynamoDB transaction. * The janitor acts only on a fresh read. Immediately before it deregisters or terminates a registered runner it takes a per-runner read from GitHub and requires the runner to have been suspect since an earlier sweep. Whenever that fresh read contradicts the listing the suspicion was raised on — the runner is back, or it is `busy` — the strike memory is cleared and the runner is left alone. A running VM with no runner registration to read is instead guarded by a strongly consistent re-scan of the table taken the instant before termination, which aborts the kill if a mapping row has appeared in the meantime. Every one of these paths acts only on a `RUNNING` VM, so a warm `SUSPENDED` VM is listed by the sweep and left alone. * Each janitor phase and each row is isolated, so a sweep completes and reports its counters — including `errors` — even when individual items fail. Those counters reach CloudWatch when `emitMetrics` is on; see [Monitoring](/guides/monitoring/). # Getting started A **runner set** is one deployment of this construct: the webhook, queue, launcher, and janitor that turn GitHub’s `workflow_job` events into MicroVMs. A **runner class** is a label paired with the VM size and image that jobs carrying that label run on. A runner set serves an organization or a list of repositories, and defines at least one class. This guide deploys a runner set, creates the GitHub App it authenticates with, and moves a job onto it. ## What you need [Section titled “What you need”](#what-you-need) * An AWS account in a region where AWS Lambda MicroVMs is available — `us-east-1`, `us-east-2`, `us-west-2`, `eu-west-1`, or `ap-northeast-1` — and credentials that can deploy a CDK stack to it. * The AWS CDK CLI (v2). * A GitHub account you can install a GitHub App on. An organization or a personal account both work. Install the library: ```bash npm install cdk-github-microvm-runners ``` ```bash pip install cdk-github-microvm-runners ``` The examples below are TypeScript. The API is the same in each language, with names rendered in that language’s conventions — [API.md](/api/) carries both. ## 1. Define the runner set [Section titled “1. Define the runner set”](#1-define-the-runner-set) The construct takes two required properties: how it authenticates to GitHub, and which organization or repositories it serves. Everything else has a default. ```ts import { App, CfnOutput, Stack } from 'aws-cdk-lib'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; import { GithubAppId, GithubAppKey, GithubAuth, GithubMicrovmRunners, MicrovmSize, RunnerScope, } from 'cdk-github-microvm-runners'; const app = new App(); const stack = new Stack(app, 'Runners', { env: { region: 'us-east-1' } }); const appId = Secret.fromSecretNameV2( stack, 'AppId', 'microvm-runner/dev/app-id', ); const privateKey = Secret.fromSecretNameV2( stack, 'AppKey', 'microvm-runner/dev/app-private-key', ); const webhookSecret = Secret.fromSecretNameV2( stack, 'WebhookSecret', 'microvm-runner/dev/webhook-secret', ); const runners = new GithubMicrovmRunners(stack, 'Runners', { github: GithubAuth.app({ appId: GithubAppId.fromSecret(appId), privateKey: GithubAppKey.fromSecret(privateKey), webhookSecret, }), scope: RunnerScope.org('my-org'), }); // A runner class: the `microvm` label, on 4 GB VMs. runners.addRunnerClass('microvm', { size: MicrovmSize.GB4 }); new CfnOutput(stack, 'WebhookUrl', { value: runners.webhookUrl }); new CfnOutput(stack, 'SetupCommand', { value: runners.setupCommand }); ``` The handlers read all three secrets at run time, which is why the stack can be deployed before the App exists. ## 2. Deploy [Section titled “2. Deploy”](#2-deploy) ```bash cdk deploy --no-rollback ``` Deploy with `--no-rollback`, here and on every later deploy. An image build occasionally fails on the service side (“Build workflow was interrupted by an exception”), and when the stack then rolls back in full, the rollback deletes the newest version of each image, including the version a runner class whose build failed was still using, so that class falls back to an older build. With `--no-rollback`, CloudFormation rolls back only the failed image and keeps its working version, the deploy still ends in `UPDATE_FAILED`, and running the same deploy again finishes it. The stack’s `WebhookUrl` output is the address GitHub delivers events to, and its `SetupCommand` output is the step 3 command with its values already filled in. Both exist because the code above declared them — a stack written without those two `CfnOutput` lines deploys fine and has no outputs, and the step 3 helper, which reads `WebhookUrl` from the stack, then needs the address another way: the construct’s `webhookUrl` property carries it, and the helper accepts it directly as `--webhook-url`. ## 3. Create the GitHub App [Section titled “3. Create the GitHub App”](#3-create-the-github-app) The App is what lets the runner set receive `workflow_job` events and register runners. A helper creates it through GitHub’s App-manifest flow. The helper reads `WebhookUrl` from the deployed stack, creates the App pointed at that URL with the permissions the runners need (`actions: read` and organization `self-hosted runners: write`) and the `workflow_job` subscription, writes the App ID, private key, and webhook secret to Secrets Manager under `microvm-runner/dev`, and opens the install page. Install it there, granting access to the repositories the runner set serves. It needs three things, which are the same three the stack was built with: ```bash export GITHUB_ORG=my-org # the organization the App is installed on export STACK_NAME=Runners # the CDK stack deployed above export AWS_REGION=us-east-1 # the region it was deployed to ``` Then, with AWS credentials available: ```bash npx cdk-github-microvm-runners setup \ --org "$GITHUB_ORG" \ --stack "$STACK_NAME" \ --region "$AWS_REGION" ``` `npx` fetches the helper from the same package the construct came from, so this is the same command whether the stack is written in TypeScript or Python. `cdk deploy` also prints this line with the values already filled in, as the stack’s `SetupCommand` output — copy that instead if you would rather not set the variables: ```plaintext Runners.SetupCommand = npx cdk-github-microvm-runners@0.1 setup --org my-org --stack Runners --region us-east-1 ``` That is CloudFormation’s output format: `Runners` is the stack, `SetupCommand` is the output name, and everything after `=` is the command. The command is safe to re-run. Every run reads the stored secrets and the installation first and then does what is missing, so the same line finishes an interrupted setup and reports on a finished one. `--doctor` limits a run to that report, and `--help` lists every flag. Pass `--profile` to pick a named AWS profile. Pass `--secret-prefix` to write the secrets under a different prefix, and use the same prefix in the stack. A runner set scoped to repositories rather than an organization prints `--account ` in place of `--org`. That App asks for repository `Administration: write`, which is what GitHub requires to register a runner on a single repository. A runner set serving several organizations, `RunnerScope.orgs([...])`, prints `--org` with the first organization it lists. Run that setup once, then install the same App on each of the other organizations from its settings page (the App must be public to be installed outside the organization that owns it). The other organizations need nothing else: the runner set registers each job’s runner in the organization that owns the job’s repository. From a clone of this repository, `node scripts/setup-github-app.mjs` takes the same flags. To create the App by hand instead, give it `actions: read` and organization `self-hosted runners: write` — or repository `Administration: write` for a runner set scoped to repositories — subscribe it to `workflow_job`, set its webhook URL to the stack’s `WebhookUrl` and its webhook secret to a value you choose, then store the App ID, the private-key PEM, and that webhook secret under the three secret names the stack reads. ## 4. Run a job on it [Section titled “4. Run a job on it”](#4-run-a-job-on-it) In a repository the App has access to, point a job at the class label: ```yaml jobs: build: runs-on: [self-hosted, microvm] steps: - uses: actions/checkout@v6 # ... ``` On push, the App delivers a `workflow_job` event to the webhook, the launcher starts a MicroVM, a single-use runner registers with GitHub, the job runs, and the VM is removed. Runner VMs are ARM64, so the tools and container images a job pulls need arm64 builds. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Toolchains](/guides/toolchains/) — bake the Python and Node versions your `setup-*` steps ask for into the image. * [Runner images](/guides/images/) — what an image contains, and how to supply your own Dockerfile. * [Warm pools](/guides/warm-pools/) — keep VMs booted ahead of time so jobs skip the boot wait. * [Logging](/guides/logging/) — image build output and VM console capture, both opt-in. * [Onboarding a repo](/guides/onboarding/) — the full checklist for moving an existing repository’s CI over. * [Security](/guides/security/) — the security model, and how a job obtains AWS credentials through GitHub OIDC. * [Service quotas](/guides/service-quotas/) — sizing a runner set for concurrency. # Runner images A runner VM boots from a MicroVM image, and an image is an `AWS::Lambda::MicrovmImage` resource in your own AWS account. Every image is built from a Dockerfile on an Amazon Linux 2023 base, and CloudFormation creates it when you deploy the stack — so images are built at deploy time rather than when a job arrives, and a launch boots an image that already exists. Everything a job finds on the machine comes from that image: the operating system, the GitHub Actions runner, Docker, the AWS CLI, and the language versions available to `setup-*` steps. Each image is named for the runner set that owns it, `` plus a short hash of its contents. ## What an image contains [Section titled “What an image contains”](#what-an-image-contains) Every image starts from the same base: the GitHub Actions runner, Docker, the AWS CLI, and the packages the runner itself needs. A runner class that adds nothing to that base gets an image with exactly those tools, which is enough for a job that uses only them. An image can carry more — extra system packages, setup commands, files, environment variables, and the [toolchains](/guides/toolchains/) that `setup-python` and `setup-node` resolve. The next section covers how a runner class asks for those, and how to supply a Dockerfile of your own instead. ## Containers in a job [Section titled “Containers in a job”](#containers-in-a-job) A job can build and run containers. The base image carries the Docker engine, and the VM’s agent starts it, as root, when the job’s configuration arrives — not at boot. It starts `containerd` and, at the same moment, `dockerd` pointed at that `containerd`, so the two daemons’ start-up overlaps; `dockerd` waits for `containerd`’s socket to appear. On a Docker-capable image the agent will not accept a job until `dockerd` actually answers: it waits up to 120 seconds for a real `docker info` to succeed before starting the runner, and logs how long `dockerd` took. A job on a Docker image therefore waits for the daemon to start, on a cold VM and a warm one alike. So a job never lands on a VM whose `docker` commands would fail because the daemon had not finished starting — the race that a job arriving a second after the runner connected used to lose. If the daemon never answers within that window the runner is not started, the job stays queued, and the VM is reaped as a stuck launch rather than taking the job and failing it. An image with no `dockerd` is unaffected and starts the runner straight away. Job steps reach it through `/var/run/docker.sock`, which the `runner` user holds through the `docker` group — no `sudo` involved, which matters because job steps cannot escalate privileges ([Security](/guides/security/#what-job-code-can-and-cannot-do-on-the-vm)). Do not try to start or restart the daemon from a job; it is already running, and the job cannot. The daemon runs with two MicroVM-specific settings, and both are visible from a job: * **`--storage-driver=vfs`.** vfs copies layers in full rather than overlaying them, so building or pulling a large image is slower here than on a machine with overlay2. * **`--iptables=false`.** The daemon sets up no NAT, so a container on the default bridge network has **no outbound path**. Run containers that need the network with `--network=host`, and pass `--network=host` to `docker build` when a build step fetches from the network. `docker pull` itself is unaffected — the daemon fetches from the host side. The output of both daemons, `containerd` and `dockerd`, is written to `/var/log/microvm-runner-dockerd.log`, readable from any job step. When a `docker` command cannot reach the daemon, that file says why. Multi-architecture builds through QEMU/binfmt do not work in a VM: the installer reports success but `/proc/sys/fs/binfmt_misc` stays empty, so amd64 images fail with an exec-format error. The buildx `docker-container` driver works when the builder is created with `--buildkitd-flags '--allow-insecure-entitlement network.host'` and the build runs with `--allow network.host --network=host`; `--driver-opt network=host` alone does not give `RUN` steps a network. ## One image per runner class [Section titled “One image per runner class”](#one-image-per-runner-class) A runner class’s size is part of its image. The size is written onto the image resource when the image is built, rather than chosen when a VM launches, so each runner class builds an image of its own — two classes running identical software at different sizes are still two images. When a job arrives, the launcher matches its `runs-on` labels to a runner class and boots a VM from that class’s image. Those image ARNs are also how a runner set recognizes its own VMs: running MicroVMs carry no tags, so the launcher and janitor filter the account’s VM list down to the ARNs they built. ## Describing an image [Section titled “Describing an image”](#describing-an-image) Every image comes from a Dockerfile, and there are three ways to arrive at one, differing in who writes it. `RunnerImage.fromOptions()` renders one for you, from the base plus any options you declare. `RunnerImage.fromDockerfile()` reads one you wrote from a directory on disk, and `RunnerImage.fromInline()` takes one you wrote as text in your CDK code. `RunnerImage.fromOptions()` takes what to add to the base as options: ```ts RunnerImage.fromOptions({ systemPackages: ['jq', 'ripgrep'], setupCommands: ['npm install -g pnpm@10'], environment: { LANG: 'C.UTF-8' }, toolchains: [RunnerToolchain.python('3.12.7')], }); ``` * `systemPackages` — extra `dnf` packages alongside the base set. * `setupCommands` — extra `RUN` commands, in order, after packages, assets, and environment are laid down. * `assets` — files and directories copied into the image. * `environment` — environment variables baked into the image. * `toolchains` — language versions placed in the hosted tool cache for `setup-python` and `setup-node`. See [Toolchains](/guides/toolchains/). * `runnerVersion` — which `actions/runner` release to install. * `additionalOsCapabilities` — the Linux capabilities the VM’s operating system runs with, as a list of capability names. Defaults to `['ALL']`; pass a narrower list to run with fewer. ### Building from your own Dockerfile [Section titled “Building from your own Dockerfile”](#building-from-your-own-dockerfile) `RunnerImage.fromDockerfile(dir)` uses the Dockerfile at `dir/Dockerfile` and takes that whole directory as the build context, so anything beside the Dockerfile is available to `COPY`. The options above belong to `RunnerImage.fromOptions()`; here the Dockerfile decides everything. Your Dockerfile takes on everything `RunnerImage.fromOptions()` was doing, which is more than copying the agent in. [What a custom image must provide](#what-a-custom-image-must-provide) below states the whole contract. This is a Dockerfile that meets it: ```dockerfile FROM public.ecr.aws/lambda/microvms:al2023-minimal # The agent is a Node program, and it starts the runner as the `runner` user. RUN dnf install -y nodejs22 sudo shadow-utils tar git && dnf clean all RUN useradd -m runner # The GitHub Actions runner, where the agent looks for it. RUN mkdir -p /opt/runner && cd /opt/runner \ && curl -fsSLo r.tgz https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-arm64-2.337.0.tar.gz \ && tar xzf r.tgz && rm r.tgz && chown -R runner:runner /opt/runner # Staged into the build context by the construct. COPY microvm-runner/agent.mjs /opt/microvm-runner/agent.mjs COPY microvm-runner/entrypoint.sh /opt/microvm-runner/entrypoint.sh ENTRYPOINT ["/opt/microvm-runner/entrypoint.sh"] ``` ```ts import * as path from 'path'; runners.addRunnerClass('custom', { size: MicrovmSize.GB4, image: RunnerImage.fromDockerfile(path.join(__dirname, 'runner-image')), }); ``` Editing that Dockerfile ships on the next `cdk deploy` as a new image version, which the next launch picks up. ### Writing the Dockerfile inline [Section titled “Writing the Dockerfile inline”](#writing-the-dockerfile-inline) `RunnerImage.fromInline(dockerfile)` takes the Dockerfile as text, so a short one lives in the CDK code beside the runner class it belongs to rather than in a file of its own: ```ts runners.addRunnerClass('custom', { size: MicrovmSize.GB4, image: RunnerImage.fromInline(` FROM public.ecr.aws/lambda/microvms:al2023-minimal RUN dnf install -y nodejs22 sudo shadow-utils tar git jq && dnf clean all RUN useradd -m runner RUN mkdir -p /opt/runner && cd /opt/runner \\ && curl -fsSLo r.tgz https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-arm64-2.337.0.tar.gz \\ && tar xzf r.tgz && rm r.tgz && chown -R runner:runner /opt/runner COPY microvm-runner/agent.mjs /opt/microvm-runner/agent.mjs COPY microvm-runner/entrypoint.sh /opt/microvm-runner/entrypoint.sh ENTRYPOINT ["/opt/microvm-runner/entrypoint.sh"] `), }); ``` The same contract applies — an inline Dockerfile is only a different way to supply the text. The construct checks for the agent `COPY` line as it reads it, and says so if that line is missing; the rest of the contract is not something it can check. The build context holds the Dockerfile you supplied and the `microvm-runner/` directory the construct stages, and nothing else — an inline Dockerfile brings no files of its own, so those two agent files are what a `COPY` can name. A Dockerfile that needs to copy your own files in belongs with `RunnerImage.fromDockerfile(dir)`, which stages a whole directory as the build context. ### What a custom image must provide [Section titled “What a custom image must provide”](#what-a-custom-image-must-provide) `RunnerImage.fromOptions()` builds an image that already satisfies all of this. A Dockerfile of your own replaces that image rather than adding to it, so it has to satisfy the contract itself. The construct stages two files into the build context under `microvm-runner/`: `agent.mjs`, and the `entrypoint.sh` that runs it. The agent serves the lifecycle hooks the MicroVM service calls, and starts the GitHub Actions runner when a job arrives. What it needs to do that: | The image must have | Because | | ----------------------------------------------------------------------- | ----------------------------------------------------------- | | **Node** on `PATH` | `entrypoint.sh` starts the agent with `node` | | **The Actions runner at `/opt/runner`** | the agent runs `./run.sh --jitconfig …` from that directory | | **A user named `runner`** | the agent starts the runner as that user, not as root | | **`sudo`** | which is how it does so | | **Both agent files copied in, and `entrypoint.sh` as the `ENTRYPOINT`** | nothing starts the agent otherwise | The base image is `al2023-minimal`, and it is genuinely minimal. Two absences catch people out: **`find` and `xargs` are not installed** — `findutils` is not part of the base package set — and neither is anything else you might assume from a fuller distribution. A script that shells out to `find` gets “command not found”, which with `2>/dev/null` is indistinguishable from finding nothing. Node is the one interpreter the contract guarantees, so prefer it for anything that walks the filesystem, or install what you need explicitly through `systemPackages`. Only the last of those is checked for you, and only partly: the construct looks for a line referencing `microvm-runner/agent.mjs` and refuses a Dockerfile without one. It cannot check the rest, because there is no reliable way to tell from Dockerfile text whether a package manager invocation put Node on the path. The two ways this goes wrong fail very differently, which is worth knowing before you debug one. **Without Node, the deploy fails.** The Docker build itself succeeds — nothing in it runs Node — and then the platform boots the image and waits for the agent to answer its ready hook. Nothing answers, and CloudFormation gives up on the image: ```plaintext Resource of type 'AWS::Lambda::MicrovmImage' with identifier '...:microvm-image:' did not stabilize. (HandlerErrorCode: NotStabilized) ``` That message names neither the hook nor the missing interpreter, so it is worth recognizing: an image that builds and then fails to stabilize is an image whose agent never started. The build log ends with the last successful Docker step and says nothing more. At least the failure arrives at `cdk deploy`, before any job is at stake. **Without the runner, the user, or `sudo`, nothing fails.** The agent starts, answers the ready hook, and the image builds. The VM launches and registers a runner with GitHub. Then a job arrives, the agent tries to start the runner, and cannot — so the job sits queued against a runner that will never take it, until the janitor reaps the VM. Everything is green except the job. If a custom image takes jobs but never runs them, that is where to look first. ## Keeping the runner current [Section titled “Keeping the runner current”](#keeping-the-runner-current) GitHub requires a self-hosted runner to install each new `actions/runner` release within **30 days** of its publication. Past that window GitHub stops queuing jobs to the runner, and registration itself needs at least `2.329.0`. The rule and its enforcement timeline are in GitHub’s [changelog](https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners/). ### Self-update [Section titled “Self-update”](#self-update) There is no supported way to stop a just-in-time runner self-updating. `--disableupdate` is an option of `config.sh`, which registers a runner locally; a just-in-time runner skips that step and takes its settings from the configuration GitHub generates for it, and neither `run.sh` nor GitHub’s API for generating that configuration has an update setting. When a runner falls too far behind, what has been seen here is GitHub refusing it rather than updating it: runners on a deprecated release connected, logged “Runner version v2.335.1 is deprecated and cannot receive messages”, and exited without taking a job. Currency comes from the pin below plus watching for new releases (`runner-version-watch`), not from runners updating themselves: bump the pin and redeploy, and the next launch boots an image built with the newer runner. The library pins a default release and installs it on every image that does not ask for a specific one. That pin is what `RunnerVersion.libraryDefault()` selects — it is the library’s default, **not** GitHub’s latest release, which is why the older `RunnerVersion.latest()` is deprecated: it never fetched anything latest, it only ever returned this same pin. Pin an explicit release with `RunnerVersion.of()`: ```ts RunnerImage.fromOptions({ runnerVersion: RunnerVersion.of('2.337.0'), }); ``` ### What falling behind looks like [Section titled “What falling behind looks like”](#what-falling-behind-looks-like) A VM whose runner is too old to be queued work still **boots, registers, and appears online for a moment** — the runner connects to GitHub outbound and looks healthy. GitHub simply never hands it a job, so it sits idle, GitHub marks it offline, and the janitor reaps it as a runner GitHub lost track of. The signature is a rising `stuckRunnersReaped()` — registered runners GitHub no longer knows about, reaped as idle — while jobs pile up queued against runners that will never take them. See [Monitoring](/guides/monitoring/) for that metric and its alarm. ### What to do [Section titled “What to do”](#what-to-do) Bump the pin and redeploy: either move the whole library forward to a release whose default is current, or pin a newer release with `RunnerVersion.of('')` on the affected images. Either change ships on the next `cdk deploy` as a new image version, which the next launch picks up (see [How an image changes](#how-an-image-changes)). To catch this before GitHub does, watch the version and alarm on the reaping signature — both are covered in [Monitoring](/guides/monitoring/). ## Sharing an image across runner classes [Section titled “Sharing an image across runner classes”](#sharing-an-image-across-runner-classes) Classes whose jobs need the same contents can share one `RunnerImage` — hoist it to a `const` and pass it to each call: ```ts const image = RunnerImage.fromOptions({ toolchains: [RunnerToolchain.python('3.12.7')], }); runners.addRunnerClass('microvm', { size: MicrovmSize.GB4, image }); runners.addRunnerClass('microvm-8gb', { size: MicrovmSize.GB8, image }); ``` Both classes bake in Python 3.12.7, and each still builds its own MicroVM image because size is written into the image at build time. Classes whose jobs need different contents each take their own image: ```ts runners.addRunnerClass('lint', { size: MicrovmSize.GB1, image: leanImage }); runners.addRunnerClass('test', { size: MicrovmSize.GB4, image: heavyImage }); ``` ## How an image changes [Section titled “How an image changes”](#how-an-image-changes) An image’s **name** is fixed for the life of the runner class. Its **contents** are what change. The build context — the rendered Dockerfile, any assets you supply, and the packaged in-VM agent — is uploaded as a content-hashed asset, so editing any option above, or a single line of an inline Dockerfile, changes what the image is built from. Leaving it alone changes nothing and the deploy is a no-op. Every such change **updates the image in place and adds a version**. The next launch picks up the newest version, while VMs already running keep the version they booted from, and the janitor prunes old versions as it sweeps. Nothing you can change about an image causes CloudFormation to replace it. That is deliberate, and it is why the name holds still. `Name` is the only property of the underlying resource whose change forces a replacement, and a replacement is the only way a deploy can fail with: ```plaintext Resource handler returned message: "null already exists." (HandlerErrorCode: AlreadyExists) ``` Image names are never reclaimed. When the name moved with the content, every version of the content a runner set had ever built left its name reserved in the account permanently — so reverting a change, downgrading the library, flipping an option back, or A/B-ing a toolchain could land on a name its own history already held, and the whole stack update rolled back. A fixed name removes that failure entirely rather than recovering from it. > **Upgrading from a version before this changed:** the first deploy after the upgrade moves the image to its stable name, which replaces the image once. That name has never been used, so it cannot collide. Later deploys never replace the image again. The old content-hash-named images are left behind; they are inert, and you can delete them with `aws lambda-microvms delete-microvm-image --image-identifier `. Let that deploy finish. Interrupting the CLI — Ctrl-C, a dropped connection — stops the local process, while CloudFormation carries on server-side with the stack in `UPDATE_IN_PROGRESS`, and a job that runs before the image resource settles boots the previous image, which looks like the change silently not arriving. Wait for the stack to reach `UPDATE_COMPLETE` before re-running the jobs that need the new image. Image builds are silent by default. To see one — why an image failed to build, or what a setup command did — turn on `imageLogs: ImageLogs.enabled()`, which streams the build output to CloudWatch. See [Logging](/guides/logging/). ## What a size gives you [Section titled “What a size gives you”](#what-a-size-gives-you) A size preset is a floor rather than an allocation. The number names the minimum the image is built with, and the platform provisions above it — on the accounts this has been measured on, at roughly four times the requested memory and vCPU: | `MicrovmSize` | Floor | Measured | | ------------- | -------------- | --------------- | | `GB1` | 1 GB, 0.5 vCPU | \~4 GB, 2 vCPU | | `GB2` | 2 GB, 1 vCPU | \~8 GB, 4 vCPU | | `GB4` | 4 GB, 2 vCPU | \~16 GB, 8 vCPU | Two things follow from that. A job usually fits a smaller preset than its memory figure suggests — a build that peaks at 3 GB runs on `GB1`. And the account’s memory quota is charged the measured allocation rather than the floor, which is what decides how many VMs run at once; [Service quotas](/guides/service-quotas/) covers the arithmetic. The multiplier is the platform’s behaviour, not something this construct sets, so it is an observation rather than a guarantee. Measure the workload where the margin matters. ## Disk budget [Section titled “Disk budget”](#disk-budget) Disk comes with the size, and unlike memory it is the figure you get. Each [MicroVM size](https://docs.aws.amazon.com/lambda/latest/dg/microvms-images.html) the construct exposes carries a fixed allowance: | `MicrovmSize` | Disk | | --------------------- | ----- | | `GB0_5`, `GB1`, `GB2` | 8 GB | | `GB4` | 16 GB | | `GB8` | 32 GB | That disk holds the whole image — the base OS, the fixed package set, the AWS CLI, the runner binary, and everything added through the options above — along with what the job writes at run time: its checkout, dependency installs, build artifacts, and Docker layers if the job uses containers. A Python toolchain keeps its build dependencies in the image so `pip` can compile C extensions at job time, so several baked versions add up. # Logging Three things produce output, and each goes to a different place. The **build** is CloudFormation building a runner class’s image at deploy time. The **VM** is the machine that boots from that image and runs the runner agent. The **job** is the workflow run GitHub sends to that VM. | Level | Covers | Goes to | Turned on by | | ----- | ------------------------------------- | -------------- | ----------------- | | Build | the Docker build and its ready probe | CloudWatch | `imageLogs` | | VM | boot, the agent, the runner’s session | CloudWatch | `consoleLogs` | | Job | what the workflow’s steps printed | GitHub Actions | always, by GitHub | GitHub handles the job level without anything from this construct: step output goes from the runner to GitHub and appears in the Actions UI, the same as on a GitHub-hosted runner. The construct handles the build and VM levels. Both are off until you turn them on, both take their own log group, and neither contains the job’s step output. ## Image build logs [Section titled “Image build logs”](#image-build-logs) `ImageLogs.enabled()` sends build output to the platform’s default group: ```ts new GithubMicrovmRunners(stack, 'Runners', { github, scope, imageLogs: ImageLogs.enabled(), }); ``` Passing an `ILogGroup` sends it to a group whose retention and encryption you control, and the construct grants the image build role write access to it: ```ts imageLogs: ImageLogs.enabled(myBuildLogGroup), ``` A build writes two kinds of stream, told apart by the version in the stream name. The build itself is version `0.0` and carries the Dockerfile’s steps and their output: ```plaintext #6 [2/9] RUN dnf install -y libicu bash git docker jq tar zip unzip nodejs22 sudo shadow-utils && dnf clean all #7 [3/9] RUN curl -fsSLo /tmp/gh.asc https://cli.github.com/packages/githubcli-archive-keyring.asc && rpm --import /tmp/gh.asc && rm /tmp/gh.asc && curl -fsSLo /etc/yum.repos.d/gh-cli.repo https://cli.github.com/packages/rpm/gh-cli.repo && dnf install -y gh && dnf clean all #8 [4/9] RUN curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip -o /tmp/awscliv2.zip #14 exporting layers #14 exporting layers 23.1s done ``` The platform then runs the built image once to confirm it responds to the ready probe. That run’s console goes to a separate stream, at the image’s own version. ## Runtime console logs [Section titled “Runtime console logs”](#runtime-console-logs) `ConsoleLogs.enabled()` captures each VM’s console. The platform writes it using the VM’s execution role, so this requires `vmExecutionRole` — the construct does not create a VM identity for you — and you grant that role the two write actions on the console group: ```ts const runners = new GithubMicrovmRunners(stack, 'Runners', { github, scope, vmExecutionRole: role, consoleLogs: ConsoleLogs.enabled(), }); runners.vmConsoleLogGroup!.grant( role, 'logs:CreateLogStream', 'logs:PutLogEvents', ); ``` Called with no argument, `ConsoleLogs.enabled()` has the construct create the group using the runner set’s `logRetention` (two weeks by default), its `removalPolicy`, and its `encryptionKey` when one is set. Passing an `ILogGroup` uses that group instead. Either way the resolved group is `runners.vmConsoleLogGroup`, which is optional on the construct and set only on a runner set that opted into console capture. Streams are named `[]`, one per VM, and warm-pool VMs stream from boot. A stream covers the runner connecting, the job it picked up, and teardown: ```plaintext √ Connected to GitHub Current runner version: '2.337.0' 2026-07-24 19:46:05Z: Listening for Jobs 2026-07-24 19:46:06Z: Running job: probe 2026-07-24 19:46:09Z: Job probe completed with result: Succeeded √ Removed .credentials √ Removed .runner Runner listener exit with 0 return code, stop the service, no retry needed. Exiting runner... runner exited 0 ``` A stream carries the job’s name and result, not what its steps printed. The `microvmId` in the stream name correlates to the launcher’s own logs and to the runner table. A VM that never runs a job still gets a stream. A pre-booted warm-pool VM terminated before any job arrives leaves one line: ```plaintext microvm-runner agent on 8080 ``` Those VMs have no record on GitHub: no job, no log. Their console stream is the only record they ran. A VM that boots but never registers a runner is the same case — the job stays queued on GitHub with an empty log, and the console stream shows how far the VM got. ### What the role exposes [Section titled “What the role exposes”](#what-the-role-exposes) A MicroVM’s instance metadata service serves the execution role’s credentials to the code running inside the job. Whatever that role can do, job code can do, for the whole run. [The security guide](/guides/security/#the-vm-and-the-job) covers the VM’s identity in full. A role carrying the two console-write actions can write to the console log group, and nothing else. Enabling or disabling capture each takes a deploy, and a stream holds only what a VM printed while capture was on. ## Collecting build and VM logs in one group [Section titled “Collecting build and VM logs in one group”](#collecting-build-and-vm-logs-in-one-group) The two go to separate groups by default. Passing the same `ILogGroup` to both puts build output and VM consoles together: ```ts const logs = new LogGroup(stack, 'RunnerLogs'); new GithubMicrovmRunners(stack, 'Runners', { github, scope, vmExecutionRole: role, imageLogs: ImageLogs.enabled(logs), consoleLogs: ConsoleLogs.enabled(logs), }); ``` # Monitoring Two kinds of CloudWatch metric are available. **AWS emits metrics for the resources the construct creates** — the SQS queues and the handler Lambdas — the way it does for any queue or function. Those are there from the first deploy. **The runner set emits metrics of its own** about what it did: VMs reaped, launches served, warm-pool hits. `emitMetrics` turns those on: ```ts new GithubMicrovmRunners(stack, 'Runners', { github, scope, emitMetrics: true, }); ``` ## What AWS reports on its own [Section titled “What AWS reports on its own”](#what-aws-reports-on-its-own) The queues and handler Lambdas are construct properties, so their standard metrics are reachable from the first deploy: ```ts runners.deadLetterQueue.metricApproximateNumberOfMessagesVisible(); runners.deadLetterQueue.metricApproximateAgeOfOldestMessage(); runners.jobQueue.metricApproximateNumberOfMessagesVisible(); runners.launcherFunction.metricErrors(); runners.launcherFunction.metricThrottles(); runners.webhookFunction.metricErrors(); runners.janitorFunction.metricErrors(); ``` ## What the runner set reports [Section titled “What the runner set reports”](#what-the-runner-set-reports) With `emitMetrics` on, the handlers write to the `MicrovmRunners` namespace in two shapes. **Per runner set**, dimensioned by `RunnerSetId`, the janitor emits one envelope per sweep. Two runner sets in the same account and region report separately. | Accessor | Counts | | --------------------------------- | ------------------------------------------------------------------- | | `orphansReaped()` | running VMs with no mapping row, terminated | | `stuckRunnersReaped()` | registered runners GitHub had lost track of | | `suspectsCleared()` | suspicions withdrawn when a fresh read contradicted | | `lifetimeKills()` | VMs terminated for exceeding their lifetime | | `imageVersionsPruned()` | superseded image versions removed | | `tableRowsCleaned()` | stale runner-table rows deleted | | `stuckLaunchesRecovered()` | dead-lettered launches re-driven onto the queue | | `stuckClaimsRelaunched()` | launch claims taken over after an attempt died | | `unregisteredRelaunched()` | still-queued jobs re-launched when their VM never registered | | `unregisteredRelaunchExhausted()` | jobs released (claim deleted) after hitting the shared relaunch cap | | `errors()` | failures the sweep isolated and continued past | | `queuedJobScanTruncated()` | 1 when a sweep hit its GitHub-listing cap mid-scan | **Per runner class**, dimensioned by `RunnerSetId` and `SizeClass`, the launcher and warm pool emit one envelope per event. Each accessor takes the class label: | Accessor | Reports | | ------------------------------- | ------------------------------------------------------------ | | `warmHit(label)` | launches served by a pre-booted VM | | `coldBoot(label)` | launches that booted a new VM | | `capacityRejected(label)` | launches refused because the account hit its quota | | `cancelledBeforeLaunch(label)` | launches skipped because the job had already stopped waiting | | `warmThrottled(label)` | warm-path attempts that fell back to a cold boot | | `warmSpinUpMs(label)` | spin-up time on the warm path | | `coldSpinUpMs(label)` | spin-up time on the cold path | | `poolCurrent(label)` | VMs currently in the warm pool | | `poolTarget(label)` | VMs the pool is converging toward | | `poolLaunched(label)` | VMs a sweep added to the pool | | `poolLaunchFailed(label)` | pool launches that failed | | `oldestQueuedJobSeconds(label)` | seconds the class’s oldest queued job has waited (0 if none) | | `queuedJobs(label)` | jobs this class owns that are still queued | ```ts runners.metrics.capacityRejected('microvm'); runners.metrics.poolCurrent('microvm'); ``` `capacityRejected` is what a runner set reports when it reaches its account’s MicroVM memory quota, or its own `maxConcurrentVms`; [Service quotas](/guides/service-quotas/) covers that ceiling and how to raise it. `cancelledBeforeLaunch` counts jobs that stopped waiting for a runner before their launch was processed — cancelled, or their run deleted. Nothing is booted for these, so a rising count is work avoided rather than work lost. It is routine on a repository using concurrency groups, where every re-push cancels the run it superseded. A count that dwarfs `coldBoot` says the workflows feeding this runner set are cancelled more often than they finish, which is usually a question about their triggers. The two spin-up metrics, `poolCurrent`/`poolTarget`, and `oldestQueuedJobSeconds`/`queuedJobs` read as averages, since each reports an absolute value; the rest are sums. `oldestQueuedJobSeconds` and `queuedJobs` come from a read-only pass the janitor makes over GitHub each sweep (only with `emitMetrics` on), asking how long jobs have been waiting per class. A job counts toward a class when it requests that class’s label — the same match the launcher makes when it registers a runner for the job, and the same routing GitHub then does (the runner carries the job’s full label set, so extra labels like `linux` or `ARM64` do not disqualify it). A job that requests none of this set’s class labels is not one this set serves, so it does not count here. The pass is bounded per sweep and paginates within that budget: GitHub lists runs newest-first, so it reads older pages until the budget is spent. If the budget stops it with pages still unread it reports `queuedJobScanTruncated` = 1 — a completed scan never under-reports the oldest queued jobs without saying so. ## Ready-made alarms [Section titled “Ready-made alarms”](#ready-made-alarms) Six methods build a `cloudwatch.Alarm` carrying a default threshold. An alarm exists where you call one and give it a scope: ```ts import * as cw_actions from 'aws-cdk-lib/aws-cloudwatch-actions'; import * as sns from 'aws-cdk-lib/aws-sns'; const topic = new sns.Topic(stack, 'RunnerAlarms'); runners.metrics .deadLetterQueueNotEmptyAlarm(stack) .addAlarmAction(new cw_actions.SnsAction(topic)); ``` `deadLetterQueueNotEmptyAlarm` watches the messages visible in the dead-letter queue. Each one is a launch or terminate intent SQS gave up redriving, so the job it carries waits on `recoverStuckLaunches` to re-drive it or on someone to drain the queue. It reads an SQS metric, so it works with or without `emitMetrics`. `sweepErrorsAlarm` watches the janitor’s `errors` counter. A non-zero value means a sweep finished with part of the runner set’s state unreconciled. It fires only when three consecutive sweeps report an error, because one sweep error on its own is usually transient — a GitHub API call that lost its connection, a throttled describe — and the sweep is convergent, so the work is retried five minutes later regardless. A real reconciliation failure (expired credentials, a revoked App installation, a broken table) fails every sweep and still announces itself within fifteen minutes. `stuckLaunchesRecoveredAlarm` watches `stuckLaunchesRecovered`. The runner set property `recoverStuckLaunches` drives that counter and is on by default, so a non-zero value is real: recovery is working, and something upstream is losing launches often enough to need it. Treat a persistently high count as a signal to find that cause, not as a healthy steady state. Setting the property to false silences the counter along with the recovery itself. `recoverStuckLaunches` also re-launches a job the instant its runner is reaped as `unregistered` — a VM that launched but never registered with GitHub, so the job never ran and stayed `queued`. The `unregisteredRelaunched()` counter reports those recoveries. Both recovery paths — this `unregistered` reap and the committed-claim reconciliation behind `stuckClaimsRelaunched` — share ONE per-job attempt counter (threaded through the launch message onto the fresh claim), so a job that keeps failing to register is re-launched only a bounded number of times *in total* across both paths, not a fresh allowance per path. Past the bound the janitor releases the claim (deletes it, so nothing resurrects it), counts `unregisteredRelaunchExhausted()`, and logs a structured error naming the repo, job and attempt count rather than looping silently. The job then stays `queued`, loudly — the queued-job-age alarm and the exhausted metric both fire — until the operator fixes the cause and re-runs the job. Any non-zero `unregisteredRelaunchExhausted` is a job that stranded despite recovery — a bad image, a broken egress route, or a boot that never completes — so it is worth an alarm of its own once `emitMetrics` is on. `stuckRunnersReapedAlarm` watches the janitor’s `stuckRunnersReaped` counter — runners that registered with GitHub and then went nowhere, reaped once a second sweep saw them idle past `idleRunnerGraceSeconds`. It is the alarm for a refused runner version (see below): it fires on a sum of 6 or more over a single 15-minute period, well above the handful a healthy set reaps and well below the rate a refusal drives. `capacityRejectedAlarm(scope, label)` watches one runner class’s `capacityRejected` counter — launches the MicroVM service turned away for capacity, each a job queueing behind the account’s memory quota or the set’s `maxConcurrentVms` rather than running. It fires on one rejection in each of three consecutive 5-minute periods, so a lone burst the next launch clears does not page. It is per class, so it takes the class label and builds its alarm under an id unique to that label — call it once for each class you want watched, in the same scope, and the alarms do not collide: ```ts runners.metrics.capacityRejectedAlarm(stack, 'small'); runners.metrics.capacityRejectedAlarm(stack, 'large'); ``` `queuedJobAgeAlarm(scope, label)` watches one runner class’s `oldestQueuedJobSeconds`. It is the alarm for a job that never gets a runner at all — the failure the others structurally cannot see. The reap and stuck-launch alarms all watch things that happen *after* a launch: a VM that registered and went idle, a launch that dead-lettered, an error a sweep counted. When no launch happens in the first place — a webhook that was misrouted or dropped, a GitHub App failure, a launch bug, or a MicroVM quota wall — there is no VM to reap and no error to count, and the job just sits `queued` with nothing anywhere saying so. This alarm reads the janitor’s queued-job measurement and fires when a class’s oldest queued job crosses 20 minutes over its aggregation period (read as a maximum): ```ts runners.metrics.queuedJobAgeAlarm(stack, 'microvm'); ``` It is the one alarm here that **treats missing data as breaching**. Its metric is emitted every sweep for every class — 0 when nothing is queued — so the metric going absent does not mean “nothing queued”, it means the janitor itself has stopped reporting, which is its own failure worth paging on. A sweep that overruns its Lambda timeout writes no datapoint for that period, so a persistently slow or crashing janitor reads exactly like a silent one and pages — that is the intended “janitor went silent” signal, not a false alarm. Because absence pages, the period has to be long enough that every period it evaluates holds at least one sweep’s datapoint. Its default period is therefore **not** the usual 5 minutes but `max(5 min, 2 × janitorInterval)` rounded up to a whole minute — 10 minutes at the default 5-minute `janitorInterval`, 20 minutes at a 10-minute one — so a single missed sweep still leaves a datapoint in the period and cannot page. A `period` you pass that is shorter than `janitorInterval` is rejected at synth: it would guarantee empty periods and a permanent false ALARM. Under **PAT auth with org scope** (`RunnerScope.org` or `RunnerScope.orgs`) this alarm is unavailable and throws at synth. The queued-job scan enumerates the org’s repositories through the App installation-repositories endpoint, which a personal access token cannot call (GitHub returns 403), so the janitor skips the scan and never emits `oldestQueuedJobSeconds`. `emitMetrics: true` in that configuration also raises a synth-time warning saying so. Use a GitHub App, or scope the runner set to an explicit repository list (`RunnerScope.repos([...])`), to watch queued-job age. It is per class, so it takes the class label and builds its alarm under an id unique to that label — call it once for each class you want watched, in the same scope, and the alarms do not collide. Those five read metrics the handlers emit, so they require `emitMetrics: true` and throw at synth without it. Each takes an optional `RunnerAlarmOptions { threshold?, evaluationPeriods?, period? }`. The defaults are `threshold: 1`, `evaluationPeriods: 1` — 3 for the sweep-errors, stuck-launch, and capacity-rejected alarms, all of which watch signals that only mean something when they persist — and `period: Duration.minutes(5)`, except `stuckRunnersReapedAlarm`, which defaults to `threshold: 6` over `Duration.minutes(15)`, and `queuedJobAgeAlarm`, which defaults to `threshold: 1200` (seconds) over `max(5 min, 2 × janitorInterval)`. All compare with `>=` and treat missing data as not breaching — except `queuedJobAgeAlarm`, which treats it as breaching. Pass any of the three to change it: ```ts runners.metrics.sweepErrorsAlarm(stack, { threshold: 5, evaluationPeriods: 2, }); ``` Each builds its alarm under a fixed construct id, so call a given one once per scope — except `capacityRejectedAlarm` and `queuedJobAgeAlarm`, whose ids carry the class label, so they are called once per class instead. ## What a refused runner looks like [Section titled “What a refused runner looks like”](#what-a-refused-runner-looks-like) GitHub can refuse a runner version — a runner that registers with a version GitHub has stopped accepting is told to update and never picks up a job. The launcher keeps launching, each VM registers, sits idle, and is reaped, and jobs pile up behind runners that are present but useless. It is a quiet failure. Nothing errors: launches succeed, registrations succeed, the janitor sweeps cleanly. On 2026-09-24 a refused version drove `stuckRunnersReaped` to 14–28 an hour for about six hours, peaking at 8–10 in a single five-minute sweep, against a month-long baseline that never exceeded 3 in any hour — and the dead-letter, sweep-errors, and stuck-launch alarms all stayed OK the whole time, because none of them watches this. `stuckRunnersReaped` rising is the signal, which is what `stuckRunnersReapedAlarm` is for: ```ts runners.metrics.stuckRunnersReapedAlarm(stack); ``` A sustained climb here means the runners this set launches are being turned away. Check the runner version the set builds into its image against the version GitHub currently requires, and roll the image forward if it has fallen behind. ## Building your own [Section titled “Building your own”](#building-your-own) Every accessor above returns a standard `cloudwatch.Metric`, so any of them can back an alarm: ```ts runners.metrics .capacityRejected('microvm') .createAlarm(stack, 'QuotaRejections', { threshold: 1, evaluationPeriods: 1, }); runners.launcherFunction.metricErrors().createAlarm(stack, 'LauncherErrors', { threshold: 1, evaluationPeriods: 1, }); ``` They also chart, which is where the per-class metrics earn their second dimension — one widget per runner class, or several classes on one: ```ts import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; const dashboard = new cloudwatch.Dashboard(stack, 'RunnerDashboard'); dashboard.addWidgets( new cloudwatch.GraphWidget({ title: 'Warm hits vs cold boots — microvm', left: [ runners.metrics.warmHit('microvm'), runners.metrics.coldBoot('microvm'), ], }), new cloudwatch.GraphWidget({ title: 'Spin-up time', left: [ runners.metrics.warmSpinUpMs('microvm'), runners.metrics.coldSpinUpMs('microvm'), ], }), new cloudwatch.GraphWidget({ title: 'Warm pool', left: [ runners.metrics.poolCurrent('microvm'), runners.metrics.poolTarget('microvm'), ], }), ); ``` # Onboarding a repository onto a runner set A runner set is one `GithubMicrovmRunners` deployment: the construct in an AWS account, together with the GitHub App installed on the account and granted access to the repositories the runner set serves. [Getting started](/guides/getting-started/) covers deploying one; what follows moves an existing repository’s CI onto a runner set that already exists. ## 1. Point a job at the runner set [Section titled “1. Point a job at the runner set”](#1-point-a-job-at-the-runner-set) Each runner class the runner set declares carries a label, which is the first argument of that class’s `addRunnerClass` call: ```ts runners.addRunnerClass('microvm', { size: MicrovmSize.GB4, image }); ``` The class has to be deployed on the runner set before a workflow names its label, so a class added for this migration ships — and runs a job — ahead of the pull request that switches `runs-on`. A job runs on the runner set when its `runs-on` names that label alongside `self-hosted`: ```yaml jobs: gate: runs-on: [self-hosted, microvm] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v7 with: python-version: '3.12' ``` The VMs are ARM64, so the tools and container images a job pulls need arm64 builds. Routing is decided per job, so you can migrate one job at a time and let hosted and MicroVM jobs share a workflow; jobs you have not moved keep running on GitHub-hosted runners. Each job gets its own MicroVM, which is removed once the job finishes, so every job starts from the image as built. If the runner set declares several classes, such as a lean `microvm-lint` alongside a larger `microvm`, use the label whose VM size fits the job’s memory profile. [Service quotas](/guides/service-quotas/) covers how classes trade size against concurrency. The runner set’s GitHub App also needs access to this repository, which is what puts its `workflow_job` events on the webhook. Add the repository under **Repository access** on the App’s installation, alongside the ones the runner set already serves. A public repository also needs its runner group to allow it. The runner set registers its runners in the organization’s Default runner group, which serves public repositories only when its **Allow public repositories** setting is on, and an organization’s Default group has it off. Until it’s on, GitHub never dispatches a public repository’s jobs to the runner set, and they wait queued with no error. ## 2. Declare the labels to actionlint [Section titled “2. Declare the labels to actionlint”](#2-declare-the-labels-to-actionlint) If the repository lints its workflows with [actionlint](https://github.com/rhysd/actionlint), give it the runner set’s labels so it recognizes them. actionlint validates `runs-on` against a known set of labels, and a custom label needs an entry in a `.github/actionlint.yaml` at the root of the repository being migrated: .github/actionlint.yaml ```yaml self-hosted-runner: labels: - microvm - microvm-lint ``` List one entry per runner class your workflows target, which are the labels the runner set passed to `addRunnerClass`. The entry tells actionlint the label is valid, so it affects linting only, not where a job runs. ## 3. Set up AWS access for jobs that need it [Section titled “3. Set up AWS access for jobs that need it”](#3-set-up-aws-access-for-jobs-that-need-it) A job that needs AWS obtains credentials for that run through GitHub OIDC: ```yaml permissions: id-token: write # required for OIDC contents: read steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }} aws-region: us-east-1 ``` The role’s trust policy must permit the repository’s OIDC subject. The [security guide](/guides/security/#aws-access-for-jobs) covers finding the exact `sub` claim your repository’s tokens carry and writing the trust policy against it. ## 4. Bake in the toolchains the job’s `setup-*` steps need [Section titled “4. Bake in the toolchains the job’s setup-\* steps need”](#4-bake-in-the-toolchains-the-jobs-setup--steps-need) `actions/setup-python` and `actions/setup-node` resolve versions from the image’s tool cache. Bake in every Python version your workflows request: `actions/python-versions` publishes no linux-arm64 build, so the tool cache is where a job finds one. Node versions are worth baking for speed — `actions/node-versions` does publish linux-arm64, so `setup-node` downloads an un-baked version and the job runs on it, at about 24 seconds per job. Either is a change on the runner set rather than in the migrating repository: the operator adds the toolchain to the image (`RunnerImage.fromOptions({ toolchains: [...] })`) and redeploys, as [Toolchains](/guides/toolchains/) describes. The workflow itself stays as written. ## 5. The concurrency ceiling [Section titled “5. The concurrency ceiling”](#5-the-concurrency-ceiling) Whether coinciding runs — a pull-request gate and a push gate on the same commit, for example — run in parallel or serialize is set by two ceilings: ```plaintext usable concurrency = min( maxConcurrentVms , what the memory quota admits ) ``` `maxConcurrentVms` is a prop on the construct. The memory term is **Max allocated memory**, an AWS service quota held per account and per region, which every running VM draws against according to its class’s size. A launch that arrives at the ceiling is re-queued and retried until a slot frees. [Service quotas](/guides/service-quotas/) covers how the ceiling is sized, how to raise it, what to do when it is frozen, and what happens to a launch that waits through sustained saturation. ## What to expect on wall time [Section titled “What to expect on wall time”](#what-to-expect-on-wall-time) Moving a job onto a runner set does not, by itself, make it faster. Measured across nine production jobs — pytest suites, jest, lint, type-checking — wall time was a wash against GitHub-hosted runners: within a minute either way on runs of ten to twenty minutes, and marginally slower for short single-threaded jobs on the smallest class. A single-threaded step runs on one core wherever it runs, and a MicroVM’s single-core speed is close to a hosted runner’s. What does change: a job is picked up in seconds rather than waiting on hosted queue depth, the VM is billed per second in your own account with no hosted-minute metering, and the job runs natively on arm64, which matters when its artifacts target arm64. Speed comes from parallelism. A size preset’s vCPUs are over-provisioned roughly four-fold — a `GB4` VM comes up with 8 — and a single-process suite leaves them idle. A job that splits its work across processes, with `pytest -n`, jest workers, or a parallel build, is where those cores pay. # Security ``` flowchart TD GH["GitHub Actions"] -->|"workflow_job, HMAC-signed"| WH["Webhook Lambda — public URL"] WH -->|"signature verified"| Q["SQS queue"] Q --> LN["Launcher Lambda"] SM[("Secrets Manager")] -.->|"webhook secret only"| WH SM -.->|"App ID, private key, webhook secret"| LN LN -->|"launches"| VM subgraph VM["MicroVM runner"] JOB["job steps"] end VM -.->|"standing role — vmExecutionRole"| AWS["AWS"] JOB -.->|"assumes a per-job role via OIDC"| STS["AWS STS"] STS -.->|"short-lived credentials"| AWS ``` ## Intake: the webhook [Section titled “Intake: the webhook”](#intake-the-webhook) GitHub’s webhook delivery arrives at a public Lambda Function URL (`authType: NONE`), because GitHub cannot sign its deliveries with SigV4. The handler verifies GitHub’s HMAC-SHA256 signature over the raw request body with a timing-safe comparison and rejects any request that does not match. ## Which repositories a runner set serves [Section titled “Which repositories a runner set serves”](#which-repositories-a-runner-set-serves) A valid signature establishes that a delivery came from GitHub through your App. It does not establish which repository the job belongs to, and those are different questions: a GitHub App can be installed on more repositories, and more organizations, than a given runner set is configured for, and every one of those deliveries is correctly signed. So `scope` is enforced, not descriptive. The webhook compares the delivery’s repository against it before anything reaches the queue, and the launcher compares again before it calls GitHub, counts capacity, claims the job, or starts a VM — a message arriving by any route other than that webhook is checked too. Out-of-scope deliveries are ignored, and nothing is spent on them. `RunnerScope.org('acme')` serves every repository in that organization. `RunnerScope.orgs(['acme', 'globex'])` serves every repository in either organization, and registers each runner in the organization that owns the job’s repository. `RunnerScope.repos(['acme/api', 'acme/web'])` serves those two and nothing else. Matching is case-insensitive, as GitHub is. ### Public repositories [Section titled “Public repositories”](#public-repositories) A runner set serves the repositories you name, and anyone who can get a workflow into one of those repositories can run code on its VMs. On a private repository that is the people you have already trusted with commit access. A **public** repository is different, and it is the standard caution about self-hosted runners generally rather than anything specific to this construct. A pull request from a fork runs the workflow file **as the fork wrote it**, so someone who forks, edits the workflow, and opens a pull request is proposing code that would execute on your runners. GitHub’s default of requiring approval for first-time contributors is what stands in front of that, and it is a review step rather than a boundary. What that person would get here is a VM with no AWS identity — the default is no execution role, and a MicroVM’s IMDS serves nothing — that is destroyed after the single job. So the exposure is your compute and your network egress, not your AWS account, unless the runner set sets `vmExecutionRole` or attaches a VPC connector, which is why both are covered above. Point a runner set at public repositories only deliberately, with fork-PR approval left on. ## The control plane [Section titled “The control plane”](#the-control-plane) The webhook, launcher, and janitor are the control plane, and they hold what a runner set needs to talk to GitHub and AWS. The GitHub App’s ID, private key, and webhook secret live in AWS Secrets Manager and are read at run time. They are not read by all three. The launcher and janitor act as the App — minting installation tokens, registering and removing runners — so they read the App’s credentials. The webhook does not: it verifies a signature and puts a message on a queue, so it reads the webhook secret and nothing else. That split matters because the webhook is the one part of a runner set reachable from the internet, and the credentials that can act as the App are what should not sit behind it. A private key can live in KMS instead — `GithubAppKey.fromKmsKey()` signs the App’s JWTs through `kms:Sign`, so the key material never leaves KMS and the handlers hold only permission to sign with it. The launcher’s AWS permissions are scoped to what a launch needs. Attaching a role to a VM requires `iam:PassRole`, and the launcher can pass only the runner set’s single execution role, on that role’s ARN alone; when no execution role is configured, the launcher gets no `iam:PassRole` grant at all. When runners egress through a VPC connector, the same pattern applies with `lambda:PassNetworkConnector` scoped to that connector’s ARN. The DynamoDB runner table holds runner-to-VM correlation and launch claims, no secrets, and its rows expire through a TTL. ## The VM and the job [Section titled “The VM and the job”](#the-vm-and-the-job) A runner set has two layers that can each hold an AWS identity: the **VM**, the machine the launcher starts, and the **job**, the workflow run GitHub sends to it. A role attached to the VM belongs to the machine for its whole life; a role the job assumes belongs to that one workflow run. A VM carries no AWS identity by default. A job that needs AWS permissions can be configured to obtain its own, one of two ways. **The job’s own role, through GitHub OIDC.** The job adds a step that trades a GitHub-signed token for short-lived credentials, scoped to the repository and branch it came from. Nothing is stored on the VM. **A standing role on the VM, `vmExecutionRole`.** A role you attach to the VM itself. Its credentials are readable by all job code through the instance metadata service for the whole run, with no workflow step involved. | | Per-job OIDC | `vmExecutionRole` | | -------------- | ---------------------------------- | ---------------------------------------- | | Arranged by | the workflow author | the runner-set operator | | Granularity | per job, per workflow | one role for the whole set | | Lifetime | short-lived, expires with the job | standing, present for the whole run | | Readable by | the job, only after it assumes | all job code, via IMDS, the whole run | | Workflow setup | `id-token: write` + an assume step | none — the credentials are already there | | Cross-account | yes | the runner set’s account | Per-job OIDC is the narrower grant, and it stores nothing on the VM. Reach for it first: it is the path most jobs should use, and a runner set that never sets `vmExecutionRole` gives job code no AWS identity to find. A standing role covers what a workflow step cannot: capturing a runner’s runtime console is the VM’s own action, so it runs on an execution role. It also gives every runner in the set one identity without each workflow setting up OIDC, at the cost that all job code can read it for the whole run. Scope it to what the VM itself has to do — the console-capture role in [Logging](/guides/logging/) carries two log actions and nothing else — rather than to what the jobs would like to have. The two can coexist. A VM can carry a `vmExecutionRole` for a baseline identity while a job still assumes a tighter role through OIDC for a particular step. [AWS access for jobs](#aws-access-for-jobs) covers the OIDC setup, and [Giving a VM a standing identity](#giving-a-vm-a-standing-identity) covers `vmExecutionRole`. ## What job code can and cannot do on the VM [Section titled “What job code can and cannot do on the VM”](#what-job-code-can-and-cannot-do-on-the-vm) A job’s steps run as the `runner` user, and job steps run with the `no_new_privs` process flag set, so nothing a step starts can gain privileges. What that means in practice, on the default image: * **No `sudo`, and no root.** `sudo` fails with “The no new privileges flag is set”, so a job cannot install system packages with `dnf`, start system services, or write outside what the `runner` user owns. Software a job needs on the machine goes into the image at build time — [Runner images](/guides/images/) covers the options, and none of them require anything of the workflow. * **Containers work without any of that.** The VM’s agent starts the Docker daemon as root when the job’s configuration arrives, before the runner starts and so before any step runs; job steps reach it through the socket, which the `runner` user holds through the `docker` group. `no_new_privs` restricts what a step’s own processes can become, not what the already-running daemon does on its behalf. [Runner images](/guides/images/#containers-in-a-job) covers the daemon’s settings and their consequences. * **No AWS identity**, unless the runner set opts into `vmExecutionRole`: the IMDS serves nothing by default, as the previous section covers. * **The network is the runner set’s egress** — direct internet by default, or the VPC’s routing when a connector is attached, as the next section covers. There is no inbound path to a job. * **Nothing survives the job.** The VM is destroyed after its single job, so state a job writes — caches, credentials, tool installs into the home directory — is gone with it. The boundary is per-VM rather than per-step: every step of the one job the VM runs sees the same user, the same identity, and the same network. ## Networking [Section titled “Networking”](#networking) By default a runner VM egresses directly to the internet, the same as a Lambda function with no VPC configuration. That is `RunnerNetwork.internetEgress()`, and it is what the construct uses when you set no `network`: ```ts // The default — direct internet egress, not in a VPC. new GithubMicrovmRunners(stack, 'Runners', { github, scope, }); ``` To route a runner set’s egress through a VPC — so it follows your subnets, security groups, and route tables, including NAT, VPC endpoints, and egress filtering — attach a Lambda runtime connector. There are two ways. `RunnerNetwork.vpc(vpc)` has the construct build the connector from a VPC you pass, along with the network interfaces and the operator role it needs: ```ts new GithubMicrovmRunners(stack, 'Runners', { github, scope, network: RunnerNetwork.vpc(vpc), }); ``` The interfaces land in the VPC’s private-with-egress subnets and get a new security group by default; pass either to place them yourself: ```ts network: RunnerNetwork.vpc(vpc, { subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, securityGroups: [mySecurityGroup], }), ``` `RunnerNetwork.vpcConnector(arns)` attaches runners to Lambda runtime connectors you already manage, by ARN, when a connector exists in your account and you want the runners on it rather than having the construct create one: ```ts network: RunnerNetwork.vpcConnector([ 'arn:aws:lambda:us-east-1::network-connector:', ]), ``` Egress is a runner set’s only network path, so the VPC’s routing decides what a job can reach: a VPC without a NAT route keeps jobs off the public internet, and VPC endpoints give them private paths to AWS services. That routing decides it in both directions. Whatever a runner set can reach through its connector, the jobs running on it can reach, with whatever code the workflow contains. Each VM being ephemeral and single-use limits what persists between jobs; it is not a network boundary, and it does not narrow what a job can talk to while it runs. So choose a runner set’s subnets and security groups the way you would for anything else that executes code you did not write. ## AWS access for jobs [Section titled “AWS access for jobs”](#aws-access-for-jobs) The job presents a token that GitHub signs, describing the repository, branch, and workflow it belongs to, and AWS STS lets it assume a role scoped to exactly that identity. This works the same way on a runner set’s runners as on GitHub-hosted ones. GitHub’s [OpenID Connect hardening guide](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) and AWS’s [OIDC identity provider docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) describe the mechanism in full. The role’s trust policy carries the scoping, and two properties of GitHub’s tokens and of IAM’s validation determine what it has to say. ### Condition on `sub` [Section titled “Condition on sub”](#condition-on-sub) A working trust policy conditions on the `sub` claim. For a role that one repository’s main-branch workflows may assume, with the subject in one of the two forms the next section covers: ```json { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:OWNER@/REPO@:ref:refs/heads/main" } } } ``` `sub` is required because IAM enforces it. A trust policy that scopes on any other claim — `repository` or `ref`, for example, which are also in the token — is rejected when you deploy it, with this message: ```plaintext Trust policy ... must evaluate, using StringEquals, StringLike or StringEqualsIgnoreCase, token.actions.githubusercontent.com:sub or token.actions.githubusercontent.com:job_workflow_ref which is not scoped to all. ``` ### Match the exact subject the tokens carry [Section titled “Match the exact subject the tokens carry”](#match-the-exact-subject-the-tokens-carry) GitHub decides the form, and it issues one of two: ```plaintext name form: repo:OWNER/REPO:ref:refs/heads/main ID form: repo:OWNER@/REPO@:ref:refs/heads/main ``` Which one a repository gets follows its creation date. Repositories created after July 15, 2026 use the ID form by default; older ones use the name form unless they opt in to [immutable subject claims](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/), and once a repository is on the ID form those IDs cannot be removed, even by customizing the claim. The ID form is the stronger of the two, because the numeric IDs are immutable — a condition written against them survives a repository rename and cannot be claimed by someone who recreates a deleted repository under the same name. The creation-date rule tells you what to expect, but a condition written against the wrong form returns `Not authorized to perform sts:AssumeRoleWithWebIdentity`, so read the subject from a real token (next section) before writing the condition rather than assuming. ### Finding your `sub` [Section titled “Finding your sub”](#finding-your-sub) You can assemble the subject without running a workflow. One API call returns the two numeric IDs and the repository’s creation date, which is what the form turns on: ```bash gh api repos/OWNER/REPO --jq '{owner_id: .owner.id, repo_id: .id, created_at}' ``` Combine them into the form the repository uses — the ID form on immutable claims (created after July 15, 2026, or opted in), the name form otherwise: ```plaintext ID form: repo:OWNER@/REPO@:ref:refs/heads/BRANCH name form: repo:OWNER/REPO:ref:refs/heads/BRANCH ``` The one thing this cannot show is the literal token, because GitHub mints an OIDC token only during a workflow run. To confirm the exact value — worth doing if an older repository may have opted in — decode a token from inside a workflow once, with `permissions: id-token: write`: ```yaml - run: | T=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value) node -e 'const p=process.argv[1].split(".")[1];console.log(JSON.parse(Buffer.from(p,"base64url").toString()).sub)' "$T" ``` Condition on exactly the value you settle on. ### The CDK side [Section titled “The CDK side”](#the-cdk-side) The role has two parts: a trust policy that says who may assume it — the OIDC condition above — and permissions that say what it can do once assumed. ```ts import * as iam from 'aws-cdk-lib/aws-iam'; // Once per account: the GitHub OIDC identity provider. const provider = new iam.OpenIdConnectProvider(stack, 'GithubOidc', { url: 'https://token.actions.githubusercontent.com', clientIds: ['sts.amazonaws.com'], }); const jobRole = new iam.Role(stack, 'DeployRole', { // Trust: only this repository's main-branch workflows may assume it. assumedBy: new iam.OpenIdConnectPrincipal(provider, { StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', 'token.actions.githubusercontent.com:sub': 'repo:OWNER@/REPO@:ref:refs/heads/main', }, }), }); // Permissions: what a job holding this role can do. bucket.grantWrite(jobRole); ``` The permissions are whatever you grant the role — a job gets exactly that and nothing more. `bucket.grantWrite(jobRole)` attaches a policy like this: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::my-bucket/*" } ] } ``` ### The workflow side [Section titled “The workflow side”](#the-workflow-side) ```yaml permissions: id-token: write # lets the job request a GitHub OIDC token contents: read steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam:::role/ aws-region: us-east-1 - run: aws sts get-caller-identity # → arn:aws:sts::…:assumed-role/DeployRole/GitHubActions ``` The credentials exist only for the duration of the job and expire with it. After the `configure-aws-credentials` step, `aws sts get-caller-identity` reports the assumed role. ### Rules for the `sub` condition [Section titled “Rules for the sub condition”](#rules-for-the-sub-condition) * Never wildcard the owner or repository portion of `sub`. A pattern like `repo:my-org*/...` also matches `my-org-anything/...`, which is a different owner; wildcards belong only after the repository identity is pinned, for example `…:ref:refs/heads/*` to allow any branch of the exact repository through `StringLike`. * Pin a branch or environment rather than trusting the bare repository. A repository-wide subject (`repo:owner/name:*`) includes `refs/pull/N/merge`, which would let fork pull requests assume the role, whereas a `ref:refs/heads/main` pin excludes pull-request runs. * Always keep the `aud` condition (`sts.amazonaws.com`) alongside `sub`. * Use one role per trust boundary — deploy roles for deploy workflows, read-only roles for pull-request checks — and grant permissions with CDK’s `grant*` methods rather than anything broader. For a construct that packages the provider and role, [`aws-cdk-github-oidc`](https://constructs.dev/packages/aws-cdk-github-oidc) is jsii-published for all CDK languages. Its filters build name-based subjects, so a repository on the ID form supplies that subject directly. ## Giving a VM a standing identity [Section titled “Giving a VM a standing identity”](#giving-a-vm-a-standing-identity) `vmExecutionRole` attaches a role you provide to the VM, giving its jobs a standing AWS identity for their whole run. The role’s credentials are readable from inside the job through the instance metadata service: ```ts new GithubMicrovmRunners(stack, 'Runners', { github, scope, vmExecutionRole: role, }); ``` The role and its permissions are yours: the construct adds nothing to a role you supply, and it never creates one for you. Runtime console capture runs on this same role: the platform writes those logs using the VM’s execution role, so `consoleLogs` requires `vmExecutionRole`, and the console-write actions on the log group are yours to grant. [Logging](/guides/logging/) covers it. ## Account-level controls [Section titled “Account-level controls”](#account-level-controls) `encryptionKey` applies a customer-managed KMS key to the runner set’s stateful resources — the DynamoDB table, the SQS queues, and the log groups — so their data at rest is under a key you own and can rotate or revoke: ```ts new GithubMicrovmRunners(stack, 'Runners', { github, scope, encryptionKey: key, }); ``` `permissionsBoundary` applies a managed policy as the permissions boundary on every IAM role the construct creates — the webhook, launcher, and janitor roles, the image build roles, and any execution or connector role — so none of them can exceed the boundary your organization sets, whatever the construct grants. Reference an organization-mandated boundary by ARN, or define one: ```ts import * as iam from 'aws-cdk-lib/aws-iam'; const boundary = iam.ManagedPolicy.fromManagedPolicyArn( stack, 'Boundary', 'arn:aws:iam:::policy/OrganizationRoleBoundary', ); new GithubMicrovmRunners(stack, 'Runners', { github, scope, permissionsBoundary: boundary, }); ``` A boundary is a ceiling, not a grant: each role still holds only the permissions the construct gives it, and the boundary caps them. A common one lets a role do its normal work but denies privilege escalation, so even an over-granted role could not touch IAM or account settings: ```json { "Effect": "Deny", "Action": ["iam:*", "organizations:*", "account:*"], "Resource": "*" } ``` # Service quotas: sizing a runner set for concurrency and launch rate AWS Lambda MicroVMs is a quota-gated service. Account-level quotas set how many runners a runner set can run at once and how quickly it can start them, and new accounts are provisioned below the AWS published defaults, so those ceilings come into play as soon as more than one job runs at a time. ## The quotas that gate a runner set [Section titled “The quotas that gate a runner set”](#the-quotas-that-gate-a-runner-set) All three live under the **Lambda** service in Service Quotas. Each is held per account and per region, so raise it in every region a runner set runs in. | Quota | Code | AWS default | What it caps | | ----------------------------------------- | ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------- | | **Max allocated memory** | `L-CD1C0CC4` | 1024 GB | Total memory summed across **all running MicroVMs** in the account and region. This is the real concurrency ceiling. | | **Rate of RunMicrovm API requests** | `L-535CA9B6` | 5 /sec | Sustained launch rate — how many runners you can start per second. | | **Burst rate of RunMicrovm API requests** | `L-91B95582` | 5 /sec | Burst launch rate — at a burst of `1`, launches admit one at a time, so simultaneous arrivals queue. | In the Service Quotas console, find the memory quota by its name, `Max allocated memory`, or by its code, `L-CD1C0CC4`. ## How allocated memory is counted [Section titled “How allocated memory is counted”](#how-allocated-memory-is-counted) The memory quota counts each VM at its **actual** allocated memory, and the platform over-provisions every VM to **\~4× its requested size class** — a `GB1` VM comes up with \~4 GB kernel-visible, a `GB4` with \~16 GB. Admission is strict-exceeds: a new launch is blocked only while the account’s actual allocated memory **already exceeds** the quota (`>`, not `≥`), so a launch that lands the total exactly *at* the quota still admits. The ceiling that follows is **`quota ÷ (4 × requested size)`**, so **4× over-provisioning quarters a frozen quota**. The boundary observations behind this rule come from a single run, the `>`-versus-`≥` edge is inferred rather than directly observed, and the 4× factor may be capacity-dependent. ## Reaching the memory quota [Section titled “Reaching the memory quota”](#reaching-the-memory-quota) When a launch would push total running memory past **Max allocated memory**, the platform rejects it: ```plaintext ServiceQuotaExceededException: The base maximum allocated memory limit has been reached for this account. ``` The launcher’s message stays on the queue and is retried, and the janitor re-drives anything that dead-letters. The job serializes, waiting until a running VM frees enough memory. What clears it is more concurrent capacity: a higher quota, or smaller VMs, so more fit in the same memory budget. ## Mapping the memory quota to `maxConcurrentVms` [Section titled “Mapping the memory quota to maxConcurrentVms”](#mapping-the-memory-quota-to-maxconcurrentvms) `maxConcurrentVms` (default `10`) caps how many MicroVMs the runner set will run at once, across all runner classes. The memory quota sets the effective ceiling, measured against each VM’s **actual** (\~4× requested) allocation, so the quota has to cover the **worst-case mix at 4×**: ```plaintext Max allocated memory (GB) ≥ Σ over concurrent VMs of (4 × that VM's class size, GB) + headroom ``` For a single-class runner set that’s `maxConcurrentVms × 4 × class size`. The same over-provisioning decides which preset a workload needs in the first place, which [Runner images](/guides/images/) covers. Size classes, requested vs. what actually lands: | `MicrovmSize` | Requested (GB) | Actual \~4× (GB) | | ------------- | -------------- | ---------------- | | `GB0_5` | 0.5 | \~2 | | `GB1` | 1 | \~4 | | `GB2` | 2 | \~8 | | `GB4` | 4 | \~16 | | `GB8` | 8 | \~32 | As a worked example, `maxConcurrentVms: 10` on `GB4` needs `10 × 16 = 160 GB` of *Max allocated memory*, plus headroom, because a VM being torn down still counts until it’s fully gone. The AWS default of 1024 GB covers that; on a throttled **8 GB** account a single `GB4` (≈16 GB actual) already exceeds the quota and monopolizes the runner set until it exits. `maxConcurrentVms` counts VMs in the `RUNNING` state, so a VM that has been launched but has not reached `RUNNING` holds account memory without counting toward the cap. During a burst the runner set can briefly run more than the configured number, which is part of what the headroom covers. With `maxConcurrentVms` set above what the memory quota covers, the quota is what binds: launches past it fail with the error above and serialize. ## When the quota is frozen: split by memory profile [Section titled “When the quota is frozen: split by memory profile”](#when-the-quota-is-frozen-split-by-memory-profile) A quota increase is not always granted. On an account held at a low memory quota, the lever left is the size class — smaller VMs, so more of them fit under the same ceiling. But do not size a whole gate down on the assumption that it is uniformly small: measure each step’s peak memory first, because a gate is often bimodal rather than big. One production gate, measured: strict mypy over 306 files peaked at 443 MB, while the pytest battery in the same gate peaked near 4 GiB. Sizing the whole gate down to `GB1` would have run the tests out of memory; keeping it whole on `GB4` made every lint-only run pay the big VM’s footprint — and on that account’s frozen 8 GB quota, a single `GB4` at \~16 GB actual monopolizes the runner set. The fix was two runner classes: lint and type-checking on `GB1`, tests on `GB4`, each job routed by its label. The small jobs then run two or three wide among themselves instead of competing for the big slots, and the repository gains a fast required check — about three minutes — alongside the long one. ## Running near capacity [Section titled “Running near capacity”](#running-near-capacity) When the runner set is at `maxConcurrentVms`, or against the memory quota, a new launch is re-queued and retried until a slot frees. Those capacity retries share the dead-letter queue’s redrive budget (`maxReceiveCount`, default **20**, tunable), so a launch that keeps hitting a full runner set across \~20 redrives, each after the 180s visibility timeout, will eventually dead-letter — roughly a **1-hour** saturation window at the default. Three things matter for a runner set expected to run sustained at capacity. Raising `maxReceiveCount` gives genuine saturation a longer queue wait before a launch dead-letters. `recoverStuckLaunches`, on by default, re-drives any launch that does dead-letter during a long saturation or a GitHub outage, once its job is launchable again; turn it off and a dead-lettered launch waits in the dead-letter queue until you drain it yourself. The third is capacity itself, which under a fixed memory quota means moving to a smaller size class. ## Checking your quotas [Section titled “Checking your quotas”](#checking-your-quotas) Per account/region, with your CLI profile: ```bash for code in L-CD1C0CC4 L-535CA9B6 L-91B95582; do aws service-quotas get-service-quota \ --service-code lambda --quota-code "$code" \ --profile "$PROFILE" --region "$REGION" \ --query 'Quota.{Name:QuotaName,Applied:Value}' --output json done ``` Compare `Applied` against the defaults in the table above. A new account starts below the published defaults, and AWS raises it automatically as the account uses MicroVMs. Service Quotas accepts only values at or above the default (1024 GB for **Max allocated memory**), so a request cannot express anything in between. A restore-to-default request on a new account goes to a support case and may be denied. Running real workloads is what raises the quota. A fresh request shows `PENDING` with `CaseId: null`, which means it has not been triaged yet, so re-poll its `Status` rather than reading the submit response. # Toolchains A runner VM boots from an image you build, and [`actions/setup-python`](https://github.com/actions/setup-python) and [`actions/setup-node`](https://github.com/actions/setup-node) resolve the version a job asks for from that image’s hosted tool cache. Declaring toolchains is how versions get into that cache, so a workflow’s `setup-*` steps work the way they do on a GitHub-hosted runner. A toolchain is specifically a language runtime placed in the tool cache for those actions to find, and `RunnerToolchain` installs two: Python and Node. Software a job invokes directly, rather than through a `setup-*` action, goes into an image through `RunnerImage`’s `systemPackages`, `setupCommands`, and `assets` options, which [Runner images](/guides/images/) covers along with how images are built and how large they can be. ## Declaring a toolchain [Section titled “Declaring a toolchain”](#declaring-a-toolchain) `RunnerToolchain` has two methods: ```ts RunnerToolchain.python('3.12.7'); RunnerToolchain.node('22.11.0'); ``` Pass them to a `RunnerImage`, and give that image to the runner class whose jobs need them: ```ts runners.addRunnerClass('microvm', { size: MicrovmSize.GB4, image: RunnerImage.fromOptions({ toolchains: [ RunnerToolchain.python('3.12.7'), RunnerToolchain.node('22.11.0'), ], }), }); ``` A workflow that targets that class then uses `setup-*` as written: ```yaml runs-on: [self-hosted, microvm] steps: - uses: actions/setup-python@v7 with: python-version: '3.12' ``` `toolchains` defaults to an empty list, so an image carries the versions it names. Declare them per runner class, for the versions that class’s workflows use. ## Versions [Section titled “Versions”](#versions) Each toolchain pins a full three-part semver, because the version names the cache directory it is baked into — `/opt/hostedtoolcache/Python/3.12.7/arm64`. A partial version throws at synth time: ```plaintext RunnerToolchain: version must be a full semver like '3.12.7', got '3.12'. ``` Several versions of the same language live in one image, each in its own directory: ```ts toolchains: [ RunnerToolchain.python('3.11.9'), RunnerToolchain.python('3.12.7'), RunnerToolchain.node('20.18.0'), RunnerToolchain.node('22.11.0'), ], ``` A workflow’s version selector is a semver range, so `python-version: '3.12'` resolves to the baked `3.12.7` and `node-version: '20'` to the baked `20.18.0`. Moving from `3.12.6` to `3.12.7` is an edit to the version string. That changes the rendered Dockerfile and with it the image’s content hash, which builds a new image version, the same as any other change to `RunnerImage.fromOptions()`’s options. ## When a workflow asks for a version the image does not carry [Section titled “When a workflow asks for a version the image does not carry”](#when-a-workflow-asks-for-a-version-the-image-does-not-carry) Both actions read the tool cache first and otherwise resolve the version from GitHub’s published builds, and they differ in what is published for the runner’s architecture. `actions/python-versions` publishes no linux-arm64 builds, so a Python version the image does not carry ends the job at that step: ```plaintext The version '3.12' with architecture 'arm64' was not found for this operating system ``` Bake every Python version your workflows request. That coverage is per-version, across every repository the runner set serves: an image baked with `3.12.7` for one repository fails another repository’s `python-version: '3.11'` job at exactly this step, and the fix is baking `3.11.9` alongside, as the multi-version example above does. `actions/node-versions` does publish linux-arm64 builds, so `setup-node` downloads a Node version the image does not carry and the job runs on it. That costs about 24 seconds, and since each job gets its own VM, the download repeats on every job that asks for an un-baked version rather than accumulating in a shared cache. Bake the Node versions your workflows use when you would rather not pay that each time. Several baked versions add up against the image’s disk allowance; [Runner images](/guides/images/) covers the budget and how to fit inside it. # Warm pools A job normally waits for a MicroVM to boot. A warm pool removes that wait by keeping VMs booted and suspended ahead of time, so a job resumes one instead of starting one. A pool belongs to a runner class, and is one property: ```ts runners.addRunnerClass('microvm', { size: MicrovmSize.GB4, warmPoolSize: 3, }); ``` That keeps three VMs of that class ready. It is a count, not a flag — classes without `warmPoolSize` cold-launch every job, which is the default. ## What it changes [Section titled “What it changes”](#what-it-changes) A cold launch boots a VM from the class’s image, then registers a runner against it. The warm path resumes a VM that is already booted, so the boot is gone from the job’s critical path. The saving is the boot, and only the boot. Measured on a bench runner, the launcher’s own slice — enqueue through to the VM running — came out at 6.3–9.0 s warm against 3.8–7.2 s cold. Those ranges overlap on small samples, so treat the warm path as removing boot from the critical path rather than as a fixed multiple faster. Everything after the VM is running is unchanged: the runner still starts and registers per job, which is most of the wait either way. See [Start latency](/guides/architecture/#start-latency) for where the time actually goes. Nothing about the job changes. A job cannot tell which path served it, and the image, size, and labels are the same either way. Only the wait before it starts is different. ## How the pool stays full [Section titled “How the pool stays full”](#how-the-pool-stays-full) Registering a class with a `warmPoolSize` adds a scheduled Lambda to the runner set. It runs every `warmPoolInterval` (default two minutes), compares the class’s pool against its target, and boots and suspends whatever is missing. A runner set with no warm class never creates this function at all. When a job arrives, the launcher claims a suspended VM with a conditional write keyed on that VM’s id, so two launches arriving together cannot claim the same one — the loser cold-launches instead. It also falls back to a cold launch when the pool is empty, which is what happens for the first jobs after a burst drains it and before the next sweep refills it. That fallback means a warm pool is an optimization, never a dependency. A pool that fails to refill costs latency, not correctness. ## How long a pooled VM lasts [Section titled “How long a pooled VM lasts”](#how-long-a-pooled-vm-lasts) Every MicroVM carries a platform lifetime cap, fixed when the VM is created and never changeable afterwards — `ResumeMicrovm` takes only a VM id, and the service has no update call. The clock runs from creation, and it keeps running while the VM sits suspended in the pool. So a pooled VM is created with the platform’s **maximum** lifetime of eight hours rather than the job budget a cold launch uses. That is deliberate: were a pooled VM capped at `maxJobDuration` plus its grace, the time it spent waiting in the pool would come out of the job’s budget, and a VM claimed near the end of that window would be terminated part-way through the job it had just accepted. Two things follow, both automatic: * The sweep **retires a pooled VM once its remaining lifetime can no longer cover a full `maxJobDuration`**, and refills the slot. Retired VMs do not count toward the pool’s target, so `poolCurrent` reflects VMs that can actually take a job. * The launcher **skips any pooled VM without the budget to outlive the job** and cold-launches instead, logging the skip. This is a backstop for the window between sweeps. The same two places also retire and skip a pooled VM built from a **superseded image version**. An image keeps its name when its contents change (see [Images](/guides/images/)), so its ARN stays the same across rebuilds, and a VM suspended before a deploy would otherwise still look like a match afterwards and run a job on the previous image. Version equality is what actually establishes that a pooled VM carries the image the deploy published. With the default two-minute interval you will not normally see either. Both matter when a pool has been sitting idle for hours — after a quiet weekend, say — which is exactly when a stale VM would otherwise be claimed. Because the cap is the platform maximum, the platform’s own runaway-job guard is looser on a warm VM than a cold one. The janitor’s lifetime sweep, which works from the runner record rather than the VM, is what still bounds a job that hangs. ## What it costs [Section titled “What it costs”](#what-it-costs) Pooled VMs are suspended, not running, and **do not count against `maxConcurrentVms`** — the runner set’s cap counts VMs in the `RUNNING` state, which a pooled VM only enters once a job resumes it. A pool of three alongside `maxConcurrentVms: 10` does not leave room for seven. They are still VMs in your account, so they draw on the account’s MicroVM memory quota according to how the platform accounts for suspended VMs. [Service quotas](/guides/service-quotas/) covers that ceiling. Size a pool against it: a large pool on a large size class can consume capacity that running jobs then have to wait for. ## Whether it is working [Section titled “Whether it is working”](#whether-it-is-working) With `emitMetrics` on, the split between the two paths is directly visible. `warmHit` counts launches served by a pooled VM and `coldBoot` counts those that booted one, both per runner class, so the ratio between them is how well the pool is keeping up with arrivals. `warmSpinUpMs` and `coldSpinUpMs` are the corresponding times. `poolCurrent` against `poolTarget` says whether the pool is converging. `poolCurrent` is sampled at the start of a sweep, before that sweep tops the pool up, so it reads low for one interval after a drain and recovers on the next — a single low sample is not a fault. `warmThrottled` counts warm-path attempts that fell back to a cold boot. A steady stream of those against a healthy `poolCurrent` means jobs are arriving faster than the interval refills the pool, which is an argument for a larger pool rather than a shorter interval. [Monitoring](/guides/monitoring/) has the full list and how to alarm on them.