Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/adr/002-runner-orchestration-provider-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,36 @@ not below `modules/runner-config`. This keeps the common composition module
small and prevents provider-owned resources from becoming part of the common
contract.

### `runner-config` is the provider-neutral composition boundary

`modules/runner-config` is an internal composition module selected by
`multi-runner`; it is not a standalone public entry point. It receives one
resolved runner configuration and owns the common runner identity, IAM role,
runner bootstrap parameters, SSM housekeeper composition, and the capability
connections between the selected providers.

`runner-config` dispatches exactly one typed orchestration provider and one
typed compute provider. Provider selection is made from the plan-known typed
wrappers, not from a string discriminator or runtime fallback. The selected
provider receives the resolved common runner settings and returns only the
provider-specific resources, environment variables, IAM fragments, and
outputs required by the orchestration provider.

Webhook queues, Lambda functions, schedules, and retry behavior remain owned
by the webhook orchestration provider. EC2 instances, Lambda MicroVM capacity,
image publication, and provider-specific bootstrap behavior remain owned by
their compute providers. `runner-config` connects these capabilities but does
not absorb either provider's implementation.

For Lambda MicroVM runners, the image is an immutable runtime artifact. The
runner configuration and its sensitive, short-lived bootstrap value are
published through the runner-config SSM contract and retrieved when the
MicroVM starts. Tenant-specific runner configuration, registration tokens, and
JIT payloads must not be baked into the image or its Terraform configuration.
The image therefore supplies the runner and lifecycle-hook runtime, while the
selected compute provider supplies the lane-specific SSM path and execution
permissions.

