Skip to content

Commit 640a73a

Browse files
refactor(runner-stack): isolate provider trust policies
1 parent 5b2e685 commit 640a73a

26 files changed

Lines changed: 315 additions & 124 deletions

.github/workflows/terraform.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ jobs:
8686
"lambda",
8787
"multi-runner",
8888
"compute-providers/ec2",
89+
"compute-providers/ec2/trust-policy",
8990
"compute-providers/microvm",
91+
"compute-providers/microvm/trust-policy",
9092
"runner-binaries-syncer",
9193
"runner-stack",
9294
"runner-stack/job-retry",
@@ -227,7 +229,9 @@ jobs:
227229
- modules/runner-stack/scale-runners
228230
- modules/runner-stack/ssm-housekeeper
229231
- modules/compute-providers/ec2
232+
- modules/compute-providers/ec2/trust-policy
230233
- modules/compute-providers/microvm
234+
- modules/compute-providers/microvm/trust-policy
231235
defaults:
232236
run:
233237
working-directory: ${{ matrix.module }}

docs/modules/internal/compute-provider-refactor.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,27 @@ The implementation is split into orchestration, provider-neutral control-plane c
2222
| `runner-stack/pool` | Optional scheduled runner-pool resources and their Lambda and IAM wiring. |
2323
| `runner-stack/job-retry` | Optional queued-job retry resources and their Lambda and IAM wiring. |
2424
| `runner-stack/ssm-housekeeper` | Parameter Store cleanup Lambda, schedule, logging, and IAM resources. |
25-
| `compute-providers/<provider>` | Provider-specific resources, runner-role trust and permission requirements, and the IAM and environment-variable fragments consumed by the common control plane. |
25+
| `compute-providers/<provider>/trust-policy` | Provider-specific default runner-role trust, merged with the optional caller-provided trust document before the common role is created. |
26+
| `compute-providers/<provider>` | Provider-specific resources, permission requirements, and the IAM and environment-variable fragments consumed by the common control plane after the runner role is resolved. |
2627

2728
The EC2 provider owns the instance profile, launch template, security group, AMI and bootstrap parameters, runner log groups, EC2 policy statements, and EC2 Lambda environment variables. The MicroVM provider owns the Lambda MicroVM runtime configuration, execution-role policy, and MicroVM Lambda environment variables. Terraform does not manage MicroVM lifecycle resources directly; the runtime control plane creates and terminates MicroVM runners.
2829

2930
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.
3031

3132
`runner-stack` selects a compute provider from the single populated typed block under `compute_provider`. For example, `compute_provider = { ec2 = { ... } }` selects EC2 and `compute_provider = { microvm = { ... } }` selects MicroVM; 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. Native input validation enforces this common selection rule, while each compute-provider module owns its provider-specific semantic validation. The stack passes `compute_provider.<provider>` to the selected provider 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.
3233

33-
The common stack creates or selects the runner IAM role, but the selected provider owns the role's trust-policy document. Each provider exposes that document through a dedicated `assume_role_policy` output and separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the trust document when it creates the runner role and attaches the returned permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role.
34+
The common stack creates or selects the runner IAM role, but the selected provider owns the role's default trust-policy document. Each provider implements a small `trust-policy` submodule that accepts `additional_trust_policy_json` and returns the final `assume_role_policy`. The full provider separately returns its nested `provider` contract containing `policies.runner`, `policies.scale_up`, `policies.scale_down`, and `policies.pool`, component environment variables, and provider resources. The common stack uses the isolated trust-policy output when it creates the runner role and attaches the full provider's permission documents to the roles owned by the corresponding common components. A provider never creates or attaches a common IAM role.
3435

35-
The trust relationship is deliberately rendered by a dedicated file and output inside each compute-provider module:
36+
The trust relationship is deliberately rendered by an isolated provider submodule:
3637

3738
1. `runner-stack` selects the provider from the populated typed block.
38-
2. `compute-providers/<provider>/assume-role.tf` renders the provider-specific policy without referencing the runner-role input.
39-
3. `runner-stack` creates or selects the common runner role from that policy.
39+
2. `compute-providers/<provider>/trust-policy` combines the provider default with `runner.iam.additional_trust_policy_json` without referencing the runner-role input.
40+
3. `runner-stack` creates or selects the common runner role from the returned `assume_role_policy`.
4041
4. The full compute provider receives the resolved role so it can create resources such as the EC2 instance profile and render `iam:PassRole` statements.
4142
5. The provider returns its nested policy, environment-variable, and resource contract.
4243
6. The common components attach the returned policies to the runner, scale-up, scale-down, and pool roles they own.
4344

