|
| 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. |
0 commit comments