diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml
new file mode 100644
index 0000000..f7ada3d
--- /dev/null
+++ b/.github/workflows/terraform.yml
@@ -0,0 +1,143 @@
+---
+name: Terraform
+
+on:
+ pull_request:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+jobs:
+ fmt-lint-validate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v2
+
+ - name: Setup Terraform Linters
+ uses: terraform-linters/setup-tflint@v4
+ with:
+ github_token: ${{ env.GITHUB_TOKEN }}
+
+ - name: Terraform Format
+ id: fmt
+ run: terraform fmt -check -recursive
+
+ - name: Terraform Init
+ id: init
+ run: terraform init
+
+ - name: Terraform Validate
+ id: validate
+ run: terraform validate -no-color
+
+ - name: Terraform Lint
+ id: lint
+ run: tflint --no-color --recursive --format compact
+
+ - uses: actions/github-script@v6
+ if: github.event_name == 'pull_request' || always()
+ with:
+ github-token: ${{ env.GITHUB_TOKEN }}
+ script: |
+ // 1. Retrieve existing bot comments for the PR
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ })
+ const botComment = comments.find(comment => {
+ return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
+ })
+
+ // 2. Prepare format of the comment
+ const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
+ #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
+ #### Terraform Lint 📖\`${{ steps.lint.outcome }}\`
+ #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
+ Validation Output
+
+ \`\`\`\n
+ ${{ steps.validate.outputs.stdout }}
+ \`\`\`
+
+
[| no | +| [configuration](#input\_configuration) | The configuration string for the workspace that you create | `string` | `null` | no | +| [data\_sources](#input\_data\_sources) | The data sources for the workspace. Valid values are `AMAZON_OPENSEARCH_SERVICE`, `ATHENA`, `CLOUDWATCH`, `PROMETHEUS`, `REDSHIFT`, `SITEWISE`, `TIMESTREAM`, `XRAY` | `list(string)` | `[]` | no | +| [grafana\_version](#input\_grafana\_version) | Specifies the version of Grafana to support in the new workspace. If not specified, the default version for the `aws_grafana_workspace` resource will be used. See `aws_grafana_workspace` documentation for available options. | `string` | `"8.4"` | no | +| [iam\_role\_arn](#input\_iam\_role\_arn) | The arn of the IAM role to use for grafana workspace | `string` | `null` | no | +| [license\_type](#input\_license\_type) | The type of license for the workspace license association. Valid values are `ENTERPRISE` and `ENTERPRISE_FREE_TRIAL` | `string` | `null` | no | +| [network\_access\_control](#input\_network\_access\_control) | Configuration for network access to your workspace |
"AWS_SSO"
]
object({| `null` | no | +| [notification\_destinations](#input\_notification\_destinations) | The notification destinations. If a data source is specified here, Amazon Managed Grafana will create IAM roles and permissions needed to use these destinations. Must be set to `SNS` | `list(string)` |
prefix_list_ids = list(string)
vpce_ids = list(string)
})
[| no | +| [organization\_role\_name](#input\_organization\_role\_name) | The role name that the workspace uses to access resources through Amazon Organizations | `string` | `null` | no | +| [organizational\_units](#input\_organizational\_units) | The Amazon Organizations organizational units that the workspace is authorized to use data sources from | `list(string)` | `[]` | no | +| [permission\_type](#input\_permission\_type) | The permission type of the workspace. If `SERVICE_MANAGED` is specified, the IAM roles and IAM policy attachments are generated automatically. If `CUSTOMER_MANAGED` is specified, the IAM roles and IAM policy attachments will not be created | `string` | `"SERVICE_MANAGED"` | no | +| [role\_association](#input\_role\_association) | List of user/group IDs to assocaite to a role |
"SNS"
]
list(object({| `[]` | no | +| [vpc\_configuration](#input\_vpc\_configuration) | The configuration settings for an Amazon VPC that contains data sources for your Grafana workspace to connect to |
group_ids = optional(list(string))
role = string
user_ids = optional(list(string))
}))
object({| `null` | no | +| [workspace\_api\_key](#input\_workspace\_api\_key) | List of workspace API Key resources to create |
security_group_ids = list(string)
subnet_ids = list(string)
})
list(object({| `[]` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [license\_expiration](#output\_license\_expiration) | If `license_type` is set to `ENTERPRISE`, this is the expiration date of the enterprise license | +| [license\_free\_trial\_expiration](#output\_license\_free\_trial\_expiration) | If `license_type` is set to `ENTERPRISE_FREE_TRIAL`, this is the expiration date of the free trial | +| [workspace](#output\_workspace) | The Grafana workspace details | +| [workspace\_api\_keys](#output\_workspace\_api\_keys) | The workspace API keys created including their attributes | +| [workspace\_iam\_role](#output\_workspace\_iam\_role) | IAM role details of the Grafana workspace | + \ No newline at end of file diff --git a/examples/example.tf b/examples/example.tf new file mode 100644 index 0000000..dcf6223 --- /dev/null +++ b/examples/example.tf @@ -0,0 +1,47 @@ +provider "aws" { + region = "eu-central-1" +} + +module "example_grafana_workspace" { + source = "../" + name = "default-workspace" + description = "AWS Managed Grafana service example workspace" + account_access_type = "CURRENT_ACCOUNT" + authentication_providers = ["SAML"] + permission_type = "SERVICE_MANAGED" + data_sources = ["ATHENA", "TIMESTREAM", "XRAY"] + + role_association = [ + { + role = "ADMIN" + group_ids = ["*******"] + }, + { + role = "EDITOR" + user_ids = ["*******"] + } + ] + + workspace_api_key = [ + { + name = "admin" + role = "ADMIN" + seconds_to_live = 3600 + }, + { + name = "editor" + role = "EDITOR" + seconds_to_live = 3600 + }, + { + name = "viewer" + role = "VIEWER" + seconds_to_live = 3600 + } + ] + + tags = { + Environment = "development" + Stack = "grafana" + } +} \ No newline at end of file diff --git a/iam.tf b/iam.tf new file mode 100644 index 0000000..cd47b57 --- /dev/null +++ b/iam.tf @@ -0,0 +1,111 @@ +locals { + create_iam_role = var.iam_role_arn == null ? true : false + + iam_data_source_policies = { + ATHENA = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonGrafanaAthenaAccess" + CLOUDWATCH = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonGrafanaCloudWatchAccess" + REDSHIFT = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonGrafanaRedshiftAccess" + SITEWISE = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWSIoTSiteWiseReadOnlyAccess" + TIMESTREAM = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonTimestreamReadOnlyAccess" + XRAY = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AWSXrayReadOnlyAccess" + } +} + +data "aws_iam_policy_document" "assume_policy" { + count = local.create_iam_role ? 1 : 0 + + statement { + sid = "GrafanaAssume" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["grafana.${data.aws_partition.current.dns_suffix}"] + } + } +} + +data "aws_iam_policy_document" "default" { + count = local.create_iam_role ? 1 : 0 + + dynamic "statement" { + for_each = contains(var.data_sources, "AMAZON_OPENSEARCH_SERVICE") ? { create : true } : {} + + content { + actions = [ + "es:ESHttpGet", + "es:DescribeElasticsearchDomains", + "es:ListDomainNames", + ] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = contains(var.data_sources, "AMAZON_OPENSEARCH_SERVICE") ? { create : true } : {} + + content { + actions = ["es:ESHttpGet"] + resources = [ + "arn:${data.aws_partition.current.partition}:es:*:*:domain/*/_msearch*", + "arn:${data.aws_partition.current.partition}:es:*:*:domain/*/_opendistro/_ppl", + ] + } + } + + dynamic "statement" { + for_each = contains(var.data_sources, "PROMETHEUS") ? { create : true } : {} + + content { + actions = [ + "aps:ListWorkspaces", + "aps:DescribeWorkspace", + "aps:QueryMetrics", + "aps:GetLabels", + "aps:GetSeries", + "aps:GetMetricMetadata", + ] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = contains(var.notification_destinations, "SNS") ? { create : true } : {} + + content { + actions = ["sns:Publish"] + resources = ["arn:${data.aws_partition.current.partition}:sns:*:${data.aws_caller_identity.current.account_id}:grafana*"] + } + } +} + +resource "aws_iam_role" "default" { + count = local.create_iam_role ? 1 : 0 + + name = "GrafanaExecutionRole-${var.name}" + assume_role_policy = data.aws_iam_policy_document.assume_policy[0].json + tags = var.tags +} + +resource "aws_iam_policy" "default" { + count = local.create_iam_role ? 1 : 0 + + name = "GrafanaExecutionRolePolicy-${var.name}" + policy = data.aws_iam_policy_document.default[0].json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "data_sources" { + for_each = { for i, v in var.data_sources : v => local.iam_data_source_policies[v] if local.create_iam_role } + + role = aws_iam_role.default[0].name + policy_arn = each.value +} + +resource "aws_iam_role_policy_attachment" "default" { + count = local.create_iam_role ? 1 : 0 + + role = aws_iam_role.default[0].name + policy_arn = aws_iam_policy.default[0].arn +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..bf981a3 --- /dev/null +++ b/main.tf @@ -0,0 +1,61 @@ +data "aws_partition" "current" {} +data "aws_caller_identity" "current" {} + +resource "aws_grafana_workspace" "default" { + account_access_type = var.account_access_type + authentication_providers = var.authentication_providers + configuration = var.configuration + data_sources = var.data_sources + description = var.description + grafana_version = var.grafana_version + name = var.name + notification_destinations = var.notification_destinations + organization_role_name = var.organization_role_name + organizational_units = var.organizational_units + permission_type = var.permission_type + role_arn = local.create_iam_role ? aws_iam_role.default[0].arn : var.iam_role_arn + tags = var.tags + + dynamic "network_access_control" { + for_each = var.network_access_control != null ? { create : true } : {} + + content { + prefix_list_ids = var.network_access_control.prefix_list_ids + vpce_ids = var.network_access_control.vpce_ids + } + } + + dynamic "vpc_configuration" { + for_each = var.vpc_configuration != null ? { create : true } : {} + + content { + security_group_ids = var.vpc_configuration.security_group_ids + subnet_ids = var.vpc_configuration.subnet_ids + } + } +} + +resource "aws_grafana_workspace_api_key" "default" { + for_each = { for i, v in var.workspace_api_key : v.name => v } + + key_name = each.value.name + key_role = each.value.role + seconds_to_live = each.value.seconds_to_live + workspace_id = aws_grafana_workspace.default.id +} + +resource "aws_grafana_license_association" "default" { + count = var.license_type != null ? 1 : 0 + + license_type = var.license_type + workspace_id = aws_grafana_workspace.default.id +} + +resource "aws_grafana_role_association" "this" { + for_each = { for i, v in var.role_association : v.role => v } + + group_ids = each.value.group_ids + role = each.value.role + user_ids = each.value.user_ids + workspace_id = aws_grafana_workspace.default.id +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..463bb3f --- /dev/null +++ b/outputs.tf @@ -0,0 +1,24 @@ +output "workspace" { + description = "The Grafana workspace details" + value = aws_grafana_workspace.default +} + +output "workspace_api_keys" { + description = "The workspace API keys created including their attributes" + value = aws_grafana_workspace_api_key.default +} + +output "workspace_iam_role" { + description = "IAM role details of the Grafana workspace" + value = try(aws_iam_role.default, null) +} + +output "license_free_trial_expiration" { + description = "If `license_type` is set to `ENTERPRISE_FREE_TRIAL`, this is the expiration date of the free trial" + value = try(aws_grafana_license_association.default[0].free_trial_expiration, null) +} + +output "license_expiration" { + description = "If `license_type` is set to `ENTERPRISE`, this is the expiration date of the enterprise license" + value = try(aws_grafana_license_association.default[0].license_expiration, null) +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..e785edf --- /dev/null +++ b/variables.tf @@ -0,0 +1,147 @@ +variable "account_access_type" { + type = string + default = "CURRENT_ACCOUNT" + description = "The type of account access for the workspace. Valid values are `CURRENT_ACCOUNT` and `ORGANIZATION`. If ORGANIZATION is specified, then organizational_units must also be present" + + validation { + condition = contains(["CURRENT_ACCOUNT", "ORGANIZATION"], var.account_access_type) + error_message = "Valid values are \"CURRENT_ACCOUNT\" or \"ORGANIZATION\"." + } +} + +variable "authentication_providers" { + type = list(string) + default = ["AWS_SSO"] + description = "The authentication providers for the workspace. Valid values are `AWS_SSO`, `SAML`, or both" + + validation { + condition = alltrue([ + for v in var.authentication_providers : contains(["AWS_SSO", "SAML"], v) + ]) + error_message = "Valid values are \"AWS_SSO\" or \"SAML\" or or both." + } +} + +variable "configuration" { + type = string + default = null + description = "The configuration string for the workspace that you create" +} + +variable "data_sources" { + type = list(string) + default = [] + description = "The data sources for the workspace. Valid values are `AMAZON_OPENSEARCH_SERVICE`, `ATHENA`, `CLOUDWATCH`, `PROMETHEUS`, `REDSHIFT`, `SITEWISE`, `TIMESTREAM`, `XRAY`" + + validation { + condition = alltrue([ + for v in var.data_sources : contains(["AMAZON_OPENSEARCH_SERVICE", "ATHENA", "CLOUDWATCH", "PROMETHEUS", "REDSHIFT", "SITEWISE", "TIMESTREAM", "XRAY"], v) + ]) + error_message = "Valid values are \"AMAZON_OPENSEARCH_SERVICE\" or \"ATHENA\" or \"CLOUDWATCH\" or \"PROMETHEUS\" or \"REDSHIFT\" or \"SITEWISE\" or \"TIMESTREAM\" or \"XRAY\"." + } +} + +variable "description" { + type = string + description = "The workspace description" +} + +variable "grafana_version" { + type = string + default = "8.4" + description = "Specifies the version of Grafana to support in the new workspace. If not specified, the default version for the `aws_grafana_workspace` resource will be used. See `aws_grafana_workspace` documentation for available options." +} + +variable "iam_role_arn" { + type = string + default = null + description = "The arn of the IAM role to use for grafana workspace" +} + +variable "license_type" { + type = string + default = null + description = "The type of license for the workspace license association. Valid values are `ENTERPRISE` and `ENTERPRISE_FREE_TRIAL`" + + validation { + condition = var.license_type == null ? true : contains(["ENTERPRISE", "ENTERPRISE_FREE_TRIAL"], var.license_type) + error_message = "Valid values are \"ENTERPRISE\" or \"ENTERPRISE_FREE_TRIAL\"." + } +} + +variable "name" { + type = string + description = "The Grafana workspace name" +} + +variable "network_access_control" { + type = object({ + prefix_list_ids = list(string) + vpce_ids = list(string) + }) + default = null + description = "Configuration for network access to your workspace" +} + +variable "notification_destinations" { + type = list(string) + default = ["SNS"] + description = "The notification destinations. If a data source is specified here, Amazon Managed Grafana will create IAM roles and permissions needed to use these destinations. Must be set to `SNS`" +} + +variable "organization_role_name" { + type = string + default = null + description = "The role name that the workspace uses to access resources through Amazon Organizations" +} + +variable "organizational_units" { + type = list(string) + default = [] + description = "The Amazon Organizations organizational units that the workspace is authorized to use data sources from" +} + +variable "permission_type" { + type = string + default = "SERVICE_MANAGED" + description = "The permission type of the workspace. If `SERVICE_MANAGED` is specified, the IAM roles and IAM policy attachments are generated automatically. If `CUSTOMER_MANAGED` is specified, the IAM roles and IAM policy attachments will not be created" + + validation { + condition = contains(["CUSTOMER_MANAGED", "SERVICE_MANAGED"], var.permission_type) + error_message = "Valid values are \"CUSTOMER_MANAGED\" or \"SERVICE_MANAGED\"." + } +} + +variable "role_association" { + type = list(object({ + group_ids = optional(list(string)) + role = string + user_ids = optional(list(string)) + })) + default = [] + description = "List of user/group IDs to assocaite to a role" +} + +variable "vpc_configuration" { + type = object({ + security_group_ids = list(string) + subnet_ids = list(string) + }) + default = null + description = "The configuration settings for an Amazon VPC that contains data sources for your Grafana workspace to connect to" +} + +variable "workspace_api_key" { + type = list(object({ + name = string + role = string + seconds_to_live = number + })) + default = [] + description = "List of workspace API Key resources to create" +} + +variable "tags" { + type = map(string) + description = "A mapping of tags to assign to the resources" +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..b3dfba4 --- /dev/null +++ b/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + } + } +}
name = string
role = string
seconds_to_live = number
}))