diff --git a/README.md b/README.md index 51e62fb..66abf26 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Enhance your Terraform workflows with AI-powered insights while maintaining secu | [aws_iam_role.runtask_request](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.runtask_rule](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.runtask_states](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.runtask_callback](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.runtask_eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.runtask_fulfillment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.runtask_rule](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | @@ -206,6 +207,7 @@ Enhance your Terraform workflows with AI-powered insights while maintaining secu | [deploy\_waf](#input\_deploy\_waf) | Set to true to deploy CloudFront and WAF in front of the Lambda function URL | `string` | `false` | no | | [event\_bus\_name](#input\_event\_bus\_name) | EventBridge event bus name | `string` | `"default"` | no | | [event\_source](#input\_event\_source) | EventBridge source name | `string` | `"app.terraform.io"` | no | +| [github\_api\_token\_arn](#input\_github\_api\_token\_arn) | The ARN of the secret containing the GitHub API token | `string` | `null` | no | | [lambda\_architecture](#input\_lambda\_architecture) | Lambda architecture (arm64 or x86\_64) | `string` | `"x86_64"` | no | | [lambda\_default\_timeout](#input\_lambda\_default\_timeout) | Lambda default timeout in seconds | `number` | `120` | no | | [lambda\_python\_runtime](#input\_lambda\_python\_runtime) | Lambda Python runtime | `string` | `"python3.11"` | no | diff --git a/examples/basic/.header.md b/examples/basic/.header.md index cf91885..595d70e 100644 --- a/examples/basic/.header.md +++ b/examples/basic/.header.md @@ -2,12 +2,6 @@ Follow the steps below to deploy the module and attach it to your HCP Terraform (Terraform Cloud) organization. -* Build and package the Lambda files - - ``` - make all - ``` - * Deploy the module ```bash diff --git a/examples/basic/README.md b/examples/basic/README.md index 3942eba..f14512e 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -3,12 +3,6 @@ Follow the steps below to deploy the module and attach it to your HCP Terraform (Terraform Cloud) organization. -* Build and package the Lambda files - - ``` - make all - ``` - * Deploy the module ```bash diff --git a/examples/github/.header.md b/examples/github/.header.md new file mode 100644 index 0000000..27148c3 --- /dev/null +++ b/examples/github/.header.md @@ -0,0 +1,34 @@ +# Usage Example + +Follow the steps below to deploy the module and attach it to your HCP Terraform (Terraform Cloud) organization. In order to create comments in Pull requests you'd also need GitHub API token. + +![github_example](../../images/github.png) + +* The GitHub token needs to be a [fine-grained personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) with the following permissions: + +* Read access to metadata +* Read and Write access to pull requests + +* Deploy the module + + ```bash + terraform init + terraform plan + terraform apply + ``` + +* (Optional, if using HCP Terraform) Add the cloud block in `providers.tf` + + ```hcl + terraform { + + cloud { + # TODO: Change this to your HCP Terraform org name. + organization = "" + workspaces { + ... + } + } + ... + } + ``` diff --git a/examples/github/.terraform-docs.yaml b/examples/github/.terraform-docs.yaml new file mode 100644 index 0000000..6dc99de --- /dev/null +++ b/examples/github/.terraform-docs.yaml @@ -0,0 +1,21 @@ +formatter: markdown +header-from: .header.md +settings: + anchor: true + color: true + default: true + escape: true + html: true + indent: 2 + required: true + sensitive: true + type: true + lockfile: false + +sort: + enabled: true + by: required + +output: + file: README.md + mode: replace diff --git a/examples/github/README.md b/examples/github/README.md new file mode 100644 index 0000000..376cd7b --- /dev/null +++ b/examples/github/README.md @@ -0,0 +1,84 @@ + +# Usage Example + +Follow the steps below to deploy the module and attach it to your HCP Terraform (Terraform Cloud) organization. In order to create comments in Pull requests you'd also need GitHub API token. + +![github\_example](../../images/github.png) + +* The GitHub token needs to be a [fine-grained personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) with the following permissions: + +* Read access to metadata +* Read and Write access to pull requests + +* Deploy the module + + ```bash + terraform init + terraform plan + terraform apply + ``` + +* (Optional, if using HCP Terraform) Add the cloud block in `providers.tf` + + ```hcl + terraform { + + cloud { + # TODO: Change this to your HCP Terraform org name. + organization = "" + workspaces { + ... + } + } + ... + } + ``` + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.0.7 | +| [aws](#requirement\_aws) | 5.56.1 | +| [tfe](#requirement\_tfe) | ~>0.38.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 5.56.1 | +| [tfe](#provider\_tfe) | ~>0.38.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [hcp\_tf\_run\_task](#module\_hcp\_tf\_run\_task) | ../.. | n/a | + +## Resources + +| Name | Type | +|------|------| +| [aws_secretsmanager_secret.github_api_token](https://registry.terraform.io/providers/hashicorp/aws/5.56.1/docs/resources/secretsmanager_secret) | resource | +| [aws_secretsmanager_secret_version.github_api_token](https://registry.terraform.io/providers/hashicorp/aws/5.56.1/docs/resources/secretsmanager_secret_version) | resource | +| [tfe_organization_run_task.bedrock_plan_analyzer](https://registry.terraform.io/providers/hashicorp/tfe/latest/docs/resources/organization_run_task) | resource | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/5.56.1/docs/data-sources/region) | data source | +| [tfe_organization.hcp_tf_org](https://registry.terraform.io/providers/hashicorp/tfe/latest/docs/data-sources/organization) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [github\_api\_token](#input\_github\_api\_token) | GitHub API token for creating comments in PRs | `string` | n/a | yes | +| [hcp\_tf\_org](#input\_hcp\_tf\_org) | HCP Terraform Organization name | `string` | n/a | yes | +| [hcp\_tf\_token](#input\_hcp\_tf\_token) | HCP Terraform API token | `string` | n/a | yes | +| [region](#input\_region) | AWS region to deploy the resources | `string` | `"us-east-1"` | no | +| [tf\_run\_task\_image\_tag](#input\_tf\_run\_task\_image\_tag) | value for the docker image tag to be used by the run task logic | `string` | `"latest"` | no | +| [tf\_run\_task\_logic\_iam\_roles](#input\_tf\_run\_task\_logic\_iam\_roles) | values for the IAM roles to be used by the run task logic | `list(string)` | `[]` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [runtask\_url](#output\_runtask\_url) | n/a | + \ No newline at end of file diff --git a/examples/github/main.tf b/examples/github/main.tf new file mode 100644 index 0000000..080bdcd --- /dev/null +++ b/examples/github/main.tf @@ -0,0 +1,40 @@ +##################################################################################### +# Terraform module examples are meant to show an _example_ on how to use a module +# per use-case. The code below should not be copied directly but referenced in order +# to build your own root module that invokes this module +##################################################################################### + +data "aws_region" "current" {} + +data "tfe_organization" "hcp_tf_org" { + name = var.hcp_tf_org +} + +resource "aws_secretsmanager_secret" "github_api_token" { + #checkov:skip=CKV2_AWS_57:run terraform apply to rotate api key + #checkov:skip=CKV_AWS_149:skipping KMS based encryption as it's just an example setup + name = "tf_ai_github_api_token" +} + +resource "aws_secretsmanager_secret_version" "github_api_token" { + secret_id = aws_secretsmanager_secret.github_api_token.id + secret_string = var.github_api_token +} + +module "hcp_tf_run_task" { + source = "../.." + aws_region = data.aws_region.current.name + hcp_tf_org = data.tfe_organization.hcp_tf_org.name + run_task_iam_roles = var.tf_run_task_logic_iam_roles + github_api_token_arn = aws_secretsmanager_secret_version.github_api_token.arn + deploy_waf = true +} + +resource "tfe_organization_run_task" "bedrock_plan_analyzer" { + enabled = true + organization = data.tfe_organization.hcp_tf_org.name + url = module.hcp_tf_run_task.runtask_url + hmac_key = module.hcp_tf_run_task.runtask_hmac + name = "Bedrock-TF-Plan-Analyzer" + description = "Analyze TF plan using Amazon Bedrock" +} diff --git a/examples/github/outputs.tf b/examples/github/outputs.tf new file mode 100644 index 0000000..55a37d9 --- /dev/null +++ b/examples/github/outputs.tf @@ -0,0 +1,3 @@ +output "runtask_url" { + value = module.hcp_tf_run_task.runtask_url +} diff --git a/examples/github/providers.tf b/examples/github/providers.tf new file mode 100644 index 0000000..256fea5 --- /dev/null +++ b/examples/github/providers.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">= 1.0.7" + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.56.1" + } + + tfe = { + source = "hashicorp/tfe" + version = "~>0.38.0" + } + } +} + +provider "aws" { + region = var.region +} + +provider "aws" { + alias = "cloudfront_waf" + region = "us-east-1" # for Cloudfront WAF only, must be in us-east-1 +} + +provider "tfe" { + token = var.hcp_tf_token +} diff --git a/examples/github/variables.tf b/examples/github/variables.tf new file mode 100644 index 0000000..7430962 --- /dev/null +++ b/examples/github/variables.tf @@ -0,0 +1,34 @@ +variable "hcp_tf_org" { + type = string + description = "HCP Terraform Organization name" +} + +variable "hcp_tf_token" { + type = string + sensitive = true + description = "HCP Terraform API token" +} + +variable "tf_run_task_logic_iam_roles" { + type = list(string) + description = "values for the IAM roles to be used by the run task logic" + default = [] +} + +variable "region" { + type = string + description = "AWS region to deploy the resources" + default = "us-east-1" +} + +variable "tf_run_task_image_tag" { + type = string + description = "value for the docker image tag to be used by the run task logic" + default = "latest" +} + +variable "github_api_token" { + type = string + description = "GitHub API token for creating comments in PRs" + sensitive = true +} diff --git a/iam.tf b/iam.tf index de073d9..38ab71f 100644 --- a/iam.tf +++ b/iam.tf @@ -62,6 +62,15 @@ resource "aws_iam_role_policy_attachment" "runtask_callback" { policy_arn = local.lambda_managed_policies[count.index] } +resource "aws_iam_role_policy" "runtask_callback" { + count = var.github_api_token_arn != null ? 1 : 0 + name = "${local.solution_prefix}-runtask-callback-policy" + role = aws_iam_role.runtask_callback.id + policy = templatefile("${path.module}/templates/role-policies/runtask-callback-lambda-role-policy.tpl", { + github_api_token_arn = [var.github_api_token_arn] + }) +} + ################# IAM for run task fulfillment ################## resource "aws_iam_role" "runtask_fulfillment" { name = "${local.solution_prefix}-runtask-fulfillment" @@ -131,4 +140,4 @@ resource "aws_iam_role_policy" "runtask_rule" { policy = templatefile("${path.module}/templates/role-policies/runtask-rule-role-policy.tpl", { resource_runtask_states = aws_sfn_state_machine.runtask_states.arn }) -} \ No newline at end of file +} diff --git a/images/github.png b/images/github.png new file mode 100644 index 0000000..8fd534d Binary files /dev/null and b/images/github.png differ diff --git a/kms.tf b/kms.tf index 5ce3c22..9625d03 100644 --- a/kms.tf +++ b/kms.tf @@ -30,4 +30,4 @@ resource "aws_kms_alias" "runtask_waf" { provider = aws.cloudfront_waf name = "alias/${local.solution_prefix}-runtask-WAF" target_key_id = aws_kms_key.runtask_waf[count.index].key_id -} \ No newline at end of file +} diff --git a/lambda.tf b/lambda.tf index eea2f5b..5271fc4 100644 --- a/lambda.tf +++ b/lambda.tf @@ -113,10 +113,16 @@ resource "aws_lambda_function" "runtask_callback" { tracing_config { mode = "Active" } + environment { + variables = { + GITHUB_API_TOKEN_ARN = var.github_api_token_arn + } + } tags = local.combined_tags #checkov:skip=CKV_AWS_116:not using DLQ #checkov:skip=CKV_AWS_117:VPC is not required #checkov:skip=CKV_AWS_272:skip code-signing + #checkov:skip=CKV_AWS_173:no sensitive data in env var } resource "aws_cloudwatch_log_group" "runtask_callback" { @@ -190,4 +196,4 @@ resource "aws_cloudwatch_log_group" "runtask_fulfillment_output" { retention_in_days = var.cloudwatch_log_group_retention kms_key_id = aws_kms_key.runtask_key.arn tags = local.combined_tags -} \ No newline at end of file +} diff --git a/lambda/runtask_callback/handler.py b/lambda/runtask_callback/handler.py index 026c494..a1d9c76 100644 --- a/lambda/runtask_callback/handler.py +++ b/lambda/runtask_callback/handler.py @@ -19,10 +19,13 @@ import logging import os import re +import boto3 from urllib.request import urlopen, Request from urllib.error import HTTPError, URLError +from botocore.exceptions import ClientError HCP_TF_HOST_NAME = os.environ.get("HCP_TF_HOST_NAME", "app.terraform.io") +GITHUB_API_TOKEN_ARN = os.environ.get("GITHUB_API_TOKEN_ARN", None) logger = logging.getLogger() log_level = os.environ.get("log_level", logging.INFO) @@ -92,6 +95,34 @@ def lambda_handler(event, _): endpoint, headers, bytes(json.dumps(payload), encoding="utf-8") ) logger.debug("HCP Terraform response: {}".format(response)) + + try: + GITHUB_API_TOKEN = get_github_api_token(GITHUB_API_TOKEN_ARN) + if event["payload"]["detail"]["vcs_pull_request_url"] and GITHUB_API_TOKEN: + comment_payload = extract_github_comment_payload( + attributes=event["payload"]["result"]["fulfillment"], + ) + endpoint = extract_github_endpoint( + endpoint=event["payload"]["detail"]["vcs_pull_request_url"] + ) + if endpoint: + create_github_comment( + endpoint=endpoint, + github_api_token=GITHUB_API_TOKEN, + payload=bytes(json.dumps(comment_payload), encoding="utf-8"), + ) + else: + logger.info( + "Invalid GitHub endpoint URL, skipping comment creation" + ) + else: + logger.info( + "No GitHub pull request URL found or GitHub API token provided, skipping comment creation" + ) + except Exception as e: + logger.error("Error while creating GitHub comment: {}".format(e)) + pass + return "completed" except Exception as e: @@ -128,3 +159,68 @@ def validate_endpoint(endpoint): # validate that the endpoint hostname is valid pattern = "^https:\/\/" + str(HCP_TF_HOST_NAME).replace(".", "\.") + "\/" + ".*" result = re.match(pattern, endpoint) return result + + +def get_github_api_token(github_api_token_arn): + session = boto3.session.Session() + client = session.client( + service_name="secretsmanager", + region_name=os.environ.get("AWS_REGION", "us-east-1"), + ) + try: + get_secret_value_response = client.get_secret_value(SecretId=github_api_token_arn) + except ClientError as e: + logging.exception("Exception: {}".format(e)) + return None + + return get_secret_value_response["SecretString"] + + +def extract_github_endpoint(endpoint): + pattern = r"https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)" + match = re.match(pattern, endpoint) + result = None + if match: + owner, repo, issue_number = match.groups() + result = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments" + else: + return None + return result + + +def extract_github_comment_payload(attributes): + comment_md = f"# AI Terraform plan analyzer\n\n {attributes['message']}\n\n" + + if attributes["status"] == "passed": + comment_md += "**Status**: Passed :white_check_mark:\n" + else: + comment_md += "**Status**: Failed :x:\n" + + comment_md += f"**CloudWatch logs**: [view more details]({attributes['url']})\n" + + for result in attributes["results"]: + comment_md += f"### {result['attributes']['description']}\n\n" + comment_md += f"{result['attributes']['body'].replace('##', '####')}\n" + + comment_payload = { + "body": comment_md, + } + return comment_payload + + +def create_github_comment(endpoint, github_api_token, payload): + headers = { + "Authorization": f"Bearer {github_api_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + request = Request(endpoint, headers=headers, data=payload, method="POST") + try: + with urlopen(request, timeout=10) as response: # nosec URL validation + return response.read(), response + except HTTPError as error: + logger.error(error.status, error.reason) + except URLError as error: + logger.error(error.reason) + except TimeoutError: + logger.error("Request timed out") diff --git a/lambda/runtask_callback/requirements.txt b/lambda/runtask_callback/requirements.txt index e69de29..3cc0f34 100644 --- a/lambda/runtask_callback/requirements.txt +++ b/lambda/runtask_callback/requirements.txt @@ -0,0 +1 @@ +boto3==1.34.123 diff --git a/lambda/runtask_fulfillment/requirements.txt b/lambda/runtask_fulfillment/requirements.txt index 31fd63d..bd60bb9 100644 --- a/lambda/runtask_fulfillment/requirements.txt +++ b/lambda/runtask_fulfillment/requirements.txt @@ -1,3 +1,3 @@ requests==2.32.2 boto3==1.34.123 -markdown_to_json==2.1.1 \ No newline at end of file +markdown_to_json==2.1.1 diff --git a/lambda/runtask_fulfillment/tools/get_ami_releases.py b/lambda/runtask_fulfillment/tools/get_ami_releases.py index 57c20d1..3fba6b1 100644 --- a/lambda/runtask_fulfillment/tools/get_ami_releases.py +++ b/lambda/runtask_fulfillment/tools/get_ami_releases.py @@ -1,10 +1,12 @@ +import os import requests import boto3 import markdown_to_json from utils import logger +aws_region = os.environ.get("AMI_RELEASES_REGION",'us-east-1') session = boto3.Session() -ec2_client = session.client(service_name="ec2") +ec2_client = session.client(service_name="ec2",region_name=aws_region) class GetECSAmisReleases: def execute(self, ami_ids): diff --git a/locals.tf b/locals.tf index 75b3230..67a690f 100644 --- a/locals.tf +++ b/locals.tf @@ -30,4 +30,4 @@ locals { Solution = local.solution_prefix } ) -} \ No newline at end of file +} diff --git a/providers.tf b/providers.tf index 850d2cb..f7aad7c 100644 --- a/providers.tf +++ b/providers.tf @@ -26,3 +26,7 @@ provider "aws" { region = "us-east-1" alias = "cloudfront_waf" } + +provider "awscc" { + region = var.aws_region +} diff --git a/secrets.tf b/secrets.tf index 9c2329c..23a1ca2 100644 --- a/secrets.tf +++ b/secrets.tf @@ -34,4 +34,4 @@ resource "aws_secretsmanager_secret_version" "runtask_cloudfront" { count = local.waf_deployment secret_id = aws_secretsmanager_secret.runtask_cloudfront[count.index].id secret_string = random_uuid.runtask_cloudfront[count.index].result -} \ No newline at end of file +} diff --git a/templates/role-policies/runtask-callback-lambda-role-policy.tpl b/templates/role-policies/runtask-callback-lambda-role-policy.tpl new file mode 100644 index 0000000..ae682e1 --- /dev/null +++ b/templates/role-policies/runtask-callback-lambda-role-policy.tpl @@ -0,0 +1,14 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue" + ], + "Resource": ${jsonencode(github_api_token_arn)}, + "Effect": "Allow", + "Sid": "SecretsManagerGet" + } + ] +} diff --git a/variables.tf b/variables.tf index 18654fa..e7ab0fa 100644 --- a/variables.tf +++ b/variables.tf @@ -151,4 +151,10 @@ variable "bedrock_llm_model" { description = "Bedrock LLM model to use" type = string default = "anthropic.claude-3-sonnet-20240229-v1:0" -} \ No newline at end of file +} + +variable "github_api_token_arn" { + description = "The ARN of the secret containing the GitHub API token" + type = string + default = null +}