diff --git a/argocd/config/regional-cluster/grafana/templates/auth-anonymous.yaml b/argocd/config/regional-cluster/grafana/templates/auth-anonymous.yaml new file mode 100644 index 000000000..0ab15985e --- /dev/null +++ b/argocd/config/regional-cluster/grafana/templates/auth-anonymous.yaml @@ -0,0 +1,42 @@ +{{- if not .Values.grafana.sso.secretArn }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana + namespace: {{ .Release.Namespace }} +data: + grafana.ini: | + [server] + root_url = %(protocol)s://%(domain)s:%(http_port)s/ + + [security] + disable_gravatar = true + + [database] + type = sqlite3 + path = /var/lib/grafana/grafana.db + wal = true + + [auth] + disable_login_form = true + + [auth.anonymous] + enabled = true + org_role = Admin + + [auth.basic] + enabled = false + + [users] + allow_sign_up = false + auto_assign_org_role = Admin + + [analytics] + reporting_enabled = false + check_for_updates = false + check_for_plugin_updates = false + + [log] + mode = console + level = info +{{- end }} diff --git a/argocd/config/regional-cluster/grafana/templates/auth-sso.yaml b/argocd/config/regional-cluster/grafana/templates/auth-sso.yaml new file mode 100644 index 000000000..99e55df13 --- /dev/null +++ b/argocd/config/regional-cluster/grafana/templates/auth-sso.yaml @@ -0,0 +1,57 @@ +{{- if .Values.grafana.sso.secretArn }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana + namespace: {{ .Release.Namespace }} +data: + grafana.ini: | + [server] + root_url = https://%(domain)s/ + + [security] + disable_gravatar = true + + [database] + type = sqlite3 + path = /var/lib/grafana/grafana.db + wal = true + + [auth] + disable_login_form = false + + [auth.anonymous] + enabled = false + + [auth.basic] + enabled = false + + [auth.generic_oauth] + enabled = true + name = Red Hat SSO + allow_sign_up = true + client_id = $__env{SSO_CLIENT_ID} + client_secret = $__env{SSO_CLIENT_SECRET} + scopes = openid email profile offline_access + email_attribute_path = email + login_attribute_path = preferred_username + name_attribute_path = name + auth_url = $__env{SSO_AUTH_URL} + token_url = $__env{SSO_TOKEN_URL} + api_url = $__env{SSO_API_URL} + skip_org_role_sync = true + auto_login = true + + [users] + allow_sign_up = false + auto_assign_org_role = Admin + + [analytics] + reporting_enabled = false + check_for_updates = false + check_for_plugin_updates = false + + [log] + mode = console + level = info +{{- end }} diff --git a/argocd/config/regional-cluster/grafana/templates/sso-external-secret.yaml b/argocd/config/regional-cluster/grafana/templates/sso-external-secret.yaml new file mode 100644 index 000000000..66573ba3e --- /dev/null +++ b/argocd/config/regional-cluster/grafana/templates/sso-external-secret.yaml @@ -0,0 +1,36 @@ +{{- if .Values.grafana.sso.secretArn }} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: grafana-sso + namespace: {{ .Release.Namespace }} +spec: + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + target: + name: grafana-sso + creationPolicy: Owner + template: + engineVersion: v2 + data: + SSO_CLIENT_ID: "{{ "{{ .client_id }}" }}" + SSO_CLIENT_SECRET: "{{ "{{ .client_secret }}" }}" + SSO_AUTH_URL: "{{ "{{ .oidc_url }}" }}/protocol/openid-connect/auth" + SSO_TOKEN_URL: "{{ "{{ .oidc_url }}" }}/protocol/openid-connect/token" + SSO_API_URL: "{{ "{{ .oidc_url }}" }}/protocol/openid-connect/userinfo" + data: + - secretKey: client_id + remoteRef: + key: {{ .Values.grafana.sso.secretArn }} + property: client_id + - secretKey: client_secret + remoteRef: + key: {{ .Values.grafana.sso.secretArn }} + property: client_secret + - secretKey: oidc_url + remoteRef: + key: {{ .Values.grafana.sso.secretArn }} + property: oidc_url +{{- end }} diff --git a/argocd/config/regional-cluster/grafana/templates/targetgroupbinding.yaml b/argocd/config/regional-cluster/grafana/templates/targetgroupbinding.yaml new file mode 100644 index 000000000..e7e95dbe4 --- /dev/null +++ b/argocd/config/regional-cluster/grafana/templates/targetgroupbinding.yaml @@ -0,0 +1,14 @@ +{{- if .Values.grafana.targetGroup.arn }} +--- +apiVersion: eks.amazonaws.com/v1 +kind: TargetGroupBinding +metadata: + name: grafana + namespace: {{ .Release.Namespace }} +spec: + serviceRef: + name: grafana + port: 80 + targetGroupARN: {{ .Values.grafana.targetGroup.arn }} + targetType: ip +{{- end }} diff --git a/argocd/config/regional-cluster/grafana/values.yaml b/argocd/config/regional-cluster/grafana/values.yaml index 16ee80144..c207050dc 100644 --- a/argocd/config/regional-cluster/grafana/values.yaml +++ b/argocd/config/regional-cluster/grafana/values.yaml @@ -36,40 +36,14 @@ grafana: create: true name: grafana - "grafana.ini": - server: - root_url: "%(protocol)s://%(domain)s:%(http_port)s/" + createConfigmap: false - security: - disable_gravatar: true + envFromSecrets: + - name: grafana-sso + optional: true - database: - type: sqlite3 - path: /var/lib/grafana/grafana.db - wal: true - - auth: - disable_login_form: true - - "auth.anonymous": - enabled: true - org_role: Admin - - "auth.basic": - enabled: false - - users: - allow_sign_up: false - auto_assign_org_role: Admin - - analytics: - reporting_enabled: false - check_for_updates: false - check_for_plugin_updates: false - - log: - mode: console - level: info + envFromConfigMaps: + - name: grafana-region-env service: type: ClusterIP @@ -79,9 +53,6 @@ grafana: persistence: enabled: false - envFromConfigMaps: - - name: grafana-region-env - datasources: datasources.yaml: apiVersion: 1 @@ -179,3 +150,10 @@ grafana: provider: foldersFromFilesStructure: true allowUiUpdates: false + + # Injected at runtime by ApplicationSet from cluster annotations. + # Empty defaults for helm lint. + targetGroup: + arn: "" + sso: + secretArn: "" diff --git a/config/defaults.yaml b/config/defaults.yaml index 6411ea554..1c6646885 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -94,6 +94,12 @@ observability: # @used-by observability.pagerduty.escalation_policy_id pipeline-regional-cluster-inputs/terraform.json.j2 escalation_policy_id: "P5KE444" + grafana_ingress: + # @doc observability.grafana_ingress.enabled Enable internet-facing Grafana ingress (ALB + ACM cert + DNS record). + # @used-by observability.grafana_ingress.enabled _context + # @used-by observability.grafana_ingress.enabled pipeline-regional-cluster-inputs/terraform.json.j2 + enabled: false + sns_alerting: # @doc observability.sns_alerting.enabled Enable SNS alerting for regional cluster alert consumption by automated/external services. # @used-by observability.sns_alerting.enabled _context diff --git a/config/integration/defaults.yaml b/config/integration/defaults.yaml index ceba56048..5a104b3cc 100644 --- a/config/integration/defaults.yaml +++ b/config/integration/defaults.yaml @@ -15,3 +15,5 @@ management_cluster_defaults: observability: pagerduty: enabled: true + grafana_ingress: + enabled: true diff --git a/config/templates/argocd-bootstrap/applicationset.yaml.j2 b/config/templates/argocd-bootstrap/applicationset.yaml.j2 index a33d735aa..de5a6fb6c 100644 --- a/config/templates/argocd-bootstrap/applicationset.yaml.j2 +++ b/config/templates/argocd-bootstrap/applicationset.yaml.j2 @@ -80,6 +80,11 @@ spec: tableName: '{{ '{{ .metadata.annotations.zoa_table_name }}' }}' auditTableName: '{{ '{{ .metadata.annotations.zoa_audit_table_name }}' }}' bucketName: '{{ '{{ .metadata.annotations.zoa_bucket_name }}' }}' + grafana: + targetGroup: + arn: '{{ '{{ .metadata.annotations.grafana_target_group_arn }}' }}' + sso: + secretArn: '{{ '{{ .metadata.annotations.grafana_sso_secret_arn }}' }}' # Thanos Operator values (keys must match chart's values.yaml structure) thanos: kmsKeyArn: '{{ '{{ .metadata.annotations.thanos_kms_key_arn }}' }}' diff --git a/config/templates/pipeline-regional-cluster-inputs/terraform.json.j2 b/config/templates/pipeline-regional-cluster-inputs/terraform.json.j2 index b7d2e027c..cb289f2d9 100644 --- a/config/templates/pipeline-regional-cluster-inputs/terraform.json.j2 +++ b/config/templates/pipeline-regional-cluster-inputs/terraform.json.j2 @@ -15,6 +15,7 @@ "enable_api_custom_domain": {{ regional_cluster.enable_api_custom_domain | default(false) | tojson }}, "zone_shard_count": {{ regional_cluster.zone_shard_count | default(1) | tojson }}, "enable_sns_alerting": {{ observability.sns_alerting.enabled | default(false) | tojson }}, + "enable_grafana_ingress": {{ observability.grafana_ingress.enabled | default(false) | tojson }}, "regional_id": "{% if eph_prefix %}{{ eph_prefix }}-regional{% else %}regional{% endif %}", "eph_prefix": "{{ eph_prefix | default('') }}", "iot_log_level": {{ applications['regional-cluster'].maestro.iotLogLevel | default('WARN') | tojson }} diff --git a/deploy/ephemeral/us-east-1/_merged_config.yaml b/deploy/ephemeral/us-east-1/_merged_config.yaml index 6ade70ed9..3d364e907 100644 --- a/deploy/ephemeral/us-east-1/_merged_config.yaml +++ b/deploy/ephemeral/us-east-1/_merged_config.yaml @@ -97,6 +97,12 @@ observability: # @used-by observability.pagerduty.escalation_policy_id pipeline-regional-cluster-inputs/terraform.json.j2 escalation_policy_id: "P5KE444" + grafana_ingress: + # @doc observability.grafana_ingress.enabled Enable internet-facing Grafana ingress (ALB + ACM cert + DNS record). + # @used-by observability.grafana_ingress.enabled _context + # @used-by observability.grafana_ingress.enabled pipeline-regional-cluster-inputs/terraform.json.j2 + enabled: false + sns_alerting: # @doc observability.sns_alerting.enabled Enable SNS alerting for regional cluster alert consumption by automated/external services. # @used-by observability.sns_alerting.enabled _context diff --git a/deploy/ephemeral/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml b/deploy/ephemeral/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml index 0899673e8..e65e05b23 100644 --- a/deploy/ephemeral/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml +++ b/deploy/ephemeral/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml @@ -79,6 +79,11 @@ spec: tableName: '{{ .metadata.annotations.zoa_table_name }}' auditTableName: '{{ .metadata.annotations.zoa_audit_table_name }}' bucketName: '{{ .metadata.annotations.zoa_bucket_name }}' + grafana: + targetGroup: + arn: '{{ .metadata.annotations.grafana_target_group_arn }}' + sso: + secretArn: '{{ .metadata.annotations.grafana_sso_secret_arn }}' # Thanos Operator values (keys must match chart's values.yaml structure) thanos: kmsKeyArn: '{{ .metadata.annotations.thanos_kms_key_arn }}' diff --git a/deploy/ephemeral/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml b/deploy/ephemeral/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml index 8ab03a7be..8c1eee837 100644 --- a/deploy/ephemeral/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml +++ b/deploy/ephemeral/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml @@ -79,6 +79,11 @@ spec: tableName: '{{ .metadata.annotations.zoa_table_name }}' auditTableName: '{{ .metadata.annotations.zoa_audit_table_name }}' bucketName: '{{ .metadata.annotations.zoa_bucket_name }}' + grafana: + targetGroup: + arn: '{{ .metadata.annotations.grafana_target_group_arn }}' + sso: + secretArn: '{{ .metadata.annotations.grafana_sso_secret_arn }}' # Thanos Operator values (keys must match chart's values.yaml structure) thanos: kmsKeyArn: '{{ .metadata.annotations.thanos_kms_key_arn }}' diff --git a/deploy/ephemeral/us-east-1/pipeline-regional-cluster-inputs/terraform.json b/deploy/ephemeral/us-east-1/pipeline-regional-cluster-inputs/terraform.json index 311bb8d39..38116a5ba 100644 --- a/deploy/ephemeral/us-east-1/pipeline-regional-cluster-inputs/terraform.json +++ b/deploy/ephemeral/us-east-1/pipeline-regional-cluster-inputs/terraform.json @@ -15,6 +15,7 @@ "enable_api_custom_domain": false, "zone_shard_count": 1, "enable_sns_alerting": true, + "enable_grafana_ingress": false, "regional_id": "regional", "eph_prefix": "", "iot_log_level": "WARN", diff --git a/deploy/integration/us-east-1/_merged_config.yaml b/deploy/integration/us-east-1/_merged_config.yaml index bcdbdcebb..2c9c9ee38 100644 --- a/deploy/integration/us-east-1/_merged_config.yaml +++ b/deploy/integration/us-east-1/_merged_config.yaml @@ -97,6 +97,12 @@ observability: # @used-by observability.pagerduty.escalation_policy_id pipeline-regional-cluster-inputs/terraform.json.j2 escalation_policy_id: "P5KE444" + grafana_ingress: + # @doc observability.grafana_ingress.enabled Enable internet-facing Grafana ingress (ALB + ACM cert + DNS record). + # @used-by observability.grafana_ingress.enabled _context + # @used-by observability.grafana_ingress.enabled pipeline-regional-cluster-inputs/terraform.json.j2 + enabled: true + sns_alerting: # @doc observability.sns_alerting.enabled Enable SNS alerting for regional cluster alert consumption by automated/external services. # @used-by observability.sns_alerting.enabled _context diff --git a/deploy/integration/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml b/deploy/integration/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml index c4d7c16a5..988f35601 100644 --- a/deploy/integration/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml +++ b/deploy/integration/us-east-1/argocd-bootstrap-management-cluster/applicationset.yaml @@ -79,6 +79,11 @@ spec: tableName: '{{ .metadata.annotations.zoa_table_name }}' auditTableName: '{{ .metadata.annotations.zoa_audit_table_name }}' bucketName: '{{ .metadata.annotations.zoa_bucket_name }}' + grafana: + targetGroup: + arn: '{{ .metadata.annotations.grafana_target_group_arn }}' + sso: + secretArn: '{{ .metadata.annotations.grafana_sso_secret_arn }}' # Thanos Operator values (keys must match chart's values.yaml structure) thanos: kmsKeyArn: '{{ .metadata.annotations.thanos_kms_key_arn }}' diff --git a/deploy/integration/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml b/deploy/integration/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml index 50b2adec9..c36f8a4ce 100644 --- a/deploy/integration/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml +++ b/deploy/integration/us-east-1/argocd-bootstrap-regional-cluster/applicationset.yaml @@ -79,6 +79,11 @@ spec: tableName: '{{ .metadata.annotations.zoa_table_name }}' auditTableName: '{{ .metadata.annotations.zoa_audit_table_name }}' bucketName: '{{ .metadata.annotations.zoa_bucket_name }}' + grafana: + targetGroup: + arn: '{{ .metadata.annotations.grafana_target_group_arn }}' + sso: + secretArn: '{{ .metadata.annotations.grafana_sso_secret_arn }}' # Thanos Operator values (keys must match chart's values.yaml structure) thanos: kmsKeyArn: '{{ .metadata.annotations.thanos_kms_key_arn }}' diff --git a/deploy/integration/us-east-1/pipeline-regional-cluster-inputs/terraform.json b/deploy/integration/us-east-1/pipeline-regional-cluster-inputs/terraform.json index 94f837d26..db25703c3 100644 --- a/deploy/integration/us-east-1/pipeline-regional-cluster-inputs/terraform.json +++ b/deploy/integration/us-east-1/pipeline-regional-cluster-inputs/terraform.json @@ -15,6 +15,7 @@ "enable_api_custom_domain": true, "zone_shard_count": 1, "enable_sns_alerting": true, + "enable_grafana_ingress": true, "regional_id": "regional", "eph_prefix": "", "iot_log_level": "WARN", diff --git a/docs/environment-provisioning.md b/docs/environment-provisioning.md index 30510f187..11c837ee3 100644 --- a/docs/environment-provisioning.md +++ b/docs/environment-provisioning.md @@ -46,6 +46,16 @@ aws iam update-assume-role-policy \ # Repeat for the Management account (switch credentials first) ``` +### 1.3 Request SSO client registration + +Open a request with Red Hat IT to register a new OIDC client for Grafana in Red Hat SSO. You will need: + +- **Redirect URI**: `https://grafana../login/generic_oauth` (one per region) +- **Grant type**: Authorization Code +- **Scopes**: `openid`, `email`, `profile`, `offline_access` + +Red Hat IT will provide a `client_id`, `client_secret`, and the OIDC realm URL. These are stored in the central account in [step 2.3](#23-store-sso-credentials-in-the-central-account). + ## 2. Configure the Region > **Skip this step** if reusing an existing environment/region configuration. @@ -80,7 +90,51 @@ aws ssm put-parameter --name "/infra/region-ou-path" \ --value "o-xxxxx/r-xxxx/ou-xxxx-xxxxxxxx/ou-xxxx-xxxxxxxx/" --type SecureString ``` -### 2.3 Add the environment configuration +### 2.3 Store SSO credentials in the central account + +Create a Secrets Manager secret in the central account containing the Red Hat SSO credentials from [step 1.3](#13-request-sso-client-registration). The secret must be a JSON object with the following keys: + +```bash +export AWS_PROFILE= + +aws secretsmanager create-secret \ + --name "sso/rh-internal" \ + --secret-string '{ + "client_id": "", + "client_secret": "", + "oidc_url": "" + }' +``` + +Then attach a resource policy that allows the External Secrets Operator role in any regional account (scoped by OU path) to read the secret: + +```bash +aws secretsmanager put-resource-policy \ + --secret-id "sso/rh-internal" \ + --resource-policy '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], + "Resource": "*", + "Condition": { + "StringLike": { + "aws:PrincipalArn": "arn:aws:iam::*:role/*-external-secrets-operator" + }, + "ForAnyValue:StringLike": { + "aws:PrincipalOrgPaths": "/*" + } + } + }] + }' +``` + +Replace `` with the OU path for regional accounts (e.g. `o-xxxxx/r-xxxx/ou-xxxx-xxxxxxxx`). New regions automatically get access without updating this policy. + +> **Note:** This step is only required once per environment. The identity policy on the ESO role (granting cross-account access to this secret) is provisioned automatically by Terraform when `enable_grafana_ingress` is enabled. + +### 2.4 Add the environment configuration Create a new region config file at `config//.yaml`. This inherits defaults from `config/defaults.yaml` — override only what differs. Environment-level defaults can be set in `config//defaults.yaml`. @@ -100,7 +154,7 @@ management_clusters: mc01: {} ``` -### 2.4 Render and commit +### 2.5 Render and commit ```bash uv run scripts/render.py diff --git a/scripts/bootstrap-argocd.sh b/scripts/bootstrap-argocd.sh index 5e82e9015..f57721d73 100755 --- a/scripts/bootstrap-argocd.sh +++ b/scripts/bootstrap-argocd.sh @@ -71,6 +71,8 @@ if [[ "$CLUSTER_TYPE" == "regional-cluster" ]]; then LOKI_KMS_KEY_ARN=$(echo "$OUTPUTS" | jq -r '.loki_kms_key_arn.value // ""') LOKI_DISTRIBUTOR_TARGET_GROUP_ARN=$(echo "$OUTPUTS" | jq -r '.loki_distributor_target_group_arn.value // ""') LOKI_QUERY_FRONTEND_TARGET_GROUP_ARN=$(echo "$OUTPUTS" | jq -r '.loki_query_frontend_target_group_arn.value // ""') + GRAFANA_TARGET_GROUP_ARN=$(echo "$OUTPUTS" | jq -r '.grafana_target_group_arn.value // ""') + GRAFANA_SSO_SECRET_ARN=$(echo "$OUTPUTS" | jq -r '.grafana_sso_secret_arn.value // ""') ZOA_TABLE_NAME=$(echo "$OUTPUTS" | jq -r '.zoa_table_name.value // ""') ZOA_AUDIT_TABLE_NAME=$(echo "$OUTPUTS" | jq -r '.zoa_audit_table_name.value // ""') ZOA_BUCKET_NAME=$(echo "$OUTPUTS" | jq -r '.zoa_bucket_name.value // ""') @@ -81,6 +83,8 @@ else LOKI_KMS_KEY_ARN="" LOKI_DISTRIBUTOR_TARGET_GROUP_ARN="" LOKI_QUERY_FRONTEND_TARGET_GROUP_ARN="" + GRAFANA_TARGET_GROUP_ARN="" + GRAFANA_SSO_SECRET_ARN="" ZOA_TABLE_NAME="" ZOA_AUDIT_TABLE_NAME="" ZOA_BUCKET_NAME="" @@ -115,6 +119,8 @@ RUN_TASK_OUTPUT=$(aws ecs run-task \ {\"name\": \"LOKI_KMS_KEY_ARN\", \"value\": \"$LOKI_KMS_KEY_ARN\"}, {\"name\": \"LOKI_DISTRIBUTOR_TARGET_GROUP_ARN\", \"value\": \"$LOKI_DISTRIBUTOR_TARGET_GROUP_ARN\"}, {\"name\": \"LOKI_QUERY_FRONTEND_TARGET_GROUP_ARN\", \"value\": \"$LOKI_QUERY_FRONTEND_TARGET_GROUP_ARN\"}, + {\"name\": \"GRAFANA_TARGET_GROUP_ARN\", \"value\": \"$GRAFANA_TARGET_GROUP_ARN\"}, + {\"name\": \"GRAFANA_SSO_SECRET_ARN\", \"value\": \"$GRAFANA_SSO_SECRET_ARN\"}, {\"name\": \"RHOBS_API_URL\", \"value\": \"$RHOBS_API_URL\"}, {\"name\": \"DNS_ZONE_OPERATOR_ROLE_ARN\", \"value\": \"$DNS_ZONE_OPERATOR_ROLE_ARN\"}, {\"name\": \"ZOA_TABLE_NAME\", \"value\": \"$ZOA_TABLE_NAME\"}, diff --git a/terraform/config/regional-cluster/main.tf b/terraform/config/regional-cluster/main.tf index 71a05a5bf..d09c9b367 100755 --- a/terraform/config/regional-cluster/main.tf +++ b/terraform/config/regional-cluster/main.tf @@ -51,6 +51,9 @@ provider "pagerduty" { # ============================================================================= data "aws_caller_identity" "current" {} +data "aws_caller_identity" "central" { + provider = aws.central +} locals { mc_entries = var.management_clusters != "" ? split(",", var.management_clusters) : [] @@ -129,6 +132,31 @@ resource "aws_iam_role_policy" "eso_secretsmanager" { }) } +resource "aws_iam_role_policy" "eso_central_secretsmanager" { + count = var.enable_grafana_ingress ? 1 : 0 + name = "${var.regional_id}-eso-central-secretsmanager" + role = aws_iam_role.external_secrets_operator.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "arn:aws:secretsmanager:${var.region}:${data.aws_caller_identity.central.account_id}:secret:sso/rh-internal-*" + }] + }) +} + +resource "aws_ssm_parameter" "grafana_sso_secret_arn" { + count = var.enable_grafana_ingress ? 1 : 0 + name = "/${var.regional_id}/sso/rh-internal-secret-arn" + type = "String" + value = "arn:aws:secretsmanager:${var.region}:${data.aws_caller_identity.central.account_id}:secret:sso/rh-internal" +} + resource "aws_eks_pod_identity_association" "external_secrets_operator" { cluster_name = module.regional_cluster.cluster_name namespace = "external-secrets" @@ -504,6 +532,23 @@ module "pagerduty_service" { escalation_policy_id = var.pagerduty_escalation_policy_id } +# ============================================================================= +# Grafana Ingress (Optional) - Internet-facing ALB for Grafana +# ============================================================================= + +module "grafana_ingress" { + count = var.enable_grafana_ingress && var.environment_domain != null ? 1 : 0 + source = "../../modules/grafana-ingress" + + regional_id = var.regional_id + vpc_id = module.vpc.vpc_id + public_subnet_ids = module.vpc.public_subnet_ids + node_security_group_id = module.regional_cluster.node_security_group_id + cluster_name = module.regional_cluster.cluster_name + domain_name = "grafana.${var.deployment_name}.${var.environment_domain}" + regional_hosted_zone_id = aws_route53_zone.regional[0].zone_id +} + # ============================================================================= # SNS Alerting Module (Phase 2 Alert Fan-Out) # ============================================================================= diff --git a/terraform/config/regional-cluster/outputs.tf b/terraform/config/regional-cluster/outputs.tf index 1aafbdfa2..7eee86a43 100755 --- a/terraform/config/regional-cluster/outputs.tf +++ b/terraform/config/regional-cluster/outputs.tf @@ -471,3 +471,17 @@ output "zoa_kms_key_arn" { description = "KMS key ARN for ZOA encryption (used by MC Pod Identity for S3 SSE-KMS)" value = module.zoa.kms_key_arn } + +# ============================================================================= +# Grafana Ingress Outputs +# ============================================================================= + +output "grafana_target_group_arn" { + description = "Target group ARN for Grafana TargetGroupBinding" + value = var.enable_grafana_ingress && var.environment_domain != null ? module.grafana_ingress[0].target_group_arn : null +} + +output "grafana_sso_secret_arn" { + description = "ARN of the central account Secrets Manager secret for Grafana SSO credentials" + value = var.enable_grafana_ingress ? aws_ssm_parameter.grafana_sso_secret_arn[0].value : null +} diff --git a/terraform/config/regional-cluster/variables.tf b/terraform/config/regional-cluster/variables.tf index c8fc2c366..26c678752 100755 --- a/terraform/config/regional-cluster/variables.tf +++ b/terraform/config/regional-cluster/variables.tf @@ -111,7 +111,11 @@ variable "enable_sns_alerting" { default = false } - +variable "enable_grafana_ingress" { + description = "Enable internet-facing Grafana ingress with ALB, ACM cert, and DNS" + type = bool + default = false +} # ============================================================================= # Platform API Variables diff --git a/terraform/modules/ecs-bootstrap/main.tf b/terraform/modules/ecs-bootstrap/main.tf index 365d55e6a..f550168d1 100644 --- a/terraform/modules/ecs-bootstrap/main.tf +++ b/terraform/modules/ecs-bootstrap/main.tf @@ -210,6 +210,8 @@ resource "aws_ecs_task_definition" "bootstrap" { loki_kms_key_arn: "$LOKI_KMS_KEY_ARN" loki_distributor_target_group_arn: "$LOKI_DISTRIBUTOR_TARGET_GROUP_ARN" loki_query_frontend_target_group_arn: "$LOKI_QUERY_FRONTEND_TARGET_GROUP_ARN" + grafana_target_group_arn: "$GRAFANA_TARGET_GROUP_ARN" + grafana_sso_secret_arn: "$GRAFANA_SSO_SECRET_ARN" aws_account_id: "$AWS_ACCOUNT_ID" management_clusters: "$MANAGEMENT_CLUSTERS" rhobs_api_url: "$RHOBS_API_URL" diff --git a/terraform/modules/grafana-ingress/acm.tf b/terraform/modules/grafana-ingress/acm.tf new file mode 100644 index 000000000..48a9d3f2b --- /dev/null +++ b/terraform/modules/grafana-ingress/acm.tf @@ -0,0 +1,26 @@ +resource "aws_acm_certificate" "grafana" { + domain_name = var.domain_name + validation_method = "DNS" + + tags = { + Name = "${var.regional_id}-grafana-cert" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "cert_validation" { + zone_id = var.regional_hosted_zone_id + name = tolist(aws_acm_certificate.grafana.domain_validation_options)[0].resource_record_name + type = tolist(aws_acm_certificate.grafana.domain_validation_options)[0].resource_record_type + ttl = 300 + records = [tolist(aws_acm_certificate.grafana.domain_validation_options)[0].resource_record_value] + allow_overwrite = true +} + +resource "aws_acm_certificate_validation" "grafana" { + certificate_arn = aws_acm_certificate.grafana.arn + validation_record_fqdns = [aws_route53_record.cert_validation.fqdn] +} diff --git a/terraform/modules/grafana-ingress/dns.tf b/terraform/modules/grafana-ingress/dns.tf new file mode 100644 index 000000000..5b5900d97 --- /dev/null +++ b/terraform/modules/grafana-ingress/dns.tf @@ -0,0 +1,11 @@ +resource "aws_route53_record" "grafana" { + zone_id = var.regional_hosted_zone_id + name = var.domain_name + type = "A" + + alias { + name = aws_lb.grafana.dns_name + zone_id = aws_lb.grafana.zone_id + evaluate_target_health = true + } +} diff --git a/terraform/modules/grafana-ingress/main.tf b/terraform/modules/grafana-ingress/main.tf new file mode 100644 index 000000000..89c348d60 --- /dev/null +++ b/terraform/modules/grafana-ingress/main.tf @@ -0,0 +1,86 @@ +data "aws_region" "current" {} + +# ----------------------------------------------------------------------------- +# Application Load Balancer (internet-facing) +# ----------------------------------------------------------------------------- + +resource "aws_lb" "grafana" { + name = "${var.regional_id}-grafana" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = var.public_subnet_ids + + tags = { + Name = "${var.regional_id}-grafana" + } +} + +# ----------------------------------------------------------------------------- +# Target Group +# +# Uses IP target type for TargetGroupBinding compatibility. +# The eks:eks-cluster-name tag is REQUIRED for EKS Auto Mode — +# AmazonEKSLoadBalancingPolicy only allows RegisterTargets on tagged TGs. +# ----------------------------------------------------------------------------- + +resource "aws_lb_target_group" "grafana" { + name = "${var.regional_id}-grafana" + port = 3000 + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + path = "/api/health" + port = "traffic-port" + protocol = "HTTP" + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 5 + interval = 30 + matcher = "200" + } + + tags = { + Name = "${var.regional_id}-grafana" + "eks:eks-cluster-name" = var.cluster_name + } +} + +# ----------------------------------------------------------------------------- +# HTTPS Listener (443) — terminates TLS with ACM certificate +# ----------------------------------------------------------------------------- + +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.grafana.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_validation.grafana.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.grafana.arn + } +} + +# ----------------------------------------------------------------------------- +# HTTP Listener (80) — redirects to HTTPS +# ----------------------------------------------------------------------------- + +resource "aws_lb_listener" "http_redirect" { + load_balancer_arn = aws_lb.grafana.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} diff --git a/terraform/modules/grafana-ingress/outputs.tf b/terraform/modules/grafana-ingress/outputs.tf new file mode 100644 index 000000000..07e705d4c --- /dev/null +++ b/terraform/modules/grafana-ingress/outputs.tf @@ -0,0 +1,14 @@ +output "target_group_arn" { + description = "Target group ARN for TargetGroupBinding in Kubernetes" + value = aws_lb_target_group.grafana.arn +} + +output "grafana_domain" { + description = "Grafana FQDN (e.g. grafana.us-east-1.int0.rosa.devshift.net)" + value = var.domain_name +} + +output "alb_dns_name" { + description = "ALB DNS name" + value = aws_lb.grafana.dns_name +} diff --git a/terraform/modules/grafana-ingress/security-groups.tf b/terraform/modules/grafana-ingress/security-groups.tf new file mode 100644 index 000000000..b9dc0fd1f --- /dev/null +++ b/terraform/modules/grafana-ingress/security-groups.tf @@ -0,0 +1,47 @@ +resource "aws_security_group" "alb" { + name = "${var.regional_id}-grafana-alb" + description = "Security group for internet-facing Grafana ALB" + vpc_id = var.vpc_id + + revoke_rules_on_delete = false + + tags = { + Name = "${var.regional_id}-grafana-alb" + } +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https" { + security_group_id = aws_security_group.alb.id + description = "Allow HTTPS from internet" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + description = "Allow HTTP from internet (redirected to HTTPS)" + ip_protocol = "tcp" + from_port = 80 + to_port = 80 + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_egress_rule" "alb_to_targets" { + security_group_id = aws_security_group.alb.id + description = "Allow traffic to Grafana pods" + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 + referenced_security_group_id = var.node_security_group_id +} + +resource "aws_vpc_security_group_ingress_rule" "nodes_from_alb" { + security_group_id = var.node_security_group_id + description = "Allow Grafana ALB traffic to Grafana pods" + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 + referenced_security_group_id = aws_security_group.alb.id +} diff --git a/terraform/modules/grafana-ingress/variables.tf b/terraform/modules/grafana-ingress/variables.tf new file mode 100644 index 000000000..9dcfea167 --- /dev/null +++ b/terraform/modules/grafana-ingress/variables.tf @@ -0,0 +1,44 @@ +variable "regional_id" { + description = "Regional cluster identifier for resource naming" + type = string +} + +variable "vpc_id" { + description = "VPC ID where the ALB will be created" + type = string +} + +variable "public_subnet_ids" { + description = "List of public subnet IDs for internet-facing ALB placement" + type = list(string) + + validation { + condition = length(var.public_subnet_ids) >= 2 + error_message = "At least 2 public subnets are required for ALB high availability." + } +} + +variable "node_security_group_id" { + description = "EKS node/pod security group ID. For EKS Auto Mode, use the cluster_primary_security_group_id." + type = string +} + +variable "cluster_name" { + description = "EKS cluster name — required for tagging target group with eks:eks-cluster-name for Auto Mode IAM permissions" + type = string +} + +variable "domain_name" { + description = "FQDN for the Grafana endpoint (e.g. grafana.us-east-1.int0.rosa.devshift.net)" + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]+[a-z0-9]$", var.domain_name)) + error_message = "domain_name must be a valid domain name." + } +} + +variable "regional_hosted_zone_id" { + description = "Route53 hosted zone ID for the regional domain. Used for ACM DNS validation and the Grafana alias record." + type = string +} diff --git a/terraform/modules/grafana-ingress/versions.tf b/terraform/modules/grafana-ingress/versions.tf new file mode 100644 index 000000000..3dccf26c7 --- /dev/null +++ b/terraform/modules/grafana-ingress/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.3" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +}