diff --git a/infrastructure/aws/iam/cloudwatch/README.md b/infrastructure/aws/iam/cloudwatch/README.md new file mode 100644 index 00000000..6c3f626f --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/README.md @@ -0,0 +1,133 @@ +# Module: cloudwatch + +## Description + +Deploys IAM resources that let the nullplatform logs controller (base chart) ship logs and metrics to CloudWatch, supporting both IRSA (OIDC federation) and EKS Pod Identity authentication modes. + +## Architecture + +An aws_iam_policy resource named nullplatform_cloudwatch_policy is always created, granting CloudWatch Logs write/describe permissions scoped to the provided log group ARN patterns plus cloudwatch:PutMetricData. In 'irsa' mode, the community iam-role-for-service-accounts module creates an aws_iam_role with an OIDC trust policy for the logs controller service account, attaching the policy via the module's policies map. In 'pod_identity' mode, an aws_iam_role with a pods.eks.amazonaws.com trust principal is created alongside an aws_iam_role_policy_attachment and an aws_eks_pod_identity_association for the logs controller service account. The module outputs the resulting IAM role ARN regardless of which identity mode is active. + +## Features + +- Creates aws_iam_policy granting CloudWatch Logs (CreateLogGroup/CreateLogStream/PutLogEvents/PutRetentionPolicy) and cloudwatch:PutMetricData permissions +- Scopes log group/stream actions to configurable ARN patterns (defaults to any log group in the account/region; tighten via log_group_arn_patterns) +- Configures IRSA identity mode using the community iam-role-for-service-accounts module with OIDC provider trust for the logs controller service account +- Configures Pod Identity mode by creating an aws_iam_role trusted by pods.eks.amazonaws.com and an aws_eks_pod_identity_association for the logs controller service account +- Service account namespace and name are configurable to match the base chart's namespaces.nullplatformTools + +## Basic Usage + +```hcl +module "cloudwatch_iam" { + source = "git::https://github.com/nullplatform/tofu-modules.git//infrastructure/aws/iam/cloudwatch?ref=v6.5.0" + + cluster_name = "your-cluster-name" + aws_iam_openid_connect_provider_arn = module.eks.eks_oidc_provider_arn +} +``` + +## Using Outputs + +```hcl +# Feed the role ARN into the base chart's cloudwatch.serviceAccount.annotations +module "base" { + # ... + cloudwatch_enabled = true + cloudwatch_service_account_annotations = { + "eks.amazonaws.com/role-arn" = module.cloudwatch_iam.nullplatform_cloudwatch_role_arn + } +} +``` + + + + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.52.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [nullplatform\_cloudwatch\_role](#module\_nullplatform\_cloudwatch\_role) | terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts | n/a | + +## Resources + +| Name | Type | +|------|------| +| [aws_eks_pod_identity_association.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_pod_identity_association) | resource | +| [aws_iam_policy.nullplatform_cloudwatch_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.pod_identity](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.pod_identity](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_iam\_openid\_connect\_provider\_arn](#input\_aws\_iam\_openid\_connect\_provider\_arn) | ARN of the AWS IAM OIDC provider. Required when identity\_mode is 'irsa'; ignored when identity\_mode is 'pod\_identity'. | `string` | `null` | no | +| [cluster\_name](#input\_cluster\_name) | Name of the cluster the role belongs to. Used to build the role and policy names. | `string` | n/a | yes | +| [identity\_mode](#input\_identity\_mode) | IAM identity mode: 'irsa' uses OIDC federation via the community iam-role-for-service-accounts module; 'pod\_identity' creates a native IAM role trusted by pods.eks.amazonaws.com with an EKS Pod Identity association. Note: switching modes on an existing deployment replaces the IAM role; the logs controller loses permissions during the transition until apply completes. | `string` | `"irsa"` | no | +| [log\_group\_arn\_patterns](#input\_log\_group\_arn\_patterns) | Resource ARN patterns the logs controller may write log groups/streams to. Defaults to any CloudWatch log group in the account/region; tighten to a prefix (e.g. arn:aws:logs:\*:\*:log-group:/nullplatform/\*) to restrict. | `list(string)` |
[
"arn:aws:logs:*:*:log-group:*",
"arn:aws:logs:*:*:log-group:*:*"
]
| no | +| [service\_account\_name](#input\_service\_account\_name) | Name of the logs controller ServiceAccount that assumes this role. | `string` | `"nullplatform-pod-metadata-reader-sa"` | no | +| [service\_account\_namespace](#input\_service\_account\_namespace) | Namespace of the logs controller ServiceAccount. Must match the base chart's namespaces.nullplatformTools. | `string` | `"nullplatform-tools"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [nullplatform\_cloudwatch\_role\_arn](#output\_nullplatform\_cloudwatch\_role\_arn) | ARN of the CloudWatch logs controller role | + + + diff --git a/infrastructure/aws/iam/cloudwatch/main.tf b/infrastructure/aws/iam/cloudwatch/main.tf new file mode 100644 index 00000000..2d0d63bf --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/main.tf @@ -0,0 +1,89 @@ +locals { + service_account = "${var.service_account_namespace}:${var.service_account_name}" +} + +# IRSA: OIDC federation via community module +module "nullplatform_cloudwatch_role" { + count = var.identity_mode == "irsa" ? 1 : 0 + source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts" + name = "nullplatform-${var.cluster_name}-cloudwatch-role" + use_name_prefix = false + + oidc_providers = { + main = { + provider_arn = var.aws_iam_openid_connect_provider_arn + namespace_service_accounts = [local.service_account] + } + } + + policies = { + "nullplatform_cloudwatch_policy" = aws_iam_policy.nullplatform_cloudwatch_policy.arn, + } +} + +# Pod Identity: native IAM role trusted by the EKS Pod Identity agent +resource "aws_iam_role" "pod_identity" { + count = var.identity_mode == "pod_identity" ? 1 : 0 + name = "nullplatform-${var.cluster_name}-cloudwatch-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "pods.eks.amazonaws.com" } + Action = ["sts:AssumeRole", "sts:TagSession"] + }] + }) +} + +resource "aws_iam_role_policy_attachment" "pod_identity" { + count = var.identity_mode == "pod_identity" ? 1 : 0 + role = one(aws_iam_role.pod_identity[*].name) + policy_arn = aws_iam_policy.nullplatform_cloudwatch_policy.arn +} + +resource "aws_eks_pod_identity_association" "this" { + count = var.identity_mode == "pod_identity" ? 1 : 0 + cluster_name = var.cluster_name + namespace = var.service_account_namespace + service_account = var.service_account_name + role_arn = one(aws_iam_role.pod_identity[*].arn) +} + +# Grant the logs controller permission to ship logs and metrics to CloudWatch +resource "aws_iam_policy" "nullplatform_cloudwatch_policy" { + name = "nullplatform-${var.cluster_name}-cloudwatch-policy" + description = "Policy for the nullplatform logs controller to ship logs and metrics to CloudWatch" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "CloudWatchLogsWrite" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:PutRetentionPolicy", + "logs:DescribeLogStreams", + "logs:TagResource", + "logs:ListTagsForResource" + ] + Resource = var.log_group_arn_patterns + }, + { + Sid = "CloudWatchLogsDescribe" + Effect = "Allow" + Action = ["logs:DescribeLogGroups"] + Resource = ["*"] + }, + { + # cloudwatch:PutMetricData does not support resource-level permissions. + Sid = "CloudWatchMetricsWrite" + Effect = "Allow" + Action = ["cloudwatch:PutMetricData"] + Resource = ["*"] + } + ] + }) +} diff --git a/infrastructure/aws/iam/cloudwatch/outputs.tf b/infrastructure/aws/iam/cloudwatch/outputs.tf new file mode 100644 index 00000000..56f411c5 --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/outputs.tf @@ -0,0 +1,4 @@ +output "nullplatform_cloudwatch_role_arn" { + description = "ARN of the CloudWatch logs controller role" + value = var.identity_mode == "irsa" ? one(module.nullplatform_cloudwatch_role[*].arn) : one(aws_iam_role.pod_identity[*].arn) +} diff --git a/infrastructure/aws/iam/cloudwatch/tests/cloudwatch.tftest.hcl b/infrastructure/aws/iam/cloudwatch/tests/cloudwatch.tftest.hcl new file mode 100644 index 00000000..59672643 --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/tests/cloudwatch.tftest.hcl @@ -0,0 +1,104 @@ +mock_provider "aws" { + override_data { + target = module.nullplatform_cloudwatch_role.data.aws_iam_policy_document.assume + values = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":\"test\"},\"Action\":\"sts:AssumeRoleWithWebIdentity\"}]}" + } + } + override_resource { + target = aws_iam_policy.nullplatform_cloudwatch_policy + values = { + arn = "arn:aws:iam::123456789012:policy/nullplatform-test-cluster-cloudwatch-policy" + } + } +} + +variables { + cluster_name = "test-cluster" + aws_iam_openid_connect_provider_arn = "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE" +} + +run "policy_naming_convention" { + command = plan + + assert { + condition = aws_iam_policy.nullplatform_cloudwatch_policy.name == "nullplatform-test-cluster-cloudwatch-policy" + error_message = "Policy name should follow nullplatform-{cluster}-cloudwatch-policy convention" + } +} + +run "policy_is_valid_json" { + command = plan + + assert { + condition = can(jsondecode(aws_iam_policy.nullplatform_cloudwatch_policy.policy)) + error_message = "Policy document should be valid JSON" + } +} + +run "policy_includes_cloudwatch_actions" { + command = plan + + assert { + condition = strcontains(aws_iam_policy.nullplatform_cloudwatch_policy.policy, "logs:PutLogEvents") + error_message = "Policy should allow logs:PutLogEvents" + } + assert { + condition = strcontains(aws_iam_policy.nullplatform_cloudwatch_policy.policy, "cloudwatch:PutMetricData") + error_message = "Policy should allow cloudwatch:PutMetricData" + } +} + +run "policy_respects_log_group_scope" { + command = plan + + variables { + log_group_arn_patterns = ["arn:aws:logs:us-east-1:123456789012:log-group:/nullplatform/*"] + } + + assert { + condition = strcontains(aws_iam_policy.nullplatform_cloudwatch_policy.policy, "log-group:/nullplatform/*") + error_message = "Policy should scope log actions to the provided log group ARN patterns" + } +} + +run "irsa_mode_creates_module_and_no_pod_identity_resources" { + command = plan + + assert { + condition = length(module.nullplatform_cloudwatch_role) == 1 + error_message = "IRSA mode must create exactly one module instance" + } + assert { + condition = length(aws_iam_role.pod_identity) == 0 + error_message = "IRSA mode must not create a pod_identity IAM role" + } + assert { + condition = length(aws_eks_pod_identity_association.this) == 0 + error_message = "IRSA mode must not create any Pod Identity associations" + } + assert { + condition = output.nullplatform_cloudwatch_role_arn != null + error_message = "IRSA mode must produce a non-null role ARN output" + } +} + +run "rejects_irsa_without_oidc_arn" { + command = plan + + variables { + aws_iam_openid_connect_provider_arn = null + } + + expect_failures = [var.aws_iam_openid_connect_provider_arn] +} + +run "rejects_invalid_identity_mode" { + command = plan + + variables { + identity_mode = "wireguard" + } + + expect_failures = [var.identity_mode] +} diff --git a/infrastructure/aws/iam/cloudwatch/tests/cloudwatch_pod_identity.tftest.hcl b/infrastructure/aws/iam/cloudwatch/tests/cloudwatch_pod_identity.tftest.hcl new file mode 100644 index 00000000..a19f5524 --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/tests/cloudwatch_pod_identity.tftest.hcl @@ -0,0 +1,83 @@ +mock_provider "aws" { + override_resource { + target = aws_iam_policy.nullplatform_cloudwatch_policy + values = { + arn = "arn:aws:iam::123456789012:policy/nullplatform-test-cluster-cloudwatch-policy" + } + } + override_resource { + target = aws_iam_role.pod_identity + values = { + arn = "arn:aws:iam::123456789012:role/nullplatform-test-cluster-cloudwatch-role" + } + } +} + +variables { + cluster_name = "test-cluster" + identity_mode = "pod_identity" +} + +run "creates_pod_identity_role" { + command = plan + + assert { + condition = length(aws_iam_role.pod_identity) == 1 + error_message = "Pod Identity mode should create exactly one native IAM role" + } + assert { + condition = strcontains(aws_iam_role.pod_identity[0].assume_role_policy, "pods.eks.amazonaws.com") + error_message = "Pod Identity role trust must use the pods.eks.amazonaws.com principal" + } + assert { + condition = strcontains(aws_iam_role.pod_identity[0].assume_role_policy, "sts:TagSession") + error_message = "Pod Identity role trust must allow sts:TagSession" + } + assert { + condition = length(aws_iam_role_policy_attachment.pod_identity) == 1 + error_message = "Pod Identity mode must create exactly one policy attachment" + } +} + +run "creates_single_association_for_logs_controller_sa" { + command = plan + + assert { + condition = length(aws_eks_pod_identity_association.this) == 1 + error_message = "Pod Identity mode should create one association for the logs controller SA" + } + assert { + condition = aws_eks_pod_identity_association.this[0].service_account == "nullplatform-pod-metadata-reader-sa" + error_message = "Association must target the logs controller service account" + } + assert { + condition = aws_eks_pod_identity_association.this[0].namespace == "nullplatform-tools" + error_message = "Association must target the nullplatform-tools namespace" + } +} + +run "pod_identity_mode_does_not_create_irsa_module" { + command = plan + + assert { + condition = length(module.nullplatform_cloudwatch_role) == 0 + error_message = "pod_identity mode must not instantiate the IRSA community module" + } + assert { + condition = output.nullplatform_cloudwatch_role_arn == "arn:aws:iam::123456789012:role/nullplatform-test-cluster-cloudwatch-role" + error_message = "pod_identity mode must output the Pod Identity role ARN" + } +} + +run "pod_identity_does_not_require_oidc_arn" { + command = plan + + variables { + aws_iam_openid_connect_provider_arn = null + } + + assert { + condition = length(aws_iam_role.pod_identity) == 1 + error_message = "Pod Identity mode must succeed without aws_iam_openid_connect_provider_arn" + } +} diff --git a/infrastructure/aws/iam/cloudwatch/variables.tf b/infrastructure/aws/iam/cloudwatch/variables.tf new file mode 100644 index 00000000..561b87ee --- /dev/null +++ b/infrastructure/aws/iam/cloudwatch/variables.tf @@ -0,0 +1,47 @@ +variable "cluster_name" { + description = "Name of the cluster the role belongs to. Used to build the role and policy names." + type = string +} + +variable "identity_mode" { + description = "IAM identity mode: 'irsa' uses OIDC federation via the community iam-role-for-service-accounts module; 'pod_identity' creates a native IAM role trusted by pods.eks.amazonaws.com with an EKS Pod Identity association. Note: switching modes on an existing deployment replaces the IAM role; the logs controller loses permissions during the transition until apply completes." + type = string + default = "irsa" + + validation { + condition = contains(["irsa", "pod_identity"], var.identity_mode) + error_message = "identity_mode must be either 'irsa' or 'pod_identity'." + } +} + +variable "aws_iam_openid_connect_provider_arn" { + description = "ARN of the AWS IAM OIDC provider. Required when identity_mode is 'irsa'; ignored when identity_mode is 'pod_identity'." + type = string + default = null + + validation { + condition = var.identity_mode != "irsa" || (var.aws_iam_openid_connect_provider_arn != null && var.aws_iam_openid_connect_provider_arn != "") + error_message = "aws_iam_openid_connect_provider_arn is required when identity_mode is 'irsa'." + } +} + +variable "service_account_namespace" { + description = "Namespace of the logs controller ServiceAccount. Must match the base chart's namespaces.nullplatformTools." + type = string + default = "nullplatform-tools" +} + +variable "service_account_name" { + description = "Name of the logs controller ServiceAccount that assumes this role." + type = string + default = "nullplatform-pod-metadata-reader-sa" +} + +variable "log_group_arn_patterns" { + description = "Resource ARN patterns the logs controller may write log groups/streams to. Defaults to any CloudWatch log group in the account/region; tighten to a prefix (e.g. arn:aws:logs:*:*:log-group:/nullplatform/*) to restrict." + type = list(string) + default = [ + "arn:aws:logs:*:*:log-group:*", + "arn:aws:logs:*:*:log-group:*:*" + ] +}