Files
linux-scripts/terraform-lint-reporter.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

589 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
#########################################################################################
#### terraform-lint-reporter.sh — Lint and plan Terraform configs with formatted ####
#### reports. Wraps tflint and terraform plan with severity levels and CI exit codes ####
#### Requires: bash 4+, terraform, tflint ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.01 ####
#### ####
#### Usage: ####
#### ./terraform-lint-reporter.sh --full --dir ./infra ####
#### ####
#### See --help for all options. ####
#########################################################################################
# v1.01 changes:
# - Fixed: ((0++)) returns 1 under set -e; added || true guards
# - Fixed: grep in pipeline crashes under set -euo pipefail when no matches found. Added || true guard
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
RUN_MODE=""
TF_DIR="${TF_DIR:-.}"
TFLINT_PATH="${TFLINT_PATH:-tflint}"
TF_PATH="${TF_PATH:-terraform}"
OUTPUT_FORMAT="${TF_REPORT_FORMAT:-text}"
OUTPUT_FILE=""
FAIL_ON="${TF_FAIL_ON:-error}"
AUTO_INIT="${TF_AUTO_INIT:-true}"
PLAN_FILE=""
VAR_FILE=""
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
START_TIME=""
LINT_WARNINGS=0
LINT_ERRORS=0
PLAN_ADDS=0
PLAN_CHANGES=0
PLAN_DESTROYS=0
TMPDIR_WORK=""
RESULTS_JSON="[]"
# ── Colors ────────────────────────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
return
fi
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
fi
}
# ── Logging ───────────────────────────────────────────────────────────
log() { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; }
die() { err "$*"; exit 1; }
# ── Helpers ───────────────────────────────────────────────────────────
section_header() {
echo ""
echo -e " ${BOLD}${CYAN}── $1 ──${RESET}"
echo ""
}
field() {
printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2"
}
field_color() {
printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2"
}
elapsed() {
local end_time
end_time=$(date +%s)
echo "$(( end_time - START_TIME ))s"
}
cleanup() {
if [[ -n "$TMPDIR_WORK" && -d "$TMPDIR_WORK" ]]; then
rm -rf "$TMPDIR_WORK"
fi
}
trap cleanup EXIT
make_tmpdir() {
if [[ -z "$TMPDIR_WORK" ]]; then
TMPDIR_WORK=$(mktemp -d "${TMPDIR:-/tmp}/tf-lint-reporter.XXXXXX")
fi
}
# ── Dependency checks ────────────────────────────────────────────────
require_terraform() {
if ! command -v "$TF_PATH" &>/dev/null; then
die "Terraform binary not found: ${TF_PATH}"
fi
verbose "Terraform found: $(command -v "$TF_PATH") ($("$TF_PATH" version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || "$TF_PATH" version | head -1))"
}
require_tflint() {
if ! command -v "$TFLINT_PATH" &>/dev/null; then
die "tflint binary not found: ${TFLINT_PATH} — install from https://github.com/terraform-linters/tflint"
fi
verbose "tflint found: $(command -v "$TFLINT_PATH") ($("$TFLINT_PATH" --version 2>/dev/null || echo 'unknown'))"
}
# ── Lint ─────────────────────────────────────────────────────────────
do_lint() {
require_tflint
START_TIME=$(date +%s)
section_header "Terraform Lint (tflint)"
field "Directory:" "$TF_DIR"
echo ""
local lint_output exit_code=0
verbose "Running: ${TFLINT_PATH} --format json --chdir ${TF_DIR}"
lint_output=$("$TFLINT_PATH" --format json --chdir "$TF_DIR" 2>&1) || exit_code=$?
local issues_count=0
if command -v jq &>/dev/null && echo "$lint_output" | jq empty 2>/dev/null; then
issues_count=$(echo "$lint_output" | jq '[.issues // [] | .[] ] | length' 2>/dev/null || echo 0)
if [[ "$issues_count" -gt 0 ]]; then
printf " ${BOLD}%-10s %-30s %-8s %s${RESET}\n" "SEVERITY" "RULE" "LINE" "MESSAGE"
printf " %-10s %-30s %-8s %s\n" "----------" "------------------------------" "--------" "$(printf '%0.s-' {1..30})"
echo "$lint_output" | jq -r '.issues[] | [.rule.severity, .rule.name, (.range.filename + ":" + (.range.start.line | tostring)), .message] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r sev rule loc msg; do
local color="$RESET"
case "$sev" in
error) color="$RED"; ((LINT_ERRORS++)) || true ;;
warning) color="$YELLOW"; ((LINT_WARNINGS++)) || true ;;
notice) color="$DIM" ;;
esac
printf " ${color}%-10s${RESET} %-30s %-8s %s\n" "${sev^^}" "$rule" "$loc" "$msg"
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg s "$sev" --arg r "$rule" \
--arg l "$loc" --arg m "$msg" \
'. + [{"type":"lint","severity":$s,"rule":$r,"location":$l,"message":$m}]')
done
fi
else
while IFS= read -r line; do
if [[ "$line" =~ (Error|error) ]]; then
((LINT_ERRORS++)) || true
echo -e " ${RED}${line}${RESET}"
elif [[ "$line" =~ (Warning|warning) ]]; then
((LINT_WARNINGS++)) || true
echo -e " ${YELLOW}${line}${RESET}"
elif [[ -n "$line" ]]; then
echo " $line"
fi
done <<< "$lint_output"
fi
local tf_files=0
if [[ -d "$TF_DIR" ]]; then
tf_files=$(find "$TF_DIR" -maxdepth 1 -name '*.tf' | wc -l | tr -d ' ')
fi
section_header "Lint Summary"
field "Files checked:" "$tf_files"
field_color "Errors:" "${RED}${LINT_ERRORS}${RESET}"
field_color "Warnings:" "${YELLOW}${LINT_WARNINGS}${RESET}"
field "Duration:" "$(elapsed)"
}
# ── Plan ─────────────────────────────────────────────────────────────
do_plan() {
require_terraform
START_TIME=$(date +%s)
section_header "Terraform Plan"
field "Directory:" "$TF_DIR"
field "Auto-init:" "$AUTO_INIT"
[[ -n "$VAR_FILE" ]] && field "Var file:" "$VAR_FILE"
echo ""
if [[ "$AUTO_INIT" == "true" ]]; then
log "Running terraform init..."
verbose "Running: ${TF_PATH} -chdir=${TF_DIR} init -input=false -no-color"
if ! "$TF_PATH" -chdir="$TF_DIR" init -input=false -no-color >/dev/null 2>&1; then
die "terraform init failed in ${TF_DIR}"
fi
log "Init complete"
fi
make_tmpdir
local plan_out="${TMPDIR_WORK}/tfplan"
local plan_args=(-detailed-exitcode -no-color -out="$plan_out")
[[ -n "$VAR_FILE" ]] && plan_args+=(-var-file="$VAR_FILE")
local plan_output plan_exit=0
verbose "Running: ${TF_PATH} -chdir=${TF_DIR} plan ${plan_args[*]}"
plan_output=$("$TF_PATH" -chdir="$TF_DIR" plan "${plan_args[@]}" 2>&1) || plan_exit=$?
verbose "terraform plan exit code: ${plan_exit}"
if [[ $plan_exit -eq 1 ]]; then
err "terraform plan failed:"
echo "$plan_output" | tail -20
exit 1
fi
if echo "$plan_output" | grep -qE 'Plan: [0-9]+ to add'; then
local plan_line
plan_line=$(echo "$plan_output" | { grep -oE 'Plan: [0-9]+ to add, [0-9]+ to change, [0-9]+ to destroy' || true; })
if [[ -n "$plan_line" ]]; then
PLAN_ADDS=$(echo "$plan_line" | { grep -oE '[0-9]+ to add' || true; } | { grep -oE '[0-9]+' || true; })
PLAN_CHANGES=$(echo "$plan_line" | { grep -oE '[0-9]+ to change' || true; } | { grep -oE '[0-9]+' || true; })
PLAN_DESTROYS=$(echo "$plan_line" | { grep -oE '[0-9]+ to destroy' || true; } | { grep -oE '[0-9]+' || true; })
fi
fi
if [[ -n "$PLAN_FILE" ]]; then
cp "$plan_out" "$PLAN_FILE"
log "Plan file saved to ${PLAN_FILE}"
fi
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq \
--argjson a "$PLAN_ADDS" --argjson c "$PLAN_CHANGES" --argjson d "$PLAN_DESTROYS" \
'. + [{"type":"plan","adds":$a,"changes":$c,"destroys":$d}]')
section_header "Plan Summary"
if [[ $plan_exit -eq 0 ]]; then
echo -e " ${GREEN}No changes.${RESET} Infrastructure is up-to-date."
else
field_color "Resources to add:" "${GREEN}${PLAN_ADDS}${RESET}"
field_color "Resources to change:" "${YELLOW}${PLAN_CHANGES}${RESET}"
field_color "Resources to destroy:" "${RED}${PLAN_DESTROYS}${RESET}"
fi
field "Duration:" "$(elapsed)"
}
# ── Full ─────────────────────────────────────────────────────────────
do_full() {
START_TIME=$(date +%s)
local full_start
full_start=$(date +%s)
do_lint
echo ""
do_plan
section_header "Combined Summary"
field_color "Lint errors:" "${RED}${LINT_ERRORS}${RESET}"
field_color "Lint warnings:" "${YELLOW}${LINT_WARNINGS}${RESET}"
field_color "Plan adds:" "${GREEN}${PLAN_ADDS}${RESET}"
field_color "Plan changes:" "${YELLOW}${PLAN_CHANGES}${RESET}"
field_color "Plan destroys:" "${RED}${PLAN_DESTROYS}${RESET}"
local total_elapsed=$(( $(date +%s) - full_start ))
field "Total duration:" "${total_elapsed}s"
}
# ── Fmt ──────────────────────────────────────────────────────────────
do_fmt() {
require_terraform
START_TIME=$(date +%s)
section_header "Terraform Format Check"
field "Directory:" "$TF_DIR"
echo ""
local fmt_output exit_code=0
verbose "Running: ${TF_PATH} fmt -check -diff -recursive ${TF_DIR}"
fmt_output=$("$TF_PATH" fmt -check -diff -recursive "$TF_DIR" 2>&1) || exit_code=$?
local unformatted=0
local unformatted_files=()
if [[ $exit_code -ne 0 && -n "$fmt_output" ]]; then
while IFS= read -r line; do
if [[ "$line" =~ \.tf$ ]]; then
((unformatted++)) || true
unformatted_files+=("$line")
echo -e " ${YELLOW}needs formatting:${RESET} ${line}"
fi
done <<< "$fmt_output"
fi
section_header "Format Summary"
if [[ $unformatted -eq 0 ]]; then
echo -e " ${GREEN}${RESET} All files are properly formatted"
else
field_color "Files needing format:" "${YELLOW}${unformatted}${RESET}"
echo ""
echo -e " Run ${BOLD}${TF_PATH} fmt -recursive ${TF_DIR}${RESET} to fix"
fi
field "Duration:" "$(elapsed)"
for f in "${unformatted_files[@]+"${unformatted_files[@]}"}"; do
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg f "$f" \
'. + [{"type":"fmt","file":$f,"message":"needs formatting"}]')
done
}
# ── Validate ─────────────────────────────────────────────────────────
do_validate() {
require_terraform
START_TIME=$(date +%s)
section_header "Terraform Validate"
field "Directory:" "$TF_DIR"
echo ""
if [[ "$AUTO_INIT" == "true" ]]; then
log "Running terraform init -backend=false..."
if ! "$TF_PATH" -chdir="$TF_DIR" init -backend=false -input=false -no-color >/dev/null 2>&1; then
die "terraform init failed in ${TF_DIR}"
fi
fi
local validate_output exit_code=0
verbose "Running: ${TF_PATH} -chdir=${TF_DIR} validate -json"
validate_output=$("$TF_PATH" -chdir="$TF_DIR" validate -json 2>&1) || exit_code=$?
local valid diag_count
valid=$(echo "$validate_output" | jq -r '.valid' 2>/dev/null || echo "false")
diag_count=$(echo "$validate_output" | jq '[.diagnostics // [] | .[]] | length' 2>/dev/null || echo 0)
if [[ "$valid" == "true" ]]; then
echo -e " ${GREEN}${RESET} Configuration is valid"
else
echo -e " ${RED}${RESET} Configuration has errors"
echo ""
if [[ "$diag_count" -gt 0 ]]; then
echo "$validate_output" | jq -r '.diagnostics[] | [.severity, .summary, .detail] | @tsv' 2>/dev/null | \
while IFS=$'\t' read -r sev summary detail; do
local color="$RESET"
case "$sev" in
error) color="$RED" ;;
warning) color="$YELLOW" ;;
esac
echo -e " ${color}[${sev^^}]${RESET} ${summary}"
[[ -n "$detail" ]] && echo " $detail"
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg s "$sev" \
--arg m "$summary" --arg d "$detail" \
'. + [{"type":"validate","severity":$s,"summary":$m,"detail":$d}]')
done
fi
fi
section_header "Validation Summary"
field "Valid:" "$valid"
field "Diagnostics:" "$diag_count"
field "Duration:" "$(elapsed)"
}
# ── Output formats ───────────────────────────────────────────────────
write_output() {
[[ -z "$OUTPUT_FILE" ]] && return
case "$OUTPUT_FORMAT" in
json) write_json ;;
junit) write_junit ;;
text) write_text ;;
*) die "Unknown output format: ${OUTPUT_FORMAT}" ;;
esac
log "Results written to ${OUTPUT_FILE}"
}
write_json() {
jq -n --argjson results "$RESULTS_JSON" \
--argjson lint_errors "$LINT_ERRORS" \
--argjson lint_warnings "$LINT_WARNINGS" \
--argjson plan_adds "$PLAN_ADDS" \
--argjson plan_changes "$PLAN_CHANGES" \
--argjson plan_destroys "$PLAN_DESTROYS" \
--arg dir "$TF_DIR" \
--arg mode "$RUN_MODE" \
'{
mode: $mode,
directory: $dir,
summary: {
lint_errors: $lint_errors,
lint_warnings: $lint_warnings,
plan_adds: $plan_adds,
plan_changes: $plan_changes,
plan_destroys: $plan_destroys
},
results: $results
}' > "$OUTPUT_FILE"
}
write_junit() {
local total_tests failures
total_tests=$(echo "$RESULTS_JSON" | jq '[.[] | select(.type == "lint")] | length' 2>/dev/null || echo 0)
failures=$LINT_ERRORS
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo "<testsuites tests=\"${total_tests}\" failures=\"${failures}\">"
echo " <testsuite name=\"terraform-lint-reporter\" tests=\"${total_tests}\" failures=\"${failures}\">"
echo "$RESULTS_JSON" | jq -r '.[] | select(.type == "lint") | @base64' 2>/dev/null | while read -r entry; do
local sev rule loc msg
sev=$(echo "$entry" | base64 -d | jq -r '.severity')
rule=$(echo "$entry" | base64 -d | jq -r '.rule')
loc=$(echo "$entry" | base64 -d | jq -r '.location')
msg=$(echo "$entry" | base64 -d | jq -r '.message')
if [[ "$sev" == "error" ]]; then
echo " <testcase name=\"${rule}\" classname=\"${loc}\">"
echo " <failure message=\"${msg}\" type=\"${sev}\"/>"
echo " </testcase>"
else
echo " <testcase name=\"${rule}\" classname=\"${loc}\"/>"
fi
done
echo " </testsuite>"
echo "</testsuites>"
} > "$OUTPUT_FILE"
}
write_text() {
{
echo "Terraform Lint Reporter Results"
echo "==============================="
echo ""
echo "Mode: ${RUN_MODE}"
echo "Directory: ${TF_DIR}"
echo ""
echo "$RESULTS_JSON" | jq -r '.[] | if .type == "lint" then
"[\(.severity | ascii_upcase)] \(.rule) at \(.location): \(.message)"
elif .type == "plan" then
"Plan: \(.adds) to add, \(.changes) to change, \(.destroys) to destroy"
elif .type == "fmt" then
"[FMT] \(.file): \(.message)"
elif .type == "validate" then
"[\(.severity | ascii_upcase)] \(.summary)"
else empty end' 2>/dev/null || true
echo ""
echo "Lint Errors: ${LINT_ERRORS} Warnings: ${LINT_WARNINGS}"
echo "Plan: +${PLAN_ADDS} ~${PLAN_CHANGES} -${PLAN_DESTROYS}"
} > "$OUTPUT_FILE"
}
# ── Compute exit code ────────────────────────────────────────────────
compute_exit() {
case "$FAIL_ON" in
warning)
if [[ $LINT_ERRORS -gt 0 || $LINT_WARNINGS -gt 0 ]]; then
exit 2
fi
if [[ $PLAN_DESTROYS -gt 0 || $PLAN_CHANGES -gt 0 ]]; then
exit 2
fi
;;
error)
if [[ $LINT_ERRORS -gt 0 ]]; then
exit 2
fi
if [[ $PLAN_DESTROYS -gt 0 ]]; then
exit 2
fi
;;
esac
}
# ── Usage ─────────────────────────────────────────────────────────────
show_help() {
cat <<EOF
${BOLD}${SCRIPT_NAME}${RESET} — Terraform Lint & Plan Reporter
Lint and plan Terraform configurations with formatted reports,
severity levels, and CI-friendly exit codes.
${BOLD}MODES${RESET}
--lint Run tflint on Terraform directory
--plan Run terraform init + plan, report resource changes
--full Run lint then plan, combined report
--fmt Check formatting with terraform fmt
--validate Run terraform validate, report diagnostics
${BOLD}OPTIONS${RESET}
--dir DIR Terraform directory (default: .)
--var-file FILE Terraform var file for plan
--plan-out FILE Save plan binary to file
--format FORMAT Output format: text, json, junit (default: text)
--output-file FILE Write results to file
--fail-on LEVEL Fail threshold: warning or error (default: error)
--no-init Skip automatic terraform init
--verbose Show debug output
--no-color Disable colored output
--help Show this help message
${BOLD}ENVIRONMENT VARIABLES${RESET}
TF_DIR Default Terraform directory
TF_PATH Path to terraform binary (default: terraform)
TFLINT_PATH Path to tflint binary (default: tflint)
TF_REPORT_FORMAT Default output format
TF_FAIL_ON Default fail threshold
TF_AUTO_INIT Auto-run terraform init (default: true)
VERBOSE Enable verbose output (true/false)
COLOR Color mode: auto, always, never
${BOLD}EXAMPLES${RESET}
# Lint only
${SCRIPT_NAME} --lint --dir ./infra
# Plan with var file
${SCRIPT_NAME} --plan --dir ./infra --var-file prod.tfvars
# Full lint + plan, save JSON report
${SCRIPT_NAME} --full --dir ./infra --format json --output-file report.json
# Format check in CI
${SCRIPT_NAME} --fmt --dir ./modules
# Validate configuration
${SCRIPT_NAME} --validate --dir ./infra
# Strict CI pipeline (fail on warnings)
${SCRIPT_NAME} --full --dir ./infra --fail-on warning --format junit --output-file results.xml
${BOLD}EXIT CODES${RESET}
0 All checks passed
1 Runtime error
2 Lint errors or plan changes exceed threshold
EOF
}
# ── Parse arguments ──────────────────────────────────────────────────
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--lint) RUN_MODE="lint"; shift ;;
--plan) RUN_MODE="plan"; shift ;;
--full) RUN_MODE="full"; shift ;;
--fmt) RUN_MODE="fmt"; shift ;;
--validate) RUN_MODE="validate"; shift ;;
--dir) TF_DIR="${2:?--dir requires a path}"; shift 2 ;;
--var-file) VAR_FILE="${2:?--var-file requires a path}"; shift 2 ;;
--plan-out) PLAN_FILE="${2:?--plan-out requires a path}"; shift 2 ;;
--format) OUTPUT_FORMAT="${2:?--format requires a value}"; shift 2 ;;
--output-file) OUTPUT_FILE="${2:?--output-file requires a path}"; shift 2 ;;
--fail-on) FAIL_ON="${2:?--fail-on requires a value}"; shift 2 ;;
--no-init) AUTO_INIT="false"; shift ;;
--verbose) VERBOSE="true"; shift ;;
--no-color) COLOR="never"; shift ;;
--help|-h) setup_colors; show_help; exit 0 ;;
*) die "Unknown option: $1 (see --help)" ;;
esac
done
}
# ── Main ─────────────────────────────────────────────────────────────
main() {
parse_args "$@"
setup_colors
case "$RUN_MODE" in
lint) do_lint; write_output; compute_exit ;;
plan) do_plan; write_output; compute_exit ;;
full) do_full; write_output; compute_exit ;;
fmt) do_fmt; write_output; compute_exit ;;
validate) do_validate; write_output; compute_exit ;;
"") err "No mode specified"; echo ""; show_help; exit 1 ;;
*) die "Unknown mode: ${RUN_MODE}" ;;
esac
}
main "$@"