Skip to content

Commit de3dddb

Browse files
refactor(multi-runner): decouple EC2 Terraform logic into provider modules (#5257)
## Description - Keep `modules/runners` and stable `multi_runner_config` dispatch unchanged. Stable configurations retain their historical `module.runners["configuration"]` addresses and flat `runners_map` fields. - Add explicit opt-in through `experimental.multi_runner_config_v2`. Stable and experimental configurations can coexist when their keys do not overlap; duplicate keys are rejected. - Normalize stable v1 once for shared queues, webhook matching, and runner-binary discovery while routing only v2 configurations through `modules/runner-stack`. - Make `runner-stack` the provider-neutral control plane for scale-up, scale-down, pool, job retry, SSM housekeeping, common Lambda IAM, and runner-role ownership. - Keep EC2-specific launch templates, instance profiles, security groups, AMI/bootstrap resources, runner log groups, IAM fragments, and Lambda environment fragments under `modules/compute-providers/ec2`. - Define provider-owned runner-role requirements in EC2 and attach them to the common runner role in `runner-stack`, allowing future compute providers to supply different policies without duplicating the role lifecycle. - Replace flat runner-stack inputs with ownership-based nested objects. Logging configuration is grouped under `observability.logs`, including `level`, retention, encryption, class, and tags. - Pass the canonical `compute_provider.ec2` object and nested `runner`, `github`, `ssm`, and `observability` objects directly into the EC2 resource and runner-role policy modules instead of expanding them back into prefixed scalar inputs. - Layer module, shared-resource, component, subcomponent, and EC2 runtime tags with documented precedence; provider-required EC2 bootstrap tags retain final precedence. - Group experimental v2 outputs by ownership: `runner.role`, `scale_up.{lambda,log_group,role}`, `scale_down.{lambda,log_group,role}`, nullable `pool.{lambda,log_group,role}`, and provider-specific resources under `provider.<type>`. - Use caller-known optional wrappers for external AMI parameters and KMS keys. The wrapper determines Terraform graph shape while its `arn` leaf may remain unknown until apply. - Generate runner-stack, pool, job-retry, and EC2 IAM policies with `aws_iam_policy_document` and retain provider-policy merge behavior. - Document the experimental boundary, ownership model, plan-time wrapper pattern, phased migration, and nested output contract under the internal module documentation path. This draft is stacked on #5251 because the provider boundary consumes the experimental v2 normalization introduced there. Lambda/TypeScript terminology changes are tracked separately in #5258. ## Test Plan - `pre-commit run --all-files` — Terraform fmt, TFLint, validation, and merge-conflict checks passed. - `terraform test` in `modules/runner-stack` — 11 passed. - `terraform test` in `modules/multi-runner` — 7 passed. - `terraform test` in `modules/compute-providers/ec2` — 4 passed. - `terraform test` in `modules/compute-providers/ec2/runner-role` — 3 passed. - `terraform test` in `modules/runner-stack/pool` — 1 passed. - `terraform test` in `modules/runner-stack/job-retry` — 1 passed. - `terraform validate` in `modules/lambda` — passed. - Verified `modules/runners` has no diff from `origin/main`, stable v1 still dispatches only to `module.runners`, and only the experimental map dispatches to `module.runner_stacks`. - Verified computed external role, profile, AMI-parameter, managed-policy, and KMS ARN inputs plan successfully through the real wrapper fixture. No live AWS apply was performed. Terraform tests use mocked providers, and state migration is intentionally deferred to the later migration phase. ## Related Issues Closes #5252 Depends on #5251 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d5bab2c commit de3dddb

88 files changed

Lines changed: 10145 additions & 329 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/terraform.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ jobs:
8585
"download-lambda",
8686
"lambda",
8787
"multi-runner",
88+
"compute-providers/ec2",
8889
"runner-binaries-syncer",
90+
"runner-stack",
91+
"runner-stack/job-retry",
92+
"runner-stack/scale-runners",
93+
"runner-stack/ssm-housekeeper",
8994
"runners",
9095
"setup-iam-permissions",
9196
"ssm",
@@ -214,6 +219,13 @@ jobs:
214219
matrix:
215220
module:
216221
- modules/runners
222+
- modules/multi-runner
223+
- modules/runner-stack
224+
- modules/runner-stack/job-retry
225+
- modules/runner-stack/pool
226+
- modules/runner-stack/scale-runners
227+
- modules/runner-stack/ssm-housekeeper
228+
- modules/compute-providers/ec2
217229
defaults:
218230
run:
219231
working-directory: ${{ matrix.module }}

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Besides these permissions, the lambdas also need permission to CloudWatch (for l
101101

102102
## Terraform main modules
103103

104-
Currently we support two main modules. The `runners` module is the main module for creating runners. And the 'multi-runner' module is a wrapper around the `runners` module to create multiple runners in one go. The `multi-runner` module is useful for creating runners for multiple repositories or organizations.
104+
Currently we support two main modules. The existing `runners` module remains the stable EC2 implementation, and the `multi-runner` module creates multiple runner configurations in one deployment. Stable `multi_runner_config` entries continue to use the unchanged `runners` module. Entries under `experimental.multi_runner_config_v2` use the new provider-oriented `runner-stack`. That stack coordinates internal provider-neutral modules for scale-up and scale-down, pool, retry, and SSM housekeeping, and owns the common runner role and attachments. The EC2 provider supplies EC2-specific policy requirements and owns the instance profile, launch template, bootstrap resources, and runner log groups. These child modules are implementation details of the experimental stack and are not standalone public entry points. Phase 1 supports non-overlapping v1 and v2 configurations together without moving legacy state; later releases will translate v1, ship state migration, and only then remove the v1 interface. See the [experimental compute-provider refactor](modules/internal/compute-provider-refactor.md) and [multi-runner v2 migration roadmap](modules/public/multi-runner.md#multi-runner-v2-migration-roadmap). EC2 is the only active Terraform-managed provider; microVM, CodeBuild, and other provider modules are future work.
105105

106106
Both modules are built on top of the same base modules. When using the multi-runner module you can deploy different runners with only one deployment.
107107

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Experimental compute-provider refactor
2+
3+
!!! warning "Experimental opt-in"
4+
5+
The provider-oriented Terraform interface is experimental. It is enabled for the whole module instance when `experimental.multi_runner_config_v2` is non-empty. Its schema can change before it becomes stable. When that map is empty, existing `multi_runner_config` deployments continue to use the unchanged legacy implementation. When it is non-empty, only v2 configurations are used and `multi_runner_config` is ignored.
6+
7+
## Why this refactor exists
8+
9+
The scale-up, scale-down, pool, job-retry, queue, SSM housekeeping, and GitHub registration workflows are not inherently EC2-specific. The legacy `runners` module combines that common control plane with EC2 launch templates, instance profiles, bootstrap parameters, log groups, IAM permissions, and Lambda environment variables. Adding another compute provider in that structure would require copying common behavior or adding provider conditionals throughout the module.
10+
11+
The refactor introduces a provider boundary so a future microVM or other backend can reuse the control plane. Only the policy statements, environment variables, and resources required by the selected compute provider should change.
12+
13+
## Ownership model
14+
15+
The implementation is split into orchestration, provider-neutral control-plane components, and compute-provider implementations:
16+
17+
| Layer | Owns |
18+
| --- | --- |
19+
| `multi-runner` | Module-level v1/v2 mode selection, canonical normalization, configuration keys, build queues, webhook matching, and runner-binary discovery. |
20+
| `runner-stack` | Provider dispatch, internal component wiring, shared runner configuration in SSM, and the common runner role and policy attachments. |
21+
| `runner-stack/scale-runners` | Provider-neutral scale-up and scale-down Lambdas, schedules and queue integration, and their execution roles and policies. |
22+
| `runner-stack/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. |
23+
| `runner-stack/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. |
24+
| `runner-stack/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. |
25+
| `compute-providers/<provider>` | Provider-specific resources, runner-role policy requirements, and the IAM and environment-variable fragments consumed by the common control plane. |
26+
27+
The EC2 provider currently owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. EC2 is the only implemented Terraform compute provider today.
28+
29+
The modules below `runner-stack` are internal implementation boundaries, not standalone public modules. Callers opt into the experimental interface through `experimental.multi_runner_config_v2`; `multi-runner` calls `runner-stack`, which composes the internal modules. Their direct input and output contracts may change while v2 remains experimental.
30+
31+
`runner-stack` selects a compute provider from the single populated typed block under `compute_provider`. For example, `compute_provider = { ec2 = { ... } }` selects EC2; there is no separate `type` input that can disagree with the populated block. Exactly one provider block must be populated, and its presence must be known during planning because it determines the module graph. The stack passes `compute_provider.ec2` to the EC2 module as one nested `config` object. It also passes the provider-neutral `runner`, `github`, `ssm`, and `observability` objects without expanding them back into prefixed scalar inputs. This keeps ownership visible at the module boundary and gives future compute providers an equivalent contract to implement.
32+
33+
The common stack creates or selects the runner IAM role and owns the role trust relationship. The selected provider returns a single nested contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, along with component environment variables and provider resources. The common stack attaches those permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role.
34+
35+
The trust relationship is deliberately resolved before the provider is called:
36+
37+
1. `runner-stack` creates or selects the runner role using the service principal associated with the populated provider block.
38+
2. The compute provider receives that role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements.
39+
3. The provider returns its nested policy and environment-variable contract.
40+
4. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own.
41+
42+
Returning the runner trust policy from the same resource-bearing provider module would create a Terraform dependency cycle: the role would depend on the provider output while the provider already depends on the role input. Keeping trust establishment in `runner-stack` and attaching provider permissions afterward preserves a one-way graph.
43+
44+
## Phase 1 dispatch and compatibility
45+
46+
Phase 1 makes one module-level choice. An empty `experimental.multi_runner_config_v2` selects the stable v1 path; a non-empty map selects the experimental v2 path and ignores `multi_runner_config`. The maps are never merged, so one module instance cannot dispatch some configurations through v1 and others through v2.
47+
48+
```mermaid
49+
flowchart TD
50+
Stable["multi_runner_config"] --> Select{"Is experimental.multi_runner_config_v2 non-empty?"}
51+
Experimental["experimental.multi_runner_config_v2"] --> Select
52+
Select -->|No| V1["Select and normalize v1"]
53+
Select -->|Yes| V2["Select v2 and ignore v1"]
54+
V1 --> Shared["Queues, webhook matching, binary discovery"]
55+
V2 --> Shared
56+
V1 --> Legacy["module.runners[configuration]"]
57+
V2 --> Stack["module.runner_stacks[configuration]"]
58+
Stack --> Scaling["runner-stack/scale-runners"]
59+
Stack --> Pool["runner-stack/pool"]
60+
Stack --> Retry["runner-stack/job-retry"]
61+
Stack --> Housekeeper["runner-stack/ssm-housekeeper"]
62+
Stack --> Provider["compute-providers/ec2"]
63+
Provider --> Scaling
64+
Provider --> Pool
65+
```
66+
67+
The selected input is normalized once so shared resources can consume one representation. Stable normalization does not change stable runner dispatch:
68+
69+
- When `experimental.multi_runner_config_v2` is empty, every key in `multi_runner_config` continues to call `modules/runners` at its historical `module.runners["configuration"]` address.
70+
- The stable module call receives the original v1 values for compatibility-sensitive inputs.
71+
- Stable queue tagging and the flat `runners_map` output remain unchanged.
72+
- When `experimental.multi_runner_config_v2` is non-empty, every key in that map calls `modules/runner-stack` at `module.runner_stacks["configuration"]`; no resources are created from the ignored v1 map.
73+
- Experimental resources are exposed separately through the nested `runners_map_v2` output.
74+
- The maps are not combined and duplicate keys do not need special precedence: v2 is the complete selected configuration whenever it is non-empty.
75+
76+
No state move is included in phase 1. Enabling v2 for a module instance that already manages v1 runners changes its implementation addresses; phase 1 does not migrate that state. Existing deployments should keep v2 empty until the documented state-migration phase. The current v2 path is intended for new or explicitly experimental deployments.
77+
78+
## Opting in
79+
80+
Set the complete runner configuration map inside the nested experimental object to use the provider-oriented stack:
81+
82+
```hcl
83+
module "multi_runner" {
84+
source = "github-aws-runners/github-runner/aws//modules/multi-runner"
85+
86+
# A non-empty v2 map is the module-level experimental opt-in. Any
87+
# multi_runner_config value is ignored while this map is non-empty.
88+
experimental = {
89+
multi_runner_config_v2 = {
90+
arm = {
91+
runner = {
92+
os = "linux"
93+
architecture = "arm64"
94+
maximum_count = 2
95+
}
96+
97+
compute_provider = {
98+
ec2 = {
99+
instance_types = ["m7g.large"]
100+
}
101+
}
102+
103+
matcherConfig = {
104+
labelMatchers = [["self-hosted", "linux", "arm64"]]
105+
}
106+
}
107+
}
108+
}
109+
}
110+
```
111+
112+
## Inputs, tags, and outputs
113+
114+
The v2 object groups provider-neutral settings by owner: `runner`, `github`, `queue`, `lambda`, `scale_up`, `scale_down`, `pool`, `job_retry`, `ssm`, and `observability`. Backend settings live only under `compute_provider.<provider>`. Exactly one typed provider block must be populated; that block selects the provider without a second discriminator field.
115+
116+
Tags follow the same ownership model. Module tags are defaults; shared Lambda, queue, and log-group tags override those defaults; component and subcomponent tags are applied last. EC2 runtime tags belong under `compute_provider.ec2.tags`. The EC2 bootstrap tags required by the runner are protected inside the provider and are not propagated to common resources.
117+
118+
Application logging settings stay together under `observability.logs`, including `level`, retention, encryption, class, and shared log-group tags.
119+
120+
In v1 mode, entries remain exclusively in `runners_map` and retain their flat output fields; `runners_map_v2` is empty. In v2 mode, entries are exposed exclusively through `runners_map_v2` and `runners_map` is empty. Common resources are grouped under `runner`, `scale_up`, `scale_down`, and `pool`, while provider-specific resources remain under `provider.<provider>`. For example, the common runner role is available at `runners_map_v2["configuration"].runner.role`, while EC2 launch-template and runner-log artifacts are under `runners_map_v2["configuration"].provider.ec2`. The returned provider contract may also expose a computed `provider.type` derived from the populated input block; it is output metadata, not an input discriminator. The `pool` value is null when no pool configuration is supplied.
121+
122+
## Plan-time provider selection and ownership wrappers
123+
124+
Terraform must know resource and dynamic-block shape during planning, even when an ARN is produced by another resource and remains unknown until apply. Optional inputs that enable IAM policies therefore use a caller-known object as the discriminator and keep the computed value in an `arn` leaf. The relevant configuration fragments are:
125+
126+
```hcl
127+
ssm = {
128+
kms_key = {
129+
arn = aws_kms_key.runner_parameters.arn
130+
}
131+
}
132+
133+
compute_provider = {
134+
ec2 = {
135+
ami = {
136+
id_ssm_parameter = {
137+
arn = aws_ssm_parameter.runner_ami.arn
138+
}
139+
kms_key = {
140+
arn = aws_kms_key.runner_ami.arn
141+
}
142+
}
143+
}
144+
}
145+
```
146+
147+
The populated `ec2` block tells Terraform which provider module exists and must therefore be known during planning. Within that block, each ownership-wrapper object tells Terraform that the corresponding policy exists; its `arn` may safely be computed. Values such as `observability.logs.kms_key_id`, which configure an existing resource without changing graph shape, remain nullable scalar inputs.
148+
149+
For experimental multi-runner entries, set `ssm.kms_key` to the key that encrypts the shared GitHub App and runner parameters. The stable root `kms_key_arn` input continues to serve v1 and is not used as a graph-shape discriminator for v2.
150+
151+
## Migration phases
152+
153+
1. **Phase 1 — experimental opt-in:** Keep v1 unchanged when the v2 map is empty, or select v2 for the whole module instance when the v2 map is non-empty. Existing v1 deployments do not move and should not use the v2 switch as an in-place migration mechanism.
154+
2. **Phase 2 — translate and migrate:** Deprecate the stable input, dispatch its translated representation through `runner-stack`, and provide tested `moved` blocks plus commands for addresses Terraform cannot move declaratively.
155+
3. **Phase 3 — remove v1:** After a release window in which phase 2 is available, remove the stable input and flat output adapter in a breaking release.
156+
4. **Future — retire `modules/runners`:** Handle direct consumers of the legacy module in a separate deprecation and migration effort.
157+
158+
A future compute provider must add a typed input block and return the same nested environment-variable, policy, and resource contract before it can be selected in Terraform. Populating more than one provider block, or selecting a block whose resources are not implemented, is intentionally rejected.

mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ nav:
6565
- Lambda Downloader: modules/public/download-lambda.md
6666
- Setup IAM permissions: modules/public/setup-iam-permissions.md
6767
- Submodules (internal):
68+
- Compute provider refactor (experimental): modules/internal/compute-provider-refactor.md
6869
- Runners: modules/internal/runners.md
6970
- Syncer: modules/internal/runner-binaries-syncer.md
7071
- SSM: modules/internal/ssm.md

0 commit comments

Comments
 (0)