Skip to content

Commit deaf3d5

Browse files
refactor(compute-providers): isolate EC2 runner contracts
1 parent e7d2dff commit deaf3d5

29 files changed

Lines changed: 495 additions & 339 deletions

.github/workflows/terraform.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ jobs:
8686
"lambda",
8787
"multi-runner",
8888
"compute-providers/ec2",
89+
"compute-providers/ec2/trust-policy",
8990
"runner-binaries-syncer",
9091
"runner-stack",
9192
"runner-stack/job-retry",
@@ -226,6 +227,7 @@ jobs:
226227
- modules/runner-stack/scale-runners
227228
- modules/runner-stack/ssm-housekeeper
228229
- modules/compute-providers/ec2
230+
- modules/compute-providers/ec2/trust-policy
229231
defaults:
230232
run:
231233
working-directory: ${{ matrix.module }}
Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,23 @@
1+
output "environment_variables" {
2+
description = "Provider-specific Lambda environment variable fragments consumed by runner-stack."
3+
value = local.provider_environment_variables
4+
}
5+
6+
output "policies" {
7+
description = "Provider-specific IAM policy fragments consumed by runner-stack."
8+
value = local.provider_policies
9+
}
10+
11+
output "resources" {
12+
description = "Provider-specific EC2 resources exposed by runner-stack."
13+
value = local.provider_resources
14+
}
15+
116
output "provider" {
217
description = "Nested EC2 compute-provider contract consumed by runner-stack."
318
value = {
4-
environment_variables = {
5-
scale_up = local.scale_up_environment_variables
6-
scale_down = local.scale_down_environment_variables
7-
pool = local.pool_environment_variables
8-
}
9-
policies = {
10-
runner = {
11-
inline_policies = local.runner_inline_policies
12-
managed_policy_arns = {}
13-
}
14-
scale_up = {
15-
iam_policy_json = local.scale_up_iam_policy_json
16-
additional_iam_policy_json = local.service_linked_role_policy_json
17-
managed_policy_enabled = local.ami_id_ssm_external
18-
managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
19-
}
20-
scale_down = {
21-
iam_policy_json = local.scale_down_iam_policy_json
22-
}
23-
pool = {
24-
iam_policy_json = local.pool_iam_policy_json
25-
managed_policy_enabled = local.ami_id_ssm_external
26-
managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
27-
}
28-
}
29-
resources = {
30-
launch_template = aws_launch_template.runner
31-
runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, [])
32-
logfiles = local.logfiles
33-
}
19+
environment_variables = local.provider_environment_variables
20+
policies = local.provider_policies
21+
resources = local.provider_resources
3422
}
3523
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
locals {
2+
provider_environment_variables = {
3+
scale_up = local.scale_up_environment_variables
4+
scale_down = local.scale_down_environment_variables
5+
pool = local.pool_environment_variables
6+
}
7+
8+
provider_policies = {
9+
runner = {
10+
inline_policies = local.runner_inline_policies
11+
managed_policy_arns = var.runner.iam.managed_policy_arns
12+
}
13+
scale_up = {
14+
iam_policy_json = local.scale_up_iam_policy_json
15+
additional_iam_policy_json = local.service_linked_role_policy_json
16+
managed_policy_enabled = local.ami_id_ssm_external
17+
managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
18+
}
19+
scale_down = {
20+
iam_policy_json = local.scale_down_iam_policy_json
21+
}
22+
pool = {
23+
iam_policy_json = local.pool_iam_policy_json
24+
managed_policy_enabled = local.ami_id_ssm_external
25+
managed_policy_arn = local.ami_id_ssm_external ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
26+
}
27+
}
28+
29+
provider_resources = {
30+
launch_template = aws_launch_template.runner
31+
runners_log_groups = try(aws_cloudwatch_log_group.gh_runners, [])
32+
logfiles = local.logfiles
33+
}
34+
}

modules/compute-providers/ec2/runner-instances.tf

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,6 @@ resource "aws_ssm_parameter" "runner_ami_id" {
149149
resource "aws_launch_template" "runner" {
150150
name = "${var.prefix}-action-runner"
151151

152-
lifecycle {
153-
precondition {
154-
condition = !var.config.binaries_syncer.enabled || var.config.binaries_syncer.s3 != null
155-
error_message = "config.binaries_syncer.s3 must be set when config.binaries_syncer.enabled is true."
156-
}
157-
}
158-
159152
dynamic "block_device_mappings" {
160153
for_each = var.config.block_device_mappings != null ? var.config.block_device_mappings : []
161154
content {

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ variables {
7272
arn = "arn:aws:iam::123456789012:role/provider-test-runner"
7373
name = "provider-test-runner"
7474
}
75+
managed_policy_arns = {
76+
readonly = "arn:aws:iam::aws:policy/ReadOnlyAccess"
77+
}
7578
}
7679
}
7780

@@ -176,6 +179,11 @@ run "separates_control_plane_contract_from_ec2_resources" {
176179
error_message = "The EC2 provider must return the enabled runner permission documents."
177180
}
178181

182+
assert {
183+
condition = output.provider.policies.runner.managed_policy_arns["readonly"] == "arn:aws:iam::aws:policy/ReadOnlyAccess"
184+
error_message = "The EC2 provider must return common managed runner policy inputs with its provider policies."
185+
}
186+
179187
assert {
180188
condition = toset(keys(output.provider.resources)) == toset(["launch_template", "runners_log_groups", "logfiles"])
181189
error_message = "EC2-specific artifacts must remain nested under provider resources."
@@ -391,6 +399,36 @@ run "separates_provider_runner_and_ssm_tags" {
391399
}
392400
}
393401

402+
run "rejects_external_instance_profile_with_managed_role" {
403+
command = plan
404+
405+
variables {
406+
config = {
407+
vpc_id = "vpc-12345678"
408+
subnet_ids = ["subnet-12345678"]
409+
instance_types = ["m5.large"]
410+
instance_profile = {
411+
name = "external-runner-profile"
412+
}
413+
binaries_syncer = {
414+
enabled = false
415+
}
416+
}
417+
418+
runner = {
419+
iam = {
420+
role = {
421+
arn = "arn:aws:iam::123456789012:role/provider-test-runner"
422+
name = "provider-test-runner"
423+
managed = true
424+
}
425+
}
426+
}
427+
}
428+
429+
expect_failures = [terraform_data.validate_config]
430+
}
431+
394432
run "requires_distribution_object_when_sync_is_enabled" {
395433
command = plan
396434

@@ -406,5 +444,5 @@ run "requires_distribution_object_when_sync_is_enabled" {
406444
}
407445
}
408446

409-
expect_failures = [var.config]
447+
expect_failures = [terraform_data.validate_config]
410448
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
data "aws_iam_policy_document" "default" {
2+
statement {
3+
effect = "Allow"
4+
actions = ["sts:AssumeRole"]
5+
6+
principals {
7+
type = "Service"
8+
identifiers = ["ec2.amazonaws.com"]
9+
}
10+
}
11+
}
12+
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+
])
18+
}
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+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
terraform {
2+
required_version = ">= 1.4.0"
3+
4+
required_providers {
5+
aws = {
6+
source = "hashicorp/aws"
7+
version = ">= 6.33"
8+
}
9+
}
10+
}

0 commit comments

Comments
 (0)