Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: check plan diff for staleness before applying in merge queue #290

Merged
merged 14 commits into from
Sep 8, 2024
1 change: 1 addition & 0 deletions .github/examples/pr_merge_matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ jobs:
arg_lock: ${{ github.event_name == 'merge_group' && 'true' || 'false' }}
arg_var_file: env/${{ matrix.deployment }}.tfvars
arg_workspace: ${{ matrix.deployment }}
plan_parity: true
1 change: 1 addition & 0 deletions .github/workflows/tf_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
continue-on-error: true
uses: ./
with:
plan_parity: true
arg_chdir: tests/${{ matrix.path }}
arg_command: ${{ github.event.pull_request.merged && 'apply' || 'plan' }}
arg_lock: ${{ github.event.pull_request.merged && 'true' || 'false' }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ In order to locally decrypt the TF plan file, use the following command (noting
| `encrypt_passphrase`</br>Example: `${{ secrets.KEY }}` | String passphrase to encrypt the TF plan file. |
| `fmt_enable`</br>Default: `true` | Boolean flag to enable TF fmt command and display diff of changes. |
| `label_pr`</br>Default: `true` | Boolean flag to add PR label of TF command to run. |
| `outline_enable`</br>Default: `true` | Boolean flag to add an outline diff of TF plan file. |
| `plan_parity`</br>Default: `false` | Boolean flag to compare the TF plan file with a newly-generated one to prevent stale apply. |
| `tf_tool`</br>Default: `terraform` | String name of the TF tool to use and override default assumption from wrapper environment variable. |
| `tf_version`</br>Example: `~>` 1.8.0 | String version constraint of the TF tool to install and use. |
| `update_comment`</br>Default: `false` | Boolean flag to update existing PR comment instead of creating a new comment and deleting the old one. |
Expand Down
124 changes: 100 additions & 24 deletions action.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,30 +247,61 @@ module.exports = async ({ context, core, exec, github }) => {
3
);

if (/^true$/i.test(process.env.outline_enable)) {
result_outline = cli_result
.split("\n")
.filter((line) => line.startsWith(" # "))
.map((line) => {
const diff_line = line.slice(4);
if (diff_line.includes(" created")) return "+ " + diff_line;
if (diff_line.includes(" destroyed")) return "- " + diff_line;
if (diff_line.includes(" updated") || diff_line.includes(" replaced"))
return "! " + diff_line;
return "# " + diff_line;
})
.join("\n");
if (result_outline?.length >= result_outline_limit) {
result_outline = result_outline.substring(0, result_outline_limit) + "…";
}
core.setOutput("outline", result_outline);
result_outline = cli_result
.split("\n")
.filter((line) => line.startsWith(" # "))
.map((line) => {
const diff_line = line.slice(4);
if (diff_line.includes(" created")) return "+ " + diff_line;
if (diff_line.includes(" destroyed")) return "- " + diff_line;
if (diff_line.includes(" updated") || diff_line.includes(" replaced"))
return "! " + diff_line;
return "# " + diff_line;
})
.join("\n");
if (result_outline?.length >= result_outline_limit) {
result_outline = result_outline.substring(0, result_outline_limit) + "…";
}
core.setOutput("outline", result_outline);
}

// TF apply.
if (process.env.arg_command === "apply") {
// Download the TF plan file if not auto-approved.
if (!/^true$/i.test(process.env.auto_approve)) {
// TF plan anew for later comparison if plan_parity is enabled.
if (/^true$/i.test(process.env.plan_parity)) {
await exec_tf(
[
process.env.arg_chdir,
"plan",
process.env.arg_out + ".new",
process.env.arg_var_file,
process.env.arg_destroy,
process.env.arg_compact_warnings,
process.env.arg_concise,
process.env.arg_detailed_exitcode,
process.env.arg_generate_config_out,
process.env.arg_json,
process.env.arg_lock_timeout,
process.env.arg_lock,
process.env.arg_parallelism,
process.env.arg_refresh_only,
process.env.arg_refresh,
process.env.arg_replace,
process.env.arg_target,
process.env.arg_var,
],
[
"plan",
process.env.arg_chdir,
process.env.arg_workspace_alt,
process.env.arg_backend_config,
],
3
);
}

process.env.arg_auto_approve = process.env.arg_out.replace(/^-out=/, "");
process.env.arg_var_file = process.env.arg_var = "";

Expand Down Expand Up @@ -327,9 +358,8 @@ module.exports = async ({ context, core, exec, github }) => {
`mv ${working_directory}.decrypted ${working_directory}`,
]);
}
}

if (/^true$/i.test(process.env.outline_enable)) {
// Generate an outline of the TF plan.
await exec_tf(
[process.env.arg_chdir, "show", process.env.arg_out.replace(/^-out=/, "")],
[
Expand All @@ -354,6 +384,53 @@ module.exports = async ({ context, core, exec, github }) => {
return "# " + diff_line;
})
.join("\n");

// Compare normalized output of the old TF plan with the new one.
// If they match, then replace the old TF plan with the new one to avoid stale apply.
// Otherwise, proceed with the stale apply.
if (/^true$/i.test(process.env.plan_parity)) {
await exec_tf(
[process.env.arg_chdir, "show", process.env.arg_out.replace(/^-out=/, "") + ".new"],
[
"show",
process.env.arg_chdir,
process.env.arg_workspace_alt,
process.env.arg_backend_config,
process.env.arg_var_file,
process.env.arg_destroy,
],
2
);

const result_outline_old = result_outline.split("\n").sort().join("\n");
const result_outline_new = cli_result
.split("\n")
.filter((line) => line.startsWith(" # "))
.map((line) => {
const diff_line = line.slice(4);
if (diff_line.includes(" created")) return "+ " + diff_line;
if (diff_line.includes(" destroyed")) return "- " + diff_line;
if (diff_line.includes(" updated") || diff_line.includes(" replaced"))
return "! " + diff_line;
return "# " + diff_line;
})
.sort()
.join("\n");

if (result_outline_old === result_outline_new) {
await exec.exec("/bin/bash", [
"-c",
`mv ${process.env.arg_chdir.replace(/^-chdir=/, "")}/${process.env.arg_out.replace(
/^-out=/,
""
)}.new ${process.env.arg_chdir.replace(/^-chdir=/, "")}/${process.env.arg_out.replace(
/^-out=/,
""
)}`,
]);
}
}

if (result_outline?.length >= result_outline_limit) {
result_outline = result_outline.substring(0, result_outline_limit) + "…";
}
Expand Down Expand Up @@ -423,8 +500,8 @@ module.exports = async ({ context, core, exec, github }) => {
// Render the TF fmt command output.
const output_fmt =
process.env.arg_command === "plan" &&
/^true$/i.test(process.env.fmt_enable) &&
fmt_result?.length
/^true$/i.test(process.env.fmt_enable) &&
fmt_result?.length
? `<details><summary>Format diff check.</summary>

\`\`\`diff
Expand Down Expand Up @@ -456,11 +533,10 @@ ${output_outline}
<!-- main -->
<details><summary>${result_summary}</br>

###### ${context.workflow} by @${context.actor} via [${context.eventName}](${check_url}) at ${
context.payload.pull_request?.updated_at ||
###### ${context.workflow} by @${context.actor} via [${context.eventName}](${check_url}) at ${context.payload.pull_request?.updated_at ||
context.payload.head_commit?.timestamp ||
context.payload.merge_group?.head_commit.timestamp
}.
}.
</summary>

\`\`\`hcl
Expand Down
10 changes: 5 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
description: Boolean flag to add PR comment of TF command output.
required: false
default: "true"
plan_parity:
description: Boolean flag to compare the TF plan file with a newly-generated one to prevent stale apply.
required: false
default: "false"
encrypt_passphrase:
description: String passphrase to encrypt the TF plan file.
required: false
Expand All @@ -28,10 +32,6 @@ inputs:
description: Boolean flag to add PR label of TF command to run.
required: false
default: "true"
outline_enable:
description: Boolean flag to add an outline diff of TF plan file.
required: false
default: "true"
tf_tool:
description: String name of the TF tool to use and override default assumption from wrapper environment variable.
required: false
Expand Down Expand Up @@ -282,10 +282,10 @@ runs:
# Input parameters.
cache_hit: ${{ steps.cache_plugins.outputs.cache-hit }}
comment_pr: ${{ inputs.comment_pr }}
plan_parity: ${{ inputs.plan_parity }}
encrypt_passphrase: ${{ inputs.encrypt_passphrase }}
fmt_enable: ${{ inputs.fmt_enable }}
label_pr: ${{ inputs.label_pr }}
outline_enable: ${{ inputs.outline_enable }}
tf_tool: ${{ inputs.tf_tool }}
update_comment: ${{ inputs.update_comment }}
validate_enable: ${{ inputs.validate_enable }}
Expand Down
Loading