diff --git a/.github/release-please-config.json b/.github/release-please-config.json index d8e93bd..278bfa8 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -35,6 +35,10 @@ "actions/runs-on-selector": { "component": "runs-on-selector", "initial-version": "0.0.1" + }, + "actions/terragrunt/report": { + "component": "terragrunt-report", + "initial-version": "0.0.1" } }, "plugins": [ diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json index 3088ba3..bc36ddf 100644 --- a/.github/release-please-manifest.json +++ b/.github/release-please-manifest.json @@ -2,5 +2,6 @@ "actions/semconv/pull-request": "0.0.1", "actions/release-please": "0.0.2", "actions/stale": "0.0.2", - "actions/runs-on-selector": "0.0.1" + "actions/runs-on-selector": "0.0.1", + "actions/terragrunt/report": "0.0.0" } diff --git a/actions/terragrunt/README.md b/actions/terragrunt/README.md new file mode 100644 index 0000000..32dee06 --- /dev/null +++ b/actions/terragrunt/README.md @@ -0,0 +1,9 @@ +# terragrunt + +Actions for repositories that run their infrastructure through [Terragrunt][terragrunt]. + +| Action | Does | +| --- | --- | +| [`report`](report) | Renders a Terragrunt run as a markdown report for a pull request comment and the job summary | + +[terragrunt]: https://terragrunt.gruntwork.io diff --git a/actions/terragrunt/report/README.md b/actions/terragrunt/report/README.md new file mode 100644 index 0000000..94531f6 --- /dev/null +++ b/actions/terragrunt/report/README.md @@ -0,0 +1,129 @@ +# terragrunt/report + +Runs a Terragrunt command and turns what it did into a markdown report a +reviewer can read: a table of every unit in the run, and a collapsed diff for +each unit that changes something. The same report goes to a file, ready to post +as a pull request comment, and to the job summary. + +A raw Terragrunt log is unreadable as a comment. Every line tofu writes arrives +wrapped in a timestamp, a stream name and a unit prefix, coloured, and +`run --all` interleaves the units besides. This reads Terragrunt's JSON log +instead, which is the same information without the presentation. + +## Usage + +```yaml +jobs: + plan: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@ # vX.Y.Z + - uses: jdx/mise-action@ # vX.Y.Z + + - id: plan + uses: TrogonStack/github-actions/actions/terragrunt/report@ # vX.Y.Z + with: + title: Terraform Plan + working-directory: terraform + command: terragrunt run --all -- plan -input=false -no-color -lock=false + + - if: ${{ !cancelled() && steps.plan.outputs.report-path != '' }} + run: gh pr comment "$PR" --body-file "$REPORT" + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPORT: ${{ steps.plan.outputs.report-path }} +``` + +The action decides how the output is read, and nothing about how Terragrunt is +started. `command` is a shell script, so it can be a task runner's task, a +secret loader wrapping Terragrunt, or a guard that refuses a filter before +anything runs. Credentials, filters and targets stay in the repository that +owns them. + +`!cancelled()` rather than the default, so a failed plan still reports. That is +the one a reviewer most needs to read, and the report carries the error text. +Not `always()`: a cancelled run's report is whatever tofu had written when it +was stopped. + +### Values from the workflow + +Reach anything dynamic through `env` and quote it in the script: + +```yaml + - uses: TrogonStack/github-actions/actions/terragrunt/report@ # vX.Y.Z + with: + title: Terraform Plan + command: terragrunt run --all --filter "$UNIT" -- plan -input=false -no-color + env: + UNIT: ${{ inputs.unit }} +``` + +Writing `${{ inputs.unit }}` into `command` instead expands the expression into +the source of the script before the shell sees it, so a value holding a quote +rewrites the script rather than being read by it. + +### Several reports in one job + +Every run writes to `report-path`, which defaults to one file under the runner's +temporary directory. Give each run in a job a path of its own, or the second +overwrites the first. + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `command` | required | Shell script that runs Terragrunt, executed with `bash -eo pipefail`. | +| `title` | required | Heading of the report. | +| `working-directory` | `.` | Directory the command runs in. | +| `root` | `working-directory` | Directory unit paths are cut against, so a unit reads the way it is written at `--filter`. | +| `preamble` | | Line shown under the heading, before the table. | +| `report-path` | `$RUNNER_TEMP/terragrunt-report.md` | Where the markdown report is written. | +| `summary` | `true` | Whether the report is also appended to the job summary. | + +## Outputs + +| Output | Description | +| --- | --- | +| `report-path` | The file the report was written to. Empty when rendering failed. | +| `exit-code` | The command's exit status. | + +## Fixed behaviour + +These are not inputs, on purpose. + +- `TG_LOG_FORMAT` is set to `json` for the command, and `TG_LOG_CUSTOM_FORMAT` + is removed, which would otherwise take precedence. Setting it in the + environment rather than as a flag reaches Terragrunt through whatever wraps + it. A command that writes no JSON records at all gets a warning, because an + empty report is otherwise indistinguishable from a run that did nothing. +- The step's status is the command's. A report that fails to render still fails + the step, with a warning naming the renderer, so a broken report is never read + as a broken plan, or the other way round. +- The run log is streamed while the command runs, each line tagged with its + unit, and is never truncated. The report is budgeted to fit a pull request + comment: at most 12000 characters per unit and 55000 overall. A unit over its + budget is cut and says so; a unit past the overall budget keeps its row in + the table and loses its diff. The job summary and the log still hold all of + it. +- A unit that left its provider as it found it is a row in a collapsed + `Unchanged` table, not a diff. It is still listed, because a unit that + converged and a unit missing from the run are different things. +- A failed unit shows tofu's own diagnostics, falling back to Terragrunt's + error records when tofu wrote nothing. A unit that failed only because a + dependency did has a row and nothing else, and the line above the table says + so. A run that failed before any unit started, on a root configuration + Terragrunt could not read, shows the run's own error instead of a table. +- The state lock, the refresh of every existing resource and the trailer tofu + prints without `-out` are dropped from each diff. What an apply created, + changed or destroyed is kept. +- Cancelling the job forwards the signal to the command's whole process group, + so tofu can release its state lock rather than leaving one the next run fails + on. + +A plan that runs on pull requests is best run with `-lock=false`. A plan writes +no state, and a cancellation that outlasts the runner's grace period ends tofu +before it can release a lock it holds. diff --git a/actions/terragrunt/report/action.yml b/actions/terragrunt/report/action.yml new file mode 100644 index 0000000..9c610ff --- /dev/null +++ b/actions/terragrunt/report/action.yml @@ -0,0 +1,53 @@ +name: Terragrunt report +description: >- + Runs a Terragrunt command, streams its log per unit, and renders what each + unit did as a markdown report for a pull request comment and the job summary. +author: TrogonStack + +inputs: + command: + description: >- + Shell script that runs Terragrunt, executed with `bash -eo pipefail`. The + JSON log format is set for it through `TG_LOG_FORMAT`, so a wrapper that + starts Terragrunt needs no flag passed through. Reach dynamic values + through `env` and `"$NAME"`, never through an expression written into the + script. + required: true + title: + description: Heading of the report. + required: true + working-directory: + description: Directory the command runs in. + required: false + default: . + root: + description: >- + Directory unit paths in the report are cut against, so a unit reads the + way it is written at `--filter`. Defaults to `working-directory`. + required: false + default: "" + preamble: + description: Line shown under the heading, before the table. + required: false + default: "" + report-path: + description: >- + Where the markdown report is written. Defaults to + `terragrunt-report.md` under the runner's temporary directory, so two + runs in one job need a path each. + required: false + default: "" + summary: + description: Whether the report is also appended to the job summary. + required: false + default: "true" + +outputs: + report-path: + description: The file the report was written to, ready to post as a comment. + exit-code: + description: The command's exit status. The step fails with it when it is not zero. + +runs: + using: node24 + main: lib/main.mjs diff --git a/actions/terragrunt/report/lib/core.mjs b/actions/terragrunt/report/lib/core.mjs new file mode 100644 index 0000000..4afa8c2 --- /dev/null +++ b/actions/terragrunt/report/lib/core.mjs @@ -0,0 +1,75 @@ +// A stand-in for the functions this action uses from `@actions/core`, +// carrying their names and their behaviour. The package itself is ESM-only and +// reaches `undici` through `@actions/http-client`, so depending on it would mean +// a bundler and a committed `dist/`, and the tests would then exercise something +// other than the file the runner executes. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import { EOL } from "node:os"; + +// The runner uppercases an input name and replaces spaces, and nothing else, so +// `pools-json` arrives as INPUT_POOLS-JSON and not INPUT_POOLS_JSON. +export function getInput(name, options = {}) { + const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? ""; + + if (options.required && !value) { + throw new Error(`Input required and not supplied: ${name}`); + } + + return options.trimWhitespace === false ? value : value.trim(); +} + +// Workflow commands end at a newline, so a message carrying one would close the +// annotation and log the remainder as its own line. +function escapeData(value) { + return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +export function info(message) { + process.stdout.write(`${message}${EOL}`); +} + +export function warning(message) { + process.stdout.write(`::warning::${escapeData(message)}${EOL}`); +} + +export function error(message) { + process.stdout.write(`::error::${escapeData(message)}${EOL}`); +} + +export function setFailed(message) { + // Not `process.exit`, which can truncate output still buffered on stdout. + process.exitCode = 1; + error(message); +} + +export function setOutput(name, value) { + const file = process.env.GITHUB_OUTPUT; + + if (!file) { + throw new Error("Unable to find environment variable for file command OUTPUT"); + } + + // The delimited form, because a value holding a newline would otherwise be + // read as the start of the next output. + const delimiter = `ghadelimiter_${crypto.randomUUID()}`; + + if (name.includes(delimiter) || String(value).includes(delimiter)) { + throw new Error(`Unexpected input: name and value should not contain the delimiter`); + } + + fs.appendFileSync(file, `${name}<<${delimiter}${EOL}${value}${EOL}${delimiter}${EOL}`, { + encoding: "utf8", + }); +} + +export function appendSummary(markdown) { + const file = process.env.GITHUB_STEP_SUMMARY; + + if (!file) { + throw new Error("Unable to find environment variable for file command STEP_SUMMARY"); + } + + fs.appendFileSync(file, markdown, { encoding: "utf8" }); +} diff --git a/actions/terragrunt/report/lib/index.mjs b/actions/terragrunt/report/lib/index.mjs new file mode 100644 index 0000000..548a6e5 --- /dev/null +++ b/actions/terragrunt/report/lib/index.mjs @@ -0,0 +1,198 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; + +import { appendSummary, getInput, info, setFailed, setOutput, warning } from "./core.mjs"; +import { render, streamLine } from "./report.mjs"; + +export const DEFAULT_REPORT_NAME = "terragrunt-report.md"; + +// Thrown for anything a caller can fix by editing their workflow, so main can +// report it as a GitHub error annotation rather than a stack trace. +export class InputError extends Error {} + +function parseBoolean(name, value) { + if (["true", "True", "TRUE"].includes(value)) return true; + if (["false", "False", "FALSE"].includes(value)) return false; + throw new InputError(`The \`${name}\` input must be \`true\` or \`false\`, got '${value}'.`); +} + +export function readInputs({ cwd = process.cwd(), tmp = process.env.RUNNER_TEMP || os.tmpdir() } = {}) { + const command = getInput("command", { trimWhitespace: false }); + if (command.trim() === "") { + throw new InputError("The `command` input is required: it is what runs Terragrunt."); + } + + const title = getInput("title"); + if (title === "") { + throw new InputError("The `title` input is required: it heads the report."); + } + + const workingDirectory = path.resolve(cwd, getInput("working-directory") || "."); + if (!fs.statSync(workingDirectory, { throwIfNoEntry: false })?.isDirectory()) { + throw new InputError(`The \`working-directory\` input names no directory: ${workingDirectory}`); + } + const rootInput = getInput("root"); + + return { + command, + title, + workingDirectory, + // Unit paths are cut against the directory the run starts in unless told + // otherwise, because that is the directory `--filter` is written against. + root: rootInput === "" ? workingDirectory : path.resolve(cwd, rootInput), + preamble: getInput("preamble"), + reportPath: path.resolve(cwd, getInput("report-path") || path.join(tmp, DEFAULT_REPORT_NAME)), + summary: parseBoolean("summary", getInput("summary") || "true"), + }; +} + +function exitCodeOf(code, signal) { + if (code !== null) return code; + return 128 + (os.constants.signals[signal] ?? 0); +} + +// The report reads Terragrunt's JSON log, so the format is decided here rather +// than left to every caller to remember. Setting it in the environment rather +// than as a flag reaches Terragrunt through whatever wraps it, a task runner or +// a secret loader, without the wrapper passing anything along. A custom format +// takes precedence over the JSON one, so it is removed rather than trusted to +// be absent. +export function childEnv(env = process.env) { + const next = { ...env, TG_LOG_FORMAT: "json" }; + delete next.TG_LOG_CUSTOM_FORMAT; + return next; +} + +// Runs the command, streams its log as plain prefixed text while it runs, and +// renders the report once it has finished. +// +// The status that decides whether the step failed is the command's and nothing +// else's. A renderer that fails still fails the step, but with a warning naming +// it, so nobody reads a broken renderer as a broken plan. +export async function run({ + command, + title, + workingDirectory, + root, + preamble = "", + reportPath, + summary = true, + env = process.env, + write = (text) => process.stdout.write(text), +}) { + const lines = []; + let streamError; + + // Gone before anything runs, so the path never holds a report from an earlier + // run in the job. A render that fails returns no path, but a later step + // reading the default path directly would otherwise post the old report. + fs.rmSync(reportPath, { force: true }); + + // `exec 2>&1` inside the shell rather than two pipes merged here, because two + // pipes are read in whatever order they become ready, and a diagnostic would + // then land away from the unit that wrote it. + // + // Its own process group, so a cancellation reaches Terragrunt and tofu and not + // only the shell. tofu releases the state lock when it is interrupted, and a + // job killed without that leaves a lock the next run fails on until somebody + // force-unlocks it. + const child = spawn("bash", ["-eo", "pipefail", "-c", `exec 2>&1\n${command}`], { + cwd: workingDirectory, + env: childEnv(env), + stdio: ["ignore", "pipe", "inherit"], + detached: true, + }); + + const forward = (signal) => { + try { + process.kill(-child.pid, signal); + } catch { + // Already gone, which is what the signal was asking for. + } + }; + const signals = ["SIGINT", "SIGTERM"]; + for (const signal of signals) process.on(signal, forward); + + const reader = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + reader.on("line", (line) => { + lines.push(line); + let shown = line; + try { + shown = streamLine(line, root); + } catch (error) { + streamError ??= error; + } + write(`${shown}\n`); + }); + + let exitCode; + try { + // Both, because the last line of a log with no final newline is only + // emitted once the reader sees the end of the stream. + [exitCode] = await Promise.all([ + new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => resolve(exitCodeOf(code, signal))); + }), + once(reader, "close"), + ]); + } finally { + for (const signal of signals) process.off(signal, forward); + } + + if (streamError !== undefined) { + warning(`The run log could not be rendered as it streamed (${streamError.message}); lines were shown as they came.`); + if (exitCode === 0) exitCode = 1; + } + + let report; + try { + report = render({ lines, root, title, preamble }); + } catch (error) { + warning(`The run finished with status ${exitCode} but rendering its report did not: ${error.message}`); + return { exitCode: exitCode === 0 ? 1 : exitCode, reportPath: "" }; + } + + if (report.records === 0) { + warning( + "The command wrote no Terragrunt JSON log records, so the report is empty. Check that it runs Terragrunt and that nothing overrides TG_LOG_FORMAT.", + ); + } + + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, report.markdown, "utf8"); + + if (summary) { + if (process.env.GITHUB_STEP_SUMMARY) { + appendSummary(report.markdown); + } else { + info("No job summary to write to outside a runner; the report is only in the file."); + } + } + + return { exitCode, reportPath }; +} + +export async function main() { + try { + const { exitCode, reportPath } = await run(readInputs()); + setOutput("report-path", reportPath); + setOutput("exit-code", String(exitCode)); + if (exitCode !== 0) { + // Not `setFailed`: the command has already said what went wrong, in the + // log and in the report, and an annotation repeating only the status would + // be the one thing on the run page saying nothing. + process.exitCode = exitCode; + } + } catch (thrown) { + if (thrown instanceof InputError) { + setFailed(thrown.message); + return; + } + throw thrown; + } +} diff --git a/actions/terragrunt/report/lib/main.mjs b/actions/terragrunt/report/lib/main.mjs new file mode 100644 index 0000000..54d530e --- /dev/null +++ b/actions/terragrunt/report/lib/main.mjs @@ -0,0 +1,3 @@ +import { main } from "./index.mjs"; + +await main(); diff --git a/actions/terragrunt/report/lib/report.mjs b/actions/terragrunt/report/lib/report.mjs new file mode 100644 index 0000000..b17dc83 --- /dev/null +++ b/actions/terragrunt/report/lib/report.mjs @@ -0,0 +1,344 @@ +// Turns a Terragrunt run into something a person reads. +// +// Terragrunt wraps every line tofu writes in a log record of its own: a +// timestamp, the stream name, the unit prefix and `tofu:`, each of them +// coloured. None of that renders as colour inside a fenced block in a pull +// request comment, so a plan arrives as a wall of escape codes with the diff +// pushed into the right-hand third of every line, and `run --all` interleaves +// the units besides. That is what makes a raw log unreadable as a comment, +// rather than the size of the plan. +// +// The JSON log format is the same information without the presentation: one +// object per line, the unit in `working-dir`, the stream in `level`, the text +// in `msg`. Everything here reads that. +// +// `stdout` is tofu's own stdout, and only that carries a plan or an apply. +// `info` is Terragrunt narrating plus tofu's init chatter, and `error` is the +// failure text. Terragrunt orders `stdout` above `warn`, so TG_LOG_LEVEL=warn +// still lets every record this reads through. +// +// Terragrunt's closing run summary is printed raw rather than as a record, so +// every reader here has to tolerate a line that is not JSON. + +// GitHub rejects a comment body over 65536 characters and a single plan can +// pass that on its own, so the budget is spent per unit rather than on the run +// as a whole: one large unit cannot push every other unit's diff out of the +// comment. The step summary is capped separately and far higher (1MB), and the +// run log is never truncated at all, so both remain the place to read the whole +// thing. +export const UNIT_BUDGET = 12000; +export const COMMENT_BUDGET = 55000; + +const NOOP_VERDICTS = new Set([ + "no changes", + "no output", + "0 to add, 0 to change, 0 to destroy", + "0 added, 0 changed, 0 destroyed", +]); + +export function parseRecord(line) { + let value; + try { + value = JSON.parse(line); + } catch { + return null; + } + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; +} + +// Terragrunt reports a unit by its absolute working directory, and this gives +// back the path a person would type at `--filter`. A directory outside the root +// is left whole rather than guessed at: a wrong short name is worse than a long +// right one. +export function unitOf(record, root) { + const dir = typeof record["working-dir"] === "string" ? record["working-dir"] : ""; + if (dir === "") return ""; + if (root !== "" && dir.startsWith(`${root}/`)) return dir.slice(root.length + 1); + if (dir === root) return "."; + return dir; +} + +// One trailing newline is the record's own terminator, not a blank line in the +// output, and an empty message is no lines at all rather than one empty one. +export function linesOf(record) { + const msg = typeof record.msg === "string" ? record.msg : ""; + const text = msg.endsWith("\n") ? msg.slice(0, -1) : msg; + return text === "" ? [] : text.split("\n"); +} + +// The run log a person opens while the job is still going: each line of a +// record tagged with its unit, and anything that is not a record as it came. +export function streamLine(line, root) { + const record = parseRecord(line); + if (record === null) return line; + const unit = unitOf(record, root); + const tag = unit === "" ? "" : `[${unit}] `; + return linesOf(record) + .map((text) => tag + text) + .join("\n"); +} + +// A string's length and prefix in characters, not UTF-16 code units, so an +// emoji in a plan costs what it costs GitHub. +function charLength(text) { + let n = 0; + for (const _ of text) n++; + return n; +} + +function charSlice(text, end) { + return Array.from(text).slice(0, end).join(""); +} + +function trimTrailingNewlines(text) { + return text.replace(/\n+$/, ""); +} + +// What a unit did, with the lines that are the same under every unit removed. +// +// The trailer tofu prints when no -out file was given is eight identical lines +// per unit. The state lock and the refresh of every existing resource are the +// larger share: on a unit holding a hundred resources they are a hundred lines +// above the diff, and they report what the run read, not what it changes. +// +// Nothing an apply does is dropped. Creating, Modifying and Destroying stay, +// because on an apply those lines are the record of what actually happened and +// of where it stopped if it stopped. +export function trim(text) { + const out = []; + let pending = false; + let printed = false; + for (const line of text.split("\n")) { + if (/^Note: You didn.t use the -out option/.test(line)) break; + if (/^(Acquiring|Releasing) state lock\./.test(line)) continue; + if (/: Refreshing state\.\.\./.test(line)) continue; + if (/: Reading\.\.\.$/.test(line)) continue; + if (/: Read complete after /.test(line)) continue; + if (/^\s*$/.test(line) || /^─+$/.test(line)) { + pending = true; + continue; + } + if (pending && printed) out.push(""); + pending = false; + printed = true; + out.push(line); + } + return out.join("\n"); +} + +// What is left of a unit's error records once the lines that say only that it +// failed are gone. Terragrunt reports a failure several times over, and for a +// unit that failed only because something it depends on did, it says nothing +// else. A collapsed block holding one line saying the unit failed, under a row +// already saying so, is worse than no block. +// +// Empty output therefore means there is nothing to show, not that the unit +// succeeded. +export function stripWrapper(text) { + const kept = text + .split("\n") + .filter( + (line) => + !/^tofu invocation failed in /.test(line) && + !/^Module .* has finished with an error$/.test(line) && + !/^Dependency .* just finished with an error/.test(line) && + !/^Unable to determine underlying exit code/.test(line), + ); + const first = kept.findIndex((line) => /\S/.test(line)); + return first === -1 ? "" : trimTrailingNewlines(kept.slice(first).join("\n")); +} + +// The one line that says what a unit did, reduced to what fits a table cell. +// +// An apply prints its plan first and its result last, so both lines are in the +// body and only the last one is true. Reading "Plan:" there would report an +// apply by what it intended rather than by what it did, and a partial apply is +// exactly when those differ. +export function verdict(body) { + const lines = body.split("\n"); + let line = lines.filter((l) => /^Apply complete! Resources:/.test(l)).at(-1); + if (line === undefined) line = lines.find((l) => /^Plan: [0-9]/.test(l)); + if (line !== undefined) { + if (line.endsWith(".")) line = line.slice(0, -1); + if (line.startsWith("Plan: ")) line = line.slice("Plan: ".length); + if (line.startsWith("Apply complete! Resources: ")) { + line = line.slice("Apply complete! Resources: ".length); + } + return line; + } + if (body.includes("Changes to Outputs")) return "outputs only"; + if (lines.some((l) => /^No changes\./.test(l))) return "no changes"; + return "no output"; +} + +// A fence longer than the longest run of backticks in what it has to hold. A +// plan diff is arbitrary strings, and three backticks in one would otherwise +// close the fence early and spill the rest of the plan into the comment as +// markdown. +// A unit is a directory name, and one holding `<` or `&` would otherwise break +// the `` it is written into. GitHub sanitises the HTML, so this is +// about the markup rendering, not about anything running. +export function escapeHtml(text) { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +export function fence(text) { + const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)); + return "`".repeat(Math.max(3, longest + 1)); +} + +// Whether a verdict means the unit left the provider as it found it. Those +// units are a table row and nothing more, which is the whole reason the report +// is shorter than the log: on a change touching one stack, the converged units +// are almost all of them. +export function isNoop(value) { + return NOOP_VERDICTS.has(value); +} + +function recordsOf(lines) { + const records = []; + for (const line of lines) { + const record = parseRecord(line); + if (record !== null) records.push(record); + } + return records; +} + +// Every unit that said anything tofu wrote, which is stdout, stderr and error +// and not `info`. A unit whose plan failed before tofu produced any output has +// only `error` records, and leaving it out of the table would report a failed +// run as a table of units that all converged. +// +// `.` is dropped because it is not a unit. When a run fails, Terragrunt writes +// a closing record against the run root collecting every unit's failure into +// one block, and taking it for a unit would put a verbatim copy of every +// diagnostic in the report a second time, as the largest block in it. +function unitsOf(records, root) { + const units = new Set(); + for (const record of records) { + if (!["stdout", "stderr", "error"].includes(record.level)) continue; + const unit = unitOf(record, root); + if (unit !== "" && unit !== ".") units.add(unit); + } + // Code point order, so the table reads the same wherever it is rendered. + return [...units].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +function unitStream(records, root, unit, level) { + return records + .filter((record) => record.level === level && unitOf(record, root) === unit) + .flatMap(linesOf) + .join("\n"); +} + +// A table of every unit in the run, then a collapsed diff for each unit that +// actually changed something. +export function render({ lines, root, title, preamble = "" }) { + const records = recordsOf(lines); + let rows = ""; + let quiet = ""; + let details = ""; + let spent = 0; + let budgetHit = false; + let failed = false; + + for (const unit of unitsOf(records, root)) { + // The two streams are trimmed apart and joined afterwards rather than + // trimmed together. The trailer rule cuts from its match to the end of what + // it is given, and a unit that planned cleanly and warned on stderr would + // lose the warning to the plan's own trailer. + let body = trim(unitStream(records, root, unit, "stdout")); + let unitVerdict = verdict(body); + + const failure = trimTrailingNewlines(unitStream(records, root, unit, "error")); + if (failure !== "") { + unitVerdict = "failed"; + failed = true; + // tofu writes its diagnostics to stderr and Terragrunt then repeats them + // inside an error record of its own. Preferring stderr keeps the text at + // tofu's own indentation; the record is the fallback for a unit that + // failed before tofu wrote anything, where it is the only account of what + // went wrong. + let diagnostics = trim(unitStream(records, root, unit, "stderr")); + if (diagnostics === "") diagnostics = stripWrapper(failure); + if (diagnostics !== "") { + if (body !== "") body += "\n\n"; + body += diagnostics; + } + } + + // A converged unit is still listed, in the collapsed table at the end. A + // unit missing from the report and a unit that converged are not the same + // thing, and a reviewer cannot tell them apart. + if (isNoop(unitVerdict)) { + quiet += `| \`${unit}\` | ${unitVerdict} |\n`; + continue; + } + + rows += `| \`${unit}\` | ${unitVerdict} |\n`; + // A unit that failed on a dependency has a row and nothing else to say. + if (body === "") continue; + + if (charLength(body) > UNIT_BUDGET) { + body = `${charSlice(body, UNIT_BUDGET)}\n... this unit is longer than the comment allows; read it in the job summary`; + } + const size = charLength(body); + if (spent + size > COMMENT_BUDGET) { + budgetHit = true; + continue; + } + spent += size; + + const f = fence(body); + details += `
${escapeHtml(unit)} · ${unitVerdict}\n\n`; + details += `${f}text\n${body}\n${f}\n\n`; + details += "
\n\n"; + } + + let out = `### ${title}\n\n`; + if (preamble !== "") out += `${preamble}\n\n`; + + if (failed) { + out += + "**Failed.** A unit marked `failed` with nothing collapsed under it was stopped by one that has something collapsed under it.\n\n"; + } + + if (rows !== "") { + out += `| Unit | Change |\n| --- | --- |\n${rows}\n`; + } else if (quiet !== "") { + out += "Every unit matches its configuration.\n\n"; + } else { + // No unit to attribute anything to, so the run itself failed before any + // unit started: a root configuration Terragrunt could not parse, or a + // filter it refused. The run root's own error records are then the only + // account of what went wrong, and a report without them would say nothing + // but that nothing happened. + const rootFailure = stripWrapper( + trimTrailingNewlines( + records + .filter((record) => record.level === "error" && ["", "."].includes(unitOf(record, root))) + .flatMap(linesOf) + .join("\n"), + ), + ); + if (rootFailure !== "") { + const f = fence(rootFailure); + out += `**Failed.**\n\n${f}text\n${rootFailure}\n${f}\n\n`; + } else { + out += "No unit produced any output. The run stopped before tofu started.\n\n"; + } + } + + out += details; + if (budgetHit) { + out += "_Some units are left out of this comment for length. The job summary has all of them._\n\n"; + } + + if (quiet !== "") { + out += "
Unchanged\n\n"; + out += `| Unit | Change |\n| --- | --- |\n${quiet}\n`; + out += "
\n"; + } + + return { markdown: out, records: records.length }; +} diff --git a/tests/node/terragrunt/report/core.test.mjs b/tests/node/terragrunt/report/core.test.mjs new file mode 100644 index 0000000..b5369e2 --- /dev/null +++ b/tests/node/terragrunt/report/core.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + appendSummary, + error, + getInput, + info, + setFailed, + setOutput, + warning, +} from "../../../../actions/terragrunt/report/lib/core.mjs"; + +function captureStdout(run) { + const written = []; + const original = process.stdout.write; + process.stdout.write = (chunk) => { + written.push(String(chunk)); + return true; + }; + try { + run(); + } finally { + process.stdout.write = original; + } + return written.join(""); +} + +function outputFile() { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "terragrunt-report-")), "output.txt"); + fs.writeFileSync(file, ""); + return file; +} + +function withOutputFile(run) { + const file = outputFile(); + const previous = process.env.GITHUB_OUTPUT; + process.env.GITHUB_OUTPUT = file; + try { + run(); + } finally { + if (previous === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = previous; + } + } + return fs.readFileSync(file, "utf8"); +} + +function withInput(name, value, run) { + const key = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; + process.env[key] = value; + try { + return run(); + } finally { + delete process.env[key]; + } +} + +test("getInput reads the dash spelling the runner writes", () => { + assert.equal( + withInput("report-path", "/tmp/plan.md", () => getInput("report-path")), + "/tmp/plan.md", + ); +}); + +test("getInput is empty for an absent input", () => { + assert.equal(getInput("nothing-set-here"), ""); +}); + +test("getInput trims by default", () => { + assert.equal( + withInput("title", " Plan ", () => getInput("title")), + "Plan", + ); +}); + +test("getInput keeps whitespace when asked", () => { + assert.equal( + withInput("title", " Plan ", () => getInput("title", { trimWhitespace: false })), + " Plan ", + ); +}); + +test("getInput enforces required", () => { + assert.throws(() => getInput("absent-input", { required: true }), { + message: "Input required and not supplied: absent-input", + }); +}); + +test("info writes the message and a line ending", () => { + assert.equal( + captureStdout(() => info("hello")), + `hello${os.EOL}`, + ); +}); + +test("error writes an error annotation", () => { + assert.equal( + captureStdout(() => error("broken")), + `::error::broken${os.EOL}`, + ); +}); + +test("error escapes what would end the annotation", () => { + const written = captureStdout(() => error("one\ntwo\rthree 50%")); + assert.equal(written, `::error::one%0Atwo%0Dthree 50%25${os.EOL}`); + assert.equal(written.trimEnd().split("\n").length, 1); +}); + +test("setFailed annotates and sets a failing exit code", () => { + const previous = process.exitCode; + try { + const written = captureStdout(() => setFailed("nope")); + assert.equal(written, `::error::nope${os.EOL}`); + assert.equal(process.exitCode, 1); + } finally { + process.exitCode = previous; + } +}); + +test("setOutput writes the delimited form", () => { + const written = withOutputFile(() => setOutput("report-path", "/tmp/plan.md")); + assert.match(written, /^report-path< { + const written = withOutputFile(() => setOutput("report-path", "first\nsecond")); + const [header, ...rest] = written.split(/\r?\n/); + const delimiter = header.slice("report-path<<".length); + assert.deepEqual(rest, ["first", "second", delimiter, ""]); +}); + +test("setOutput appends rather than replacing", () => { + const written = withOutputFile(() => { + setOutput("report-path", "/tmp/plan.md"); + setOutput("exit-code", "0"); + }); + assert.match(written, /^report-path< { + const previous = process.env.GITHUB_OUTPUT; + delete process.env.GITHUB_OUTPUT; + try { + assert.throws(() => setOutput("report-path", "/tmp/plan.md"), { + message: "Unable to find environment variable for file command OUTPUT", + }); + } finally { + if (previous !== undefined) { + process.env.GITHUB_OUTPUT = previous; + } + } +}); + +test("warning writes a warning annotation, escaped", () => { + assert.equal( + captureStdout(() => warning("one\ntwo 50%")), + `::warning::one%0Atwo 50%25${os.EOL}`, + ); +}); + +test("appendSummary appends markdown as it stands", () => { + const file = outputFile(); + const previous = process.env.GITHUB_STEP_SUMMARY; + process.env.GITHUB_STEP_SUMMARY = file; + try { + appendSummary("### one\n"); + appendSummary("### two\n"); + } finally { + if (previous === undefined) { + delete process.env.GITHUB_STEP_SUMMARY; + } else { + process.env.GITHUB_STEP_SUMMARY = previous; + } + } + assert.equal(fs.readFileSync(file, "utf8"), "### one\n### two\n"); +}); + +test("appendSummary needs GITHUB_STEP_SUMMARY", () => { + const previous = process.env.GITHUB_STEP_SUMMARY; + delete process.env.GITHUB_STEP_SUMMARY; + try { + assert.throws(() => appendSummary("x"), { + message: "Unable to find environment variable for file command STEP_SUMMARY", + }); + } finally { + if (previous !== undefined) { + process.env.GITHUB_STEP_SUMMARY = previous; + } + } +}); diff --git a/tests/node/terragrunt/report/fixtures.mjs b/tests/node/terragrunt/report/fixtures.mjs new file mode 100644 index 0000000..1d73318 --- /dev/null +++ b/tests/node/terragrunt/report/fixtures.mjs @@ -0,0 +1,138 @@ +// Synthetic Terragrunt JSON logs, one array of lines per case. They are built +// rather than recorded so that no real run's paths, accounts or resource names +// end up in a public repository. The record shape is Terragrunt's: one object +// per line, the unit in `working-dir`, the stream in `level`, the text in `msg`. + +export const ROOT = "/work/infra"; + +const RULE = "─".repeat(77); + +function record(unit, level, msg, extra = {}) { + const dir = unit === "." ? ROOT : `${ROOT}/${unit}`; + return JSON.stringify({ + time: "2026-01-01T00:00:00Z", + level, + "working-dir": dir, + ...extra, + msg, + }); +} + +const tofu = { "tf-path": "tofu", "tf-command-args": ["plan", "-input=false", "-no-color"] }; +const out = (unit, msg) => record(unit, "stdout", msg, tofu); +const err = (unit, msg) => record(unit, "stderr", msg, tofu); +const fail = (unit, msg) => record(unit, "error", msg); +const note = (unit, msg) => record(unit, "info", msg); + +const trailer = [ + RULE, + "", + "Note: You didn't use the -out option to save this plan, so OpenTofu can't", + 'guarantee to take exactly these actions if you run "tofu apply" now.', + "", +].join("\n"); + +function refresh(n) { + return Array.from({ length: n }, (_, i) => `example_thing.item[${i}]: Refreshing state... [id=${i}]`).join("\n"); +} + +function create(name, attrs) { + return [ + ` # example_thing.${name} will be created`, + ` + resource "example_thing" "${name}" {`, + ...Object.entries(attrs).map(([k, v]) => ` + ${k} = ${v}`), + " }", + ].join("\n"); +} + +const header = + "\nOpenTofu used the selected providers to generate the following execution\nplan. Resource actions are indicated with the following symbols:\n + create\n\nOpenTofu will perform the following actions:\n"; + +function bigDiff(name, chars) { + const lines = []; + let i = 0; + while (lines.join("\n").length < chars) { + lines.push(` + line_${String(i++).padStart(5, "0")} = "${"x".repeat(60)}"`); + } + return ` # example_thing.${name} will be created\n + resource "example_thing" "${name}" {\n${lines.join("\n")}\n }\n`; +} + +export const FIXTURES = { + "no-changes": [ + note("network", "Downloading providers"), + out("network", `Acquiring state lock. This may take a few moments...\n${refresh(3)}\n`), + out("network", "\nNo changes. Your infrastructure matches the configuration.\n\nOpenTofu has compared your real infrastructure against your configuration\nand found no differences, so no changes are needed.\n"), + out("network", "Releasing state lock. This may take a few moments...\n"), + out("dns", "\nNo changes. Your infrastructure matches the configuration.\n"), + "", + "❯❯ Run Summary 2 units 4s", + " ────────────────────────────", + " Succeeded 2", + ], + changes: [ + out("dns", "\nNo changes. Your infrastructure matches the configuration.\n"), + out("apps/web", `${refresh(4)}\nexample_lookup.zone: Reading...\nexample_lookup.zone: Read complete after 1s [id=zone]\n`), + out("apps/web", header), + out("apps/web", `\n${create("site", { name: '"web 🚀"', id: "(known after apply)" })}\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + out("apps/web", `\n${trailer}`), + err("apps/web", "\nWarning: Deprecated attribute\n\n on main.tf line 4:\n 4: legacy = true\n\nThe attribute is deprecated.\n"), + "Some line a wrapper printed that is not JSON", + "[1, 2, 3]", + ], + apply: [ + out("apps/api", header), + out("apps/api", `\n${create("svc", { name: '"api"' })}\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + out("apps/api", "example_thing.svc: Creating...\nexample_thing.svc: Creation complete after 2s [id=svc]\n"), + out("apps/api", "\nApply complete! Resources: 1 added, 0 changed, 0 destroyed.\n"), + out("apps/idle", "\nApply complete! Resources: 0 added, 0 changed, 0 destroyed.\n"), + ], + "outputs-only": [ + out("shared", "\nChanges to Outputs:\n + endpoint = \"https://example.test\"\n\nYou can apply this plan to save these new output values to the OpenTofu\nstate, without changing any real infrastructure.\n"), + out("shared", `\n${trailer}`), + ], + failed: [ + out("db", header), + out("db", `\n${create("cluster", { size: "3" })}\n`), + err("db", "\nError: creating example_thing.cluster: access denied\n\n with example_thing.cluster,\n on main.tf line 1, in resource \"example_thing\" \"cluster\":\n 1: resource \"example_thing\" \"cluster\" {\n\n"), + fail("db", "tofu invocation failed in ./db"), + fail("db", "Module ./db has finished with an error"), + fail("apps/worker", "Dependency ./db of module ./apps/worker just finished with an error. Module ./apps/worker will have to return an error too."), + fail("apps/worker", "Module ./apps/worker has finished with an error"), + out("dns", "\nNo changes. Your infrastructure matches the configuration.\n"), + fail(".", "error occurred:\n\n* Failed to execute \"tofu plan -input=false -no-color\" in ./db\n Error: creating example_thing.cluster: access denied\n"), + "❯❯ Run Summary 3 units 9s", + " Failed 2", + ], + "error-only": [ + fail("broken", "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below.\n"), + fail("broken", "\nError: Unclosed configuration block\n\n on main.tf line 1, in resource \"bad\":\n 1: resource \"bad\" {\n\nThere is no closing brace for this block before the end of the file.\n\n"), + fail("broken", "tofu invocation failed in ./broken"), + fail("broken", "Module ./broken has finished with an error"), + fail("empty", "\n"), + ], + backticks: [ + out("docs", header), + out("docs", `\n${create("readme", { body: '"```sh\\necho hi\\n```"', note: '"a ```` b"' })}\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + ], + "unit-budget": [ + out("huge", header), + out("huge", `\n${bigDiff("huge", 13000)}\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + out("small", `\n${create("one", { a: "1" })}\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + ], + "total-budget": ["a", "b", "c", "d", "e", "f"].flatMap((u) => [ + out(`stack-${u}`, header), + out(`stack-${u}`, `\n${bigDiff(u, 11000)}\nPlan: 1 to add, 0 to change, 0 to destroy.\n`), + ]), + "root-failure": [ + note(".", "Discovering units"), + fail(".", "Error reading file at path /work/infra/root.hcl: ```unclosed``` block\n"), + fail(".", "tofu invocation failed in ."), + "❯❯ Run Summary 0 units 1s", + ], + "not-terragrunt": ["plain text from something that is not Terragrunt", "", "exit 1"], + outside: [ + JSON.stringify({ level: "stdout", "working-dir": "/elsewhere/unit", msg: "\nNo changes.\n" }), + JSON.stringify({ level: "stdout", msg: "record with no working dir" }), + JSON.stringify({ level: "stdout", "working-dir": `${ROOT}/odd`, msg: 42 }), + ], +}; diff --git a/tests/node/terragrunt/report/index.test.mjs b/tests/node/terragrunt/report/index.test.mjs new file mode 100644 index 0000000..f2e0b89 --- /dev/null +++ b/tests/node/terragrunt/report/index.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + DEFAULT_REPORT_NAME, + InputError, + childEnv, + readInputs, + run, +} from "../../../../actions/terragrunt/report/lib/index.mjs"; +import { FIXTURES, ROOT } from "./fixtures.mjs"; + +function scratch() { + return fs.mkdtempSync(path.join(os.tmpdir(), "terragrunt-report-")); +} + +function withEnv(values, fn) { + const previous = {}; + for (const [key, value] of Object.entries(values)) { + previous[key] = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + const restore = () => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; + let result; + try { + result = fn(); + } catch (error) { + restore(); + throw error; + } + if (result instanceof Promise) return result.finally(restore); + restore(); + return result; +} + +function inputs(values) { + const env = {}; + for (const name of ["command", "title", "working-directory", "root", "preamble", "report-path", "summary"]) { + env[`INPUT_${name.toUpperCase()}`] = values[name]; + } + return env; +} + +// A log file and a command that replays it, which is the whole contract with +// whatever the caller really runs. +function replay(dir, name) { + const log = path.join(dir, `${name}.jsonl`); + fs.writeFileSync(log, `${FIXTURES[name].join("\n")}\n`); + return `cat "$LOG"`; +} + +async function capture(options) { + const written = []; + const result = await run({ write: (text) => written.push(text), ...options }); + return { ...result, stream: written.join("") }; +} + +test("readInputs needs a command and a title", () => { + withEnv(inputs({ title: "Plan" }), () => { + assert.throws(() => readInputs(), InputError); + }); + withEnv(inputs({ command: "true" }), () => { + assert.throws(() => readInputs(), InputError); + }); +}); + +test("readInputs defaults the root to the working directory and the report to the runner's temp", () => { + const dir = scratch(); + fs.mkdirSync(path.join(dir, "infra")); + withEnv(inputs({ command: "true", title: "Plan", "working-directory": "infra" }), () => { + const read = readInputs({ cwd: dir, tmp: "/runner/tmp" }); + assert.equal(read.workingDirectory, path.join(dir, "infra")); + assert.equal(read.root, path.join(dir, "infra")); + assert.equal(read.reportPath, path.join("/runner/tmp", DEFAULT_REPORT_NAME)); + assert.equal(read.summary, true); + }); +}); + +test("readInputs resolves an explicit root against the workspace", () => { + const dir = scratch(); + withEnv(inputs({ command: "true", title: "Plan", root: "infra/terraform", summary: "false" }), () => { + const read = readInputs({ cwd: dir }); + assert.equal(read.root, path.join(dir, "infra/terraform")); + assert.equal(read.summary, false); + }); +}); + +test("readInputs refuses a summary that is not a boolean", () => { + withEnv(inputs({ command: "true", title: "Plan", summary: "yes" }), () => { + assert.throws(() => readInputs(), { message: /`summary` input must be `true` or `false`/ }); + }); +}); + +test("readInputs refuses a working directory that does not exist", () => { + withEnv(inputs({ command: "true", title: "Plan", "working-directory": "does-not-exist" }), () => { + assert.throws(() => readInputs({ cwd: scratch() }), { message: /names no directory/ }); + }); +}); + +test("childEnv forces the JSON log format", () => { + const env = childEnv({ TG_LOG_FORMAT: "pretty", TG_LOG_CUSTOM_FORMAT: "%msg", KEEP: "1" }); + assert.equal(env.TG_LOG_FORMAT, "json"); + assert.equal(env.TG_LOG_CUSTOM_FORMAT, undefined); + assert.equal(env.KEEP, "1"); +}); + +test("run streams per unit, writes the report and keeps the command's status", async () => { + const dir = scratch(); + const command = `${replay(dir, "failed")}; exit 3`; + const reportPath = path.join(dir, "out", "plan.md"); + const summary = path.join(dir, "summary.md"); + + const result = await withEnv({ GITHUB_STEP_SUMMARY: summary }, () => + capture({ + command, + title: "Plan", + workingDirectory: dir, + root: ROOT, + reportPath, + env: { ...process.env, LOG: path.join(dir, "failed.jsonl") }, + }), + ); + + assert.equal(result.exitCode, 3); + assert.equal(result.reportPath, reportPath); + assert.match(result.stream, /^\[db\] Error: creating example_thing\.cluster: access denied$/m); + assert.match(result.stream, /^❯❯ Run Summary/m); + const markdown = fs.readFileSync(reportPath, "utf8"); + assert.match(markdown, /\| `db` \| failed \|/); + assert.equal(fs.readFileSync(summary, "utf8"), markdown); +}); + +test("run removes a report an earlier run left at the path before the command starts", async () => { + const dir = scratch(); + const reportPath = path.join(dir, "plan.md"); + fs.writeFileSync(reportPath, "stale"); + + const result = await capture({ + command: 'test ! -e "$REPORT"', + title: "Plan", + workingDirectory: dir, + root: ROOT, + reportPath, + summary: false, + env: { ...process.env, REPORT: reportPath }, + }); + + assert.equal(result.exitCode, 0); + assert.doesNotMatch(fs.readFileSync(reportPath, "utf8"), /stale/); +}); + +test("run hands the command the JSON log format", async () => { + const dir = scratch(); + const result = await capture({ + command: 'printf \'{"level":"stdout","working-dir":"%s/u","msg":"%s"}\\n\' "$PWD" "$TG_LOG_FORMAT"', + title: "Plan", + workingDirectory: dir, + root: fs.realpathSync(dir), + reportPath: path.join(dir, "plan.md"), + summary: false, + }); + assert.equal(result.exitCode, 0); + assert.match(result.stream, /^\[u\] json$/m); +}); + +test("run merges stderr into the log in the order it was written", async () => { + const dir = scratch(); + const result = await capture({ + command: "echo one; echo two >&2; echo three", + title: "Plan", + workingDirectory: dir, + root: dir, + reportPath: path.join(dir, "plan.md"), + summary: false, + }); + assert.equal(result.stream, "one\ntwo\nthree\n"); +}); + +test("run fails a pipeline that fails part way", async () => { + const dir = scratch(); + const result = await capture({ + command: "false | cat", + title: "Plan", + workingDirectory: dir, + root: dir, + reportPath: path.join(dir, "plan.md"), + summary: false, + }); + assert.equal(result.exitCode, 1); +}); + +test("run keeps a last line with no newline", async () => { + const dir = scratch(); + const result = await capture({ + command: 'printf \'{"level":"stdout","working-dir":"/r/u","msg":"Plan: 1 to add, 0 to change, 0 to destroy."}\'', + title: "Plan", + workingDirectory: dir, + root: "/r", + reportPath: path.join(dir, "plan.md"), + summary: false, + }); + assert.match(fs.readFileSync(path.join(dir, "plan.md"), "utf8"), /\| `u` \| 1 to add/); + assert.equal(result.exitCode, 0); +}); + +test("run forwards a cancellation to the command's process group", async () => { + const dir = scratch(); + const marker = path.join(dir, "interrupted"); + let ready; + const started = new Promise((resolve) => { + ready = resolve; + }); + const pending = run({ + // The trap is on the grandchild, which is the process a cancellation has to + // reach: tofu under Terragrunt under the shell. + command: `bash -c 'trap "touch ${marker}; exit 0" INT; echo ready; while :; do sleep 0.1; done' ; exit 130`, + title: "Plan", + workingDirectory: dir, + root: dir, + reportPath: path.join(dir, "plan.md"), + summary: false, + write: (text) => { + if (text.startsWith("ready")) ready(); + }, + }); + await started; + process.emit("SIGINT", "SIGINT"); + const result = await pending; + assert.ok(fs.existsSync(marker), "the grandchild never saw the interrupt"); + assert.notEqual(result.exitCode, 0); +}); diff --git a/tests/node/terragrunt/report/report.test.mjs b/tests/node/terragrunt/report/report.test.mjs new file mode 100644 index 0000000..fb8b428 --- /dev/null +++ b/tests/node/terragrunt/report/report.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + COMMENT_BUDGET, + UNIT_BUDGET, + escapeHtml, + fence, + isNoop, + linesOf, + parseRecord, + render, + streamLine, + stripWrapper, + trim, + unitOf, + verdict, +} from "../../../../actions/terragrunt/report/lib/report.mjs"; +import { FIXTURES, ROOT } from "./fixtures.mjs"; + +function report(name, extra = {}) { + return render({ lines: FIXTURES[name], root: ROOT, title: "Plan", ...extra }).markdown; +} + +test("parseRecord takes objects only", () => { + assert.deepEqual(parseRecord('{"level":"stdout"}'), { level: "stdout" }); + assert.equal(parseRecord("[1, 2]"), null); + assert.equal(parseRecord("null"), null); + assert.equal(parseRecord("Run Summary"), null); +}); + +test("unitOf cuts the root off", () => { + assert.equal(unitOf({ "working-dir": `${ROOT}/apps/web` }, ROOT), "apps/web"); + assert.equal(unitOf({ "working-dir": ROOT }, ROOT), "."); + assert.equal(unitOf({}, ROOT), ""); +}); + +test("unitOf leaves a directory outside the root whole", () => { + assert.equal(unitOf({ "working-dir": "/elsewhere/terraform/unit" }, ROOT), "/elsewhere/terraform/unit"); + assert.equal(unitOf({ "working-dir": `${ROOT}-other/unit` }, ROOT), `${ROOT}-other/unit`); +}); + +test("linesOf drops one terminating newline and reads empty as nothing", () => { + assert.deepEqual(linesOf({ msg: "a\nb\n" }), ["a", "b"]); + assert.deepEqual(linesOf({ msg: "a\n\n" }), ["a", ""]); + assert.deepEqual(linesOf({ msg: "" }), []); + assert.deepEqual(linesOf({ msg: "\n" }), []); + assert.deepEqual(linesOf({ msg: 42 }), []); +}); + +test("streamLine tags each line with its unit", () => { + const line = JSON.stringify({ level: "stdout", "working-dir": `${ROOT}/dns`, msg: "one\ntwo\n" }); + assert.equal(streamLine(line, ROOT), "[dns] one\n[dns] two"); +}); + +test("streamLine passes a line that is not a record through", () => { + assert.equal(streamLine("❯❯ Run Summary", ROOT), "❯❯ Run Summary"); +}); + +test("trim drops the lock, the refresh and the trailer", () => { + const body = [ + "Acquiring state lock. This may take a few moments...", + "x.y: Refreshing state... [id=1]", + "x.z: Reading...", + "x.z: Read complete after 0s [id=z]", + "", + "", + " + create", + "─────", + "Plan: 1 to add, 0 to change, 0 to destroy.", + "", + "Note: You didn't use the -out option to save this plan", + "anything after the note", + ].join("\n"); + assert.equal(trim(body), " + create\n\nPlan: 1 to add, 0 to change, 0 to destroy."); +}); + +test("trim keeps what an apply did", () => { + const body = "x.y: Creating...\nx.y: Creation complete after 1s [id=y]"; + assert.equal(trim(body), body); +}); + +test("stripWrapper leaves only the diagnostic", () => { + const text = [ + "tofu invocation failed in ./db", + "", + "Error: access denied", + "Module ./db has finished with an error", + ].join("\n"); + assert.equal(stripWrapper(text), "Error: access denied"); +}); + +test("stripWrapper is empty when a unit only failed on a dependency", () => { + assert.equal( + stripWrapper("Dependency ./db of module ./app just finished with an error. Module ./app will have to return an error too."), + "", + ); +}); + +test("verdict reports an apply by its result, not its plan", () => { + const body = "Plan: 2 to add, 0 to change, 0 to destroy.\nApply complete! Resources: 1 added, 0 changed, 0 destroyed."; + assert.equal(verdict(body), "1 added, 0 changed, 0 destroyed"); +}); + +test("verdict reads the plan line", () => { + assert.equal(verdict("Plan: 1 to add, 2 to change, 3 to destroy."), "1 to add, 2 to change, 3 to destroy"); +}); + +test("verdict names the quiet outcomes", () => { + assert.equal(verdict("Changes to Outputs:\n + x = 1"), "outputs only"); + assert.equal(verdict("No changes. Your infrastructure matches the configuration."), "no changes"); + assert.equal(verdict(""), "no output"); +}); + +test("isNoop covers every way of saying nothing happened", () => { + for (const value of ["no changes", "no output", "0 to add, 0 to change, 0 to destroy", "0 added, 0 changed, 0 destroyed"]) { + assert.ok(isNoop(value), value); + } + for (const value of ["outputs only", "failed", "1 to add, 0 to change, 0 to destroy"]) { + assert.ok(!isNoop(value), value); + } +}); + +test("fence outgrows the longest backtick run", () => { + assert.equal(fence("plain"), "```"); + assert.equal(fence("a ``` b"), "````"); + assert.equal(fence("a ````` b"), "``````"); +}); + +test("a run with nothing to change is one line and a collapsed table", () => { + assert.equal( + report("no-changes"), + [ + "### Plan", + "", + "Every unit matches its configuration.", + "", + "
Unchanged", + "", + "| Unit | Change |", + "| --- | --- |", + "| `dns` | no changes |", + "| `network` | no changes |", + "", + "
", + "", + ].join("\n"), + ); +}); + +test("a changed unit gets a row and its diff, without the noise", () => { + const markdown = report("changes"); + assert.match(markdown, /\| `apps\/web` \| 1 to add, 0 to change, 0 to destroy \|/); + assert.match(markdown, /
apps\/web<\/code> · 1 to add/); + assert.match(markdown, /name = "web 🚀"/); + assert.doesNotMatch(markdown, /Refreshing state|Read complete|Note: You didn't/); + assert.doesNotMatch(markdown, /not JSON/); +}); + +test("an apply is reported by what it did", () => { + const markdown = report("apply"); + assert.match(markdown, /\| `apps\/api` \| 1 added, 0 changed, 0 destroyed \|/); + assert.match(markdown, /Creation complete after 2s/); + assert.match(markdown, /Unchanged<\/summary>[\s\S]*`apps\/idle` \| 0 added, 0 changed, 0 destroyed/); +}); + +test("an outputs-only unit is shown, not collapsed", () => { + assert.match(report("outputs-only"), /\| `shared` \| outputs only \|/); +}); + +test("a failed unit carries its diagnostics and the run root is not a unit", () => { + const markdown = report("failed"); + assert.match(markdown, /^\*\*Failed\.\*\*/m); + assert.match(markdown, /\| `db` \| failed \|/); + assert.match(markdown, /\| `apps\/worker` \| failed \|/); + assert.match(markdown, /Error: creating example_thing\.cluster: access denied/); + assert.doesNotMatch(markdown, /apps\/worker<\/code>/); + assert.doesNotMatch(markdown, /`\.`/); + assert.doesNotMatch(markdown, /error occurred/); + assert.doesNotMatch(markdown, /tofu invocation failed/); +}); + +test("a unit that failed before tofu wrote anything falls back to its error records", () => { + const markdown = report("error-only"); + assert.match(markdown, /\| `broken` \| failed \|/); + assert.match(markdown, /Error: Unclosed configuration block/); + assert.doesNotMatch(markdown, /Module \.\/broken has finished/); +}); + +test("escapeHtml neutralises what would open or break a tag", () => { + assert.equal(escapeHtml("a&c"), "a<b>&c"); +}); + +test("a unit name cannot break the summary it is written into", () => { + const msg = "\nexample_thing.x will be created\n\nPlan: 1 to add, 0 to change, 0 to destroy.\n"; + const lines = [JSON.stringify({ level: "stdout", "working-dir": `${ROOT}/a&c`, msg })]; + const { markdown } = render({ lines, root: ROOT, title: "Plan" }); + assert.match(markdown, /a<b>&c<\/code>/); + assert.doesNotMatch(markdown, /a/); +}); + +test("backticks in a diff cannot close the fence", () => { + const markdown = report("backticks"); + assert.match(markdown, /^`````text$/m); + assert.match(markdown, /^`````$/m); +}); + +test("one large unit is cut to its own budget", () => { + const markdown = report("unit-budget"); + assert.match(markdown, /\.\.\. this unit is longer than the comment allows; read it in the job summary/); + assert.match(markdown, /small<\/code>/); + assert.ok(markdown.length < UNIT_BUDGET * 2); +}); + +test("units past the comment budget keep their row and lose their diff", () => { + const markdown = report("total-budget"); + assert.match(markdown, /_Some units are left out of this comment for length\. The job summary has all of them\._/); + for (const unit of ["a", "b", "c", "d", "e", "f"]) { + assert.match(markdown, new RegExp(`\\| \`stack-${unit}\` \\|`)); + } + assert.ok((markdown.match(/
/g) ?? []).length < 6); + assert.ok(markdown.length < COMMENT_BUDGET + 2000); +}); + +test("output that is not Terragrunt's says so and counts no records", () => { + const { markdown, records } = render({ lines: FIXTURES["not-terragrunt"], root: ROOT, title: "Plan" }); + assert.equal(records, 0); + assert.match(markdown, /No unit produced any output\. The run stopped before tofu started\./); +}); + +test("a run that failed before any unit shows the run's own error", () => { + assert.equal( + report("root-failure"), + [ + "### Plan", + "", + "**Failed.**", + "", + "````text", + "Error reading file at path /work/infra/root.hcl: ```unclosed``` block", + "````", + "", + "", + ].join("\n"), + ); +}); + +test("a malformed record does not take the report down", () => { + const markdown = report("outside"); + assert.match(markdown, /`\/elsewhere\/unit` \| no changes/); + assert.match(markdown, /`odd` \| no output/); +}); + +test("the preamble sits under the title", () => { + assert.match(report("no-changes", { preamble: "Dev account." }), /^### Plan\n\nDev account\.\n\nEvery unit/); +});