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"
+}