Stop log_rotate destroying logs that are still in use

Two problems, both data loss.

It gzipped any *.log older than the threshold. gzip writes the .gz and
unlinks the original, so a daemon holding that file open keeps writing
to an unlinked inode and those writes become unreachable -- the exact
failure real logrotate avoids with copytruncate or a postrotate signal.
We can do neither from here, so files currently held open are now
skipped and left for logrotate, and the run says so. If lsof is missing
the check cannot run, and that is reported rather than assumed safe.

It also deleted every .gz older than a hardcoded 90 days, on every run,
with no flag, no dry-run and no confirmation -- destroying archives on
any host with longer retention. Deletion is now opt-in via --purge-days,
prints what it will remove, and needs an interactive confirmation or
--yes. Without a tty and without --yes it refuses instead of proceeding.

Adds --dry-run, --days, -h, and set -euo pipefail. A bare numeric first
argument still works, so existing `log_rotate.sh 14` callers and cron
entries are unaffected.

Verified against a fixture directory: old logs compressed and recent
ones left alone, archives surviving when --purge-days is absent, purge
refusing non-interactively without --yes, and a file held open by a
running process skipped rather than compressed.
This commit is contained in:
2026-08-22 22:12:31 -07:00
parent db19bef328
commit 9be14e9670
+115 -16
View File
@@ -15,24 +15,123 @@
# You should have received a copy of the GNU General Public License along # You should have received a copy of the GNU General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>. # with this program. If not, see <https://www.gnu.org/licenses/>.
# #
# Usage: log_rotate.sh [days] # Usage: log_rotate.sh [--days N] [--purge-days N] [--dry-run] [--yes]
# days: rotate logs older than this many days (default 7). # --days N compress .log files older than N days (default 7)
# Description: Compresses .log files older than X days in /var/log and deletes archives older than 90 days. # --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.
DAYS="$1" set -euo pipefail
if ! [[ "$DAYS" =~ ^[0-9]+$ ]]; then
DAYS=7 DAYS=7
fi PURGE_DAYS=""
if [ $EUID -ne 0 ]; then DRY_RUN=false
echo "Please run as root to rotate system logs." 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 exit 1
fi fi
echo "Rotating logs older than $DAYS days..." # Compressing a log a daemon still holds open loses data: gzip writes
# Compress uncompressed .log files older than $DAYS days # the .gz and unlinks the original, but the daemon keeps writing to the
find /var/log -type f -name "*.log" -mtime +$DAYS ! -name "*.gz" -exec gzip {} \; # now-unlinked inode, and those writes are unreachable. Real logrotate
echo "Compressed logs older than $DAYS days." # 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) compress_logs() {
find /var/log -type f -name "*.gz" -mtime +90 -exec rm -f {} \; local count=0 skipped=0 f
echo "Removed log archives older than 90 days." 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