#!/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 ''
echo ""
echo " "
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 " "
echo " "
echo " "
else
echo " "
fi
done
echo " "
echo ""
} > "$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 <