Files
SysAdminAutomation/log_rotate.sh
T
jcoffey-dev 5d417f3c4c Change the copyright holder to Coffey Labs
The scripts were attributed to LINUXexpert.org, which is being retired as a
site and is no longer where this work lives. Coffey Labs is the organisation
these projects belong to.

One line per script, fifteen of them, and nothing else. LICENSE is deliberately
untouched: its "Copyright (C) <year> <name of author>" lines are GPL boilerplate
showing you how to write your own notice, and the Free Software Foundation's
own copyright on the licence text is not ours to edit.

Both git contributors are the same person, so there is no third-party copyright
here that could not be restated.
2026-08-30 01:26:45 -07:00

138 lines
4.6 KiB
Bash

#!/bin/bash
# log_rotate.sh - Compress and remove old log files
#
# Copyright (C) 2025 Coffey Labs
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Usage: log_rotate.sh [--days N] [--purge-days N] [--dry-run] [--yes]
# --days N compress .log files older than N days (default 7)
# --purge-days N delete .gz archives older than N days (default: never)
# --dry-run show what would happen, change nothing
# --yes skip the confirmation prompt for --purge-days
# Description: Compresses old .log files under /var/log, and optionally
# deletes old .gz archives.
#
# NOTE: this is a convenience wrapper, not a logrotate replacement. It
# does not signal daemons or truncate in place, so it only touches files
# nothing currently holds open -- see the comment above compress_logs.
set -euo pipefail
DAYS=7
PURGE_DAYS=""
DRY_RUN=false
ASSUME_YES=false
LOG_DIR=/var/log
while [ $# -gt 0 ]; do
case "$1" in
--days)
[ "${2:-}" ] && [[ "$2" =~ ^[0-9]+$ ]] || { echo "--days needs a number" >&2; exit 1; }
DAYS="$2"; shift ;;
--purge-days)
[ "${2:-}" ] && [[ "$2" =~ ^[0-9]+$ ]] || { echo "--purge-days needs a number" >&2; exit 1; }
PURGE_DAYS="$2"; shift ;;
--dry-run) DRY_RUN=true ;;
--yes) ASSUME_YES=true ;;
-h|--help) sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
[0-9]*)
# Backwards compatibility: the old interface was a bare day count.
DAYS="$1" ;;
*) echo "Unknown option: $1 (try --help)" >&2; exit 1 ;;
esac
shift
done
if [ "$EUID" -ne 0 ]; then
echo "Please run as root to rotate system logs." >&2
exit 1
fi
# Compressing a log a daemon still holds open loses data: gzip writes
# the .gz and unlinks the original, but the daemon keeps writing to the
# now-unlinked inode, and those writes are unreachable. Real logrotate
# avoids this with copytruncate or a postrotate signal. We have neither,
# so skip any file that is currently open and leave it for logrotate.
file_is_open() {
command -v lsof >/dev/null 2>&1 || return 1
lsof -- "$1" >/dev/null 2>&1
}
compress_logs() {
local count=0 skipped=0 f
echo "Compressing *.log older than $DAYS days under $LOG_DIR..."
while IFS= read -r -d '' f; do
if file_is_open "$f"; then
echo " skip (open by a running process): $f"
skipped=$((skipped + 1))
continue
fi
if [ "$DRY_RUN" = true ]; then
echo " would compress: $f"
else
gzip -- "$f" && echo " compressed: $f"
fi
count=$((count + 1))
done < <(find "$LOG_DIR" -type f -name '*.log' -mtime +"$DAYS" ! -name '*.gz' -print0)
echo "Compressed $count file(s); skipped $skipped still open."
if ! command -v lsof >/dev/null 2>&1; then
echo "WARNING: lsof not installed -- could not check whether files were open." >&2
fi
}
purge_archives() {
local list count
list="$(find "$LOG_DIR" -type f -name '*.gz' -mtime +"$PURGE_DAYS" -print)"
count="$(printf '%s' "$list" | grep -c . || true)"
if [ "$count" -eq 0 ]; then
echo "No .gz archives older than $PURGE_DAYS days."
return 0
fi
echo "$count archive(s) older than $PURGE_DAYS days:"
printf '%s\n' "$list" | head -n 10 | sed 's/^/ /'
[ "$count" -gt 10 ] && echo " ... and $((count - 10)) more"
if [ "$DRY_RUN" = true ]; then
echo "DRY RUN: nothing deleted."
return 0
fi
if [ "$ASSUME_YES" != true ]; then
if [ ! -t 0 ]; then
echo "Refusing to delete without confirmation; pass --yes for unattended runs." >&2
exit 1
fi
read -r -p "Permanently delete these $count archive(s)? (yes/NO): " reply
[ "$reply" = "yes" ] || { echo "Cancelled."; return 0; }
fi
printf '%s\n' "$list" | while IFS= read -r f; do
[ -n "$f" ] && rm -f -- "$f"
done
echo "Deleted $count archive(s)."
}
compress_logs
# Deletion is opt-in. This previously ran unconditionally at 90 days,
# with no flag, no dry-run and no confirmation -- which quietly destroyed
# archives on hosts with longer retention requirements.
if [ -n "$PURGE_DAYS" ]; then
purge_archives
else
echo "No --purge-days given; existing .gz archives left alone."
fi