diff --git a/log_rotate.sh b/log_rotate.sh index f267c81..d3dba0d 100644 --- a/log_rotate.sh +++ b/log_rotate.sh @@ -15,24 +15,123 @@ # You should have received a copy of the GNU General Public License along # with this program. If not, see . # -# Usage: log_rotate.sh [days] -# days: rotate logs older than this many days (default 7). -# Description: Compresses .log files older than X days in /var/log and deletes archives older than 90 days. - -DAYS="$1" -if ! [[ "$DAYS" =~ ^[0-9]+$ ]]; then - DAYS=7 -fi -if [ $EUID -ne 0 ]; then - echo "Please run as root to rotate system logs." +# 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 -echo "Rotating logs older than $DAYS days..." -# Compress uncompressed .log files older than $DAYS days -find /var/log -type f -name "*.log" -mtime +$DAYS ! -name "*.gz" -exec gzip {} \; -echo "Compressed logs older than $DAYS days." +# 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 +} -# Remove very old compressed logs (older than 90 days) -find /var/log -type f -name "*.gz" -mtime +90 -exec rm -f {} \; -echo "Removed log archives older than 90 days." +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