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:
+115
-16
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user