44-
The dedicated `assume_role_policy` output depends only on the provider's trust document, not on resources that consume the runner role. `runner-stack` accesses the selected counted module through a direct `[0]` reference so Terraform preserves that output-level dependency; a full splat would add the module-close dependency and recreate the role cycle. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way.
45+
The trust-policy output depends only on its input documents, not on the full provider resources that consume the runner role. This preserves provider ownership of the trust relationship while keeping the dependency graph one-way.
4546

4647
## Phase 1 dispatch and compatibility
4748

@@ -61,7 +62,7 @@ flowchart TD
6162
Stack --> Pool["runner-stack/pool"]
6263
Stack --> Retry["runner-stack/job-retry"]
6364
Stack --> Housekeeper["runner-stack/ssm-housekeeper"]
64-
Stack --> Trust["compute-providers/provider/assume-role.tf"]
65+
Stack --> Trust["compute-providers/provider/trust-policy"]
6566
Trust --> Role["common runner role"]
6667
Role --> Provider
6768
Stack --> Provider["compute-providers/<provider>"]

modules/compute-providers/ec2/README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ No modules.
4343
| [aws_ami.runner](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source |
4444
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
4545
| [aws_iam_policy_document.ami_id_ssm_parameter_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
46-
| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
4746
| [aws_iam_policy_document.cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
4847
| [aws_iam_policy_document.create_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
4948
| [aws_iam_policy_document.describe_tags](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
@@ -78,5 +77,4 @@ No modules.
7877
| <a name="output_policies"></a> [policies](#output\_policies) | Provider-specific IAM policy fragments consumed by runner-stack. |
7978
| <a name="output_provider"></a> [provider](#output\_provider) | Nested EC2 compute-provider contract consumed by runner-stack. |
8079
| <a name="output_resources"></a> [resources](#output\_resources) | Provider-specific EC2 resources exposed by runner-stack. |
81-
| <a name="output_runner_role"></a> [runner\_role](#output\_runner\_role) | Role-independent EC2 runner-role contract consumed before runner-stack creates the role. |
8280
<!-- END_TF_DOCS -->

modules/compute-providers/ec2/outputs.tf

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ output "resources" {
1313
value = local.provider_resources
1414
}
1515

16-
output "runner_role" {
17-
description = "Role-independent EC2 runner-role contract consumed before runner-stack creates the role."
18-
value = local.provider_runner_role
19-
}
20-
2116
output "provider" {
2217
description = "Nested EC2 compute-provider contract consumed by runner-stack."
2318
value = {

modules/compute-providers/ec2/tests/provider.tftest.hcl

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -446,29 +446,3 @@ run "requires_distribution_object_when_sync_is_enabled" {
446446

447447
expect_failures = [terraform_data.validate_config]
448448
}
449-
450-
run "returns_ec2_assume_role_policy" {
451-
command = plan
452-
453-
assert {
454-
condition = toset(data.aws_iam_policy_document.assume_role.statement[0].actions) == toset(["sts:AssumeRole"])
455-
error_message = "The EC2 runner role must allow sts:AssumeRole."
456-
}
457-
458-
assert {
459-
condition = anytrue([
460-
for principal in data.aws_iam_policy_document.assume_role.statement[0].principals :
461-
principal.type == "Service" && toset(principal.identifiers) == toset(["ec2.amazonaws.com"])
462-
])
463-
error_message = "The EC2 runner role must trust the EC2 service principal."
464-
}
465-
466-
assert {
467-
condition = (
468-
toset(keys(output.runner_role)) == toset(["trust_policy_json"])
469-
&& output.runner_role.trust_policy_json == data.aws_iam_policy_document.assume_role.json
470-
&& !contains(keys(output.policies.runner), "assume_role_policy")
471-
)
472-
error_message = "The EC2 provider must return trust through only the role-independent runner_role contract."
473-
}
474-
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# EC2 runner trust policy
2+
3+
This internal submodule builds the EC2 runner-role trust policy independently from EC2 resources that consume the runner role. It preserves the default EC2 service trust and optionally merges an additional IAM trust policy document supplied by the common runner stack.
4+
5+
<!-- BEGIN_TF_DOCS -->
6+
## Requirements
7+
8+
| Name | Version |
9+
|------|---------|
10+
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.4.0 |
11+
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | >= 6.33 |
12+
13+
## Providers
14+
15+
| Name | Version |
16+
|------|---------|
17+
| <a name="provider_aws"></a> [aws](#provider\_aws) | >= 6.33 |
18+
19+
## Modules
20+
21+
No modules.
22+
23+
## Resources
24+
25+
| Name | Type |
26+
|------|------|
27+
| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
28+
| [aws_iam_policy_document.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
29+
30+
## Inputs
31+
32+
| Name | Description | Type | Default | Required |
33+
|------|-------------|------|---------|:--------:|
34+
| <a name="input_additional_trust_policy_json"></a> [additional\_trust\_policy\_json](#input\_additional\_trust\_policy\_json) | Optional IAM policy document merged with the default EC2 runner-role trust policy. | `string` | `null` | no |
35+
36+
## Outputs
37+
38+
| Name | Description |
39+
|------|-------------|
40+
| <a name="output_assume_role_policy"></a> [assume\_role\_policy](#output\_assume\_role\_policy) | EC2 runner-role trust policy with the optional additional trust policy merged into it. |
41+
<!-- END_TF_DOCS -->
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
data "aws_iam_policy_document" "assume_role" {
1+
data "aws_iam_policy_document" "default" {
22
statement {
33
effect = "Allow"
44
actions = ["sts:AssumeRole"]
@@ -10,9 +10,9 @@ data "aws_iam_policy_document" "assume_role" {
1010
}
1111
}
1212

13-
locals {
14-
assume_role_policy = data.aws_iam_policy_document.assume_role.json
15-
provider_runner_role = {
16-
trust_policy_json = local.assume_role_policy
17-
}
13+
data "aws_iam_policy_document" "assume_role" {
14+
source_policy_documents = compact([
15+
data.aws_iam_policy_document.default.json,
16+
var.additional_trust_policy_json,
17+
])
1818
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
output "assume_role_policy" {
2+
description = "EC2 runner-role trust policy with the optional additional trust policy merged into it."
3+
value = data.aws_iam_policy_document.assume_role.json
4+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
mock_provider "aws" {
2+
mock_data "aws_iam_policy_document" {
3+
defaults = {
4+
json = "{}"
5+
}
6+
}
7+
}
8+
9+
run "returns_default_ec2_trust_policy" {
10+
command = plan
11+
12+
assert {
13+
condition = toset(data.aws_iam_policy_document.default.statement[0].actions) == toset(["sts:AssumeRole"])
14+
error_message = "The default EC2 runner role trust policy must allow sts:AssumeRole."
15+
}
16+
17+
assert {
18+
condition = anytrue([
19+
for principal in data.aws_iam_policy_document.default.statement[0].principals :
20+
principal.type == "Service" && toset(principal.identifiers) == toset(["ec2.amazonaws.com"])
21+
])
22+
error_message = "The default EC2 runner role trust policy must trust the EC2 service principal."
23+
}
24+
25+
assert {
26+
condition = (
27+
length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 1
28+
&& output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
29+
)
30+
error_message = "The submodule must return the final EC2 assume-role policy."
31+
}
32+
}
33+
34+
run "merges_additional_trust_policy" {
35+
command = plan
36+
37+
variables {
38+
additional_trust_policy_json = jsonencode({
39+
Version = "2012-10-17"
40+
Statement = [{
41+
Sid = "TrustedAccount"
42+
Effect = "Allow"
43+
Action = "sts:AssumeRole"
44+
Principal = { AWS = "arn:aws:iam::123456789012:root" }
45+
}]
46+
})
47+
}
48+
49+
assert {
50+
condition = (
51+
length(data.aws_iam_policy_document.assume_role.source_policy_documents) == 2
52+
&& data.aws_iam_policy_document.assume_role.source_policy_documents[1] == var.additional_trust_policy_json
53+
&& output.assume_role_policy == data.aws_iam_policy_document.assume_role.json
54+
)
55+
error_message = "The submodule must merge the additional trust policy into the final assume-role policy."
56+
}
57+
}
58+
59+
run "rejects_invalid_additional_trust_policy" {
60+
command = plan
61+
62+
variables {
63+
additional_trust_policy_json = "not-json"
64+
}
65+
66+
expect_failures = [var.additional_trust_policy_json]
67+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
variable "additional_trust_policy_json" {
2+
description = "Optional IAM policy document merged with the default EC2 runner-role trust policy."
3+
type = string
4+
default = null
5+
6+
validation {
7+
condition = var.additional_trust_policy_json == null ? true : can(jsondecode(var.additional_trust_policy_json))
8+
error_message = "additional_trust_policy_json must be valid JSON when set."
9+
}
10+
}

0 commit comments

Comments
 (0)