#!/usr/bin/env bash ############################################################################################ #### logrotate-smoke-tests.sh — Verify logrotate configs, rotation, and log hygiene #### #### Zero external dependencies. Read-only — never modifies configs or log files. #### #### Requires: bash 4+, coreutils #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### sudo ./logrotate-smoke-tests.sh #### #### #### #### See --help for all options. #### ############################################################################################ set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── LOG_DIR="${LOG_DIR:-/var/log}" MAX_LOG_SIZE_MB="${MAX_LOG_SIZE_MB:-500}" MAX_DISK_USAGE_PCT="${MAX_DISK_USAGE_PCT:-80}" MAX_ARCHIVE_AGE_DAYS="${MAX_ARCHIVE_AGE_DAYS:-90}" SKIP_SYNTAX="${SKIP_SYNTAX:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() START_TIME="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" 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' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" 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 "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_skip() { local name="$1" local reason="${2:-}" ((SKIP++)) || true ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}" fi } # ══════════════════════════════════════════════════════════════════════ # TEST SUITES # ══════════════════════════════════════════════════════════════════════ # ── 1. Binary & Config ─────────────────────────────────────────────── test_binary_and_config() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Binary & Config${RESET}" fi # 1. logrotate binary exists and is executable local lr_path lr_path=$(command -v logrotate 2>/dev/null) || lr_path="" if [[ -n "$lr_path" && -x "$lr_path" ]]; then record_pass "logrotate binary exists" "$lr_path" else record_fail "logrotate binary exists" "logrotate not found in PATH" fi # 2. /etc/logrotate.conf exists and is readable if [[ -r /etc/logrotate.conf ]]; then record_pass "/etc/logrotate.conf exists and is readable" else record_fail "/etc/logrotate.conf exists and is readable" "file missing or not readable" fi # 4. /etc/logrotate.d/ directory exists with configs if [[ -d /etc/logrotate.d ]]; then local conf_count conf_count=$(find /etc/logrotate.d -maxdepth 1 -type f 2>/dev/null | wc -l) if [[ "$conf_count" -gt 0 ]]; then record_pass "/etc/logrotate.d/ directory exists" "${conf_count} config files" else record_fail "/etc/logrotate.d/ directory exists" "directory exists but contains no config files" fi else record_fail "/etc/logrotate.d/ directory exists" "directory missing" fi } # ── 2. Config Syntax ───────────────────────────────────────────────── test_config_syntax() { if [[ "$SKIP_SYNTAX" == "true" ]]; then if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Config Syntax${RESET}" fi record_skip "logrotate.conf syntax valid" "SKIP_SYNTAX=true" return fi local lr_path lr_path=$(command -v logrotate 2>/dev/null) || lr_path="" if [[ -z "$lr_path" ]]; then if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Config Syntax${RESET}" fi record_skip "logrotate.conf syntax valid" "logrotate binary not found" return fi if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Config Syntax${RESET}" fi # 3. logrotate config syntax valid local syntax_output if syntax_output=$(logrotate -d /etc/logrotate.conf 2>&1); then record_pass "logrotate.conf syntax valid" verbose "logrotate -d output: ${syntax_output:0:200}" else record_fail "logrotate.conf syntax valid" "logrotate -d returned errors" verbose "logrotate -d output: ${syntax_output:0:500}" fi # 5. Individual config syntax check if [[ -d /etc/logrotate.d ]]; then local cfg for cfg in /etc/logrotate.d/*; do [[ -f "$cfg" ]] || continue local cfg_name cfg_name=$(basename "$cfg") local cfg_output if cfg_output=$(logrotate -d "$cfg" 2>&1); then record_pass "logrotate.d/${cfg_name}" "syntax valid" else record_fail "logrotate.d/${cfg_name}" "syntax error" verbose "logrotate -d ${cfg} output: ${cfg_output:0:500}" fi done fi } # ── 3. Log Hygiene ─────────────────────────────────────────────────── test_log_hygiene() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Log Hygiene${RESET}" fi # 6. Compressed log archives exist where expected local archive_count archive_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) 2>/dev/null | wc -l) if [[ "$archive_count" -gt 0 ]]; then record_pass "Compressed log archives exist" "${archive_count} archives found" else record_fail "Compressed log archives exist" "no .gz or .xz files found in ${LOG_DIR}" fi # 7. No oversized unrotated logs local oversized oversized=$(find "$LOG_DIR" -maxdepth 2 -type f -not -name '*.gz' -not -name '*.xz' \ -size +"${MAX_LOG_SIZE_MB}M" 2>/dev/null) || oversized="" if [[ -z "$oversized" ]]; then record_pass "No oversized unrotated logs" "all under ${MAX_LOG_SIZE_MB} MB" else local oversized_count oversized_count=$(echo "$oversized" | wc -l) local first_file first_file=$(echo "$oversized" | head -1) record_fail "No oversized unrotated logs" "${oversized_count} file(s) exceed ${MAX_LOG_SIZE_MB} MB (e.g. ${first_file})" fi # 8. Log directory permissions if [[ -d "$LOG_DIR" ]]; then local dir_owner dir_owner=$(stat -c '%U' "$LOG_DIR" 2>/dev/null) || dir_owner="unknown" local dir_perms dir_perms=$(stat -c '%a' "$LOG_DIR" 2>/dev/null) || dir_perms="000" local world_writable=false if [[ "${dir_perms: -1}" =~ [2367] ]]; then world_writable=true fi if [[ "$dir_owner" == "root" && "$world_writable" == "false" ]]; then record_pass "${LOG_DIR} permissions" "owner ${dir_owner}, mode ${dir_perms}" elif [[ "$dir_owner" != "root" ]]; then record_fail "${LOG_DIR} permissions" "owner is ${dir_owner}, expected root" else record_fail "${LOG_DIR} permissions" "world-writable (mode ${dir_perms})" fi else record_fail "${LOG_DIR} permissions" "directory does not exist" fi } # ── 4. Disk & Retention ────────────────────────────────────────────── test_disk_and_retention() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Disk & Retention${RESET}" fi # 9. /var/log disk usage under threshold local usage_pct usage_pct=$(df "$LOG_DIR" 2>/dev/null | awk 'NR==2 {gsub(/%/,""); print $5}') || usage_pct="" if [[ -n "$usage_pct" ]]; then if [[ "$usage_pct" -lt "$MAX_DISK_USAGE_PCT" ]]; then record_pass "${LOG_DIR} disk usage" "${usage_pct}% (under ${MAX_DISK_USAGE_PCT}% threshold)" else record_fail "${LOG_DIR} disk usage" "${usage_pct}% (exceeds ${MAX_DISK_USAGE_PCT}% threshold)" fi else record_skip "${LOG_DIR} disk usage" "could not determine disk usage" fi # 10. Stale .gz/.xz files older than retention threshold local stale_count stale_count=$(find "$LOG_DIR" -maxdepth 2 -type f \( -name '*.gz' -o -name '*.xz' \) \ -mtime +"${MAX_ARCHIVE_AGE_DAYS}" 2>/dev/null | wc -l) if [[ "$stale_count" -eq 0 ]]; then record_pass "No stale archives" "none older than ${MAX_ARCHIVE_AGE_DAYS} days" else record_fail "No stale archives" "${stale_count} archive(s) older than ${MAX_ARCHIVE_AGE_DAYS} days" fi # 11. logrotate status file exists and is recent local status_file="/var/lib/logrotate/logrotate.status" if [[ ! -f "$status_file" ]]; then # Some distros use /var/lib/logrotate.status status_file="/var/lib/logrotate.status" fi if [[ -f "$status_file" ]]; then local status_mtime status_mtime=$(stat -c '%Y' "$status_file" 2>/dev/null) || status_mtime=0 local now now=$(date +%s) local age_days=$(( (now - status_mtime) / 86400 )) if [[ "$age_days" -le 2 ]]; then local status_date status_date=$(date -d "@${status_mtime}" +%Y-%m-%d 2>/dev/null) || status_date="unknown" record_pass "logrotate status file" "updated ${status_date}" else record_fail "logrotate status file" "last updated ${age_days} days ago" fi else record_fail "logrotate status file" "not found at /var/lib/logrotate/logrotate.status or /var/lib/logrotate.status" fi } # ── 5. Common Misconfigs ───────────────────────────────────────────── test_misconfigs() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo "" echo -e "${BOLD}Common Misconfigs${RESET}" fi if [[ ! -d /etc/logrotate.d ]]; then record_skip "Config 'rotate' directive check" "/etc/logrotate.d not found" record_skip "Config 'compress' directive check" "/etc/logrotate.d not found" return fi # 12a. Check for missing 'rotate' directive local missing_rotate=() local cfg for cfg in /etc/logrotate.d/*; do [[ -f "$cfg" ]] || continue if ! grep -qE '^\s*rotate\s+' "$cfg" 2>/dev/null; then # Check if the main config has a global rotate if ! grep -qE '^\s*rotate\s+' /etc/logrotate.conf 2>/dev/null; then missing_rotate+=("$(basename "$cfg")") fi fi done if [[ ${#missing_rotate[@]} -eq 0 ]]; then record_pass "All configs have 'rotate' directive" else record_fail "All configs have 'rotate' directive" "missing in: ${missing_rotate[*]}" fi # 12b. Check for missing 'compress' directive local missing_compress=() for cfg in /etc/logrotate.d/*; do [[ -f "$cfg" ]] || continue if ! grep -qE '^\s*(compress|nocompress)' "$cfg" 2>/dev/null; then if ! grep -qE '^\s*compress' /etc/logrotate.conf 2>/dev/null; then missing_compress+=("$(basename "$cfg")") fi fi done if [[ ${#missing_compress[@]} -eq 0 ]]; then record_pass "All configs have 'compress' directive" else record_fail "All configs have 'compress' directive" "missing in: ${missing_compress[*]}" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_summary() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} ${LOG_DIR}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}" fi } print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } write_junit() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) cat > "$JUNIT_FILE" < JUNIT_EOF for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # XML-escape the values name=$(echo "$name" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') detail=$(echo "$detail" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; SKIP) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <