feat(rotate_logs): add a function to cleanup log dir
trunctuate big files rotate log groups remove old files
This commit is contained in:
1 parent
2d623bbff6
commit
18e1fc9325
1 file changed
+127
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
MAX_FILES_PER_GROUP = 10
|
||||
MAX_AGE_DAYS = 30
|
||||
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
TIMESTAMP_RE = re.compile(r"^(.*?)_(\d{8}_\d{6})\.log$")
|
||||
LOG_SUFFIX_RE = re.compile(r"^(.*?)\.log(?:_\d+)?$")
|
||||
|
||||
|
||||
def get_log_group(log_file: Path) -> str:
|
||||
"""
|
||||
Group examples:
|
||||
|
||||
ffmpeg_handling.log -> ffmpeg_handling
|
||||
ffmpeg_handling_20261027_193908.log -> ffmpeg_handling
|
||||
sonarr_translation_tool_*.log -> sonarr_translation_tool
|
||||
log_backup_102214.log -> log_backup
|
||||
"""
|
||||
|
||||
stem = log_file.stem
|
||||
|
||||
match = TIMESTAMP_RE.match(log_file.name)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
parts = stem.split("_")
|
||||
|
||||
while parts and (
|
||||
re.fullmatch(r"\d{6}", parts[-1])
|
||||
or re.fullmatch(r"\d{8}", parts[-1])
|
||||
or re.fullmatch(r"\d{8}_\d{6}", parts[-1])
|
||||
):
|
||||
parts.pop()
|
||||
|
||||
if parts:
|
||||
return "_".join(parts)
|
||||
|
||||
match = LOG_SUFFIX_RE.match(log_file.name)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return stem
|
||||
|
||||
|
||||
def truncate_first_half(file_path: Path) -> None:
|
||||
"""Remove the first half of a file, keeping the newest half."""
|
||||
|
||||
size = file_path.stat().st_size
|
||||
|
||||
if size <= MAX_SIZE_BYTES:
|
||||
return
|
||||
|
||||
with file_path.open("rb") as f:
|
||||
f.seek(size // 2)
|
||||
data = f.read()
|
||||
|
||||
newline_pos = data.find(b"\n")
|
||||
if newline_pos != -1:
|
||||
data = data[newline_pos + 1 :]
|
||||
|
||||
with file_path.open("wb") as f:
|
||||
f.write(data)
|
||||
|
||||
print(f"TRUNCATED {file_path.name}")
|
||||
|
||||
|
||||
def remove_old_logs(log_dir: Path) -> None:
|
||||
cutoff = datetime.now() - timedelta(days=MAX_AGE_DAYS)
|
||||
|
||||
for file in log_dir.glob("*.log"):
|
||||
if datetime.fromtimestamp(file.stat().st_mtime) < cutoff:
|
||||
print(f"REMOVED OLD {file.name}")
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def keep_latest_logs(log_dir: Path) -> None:
|
||||
groups: dict[str, list[Path]] = defaultdict(list)
|
||||
|
||||
for file in log_dir.glob("*.log"):
|
||||
groups[get_log_group(file)].append(file)
|
||||
|
||||
for group_name, files in groups.items():
|
||||
files.sort(
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for old_file in files[MAX_FILES_PER_GROUP:]:
|
||||
print(f"REMOVED EXCESS {old_file.name}")
|
||||
old_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def shrink_large_logs(log_dir: Path) -> None:
|
||||
for file in log_dir.glob("*.log"):
|
||||
truncate_first_half(file)
|
||||
|
||||
|
||||
def manage_logs(log_dir: Path) -> None:
|
||||
remove_old_logs(log_dir)
|
||||
keep_latest_logs(log_dir)
|
||||
shrink_large_logs(log_dir)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"log_dir",
|
||||
nargs="?",
|
||||
default=Path("/home/lucas/logs"),
|
||||
type=Path,
|
||||
help="Directory containing log files",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
manage_logs(args.log_dir.expanduser().resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user