diff --git a/.github/workflows/test-apply.yaml b/.github/workflows/test-apply.yaml index 21245b0c..45b87faa 100644 --- a/.github/workflows/test-apply.yaml +++ b/.github/workflows/test-apply.yaml @@ -1154,3 +1154,41 @@ jobs: echo "::error:: run_id should not be set" exit 1 fi + + long_outputs: + runs-on: ubuntu-latest + name: Apply a plan with long outputs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Plan + uses: ./terraform-plan + with: + path: tests/workflows/test-apply/long_outputs + + - name: Apply + uses: ./terraform-apply + with: + path: tests/workflows/test-apply/long_outputs + + outputs: + runs-on: ubuntu-latest + name: Apply a plan with outputs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Plan + uses: ./terraform-plan + with: + path: tests/workflows/test-apply/outputs + + - name: Apply + uses: ./terraform-apply + with: + path: tests/workflows/test-apply/outputs diff --git a/.github/workflows/test-plan.yaml b/.github/workflows/test-plan.yaml index 56777883..16e7829e 100644 --- a/.github/workflows/test-plan.yaml +++ b/.github/workflows/test-plan.yaml @@ -872,3 +872,36 @@ jobs: with: path: tests/workflows/test-plan/plan label: arm64 + + always_new: + runs-on: ubuntu-latest + name: always-new + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Plan with label + uses: ./terraform-plan + with: + path: tests/workflows/test-plan/always-new + label: always-new SHOULD BE OUTDATED + + - name: Plan 2 with label + uses: ./terraform-plan + with: + path: tests/workflows/test-plan/always-new + label: always-new SHOULD BE OUTDATED + add_github_comment: always-new + + - name: Plan + uses: ./terraform-plan + with: + path: tests/workflows/test-plan/always-new + + - name: Plan 2 + uses: ./terraform-plan + with: + path: tests/workflows/test-plan/always-new + add_github_comment: always-new diff --git a/image/actions.sh b/image/actions.sh index 41c49303..eb170a3f 100644 --- a/image/actions.sh +++ b/image/actions.sh @@ -368,17 +368,7 @@ function set-remote-plan-args() { function output() { debug_log $TOOL_COMMAND_NAME output -json - (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME output -json | convert_output) -} - -function update_status() { - local status="$1" - - if ! STATUS="$status" github_pr_comment status 2>"$STEP_TMP_DIR/github_pr_comment.stderr"; then - debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" - else - debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" - fi + (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME output -json | tee "$STEP_TMP_DIR/terraform_output.json" | convert_output) } function random_string() { diff --git a/image/entrypoints/apply.sh b/image/entrypoints/apply.sh index 4a45f2d6..b4676814 100755 --- a/image/entrypoints/apply.sh +++ b/image/entrypoints/apply.sh @@ -10,8 +10,16 @@ set-plan-args PLAN_OUT="$STEP_TMP_DIR/plan.out" +function update_comment() { + if ! github_pr_comment "$@" 2>"$STEP_TMP_DIR/github_pr_comment.stderr"; then + debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" + else + debug_file "$STEP_TMP_DIR/github_pr_comment.stderr" + fi +} + if [[ -v TERRAFORM_ACTIONS_GITHUB_TOKEN ]]; then - update_status ":orange_circle: Applying plan in $(job_markdown_ref)" + update_comment begin-apply fi exec 3>&1 @@ -54,14 +62,15 @@ function apply() { set -e if [[ $APPLY_EXIT -eq 0 ]]; then - update_status ":white_check_mark: Plan applied in $(job_markdown_ref)" + output + update_comment apply-complete "$STEP_TMP_DIR/terraform_output.json" else if lock-info "$STEP_TMP_DIR/terraform_apply.stderr"; then set_output failure-reason state-locked else set_output failure-reason apply-failed fi - update_status ":x: Error applying plan in $(job_markdown_ref)" + update_comment error exit 1 fi } @@ -91,7 +100,7 @@ if [[ $PLAN_EXIT -eq 1 ]]; then set_output failure-reason state-locked fi - update_status ":x: Error applying plan in $(job_markdown_ref)" + update_comment error exit 1 fi @@ -141,4 +150,3 @@ else fi -output diff --git a/image/entrypoints/plan.sh b/image/entrypoints/plan.sh index 07f157db..c7281fae 100755 --- a/image/entrypoints/plan.sh +++ b/image/entrypoints/plan.sh @@ -38,7 +38,7 @@ if [[ -z "$PLAN_OUT" ]]; then fi if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "issue_comment" || "$GITHUB_EVENT_NAME" == "pull_request_review_comment" || "$GITHUB_EVENT_NAME" == "pull_request_target" || "$GITHUB_EVENT_NAME" == "pull_request_review" || "$GITHUB_EVENT_NAME" == "repository_dispatch" ]]; then - if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" || "$INPUT_ADD_GITHUB_COMMENT" == "changes-only" ]]; then + if [[ "$INPUT_ADD_GITHUB_COMMENT" == "true" || "$INPUT_ADD_GITHUB_COMMENT" == "changes-only" || "$INPUT_ADD_GITHUB_COMMENT" == "always-new" ]]; then if [[ ! -v TERRAFORM_ACTIONS_GITHUB_TOKEN ]]; then echo "GITHUB_TOKEN environment variable must be set to add GitHub PR comments" diff --git a/image/src/github_pr_comment/__main__.py b/image/src/github_pr_comment/__main__.py index 411e43fd..1a7b4f19 100644 --- a/image/src/github_pr_comment/__main__.py +++ b/image/src/github_pr_comment/__main__.py @@ -1,4 +1,5 @@ import hashlib +import json import os import subprocess import re @@ -20,6 +21,7 @@ from github_pr_comment.cmp import plan_cmp, remove_warnings, remove_unchanged_attributes from github_pr_comment.comment import find_comment, TerraformComment, update_comment, serialize, deserialize from github_pr_comment.hash import comment_hash, plan_hash +from plan_renderer.outputs import render_outputs from plan_renderer.variables import render_argument_list, Sensitive from terraform.module import load_module, get_sensitive_variables from terraform import hcl @@ -286,6 +288,47 @@ def get_pr() -> PrUrl: return cast(PrUrl, pr_url) +def new_pr_comment(backend_fingerprint: bytes) -> TerraformComment: + pr_url = get_pr() + issue_url = get_issue_url(pr_url) + + headers = { + 'workspace': os.environ.get('INPUT_WORKSPACE', 'default'), + } + + if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'): + if backend_type == 'cloud': + backend_type = 'remote' + headers['backend_type'] = backend_type + + headers['label'] = os.environ.get('INPUT_LABEL') or None + + plan_modifier = {} + if target := os.environ.get('INPUT_TARGET'): + plan_modifier['target'] = sorted(t.strip() for t in target.replace(',', '\n', ).split('\n') if t.strip()) + + if replace := os.environ.get('INPUT_REPLACE'): + plan_modifier['replace'] = sorted(t.strip() for t in replace.replace(',', '\n', ).split('\n') if t.strip()) + + if os.environ.get('INPUT_DESTROY') == 'true': + plan_modifier['destroy'] = 'true' + + if plan_modifier: + debug(f'Plan modifier: {plan_modifier}') + headers['plan_modifier'] = hashlib.sha256(canonicaljson.encode_canonical_json(plan_modifier)).hexdigest() + + headers['backend'] = comment_hash(backend_fingerprint, pr_url) + + return TerraformComment( + issue_url=issue_url, + comment_url=None, + headers={k: v for k, v in headers.items() if v is not None}, + description='', + summary='', + body='', + status='' + ) + def get_comment(action_inputs: PlanPrInputs, backend_fingerprint: bytes, backup_fingerprint: bytes) -> TerraformComment: if 'comment' in step_cache: return deserialize(step_cache['comment']) @@ -298,6 +341,7 @@ def get_comment(action_inputs: PlanPrInputs, backend_fingerprint: bytes, backup_ headers = { 'workspace': os.environ.get('INPUT_WORKSPACE', 'default'), + 'closed': None } if backend_type := os.environ.get('TERRAFORM_BACKEND_TYPE'): @@ -337,6 +381,21 @@ def is_approved(proposed_plan: str, comment: TerraformComment) -> bool: debug('Approving plan based on plan text') return plan_cmp(proposed_plan, comment.body) +def truncate(text: str, max_size: int, too_big_message: str) -> str: + lines = [] + total_size = 0 + + for line in text.splitlines(): + line_size = len(line.encode()) + 1 # + newline + if total_size + line_size > max_size: + lines.append(too_big_message) + break + + lines.append(line) + total_size += line_size + + return '\n'.join(lines) + def format_plan_text(plan_text: str) -> Tuple[str, str]: """ Format the given plan for insertion into a PR comment @@ -344,34 +403,51 @@ def format_plan_text(plan_text: str) -> Tuple[str, str]: max_body_size = 50000 # bytes - def truncate(t): - lines = [] - total_size = 0 - - for line in t.splitlines(): - line_size = len(line.encode()) + 1 # + newline - if total_size + line_size > max_body_size: - lines.append('Plan is too large to fit in a PR comment. See the full plan in the workflow log.') - break - - lines.append(line) - total_size += line_size - - return '\n'.join(lines) - if len(plan_text.encode()) > max_body_size: # needs truncation - return 'trunc', truncate(plan_text) + return 'trunc', truncate(plan_text, max_body_size, 'Plan is too large to fit in a PR comment. See the full plan in the workflow log.') else: return 'text', plan_text +def format_output_status(outputs: Optional[dict], remaining_size: int) -> str: + status = f':white_check_mark: Plan applied in {job_markdown_ref()}' + stripped_output = render_outputs(outputs).strip() + + if stripped_output: + if len(stripped_output) > remaining_size: + stripped_output = truncate(stripped_output, remaining_size, 'Outputs are too large to fit in a PR comment. See the full outputs in the workflow log.') + + open_att = ' open' if len(stripped_output.splitlines()) < 6 else '' + + status += f'''\nOutputs + +```hcl +{stripped_output} +``` + +''' + + return status + +def read_outputs(path: str) -> Optional[dict]: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + debug(f'Failed to read terraform outputs from {path}') + debug(str(e)) + return None + def main() -> int: if len(sys.argv) < 2: sys.stderr.write(f'''Usage: STATUS="" {sys.argv[0]} plan + {sys.argv[0]} get + {sys.argv[0]} approved ''') return 1 @@ -399,6 +475,20 @@ def main() -> int: if action_inputs['INPUT_ADD_GITHUB_COMMENT'] == 'changes-only' and os.environ.get('TF_CHANGES', 'true') == 'false': only_if_exists = True + if action_inputs['INPUT_ADD_GITHUB_COMMENT'] == 'always-new' and comment.comment_url is not None: + # Close the existing comment + debug('Closing existing comment') + update_comment( + github, + comment, + summary=f'{comment.summary}', + headers=comment.headers | {'closed': True}, + status=':spider_web: Plan is outdated' + ) + + # Create the replacement comment + comment = new_pr_comment(backend_fingerprint) + if comment.comment_url is None and only_if_exists: debug('Comment doesn\'t already exist - not creating it') return 0 @@ -427,6 +517,31 @@ def main() -> int: else: comment = update_comment(github, comment, status=status) + elif sys.argv[1] == 'begin-apply': + if comment.comment_url is None: + debug("Can't set status of comment that doesn't exist") + return 1 + else: + comment = update_comment(github, comment, status=f':orange_circle: Applying plan in {job_markdown_ref()}') + + elif sys.argv[1] == 'error': + if comment.comment_url is None: + debug("Can't set status of comment that doesn't exist") + return 1 + else: + comment = update_comment(github, comment, headers=comment.headers | {'closed': True}, status=f':x: Error applying plan in {job_markdown_ref()}') + + elif sys.argv[1] == 'apply-complete': + if comment.comment_url is None: + debug("Can't set status of comment that doesn't exist") + return 1 + else: + outputs = read_outputs(sys.argv[2]) + + remaining_size = 55000 - len(comment.body) + + comment = update_comment(github, comment, headers=comment.headers | {'closed': True}, status=format_output_status(outputs, remaining_size)) + elif sys.argv[1] == 'get': if comment.comment_url is None: debug("Can't get the plan from comment that doesn't exist") diff --git a/image/src/github_pr_comment/comment.py b/image/src/github_pr_comment/comment.py index 8c315003..09b0c46e 100644 --- a/image/src/github_pr_comment/comment.py +++ b/image/src/github_pr_comment/comment.py @@ -38,6 +38,7 @@ class TerraformComment: 'plan_hash', # A deterministic hash of the plan (without warnings or unchanged attributes, eventually with unmasked variables) 'variables_hash', # A hash of input variables and values 'truncated' # If the plan text has been truncated (should not be used to approve plans, and will not show a complete diff) + 'closed' # If the comment has been closed for modifications ] """ diff --git a/image/src/plan_renderer/outputs.py b/image/src/plan_renderer/outputs.py new file mode 100644 index 00000000..9f61127c --- /dev/null +++ b/image/src/plan_renderer/outputs.py @@ -0,0 +1,9 @@ +from typing import Any + +from plan_renderer.variables import render_argument_list, Sensitive + + +def render_outputs(outputs: dict[str, Any]) -> str: + return render_argument_list({ + key: Sensitive() if value['sensitive'] else value['value'] for key, value in outputs.items() + }) diff --git a/tests/workflows/test-apply/long_outputs/main.tf b/tests/workflows/test-apply/long_outputs/main.tf new file mode 100644 index 00000000..7b4d28b2 --- /dev/null +++ b/tests/workflows/test-apply/long_outputs/main.tf @@ -0,0 +1,17 @@ +variable "input" { + type = string + default = "This is my long string." +} + +resource "random_string" "s" { + length = 200 + special = false +} + +output "output" { + value = join("\n", [for i in range(0, 100): var.input]) +} + +output "unknown" { + value = join("\n", [for i in range(0, 1024): random_string.s.result]) +} \ No newline at end of file diff --git a/tests/workflows/test-apply/outputs/main.tf b/tests/workflows/test-apply/outputs/main.tf new file mode 100644 index 00000000..1d3357f9 --- /dev/null +++ b/tests/workflows/test-apply/outputs/main.tf @@ -0,0 +1,26 @@ +output "sensitive" { + value = "This is a sensistive value" + sensitive = true +} + +output "not_sensitive" { + value = "This is not a sensistive value" +} + +output "sensitive_map" { + value = { + "password" = "passw0rd" + } + sensitive = true +} + +output "not_sensitive_complex" { + value = [ + { + "hello" = "world" + }, + { + "hello" = "again" + } + ] +} diff --git a/tests/workflows/test-plan/always-new/main.tf b/tests/workflows/test-plan/always-new/main.tf new file mode 100644 index 00000000..dee08246 --- /dev/null +++ b/tests/workflows/test-plan/always-new/main.tf @@ -0,0 +1,7 @@ +resource "random_string" "my_string" { + length = 11 +} + +output "s" { + value = "string" +}