```mermaid
flowchart TD
Multi["multi-runner: translate and resolve"] --> Config["runner-config: compose one runner config"]
Expand Down
16 changes: 16 additions & 0 deletions modules/compute-providers/aws/microvm/tests/provider.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,22 @@ run "rejects_invalid_metadata_path" {
expect_failures = [terraform_data.validate_config]
}

run "rejects_metadata_path_with_duplicate_separators" {
command = plan

variables {
ssm = {
paths = {
root = "/github-action-runners"
tokens = "tokens"
config = "config//invalid"
}
}
}

expect_failures = [terraform_data.validate_config]
}

run "rejects_invalid_image_resource_allowlist" {
command = plan

Expand Down
2 changes: 1 addition & 1 deletion modules/compute-providers/aws/microvm/validations.tf
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ resource "terraform_data" "validate_config" {
trim(var.ssm.paths.root, "/") != "" &&
trim(var.ssm.paths.config, "/") != "" &&
can(regex("^/[A-Za-z0-9_./-]+$", local.microvm_metadata_ssm_path)) &&
!strcontains(local.microvm_metadata_ssm_path, "//")
length(regexall("//", local.microvm_metadata_ssm_path)) == 0
)
error_message = "The derived MicroVM metadata Parameter Store path must be an absolute path containing only letters, numbers, dot, underscore, hyphen, and slash."
}
Expand Down
6 changes: 4 additions & 2 deletions modules/multi-runner/README.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions modules/multi-runner/config.experimental.resolved.tf
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,54 @@ locals {
}
tags = merge(local.normalized_config.compute_provider.aws.ec2.tags, v.compute_provider.aws.ec2.tags)
})
microvm = v.compute_provider.aws.microvm == null ? null : merge(v.compute_provider.aws.microvm, {
image_arn = try(coalesce(v.compute_provider.aws.microvm.image_arn, local.normalized_config.compute_provider.aws.microvm.image_arn), null)
image_version = try(coalesce(v.compute_provider.aws.microvm.image_version, local.normalized_config.compute_provider.aws.microvm.image_version), null)
ingress_network_connectors = v.compute_provider.aws.microvm.ingress_network_connectors != null ? (
v.compute_provider.aws.microvm.ingress_network_connectors
) : local.normalized_config.compute_provider.aws.microvm.ingress_network_connectors
egress_network_connectors = v.compute_provider.aws.microvm.egress_network_connectors != null ? (
v.compute_provider.aws.microvm.egress_network_connectors
) : local.normalized_config.compute_provider.aws.microvm.egress_network_connectors
cloudwatch_agent = {
enabled = coalesce(
v.compute_provider.aws.microvm.cloudwatch_agent.enabled,
local.normalized_config.compute_provider.aws.microvm.cloudwatch_agent.enabled,
)
config = try(coalesce(
v.compute_provider.aws.microvm.cloudwatch_agent.config,
local.normalized_config.compute_provider.aws.microvm.cloudwatch_agent.config,
), null)
}
log_files = v.compute_provider.aws.microvm.log_files != null ? (
v.compute_provider.aws.microvm.log_files
) : local.normalized_config.compute_provider.aws.microvm.log_files
environment_variables = merge(
local.normalized_config.compute_provider.aws.microvm.environment_variables,
v.compute_provider.aws.microvm.environment_variables,
)
iam = {
resource_arns = {
images = v.compute_provider.aws.microvm.iam.resource_arns.images != null ? (
v.compute_provider.aws.microvm.iam.resource_arns.images
) : local.normalized_config.compute_provider.aws.microvm.iam.resource_arns.images
}
additional_policy_json = {
scale_up = try(coalesce(
v.compute_provider.aws.microvm.iam.additional_policy_json.scale_up,
local.normalized_config.compute_provider.aws.microvm.iam.additional_policy_json.scale_up,
), null)
}
managed_policies = {
scale_up = v.compute_provider.aws.microvm.iam.managed_policies.scale_up != null ? (
v.compute_provider.aws.microvm.iam.managed_policies.scale_up
) : local.normalized_config.compute_provider.aws.microvm.iam.managed_policies.scale_up
pool = v.compute_provider.aws.microvm.iam.managed_policies.pool != null ? (
v.compute_provider.aws.microvm.iam.managed_policies.pool
) : local.normalized_config.compute_provider.aws.microvm.iam.managed_policies.pool
}
}
})
}
}
})
Expand Down
48 changes: 48 additions & 0 deletions modules/multi-runner/config.experimental.translation.tf
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,30 @@ locals {
}
}
}
microvm = {
image_arn = null
image_version = null
ingress_network_connectors = []
egress_network_connectors = []
cloudwatch_agent = {
enabled = true
config = null
}
log_files = null
environment_variables = {}
iam = {
resource_arns = {
images = null
}
additional_policy_json = {
scale_up = null
}
managed_policies = {
scale_up = null
pool = null
}
}
}
}
}

Expand Down Expand Up @@ -552,6 +576,30 @@ locals {
log_files = v.runner_config.runner_log_files
tags = v.runner_config.runner_ec2_tags
}
microvm = {
image_arn = null
image_version = null
ingress_network_connectors = []
egress_network_connectors = []
cloudwatch_agent = {
enabled = true
config = null
}
log_files = null
environment_variables = {}
iam = {
resource_arns = {
images = null
}
additional_policy_json = {
scale_up = null
}
managed_policies = {
scale_up = null
pool = null
}
}
}
}
}
}
Expand Down
126 changes: 126 additions & 0 deletions modules/multi-runner/tests/config-resolution.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ run "v1_stable_inputs_translate_into_effective_base" {
&& local.resolved_config.multi_runner_config["stable"].runner.group_name == "v1-lane"
&& local.resolved_config.multi_runner_config["stable"].orchestration_provider.webhook.runner.maximum_count == 2
&& toset(local.resolved_config.multi_runner_config["stable"].compute_provider.aws.ec2.instance_types) == toset(["m5.large"])
&& local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.image_arn == null
&& local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.cloudwatch_agent.enabled
&& length(local.resolved_config.multi_runner_config["stable"].compute_provider.aws.microvm.environment_variables) == 0
&& toset(local.effective_config.multi_runner_config["stable"].runner.labels) == toset(["linux", "self-hosted", "x64"])
)
error_message = "Stable v1 inputs must translate into the effective experimental base without leaking v2 globals."
Expand Down Expand Up @@ -443,3 +446,126 @@ run "v2_inputs_do_not_require_legacy_arguments" {
error_message = "The v2 interface must work without the stable v1 GitHub App, VPC, subnet, or runner configuration inputs."
}
}

run "v2_microvm_inputs_route_to_microvm_provider" {
command = plan

variables {
global_config = {
runner = {
os = "linux"
architecture = "arm64"
}
}

global_config_github = {
app = {
key_base64 = "experimental-app-key"
id = "experimental-app-id"
webhook_secret = "experimental-webhook-secret"
}
}

global_config_lambda = {
artifact = {
s3 = {
bucket = "global-lambda-artifacts"
}
}
}

global_config_orchestration_provider = {
webhook = {
eventbridge = {
enabled = false
}
runner = {
ephemeral = true
jit_config_enabled = true
}
lambda = {
artifact = {
s3 = {
key = "global-runners.zip"
}
}
webhook = {
artifact = {
s3 = {
key = "global-webhook.zip"
}
}
}
}
}
}

global_config_ssm = {
housekeeper = {
lambda = {
artifact = {
s3 = {
key = "global-housekeeper.zip"
}
}
}
}
}

global_config_compute_provider = {
aws = {
microvm = {
image_arn = "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
image_version = "7"
}
}
}

multi_runner_config = {
microvm = {
runner = {
name_prefix = "microvm-"
}
orchestration_provider = {
webhook = {
matcherConfig = {
labelMatchers = [["microvm"]]
}
}
}
compute_provider = {
aws = {
microvm = {
image_version = "8"
}
}
}
}
}
}

assert {
condition = (
local.use_v2_config
&& keys(local.resolved_config.multi_runner_config) == ["microvm"]
&& local.resolved_config.multi_runner_config["microvm"].runner.os == "linux"
&& local.resolved_config.multi_runner_config["microvm"].runner.architecture == "arm64"
&& local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.ec2 == null
&& local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
&& local.resolved_config.multi_runner_config["microvm"].compute_provider.aws.microvm.image_version == "8"
&& local.effective_config.orchestration_provider.webhook.lambda.webhook.artifact.s3.key == "global-webhook.zip"
)
error_message = "Experimental MicroVM lanes must resolve Linux ARM64 settings, inherit global provider values, and place the webhook artifact key under lambda.webhook.artifact."
}

assert {
condition = (
length(module.runners) == 0
&& keys(module.runner_configs) == ["microvm"]
&& output.runners_map_v2["microvm"].provider.aws.ec2 == null
&& output.runners_map_v2["microvm"].provider.aws.microvm.image_arn == "arn:aws:lambda:eu-west-1:123456789012:microvm-image:global"
&& output.runners_map_v2["microvm"].provider.aws.microvm.image_version == "8"
)
error_message = "Experimental MicroVM lanes must route through module.runner_configs and expose the MicroVM provider contract without an EC2 provider."
}
}
43 changes: 37 additions & 6 deletions modules/multi-runner/validations.tf
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,45 @@ resource "terraform_data" "validate_v2" {
precondition {
condition = alltrue([
for config in local.resolved_config.multi_runner_config : (
try(config.orchestration_provider.webhook != null, false) &&
try(config.compute_provider.aws.ec2 != null, false) &&
try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) &&
try(config.compute_provider.aws.ec2.vpc_id != null, false) &&
try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false)
try(config.orchestration_provider.webhook != null, false)
)
])
error_message = "Each experimental v2 runner lane requires a webhook provider, EC2 instance_types, vpc_id, and at least one subnet."
error_message = "Each experimental v2 runner lane requires a webhook provider."
}

precondition {
condition = alltrue([
for config in local.resolved_config.multi_runner_config : length([
for provider_config in [
try(config.compute_provider.aws.ec2, null),
try(config.compute_provider.aws.microvm, null),
] : provider_config if provider_config != null
]) == 1
])
error_message = "Each experimental v2 runner lane requires exactly one compute provider. Supported providers: aws.ec2, aws.microvm."
}

precondition {
condition = alltrue([
for config in local.resolved_config.multi_runner_config : (
!try(config.compute_provider.aws.ec2 != null, false) || (
try(length(config.compute_provider.aws.ec2.instance_types) > 0, false) &&
try(config.compute_provider.aws.ec2.vpc_id != null, false) &&
try(length(config.compute_provider.aws.ec2.subnet_ids) > 0, false)
)
)
])
error_message = "Each experimental v2 EC2 runner lane requires instance_types, vpc_id, and at least one subnet."
}

precondition {
condition = alltrue([
for config in local.resolved_config.multi_runner_config : (
!try(config.compute_provider.aws.microvm != null, false) ||
try(config.compute_provider.aws.microvm.image_arn != null, false)
)
])
error_message = "Each experimental v2 MicroVM runner lane requires image_arn."
}
}
}
Loading