#!/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 <