From dd53957e38c5ac7a3b0f7f5b95d5efa72c1c93a1 Mon Sep 17 00:00:00 2001 From: HappyBasher Date: Thu, 1 Oct 2026 12:15:46 +0200 Subject: [PATCH] feat(linbo-remote): prune old per-run logs, rotate the run record Per-run logs have unique names, so logrotate never rotates (and thus never deletes) the small ones, and its size rule would rotate a big one into an empty file of the same name. Keep them out of the existing *.log stanza with *[!0-9].log and let linbo-remote delete per-run logs older than 90 days when a new run starts (pruneRunLogs()). Rotate the run record linbo-remote_runs.jsonl monthly, keeping 12. Part 3 of #179. Signed-off-by: thomas@linuxmuster.net Assisted-by: Claude --- .../etc/logrotate.d/linuxmuster-linbo7 | 15 +++++++- .../linuxmuster/linbo/linbo_remote_cli.py | 2 + .../linuxmuster/linbo/linbo_remote_lib.py | 37 +++++++++++++++++++ tests/python/test_linbo_remote_lib.py | 28 ++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/serverfs/etc/logrotate.d/linuxmuster-linbo7 b/src/serverfs/etc/logrotate.d/linuxmuster-linbo7 index c17e3521..46ef9e5e 100644 --- a/src/serverfs/etc/logrotate.d/linuxmuster-linbo7 +++ b/src/serverfs/etc/logrotate.d/linuxmuster-linbo7 @@ -1,4 +1,7 @@ -/var/log/linuxmuster/linbo/*.log { +# *[!0-9].log leaves out the per-run linbo-remote logs +# (_linbo-remote_.log): they never grow after their run +# and are pruned by linbo-remote itself (RUN_LOG_RETENTION_DAYS). +/var/log/linuxmuster/linbo/*[!0-9].log { rotate 4 weekly compress @@ -7,3 +10,13 @@ notifempty create 600 nobody nogroup } + +# one JSON line per linbo-remote run +/var/log/linuxmuster/linbo/linbo-remote_runs.jsonl { + rotate 12 + monthly + compress + missingok + notifempty + create 644 root root +} diff --git a/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_cli.py b/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_cli.py index 8ab01c37..ce0612b9 100644 --- a/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_cli.py +++ b/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_cli.py @@ -300,6 +300,8 @@ def sendCmds(hosts, commands, wait, secrets_uploaded, dry_run=False): if wait: doWait(wait, f'Waiting {wait} second(s) for client(s) to boot', leading_blank_line=True) + lib.pruneRunLogs(environment.LINBOLOGDIR) + print() print('Sending command(s) to:') for host in hosts: diff --git a/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_lib.py b/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_lib.py index 02b7f406..761155b9 100644 --- a/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_lib.py +++ b/src/serverfs/usr/share/linuxmuster/linbo/linbo_remote_lib.py @@ -41,6 +41,7 @@ """ import json +import os import re import shlex import subprocess @@ -353,6 +354,42 @@ def runLogName(hostname, timestamp=None): RUN_RECORD_BASENAME = 'linbo-remote_runs.jsonl' +# Per-run logs older than this are deleted when a new run starts. Run records +# in RUN_RECORD_BASENAME are rotated by logrotate instead. +RUN_LOG_RETENTION_DAYS = 90 + +_RUN_LOG_RE = re.compile(r'.+_linbo-remote_\d{14}\.log$') + + +def pruneRunLogs(logdir, max_age_days=RUN_LOG_RETENTION_DAYS, now=None): + """ + Delete per-run logs (see runLogName()) in logdir whose modification time + is more than max_age_days old; returns the number removed. Only files + matching the per-run name are touched. Best effort: a file that cannot be + stat'ed or removed is skipped, pruning must never break a run. + + logrotate cannot do this: every per-run log has a unique name, so it would + never rotate (and thus never delete) the small ones, and its size rule + would rotate big ones into an empty file of the same name. + """ + cutoff = (time.time() if now is None else now) - max_age_days * 86400 + removed = 0 + try: + names = os.listdir(logdir) + except OSError: + return 0 + for name in names: + if not _RUN_LOG_RE.match(name): + continue + path = os.path.join(logdir, name) + try: + if os.path.getmtime(path) < cutoff: + os.remove(path) + removed += 1 + except OSError: + continue + return removed + def buildRunRecordPrefix(hostname, commands, started, logfile, mode='direct', dry_run=False): """ diff --git a/tests/python/test_linbo_remote_lib.py b/tests/python/test_linbo_remote_lib.py index d2a8a873..3e1be988 100644 --- a/tests/python/test_linbo_remote_lib.py +++ b/tests/python/test_linbo_remote_lib.py @@ -26,6 +26,7 @@ RUN_RECORD_BASENAME, buildOnbootCmds, buildRunRecordPrefix, + pruneRunLogs, getMacFromAd, getIpFromAd, hostsInGroup, @@ -490,3 +491,30 @@ def test_run_record_of_aborted_run_has_signal_exit_status(monkeypatch, tmp_path, proc.wait(timeout=10) record = json.loads(record_file.read_text().splitlines()[0]) assert record['rc'] == expected_rc + + +# --- retention of per-run logs ---------------------------------------------- + +def test_prune_run_logs_removes_only_old_per_run_logs(tmp_path): + now = time.time() + old = tmp_path / 'r100-pc01_linbo-remote_20260101000000.log' + fresh = tmp_path / 'r100-pc01_linbo-remote_20261001120000.log' + others = [ + tmp_path / 'r100-pc01_linbo.log', + tmp_path / 'rsync-pre-download.log', + tmp_path / RUN_RECORD_BASENAME, + tmp_path / 'r100-pc01_linbo-remote_20260101000000.log.1.gz', + ] + for f in [old, fresh, *others]: + f.write_text('x') + os.utime(f, (now - 200 * 86400, now - 200 * 86400)) + os.utime(fresh, (now - 1 * 86400, now - 1 * 86400)) + + assert pruneRunLogs(str(tmp_path), max_age_days=90, now=now) == 1 + assert not old.exists() + assert fresh.exists() + assert all(f.exists() for f in others) + + +def test_prune_run_logs_missing_dir_is_not_an_error(tmp_path): + assert pruneRunLogs(str(tmp_path / 'nope')) == 0