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 }} + \`\`\` + +
`; + + // 3. If we have a comment, update it, otherwise create a new one + if (botComment) { + github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: output + }) + } else { + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + } + + tfsec: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Terraform security scan + uses: aquasecurity/tfsec-action@v1.0.3 + with: + github_token: ${{ env.GITHUB_TOKEN }} + soft_fail: false + + - name: Terraform pr commenter + uses: aquasecurity/tfsec-pr-commenter-action@v1.3.1 + with: + github_token: ${{ env.GITHUB_TOKEN }} + tfsec_args: --concise-output --force-all-dirs + + checkov: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Run Checkov + uses: bridgecrewio/checkov-action@v12.2577.0 + with: + container_user: 1000 + directory: "/" + download_external_modules: false + framework: terraform + output_format: sarif + quiet: true + skip_check: "CKV_TF_1,CKV_AWS_108,CKV_AWS_109,CKV_AWS_111,CKV_AWS_356" + soft_fail: false + + docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Render terraform docs inside the README.md and push changes back to PR branch + uses: terraform-docs/gh-actions@v1.0.0 + with: + args: --sort-by required + git-commit-message: "docs(readme): update module usage" + git-push: true + output-file: README.md + output-method: inject + working-dir: . + continue-on-error: true # added this to prevent a PR from a remote fork failing the workflow \ No newline at end of file diff --git a/README.md b/README.md index 36f1e84..66f1ba4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ -# terraform-aws-grafana -Terraform module to create and manage Grafana +# terraform-aws-managed-grafana +Terraform module to create and manage Amazon Managed Grafana + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_grafana_license_association.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/grafana_license_association) | resource | +| [aws_grafana_role_association.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/grafana_role_association) | resource | +| [aws_grafana_workspace.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/grafana_workspace) | resource | +| [aws_grafana_workspace_api_key.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/grafana_workspace_api_key) | resource | +| [aws_iam_policy.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.data_sources](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.assume_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [description](#input\_description) | The workspace description | `string` | n/a | yes | +| [name](#input\_name) | The Grafana workspace name | `string` | n/a | yes | +| [tags](#input\_tags) | A mapping of tags to assign to the resources | `map(string)` | n/a | yes | +| [account\_access\_type](#input\_account\_access\_type) | 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 | `string` | `"CURRENT_ACCOUNT"` | no | +| [authentication\_providers](#input\_authentication\_providers) | The authentication providers for the workspace. Valid values are `AWS_SSO`, `SAML`, or both | `list(string)` |
[
"AWS_SSO"
]
| 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 |
object({
prefix_list_ids = list(string)
vpce_ids = list(string)
})
| `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)` |
[
"SNS"
]
| 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 |
list(object({
group_ids = optional(list(string))
role = string
user_ids = optional(list(string))
}))
| `[]` | no | +| [vpc\_configuration](#input\_vpc\_configuration) | The configuration settings for an Amazon VPC that contains data sources for your Grafana workspace to connect to |
object({
security_group_ids = list(string)
subnet_ids = list(string)
})
| `null` | no | +| [workspace\_api\_key](#input\_workspace\_api\_key) | List of workspace API Key resources to create |
list(object({
name = string
role = string
seconds_to_live = number
}))
| `[]` | 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" + } + } +}