diff --git a/README.md b/README.md
index 4baa86ce..a42d24cb 100644
--- a/README.md
+++ b/README.md
@@ -167,12 +167,14 @@ Terraform module that provisions an AWS serverless CI/CD pipeline used for manag
| [ecs\_task\_logs\_retention\_in\_days](#input\_ecs\_task\_logs\_retention\_in\_days) | Number of days the ECS task logs will be retained | `number` | `14` | no |
| [ecs\_tasks\_common\_env\_vars](#input\_ecs\_tasks\_common\_env\_vars) | Common env vars defined within all ECS tasks. Useful for setting Terragrunt specific env vars required to run Terragrunt commands. |
list(object({
name = string
value = string
}))
| `[]` | no |
| [enable\_branch\_protection](#input\_enable\_branch\_protection) | Determines if the branch protection rule is created. If the repository is private (most likely), the GitHub account associated with
the GitHub provider must be registered as a GitHub Pro, GitHub Team, GitHub Enterprise Cloud, or GitHub Enterprise Server account. See here for details: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches | `bool` | `true` | no |
+| [enable\_gh\_comment\_approval](#input\_enable\_gh\_comment\_approval) | Determines if execution approval votes can be sent via GitHub comments.
This will also enable Terraform plans to be commented within merged PR page | `bool` | `false` | no |
+| [enable\_gh\_comment\_pr\_plan](#input\_enable\_gh\_comment\_pr\_plan) | Determines if Terraform plans will be commented within open PR page | `bool` | `false` | no |
| [enforce\_admin\_branch\_protection](#input\_enforce\_admin\_branch\_protection) | Determines if the branch protection rule is enforced for the GitHub repository's admins.
This essentially gives admins permission to force push to the trunk branch and can allow their infrastructure-related commits to bypass the CI pipeline. | `bool` | `false` | no |
| [file\_path\_pattern](#input\_file\_path\_pattern) | Regex pattern to match webhook modified/new files to. Defaults to any file with `.hcl` or `.tf` extension. | `string` | `".+\\.(hcl|tf)$\n"` | no |
| [github\_token\_ssm\_description](#input\_github\_token\_ssm\_description) | Github token SSM parameter description | `string` | `"Github token used by Merge Lock Lambda Function"` | no |
| [github\_token\_ssm\_key](#input\_github\_token\_ssm\_key) | AWS SSM Parameter Store key for sensitive Github personal token used by the Merge Lock Lambda Function | `string` | `null` | no |
| [github\_token\_ssm\_tags](#input\_github\_token\_ssm\_tags) | Tags for Github token SSM parameter | `map(string)` | `{}` | no |
-| [github\_token\_ssm\_value](#input\_github\_token\_ssm\_value) | Registered Github webhook token associated with the Github provider. The token will be used by the Merge Lock Lambda Function.
If not provided, module looks for pre-existing SSM parameter via `var.github_token_ssm_key`".
GitHub token needs the `repo` permission to send commit statuses for private repos. (see more about OAuth scopes here: https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps) | `string` | `""` | no |
+| [github\_token\_ssm\_value](#input\_github\_token\_ssm\_value) | Registered Github token associated with the Github provider. If not provided,
module looks for pre-existing SSM parameter via `var.github_token_ssm_key`".
GitHub token needs the `repo` permission to send commit statuses and write comments
for private repos (see more about OAuth scopes here:
https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps) | `string` | `""` | no |
| [lambda\_approval\_request\_vpc\_config](#input\_lambda\_approval\_request\_vpc\_config) | VPC configuration for Lambda approval request function.
Ensure that the configuration allows for outgoing HTTPS traffic. | object({
subnet_ids = list(string)
security_group_ids = list(string)
})
| `null` | no |
| [lambda\_approval\_response\_vpc\_config](#input\_lambda\_approval\_response\_vpc\_config) | VPC configuration for Lambda approval response function.
Ensure that the configuration allows for outgoing HTTPS traffic. | object({
subnet_ids = list(string)
security_group_ids = list(string)
})
| `null` | no |
| [lambda\_trigger\_sf\_vpc\_config](#input\_lambda\_trigger\_sf\_vpc\_config) | VPC configuration for Lambda trigger\_sf function.
Ensure that the configuration allows for outgoing HTTPS traffic. | object({
subnet_ids = list(string)
security_group_ids = list(string)
})
| `null` | no |
diff --git a/docker/src/common/utils.py b/docker/src/common/utils.py
index 9528bb5b..2dff083a 100644
--- a/docker/src/common/utils.py
+++ b/docker/src/common/utils.py
@@ -82,3 +82,37 @@ def send_commit_status(state: str, target_url: str):
context=os.environ["STATUS_CHECK_NAME"],
target_url=target_url,
)
+
+
+def tf_to_diff(matchobj) -> str:
+ """Replaces Terraform plan syntax with GitHub markdown diff syntax"""
+ if matchobj["add"]:
+ return matchobj["add"].replace("+", " ").replace(" ", "+", 1)
+
+ elif matchobj["minus"]:
+ return matchobj["minus"].replace("-", " ").replace(" ", "-", 1)
+
+ elif matchobj["update"]:
+ # replace ~ with ! to highlight with orange
+ return matchobj["update"].replace("~", " ").replace(" ", "!", 1)
+
+
+def get_diff_block(plan) -> str:
+ """
+ Returns Terraform plan as a markdown diff code block
+
+ Arguments:
+ plan: Terraform Plan stdout without color formatting (use -no-color flag for plan cmd)
+ """
+ diff = re.sub(
+ r"((?P^\s*\+)|(?P^\s*\-)|(?P^\s*\~))",
+ tf_to_diff,
+ plan,
+ flags=re.MULTILINE,
+ )
+
+ return f"""
+``` diff
+{diff}
+```
+"""
diff --git a/docker/src/pr_plan/plan.py b/docker/src/pr_plan/plan.py
index 0bb78e72..c9d27904 100644
--- a/docker/src/pr_plan/plan.py
+++ b/docker/src/pr_plan/plan.py
@@ -1,11 +1,14 @@
import os
import logging
+import json
from pprint import pformat
import subprocess
import sys
+
import github
-import json
-from common.utils import get_task_log_url
+
+sys.path.append(os.path.dirname(__file__) + "/..")
+from common.utils import get_task_log_url, get_diff_block
log = logging.getLogger(__name__)
stream = logging.StreamHandler(sys.stdout)
@@ -14,18 +17,42 @@
log.setLevel(logging.DEBUG)
+def comment_pr_plan(plan: str) -> str:
+ plan_block = get_diff_block(plan)
+ comment = f"""
+## Open PR Infrastructure Changes
+### Directory: {os.environ["CFG_PATH"]}
+
+Plan
+
+{plan_block}
+
+"""
+ pr = (
+ github.Github(os.environ["GITHUB_TOKEN"], retry=3)
+ .get_repo(os.environ["REPO_FULL_NAME"])
+ .get_pull(int(os.environ["PR_ID"]))
+ )
+ pr.create_issue_comment(comment)
+
+ return comment
+
+
def main() -> None:
"""
Runs Terragrunt plan command on Terragrunt directory that has been modified
and send a commit status if enabled.
"""
- cmd = f'terragrunt plan --terragrunt-working-dir {os.environ["CFG_PATH"]} --terragrunt-iam-role {os.environ["ROLE_ARN"]}'
+ cmd = f'terragrunt plan --terragrunt-working-dir {os.environ["CFG_PATH"]} --terragrunt-iam-role {os.environ["ROLE_ARN"]} -no-color'
log.debug(f"Command: {cmd}")
try:
run = subprocess.run(cmd.split(" "), capture_output=True, text=True, check=True)
log.info(run.stdout)
state = "success"
+ if os.environ.get("COMMENT_PLAN"):
+ comment_pr_plan(run.stdout)
+
except subprocess.CalledProcessError as e:
log.info(e.stderr)
log.info(e)
diff --git a/docker/src/terra_run/run.py b/docker/src/terra_run/run.py
index feede25a..fa58513a 100644
--- a/docker/src/terra_run/run.py
+++ b/docker/src/terra_run/run.py
@@ -3,9 +3,11 @@
import subprocess
import sys
import json
+import ast
from typing import List
+
import aurora_data_api
-import ast
+import github
import boto3
sys.path.append(os.path.dirname(__file__) + "/..")
@@ -13,6 +15,7 @@
subprocess_run,
send_commit_status,
get_task_log_url,
+ get_diff_block,
)
log = logging.getLogger(__name__)
@@ -89,6 +92,29 @@ def update_new_resources() -> None:
log.info("New provider resources were not created -- skipping")
+def comment_terra_run_plan(plan) -> str:
+ """Sends a GitHub PR comment for the run's Terraform plan"""
+ plan_block = get_diff_block(plan)
+ comment = f"""
+## Deployment Infrastructure Changes
+### Directory: {os.environ["CFG_PATH"]}
+### Execution ID: {os.environ["EXECUTION_ID"]}
+
+Plan
+
+{plan_block}
+
+"""
+ pr = (
+ github.Github(os.environ["GITHUB_TOKEN"], retry=3)
+ .get_repo(os.environ["REPO_FULL_NAME"])
+ .get_pull(int(os.environ["PR_ID"]))
+ )
+ pr.create_issue_comment(comment)
+
+ return comment
+
+
def main() -> None:
"""
Primarily this function prints the results of the Terragrunt command. If the
@@ -106,11 +132,10 @@ def main() -> None:
text=True,
check=True,
)
- print(run.stdout)
+ log.info(run.stdout)
state = "success"
except subprocess.CalledProcessError as e:
- print(e.stderr)
- print(e)
+ log.error(e)
state = "failure"
log_url = get_task_log_url()
@@ -122,6 +147,9 @@ def main() -> None:
)
# send ECS task log url with task token to allow Request Approval state to use log url
# within approval email
+ if os.environ.get("COMMENT_PLAN"):
+ log.info("Commenting Terraform plan results")
+ comment_terra_run_plan(run.stdout)
if state == "success":
output = json.dumps({"LogsUrl": log_url})
sf.send_task_success(taskToken=os.environ["TASK_TOKEN"], output=output)
diff --git a/fargate.tf b/fargate.tf
index 891b4719..9eca159c 100644
--- a/fargate.tf
+++ b/fargate.tf
@@ -247,6 +247,10 @@ resource "aws_ecs_task_definition" "pr_plan" {
{
name = "LOG_STREAM_PREFIX"
value = local.pr_plan_log_stream_prefix
+ },
+ {
+ name = "COMMENT_PLAN"
+ value = var.enable_gh_comment_pr_plan ? "true" : ""
}
],
local.ecs_tasks_base_env_vars,
diff --git a/functions/webhook_receiver/invoker.py b/functions/webhook_receiver/invoker.py
index 58517b96..2dcce0df 100644
--- a/functions/webhook_receiver/invoker.py
+++ b/functions/webhook_receiver/invoker.py
@@ -56,6 +56,7 @@ def trigger_pr_plan(
base_ref: str,
head_ref: str,
head_sha: str,
+ pr_id: int,
logs_url: str,
send_commit_status: bool,
) -> None:
@@ -140,6 +141,7 @@ def trigger_pr_plan(
"name": "COMMIT_ID",
"value": head_sha,
},
+ {"name": "PR_ID", "value": str(pr_id)},
{"name": "CFG_PATH", "value": path},
{
"name": "ROLE_ARN",
diff --git a/functions/webhook_receiver/lambda_function.py b/functions/webhook_receiver/lambda_function.py
index 93815cd6..1981b66b 100644
--- a/functions/webhook_receiver/lambda_function.py
+++ b/functions/webhook_receiver/lambda_function.py
@@ -30,12 +30,13 @@ def open_pr(request: Request):
)
trigger_pr_plan(
- event.body.repository.full_name,
- event.body.pull_request.base.ref,
- event.body.pull_request.head.ref,
- event.body.pull_request.head.sha,
- context.logs_url,
- event.body.commit_status_config.get("PrPlan"),
+ repo_full_name=event.body.repository.full_name,
+ base_ref=event.body.pull_request.base.ref,
+ head_ref=event.body.pull_request.head.ref,
+ head_sha=event.body.pull_request.head.sha,
+ pr_id=event.body.pull_request.number,
+ logs_url=context.logs_url,
+ send_commit_status=event.body.commit_status_config.get("PrPlan"),
)
return JSONResponse(
diff --git a/main.tf b/main.tf
index e48a48b9..6583c3c1 100644
--- a/main.tf
+++ b/main.tf
@@ -57,6 +57,18 @@ resource "aws_sfn_state_machine" "this" {
"Name" = "TASK_TOKEN"
"Value.$" = "$$.Task.Token"
},
+ {
+ "Name" = "CFG_PATH"
+ "Value.$" = "$.cfg_path"
+ },
+ {
+ "Name" = "PR_ID"
+ "Value.$" = "States.Format('{}', $.pr_id)"
+ },
+ {
+ "Name" = "COMMENT_PLAN"
+ "Value" = var.enable_gh_comment_approval ? "true" : ""
+ }
]
)
}
diff --git a/tests/e2e/base_e2e.py b/tests/e2e/base_e2e.py
index 45b22156..ac58e3f6 100644
--- a/tests/e2e/base_e2e.py
+++ b/tests/e2e/base_e2e.py
@@ -94,8 +94,14 @@ def pr_plan_pending_statuses(self, request, mut_output, pr, repo):
)
log.debug(f"Expected count: {expected_count}")
wait = 10
+ attempts = 0
+ max_attempts = 12
statuses = []
while len(statuses) != expected_count:
+ if attempts == max_attempts:
+ pytest.fail(
+ "Max attempt reached -- Lambda Function might have failed beforehand"
+ )
log.debug(f"Waiting {wait} seconds")
time.sleep(wait)
statuses = [
@@ -104,6 +110,8 @@ def pr_plan_pending_statuses(self, request, mut_output, pr, repo):
if status.context != mut_output["merge_lock_status_check_name"]
]
+ attempts += 1
+
return statuses
@pytest.fixture(scope="class")
@@ -111,8 +119,14 @@ def pr_plan_finished_statuses(self, pr_plan_pending_statuses, mut_output, pr, re
"""Returns list of PR plan tasks' finished commit statuses"""
log.info("Waiting for all PR plan commit statuses to be updated")
wait = 15
+ attempts = 0
+ max_attempts = 12
statuses = []
while len(statuses) != len(pr_plan_pending_statuses):
+ if attempts == max_attempts:
+ pytest.fail(
+ "Max attempt reached -- ECS task might have failed beforehand"
+ )
log.debug(f"Waiting {wait} seconds")
time.sleep(wait)
statuses = [
@@ -123,6 +137,7 @@ def pr_plan_finished_statuses(self, pr_plan_pending_statuses, mut_output, pr, re
]
log.debug(f"Finished count: {len(statuses)}")
+ attempts += 1
return statuses
diff --git a/tests/fixtures/terraform/mut/basic/main.tf b/tests/fixtures/terraform/mut/basic/main.tf
index 432a1a1d..a17ec77e 100644
--- a/tests/fixtures/terraform/mut/basic/main.tf
+++ b/tests/fixtures/terraform/mut/basic/main.tf
@@ -22,7 +22,9 @@ module "mut_infrastructure_live_ci" {
enforce_admin_branch_protection = var.enforce_admin_branch_protection
- commit_status_config = var.commit_status_config
+ enable_gh_comment_pr_plan = true
+ enable_gh_comment_approval = true
+ commit_status_config = var.commit_status_config
metadb_name = var.metadb_name
metadb_username = var.metadb_username
diff --git a/tests/unit/docker/test_pr_plan.py b/tests/unit/docker/test_pr_plan.py
new file mode 100644
index 00000000..aa915797
--- /dev/null
+++ b/tests/unit/docker/test_pr_plan.py
@@ -0,0 +1,64 @@
+import os
+import logging
+from unittest.mock import patch
+
+from docker.src.pr_plan.plan import comment_pr_plan
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+
+@patch("github.Github")
+@patch.dict(
+ os.environ,
+ {"CFG_PATH": "terraform/cfg", "REPO_FULL_NAME": "user/repo", "PR_ID": "1"},
+)
+def test_comment_pr_plan(mock_gh):
+ """Ensures comment_pr_plan() formats the comment's diff block properly and returns the expected comment"""
+ plan = """
+
+Changes to Outputs:
+ - bar = "old" -> null
+ + baz = "new"
+ ~ foo = "old" -> "new"
+
+You can apply this plan to save these new output values to the Terraform
+state, without changing any real infrastructure.
+
+─────────────────────────────────────────────────────────────────────────────
+
+Note: You didn't use the -out option to save this plan, so Terraform can't
+guarantee to take exactly these actions if you run "terraform apply" now.
+
+"""
+ expected = """
+## Open PR Infrastructure Changes
+### Directory: terraform/cfg
+
+Plan
+
+
+``` diff
+
+
+Changes to Outputs:
+- bar = "old" -> null
++ baz = "new"
+! foo = "old" -> "new"
+
+You can apply this plan to save these new output values to the Terraform
+state, without changing any real infrastructure.
+
+─────────────────────────────────────────────────────────────────────────────
+
+Note: You didn't use the -out option to save this plan, so Terraform can't
+guarantee to take exactly these actions if you run "terraform apply" now.
+
+
+```
+
+
+"""
+ actual = comment_pr_plan(plan)
+
+ assert actual == expected
diff --git a/tests/unit/docker/test_terra_run.py b/tests/unit/docker/test_terra_run.py
index 2519f35e..5516d790 100644
--- a/tests/unit/docker/test_terra_run.py
+++ b/tests/unit/docker/test_terra_run.py
@@ -1,18 +1,20 @@
-from docker.src.common.utils import ServerException
-import pytest
import os
import logging
from subprocess import CalledProcessError
import json
-import aurora_data_api
from unittest.mock import patch, call
-from tests.helpers.utils import null_provider_resource, insert_records, rds_data_client
+
+import aurora_data_api
+import pytest
+
+from docker.src.common.utils import ServerException, subprocess_run
from docker.src.terra_run.run import (
update_new_resources,
get_new_provider_resources,
main,
+ comment_terra_run_plan,
)
-from docker.src.common.utils import subprocess_run
+from tests.helpers.utils import null_provider_resource, insert_records, rds_data_client
from tests.unit.docker.conftest import mock_subprocess_run
log = logging.getLogger(__name__)
@@ -183,3 +185,65 @@ def test_main(
main()
assert mock_send_commit_status.call_args_list == [call(expected_status, log_url)]
+
+
+@patch("github.Github")
+@patch.dict(
+ os.environ,
+ {
+ "CFG_PATH": "terraform/cfg",
+ "EXECUTION_ID": "run-123",
+ "REPO_FULL_NAME": "user/repo",
+ "PR_ID": "1",
+ },
+)
+def test_comment_terra_run_plan(mock_gh):
+ """Ensures comment_terra_run_plan() formats the comment's diff block properly and returns the expected comment"""
+ plan = """
+
+Changes to Outputs:
+ - bar = "old" -> null
+ + baz = "new"
+ ~ foo = "old" -> "new"
+
+You can apply this plan to save these new output values to the Terraform
+state, without changing any real infrastructure.
+
+─────────────────────────────────────────────────────────────────────────────
+
+Note: You didn't use the -out option to save this plan, so Terraform can't
+guarantee to take exactly these actions if you run "terraform apply" now.
+
+"""
+ expected = """
+## Deployment Infrastructure Changes
+### Directory: terraform/cfg
+### Execution ID: run-123
+
+Plan
+
+
+``` diff
+
+
+Changes to Outputs:
+- bar = "old" -> null
++ baz = "new"
+! foo = "old" -> "new"
+
+You can apply this plan to save these new output values to the Terraform
+state, without changing any real infrastructure.
+
+─────────────────────────────────────────────────────────────────────────────
+
+Note: You didn't use the -out option to save this plan, so Terraform can't
+guarantee to take exactly these actions if you run "terraform apply" now.
+
+
+```
+
+
+"""
+ actual = comment_terra_run_plan(plan)
+
+ assert actual == expected
diff --git a/variables.tf b/variables.tf
index 9c4beec3..d45861f9 100644
--- a/variables.tf
+++ b/variables.tf
@@ -302,9 +302,11 @@ variable "github_token_ssm_description" {
variable "github_token_ssm_value" {
description = <