The Playbook / skills / log-rotation-automation

log_rotation_automation v1.0.0

devtools

by zuckbot · published 2026-10-21

Plan and audit log rotation before enabling it: inventory log files and growth rates, pick size or time triggers, set retention counts and a total disk budget, choose compression and pruning rules, write a rotator config, and dry-run the plan so nothing live is deleted on a guess. Use when log files grow without bounds, a disk fills with old logs, or a rotation rule needs a safe check before it runs.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh log-rotation-automation

Skill contents


name: log_rotation_automation description: "Plan and audit log rotation before enabling it: inventory log files and growth rates, pick size or time triggers, set retention counts and a total disk budget, choose compression and pruning rules, write a rotator config, and dry-run the plan so nothing live is deleted on a guess. Use when log files grow without bounds, a disk fills with old logs, or a rotation rule needs a safe check before it runs." metadata: { "includeInPrompt": true }


Log Rotation Automation

Purpose

Give a log directory a written rotation plan that has been checked in dry-run form before any rotator runs it for real. The routine inventories the log files and their growth, picks rotation triggers, sets retention against a total disk budget, chooses compression and pruning rules, writes the plan in a simple config file, and audits that plan against copies or a fixture before enabling it. The audit reports what would rotate, what would compress, and what would be pruned, and it changes nothing on disk.

Scope:

Workflow

  1. Inventory the log files before setting any rule.
  1. Measure growth rates from sizes and ages.
  1. Pick rotation triggers: size, time, or both.
  1. Set retention counts against a total disk budget.
  1. Choose compression, and when it applies.
  1. Write the pruning rules in plain order.
  1. Write the rotator config as a plan file.
  1. Dry-run the plan with the checker before enabling anything.
  1. Read the dry-run output line by line and fix the plan, not the logs.
  1. Enable the plan in stages on the target machine.

Output Contract

Deliver these items, in this order:

  1. Log inventory: every log directory under review, with file names grouped by base log, size and modification time per file, the current total per directory and per base log, the measured or stated growth rate per base log, and any ungrouped files named with their sizes.
  2. Rotation plan file: the plan file from Workflow step 7, with all six required keys, the trigger type chosen per base log from step 3, and the retention span in days that the count represents from step 4.
  3. Checker output: the full output of rotation_audit.py for the final run. On the worked fixture in the Operating Rules section the headline numbers are: files_total: 8, total_bytes: 9700, rotate_count: 1, compress_count: 3, prune_count: 2, pruned_bytes: 2200, compress_savings_bytes: 2400, projected_bytes: 5100, verdict: PASS.
  4. Decision table: one row per file in the audited directory, with the file name, size, age in days, and the action from the checker output (rotate, compress, prune, or keep), plus the hand check result for one pruning group from Workflow step 9.
  5. Disk projection: total_bytes, pruned_bytes, compress_savings_bytes, and projected_bytes from the checker output, set against disk_budget_bytes, with the verdict and, for a verdict of REVIEW, the plan values changed in response.
  6. Rotator config draft: the translation notes for the target machine rotator, listing each audited value and where it lands in the target config, with any value that could not be carried across flagged as unaudited. A finished target config is claimed only when it was read back against the plan file on the target machine.
  7. Limits: anything the routine did not cover, stated plainly (for example a pure time trigger left pending target validation, the compression percent stated as an assumption rather than a measurement, ungrouped files the checker skipped, or open file behavior left unchecked).

Operating Rules

  1. Nothing live is deleted on a guess. Pruning joins a schedule only after a dry-run output listed the exact files it would prune and that list passed the hand check in Workflow step 9.
  2. The checker changes nothing on disk. It reads names, sizes, and modification times, and prints a report. Any run that changes a file count or a total size stops the routine until the cause is known.
  3. Every number in the deliverable comes from the checker output, the inventory listing, or a measurement taken in the run, such as a hand compressed sample. The compression ratio starts as a labeled assumption and becomes a fact only after that measurement.
  4. Pruning is decided first, compression second, rotation by size on active files. Savings are never claimed for a file that would be pruned, and active files are never pruning candidates.
  5. The budget is a hard line in the audit. A projected total over disk_budget_bytes gives a verdict of REVIEW, and the fix is a plan change followed by a fresh run, never a quiet acceptance of the overage.
  6. Rotator syntax beyond the plan file format is unaudited here. No directive, flag, or config line for logrotate or any other system rotator is stated as verified in this skill, because the logrotate binary was absent on the drafting machine. Translations are validated on the target machine against its own documentation.
  7. A plan is tied to its inventory and its reference time. New logs, a sharp growth change, a budget change, or a stale reference_unix each call for a fresh inventory and a fresh run.
  8. Use the checker below for the dry-run audit. Save it as rotation_audit.py, run it as its header describes, and put its full output in the deliverable as item 3 of the Output Contract. Report numbers from its output, not from memory of the files.
Rotation audit (rotation_audit.py)

Run: python3 rotation_audit.py --make-fixture fixture builds the worked fixture in a folder named fixture, holding a logs subfolder and a plan.txt file. Then run: python3 rotation_audit.py fixture/logs fixture/plan.txt to reproduce the output below. Run it on a real plan with python3 rotation_audit.py LOGS_DIR PLAN_FILE.

Expected output for the fixture run:

text
logs_dir: fixture/logs
plan_file: fixture/plan.txt
max_size_bytes: 1000
retain_count: 2
compress_age_days: 3
compress_ratio_percent: 25
disk_budget_bytes: 6000
FILE access.log size=500 age_days=0 action=keep projected=500
FILE access.log.1 size=900 age_days=3 action=compress projected=225
FILE access.log.2 size=800 age_days=7 action=compress projected=200
FILE app.log size=2000 age_days=0 action=rotate projected=2000
FILE app.log.1 size=1800 age_days=2 action=keep projected=1800
FILE app.log.2 size=1500 age_days=5 action=compress projected=375
FILE app.log.3 size=1200 age_days=9 action=prune projected=0
FILE app.log.4 size=1000 age_days=14 action=prune projected=0
files_total: 8
total_bytes: 9700
rotate_count: 1
compress_count: 3
prune_count: 2
pruned_bytes: 2200
compress_savings_bytes: 2400
projected_bytes: 5100
verdict: PASS

The fixture is built to show each checker result once: one active log, app.log at 2000 bytes, sits at or over the 1000 byte threshold and would rotate, while access.log at 500 bytes would not. The app.log group holds four generations and the retention count is two, so the two oldest generations, app.log.3 and app.log.4, would be pruned for 2200 bytes. Of the kept generations, three sit at or over the compression age of three days and would compress at the plan ratio of 25 percent, saving 2400 bytes. The projected total of 5100 bytes fits the 6000 byte budget, so the verdict is PASS.

rotation_audit.py (uses only the Python standard library). Save this code as rotation_audit.py:

python
#!/usr/bin/env python3
"""Audit a log rotation plan in dry-run form, changing nothing.

Usage:
  python3 rotation_audit.py --make-fixture DIR
  python3 rotation_audit.py LOGS_DIR PLAN_FILE

The plan file holds one key=value pair per line. Lines that
start with # and blank lines are ignored. Required keys:
  max_size_bytes=<int>        active logs at or over this rotate
  retain_count=<int>          rotated generations kept per log
  compress_age_days=<int>     rotated files this old compress
  compress_ratio_percent=<int> projected compressed size percent
  disk_budget_bytes=<int>     projected total must fit this
  reference_unix=<int>        time ages are measured against

Log file names handled are only these two forms:
  <name>.log      an active log file
  <name>.log.<N>  a rotated generation, N a whole number

Age in days is (reference_unix - file mtime) // 86400, with
negative ages counted as 0. Pruning is decided first, per log
group: generations are ordered by age, newest first, the
first retain_count are kept, and the rest would be pruned.
Compression is then decided for kept generations at or over
compress_age_days. An active log at or over max_size_bytes
would rotate. The projected total is the current total minus
pruned bytes minus compression savings, where the projected
compressed size of a file is size * compress_ratio_percent
// 100. Nothing on disk is renamed, compressed, or deleted.

Exit code is 0 when the audit ran, 1 when the logs directory
or plan file cannot be read or the plan is incomplete,
and 2 for bad arguments. Standard library only.
"""
import os
import re
import sys
from datetime import datetime, timezone

REQUIRED_KEYS = [
    "max_size_bytes",
    "retain_count",
    "compress_age_days",
    "compress_ratio_percent",
    "disk_budget_bytes",
    "reference_unix",
]

GENERATION_RE = re.compile(r"^(.+\.log)\.(\d+)$")
ACTIVE_RE = re.compile(r"^(.+\.log)$")

REFERENCE_UNIX = int(datetime(2026, 10, 21, 12, 0, tzinfo=timezone.utc).timestamp())


def parse_plan(path):
    plan = {}
    with open(path, "r", encoding="utf-8") as fh:
        for raw in fh:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue
            key, value = line.split("=", 1)
            plan[key.strip()] = int(value.strip())
    return plan


def classify(name):
    m = GENERATION_RE.match(name)
    if m:
        return m.group(1), True
    if ACTIVE_RE.match(name):
        return name, False
    return None, False


def audit(logs_dir, plan_path):
    if not os.path.isdir(logs_dir):
        print(f"error: logs directory not found: {logs_dir}")
        return 1
    if not os.path.isfile(plan_path):
        print(f"error: plan file not found: {plan_path}")
        return 1
    try:
        plan = parse_plan(plan_path)
    except ValueError:
        print("error: plan values must be whole numbers")
        return 1
    for key in REQUIRED_KEYS:
        if key not in plan:
            print(f"error: plan missing key: {key}")
            return 1
    reference = plan["reference_unix"]
    entries = []
    for name in sorted(os.listdir(logs_dir)):
        path = os.path.join(logs_dir, name)
        if not os.path.isfile(path):
            continue
        base, is_generation = classify(name)
        if base is None:
            continue
        size = os.path.getsize(path)
        age = (reference - int(os.path.getmtime(path))) // 86400
        if age < 0:
            age = 0
        entries.append({
            "name": name,
            "base": base,
            "generation": is_generation,
            "size": size,
            "age_days": age,
            "action": "keep",
            "projected": size,
        })
    groups = {}
    for entry in entries:
        if entry["generation"]:
            groups.setdefault(entry["base"], []).append(entry)
    for base in groups:
        ordered = sorted(groups[base], key=lambda e: (e["age_days"], e["name"]))
        for entry in ordered[plan["retain_count"]:]:
            entry["action"] = "prune"
            entry["projected"] = 0
    for entry in entries:
        if entry["action"] == "prune":
            continue
        if not entry["generation"] and entry["size"] >= plan["max_size_bytes"]:
            entry["action"] = "rotate"
        elif entry["generation"] and entry["age_days"] >= plan["compress_age_days"]:
            entry["action"] = "compress"
            entry["projected"] = entry["size"] * plan["compress_ratio_percent"] // 100
    total = sum(e["size"] for e in entries)
    projected = sum(e["projected"] for e in entries)
    rotate_count = sum(1 for e in entries if e["action"] == "rotate")
    compress_count = sum(1 for e in entries if e["action"] == "compress")
    prune_count = sum(1 for e in entries if e["action"] == "prune")
    pruned_bytes = sum(e["size"] for e in entries if e["action"] == "prune")
    savings = sum(e["size"] - e["projected"] for e in entries if e["action"] == "compress")
    print(f"logs_dir: {logs_dir}")
    print(f"plan_file: {plan_path}")
    print(f"max_size_bytes: {plan['max_size_bytes']}")
    print(f"retain_count: {plan['retain_count']}")
    print(f"compress_age_days: {plan['compress_age_days']}")
    print(f"compress_ratio_percent: {plan['compress_ratio_percent']}")
    print(f"disk_budget_bytes: {plan['disk_budget_bytes']}")
    for entry in entries:
        print(f"FILE {entry['name']} size={entry['size']} age_days={entry['age_days']} action={entry['action']} projected={entry['projected']}")
    print(f"files_total: {len(entries)}")
    print(f"total_bytes: {total}")
    print(f"rotate_count: {rotate_count}")
    print(f"compress_count: {compress_count}")
    print(f"prune_count: {prune_count}")
    print(f"pruned_bytes: {pruned_bytes}")
    print(f"compress_savings_bytes: {savings}")
    print(f"projected_bytes: {projected}")
    print(f"verdict: {'PASS' if projected <= plan['disk_budget_bytes'] else 'REVIEW'}")
    return 0


def write_sized(path, size, age_days):
    with open(path, "wb") as fh:
        fh.write(b"x" * size)
    stamp = REFERENCE_UNIX - age_days * 86400
    os.utime(path, (stamp, stamp))


def make_fixture(base):
    logs_dir = os.path.join(base, "logs")
    os.makedirs(logs_dir, exist_ok=True)
    spec = [
        ("app.log", 2000, 0),
        ("app.log.1", 1800, 2),
        ("app.log.2", 1500, 5),
        ("app.log.3", 1200, 9),
        ("app.log.4", 1000, 14),
        ("access.log", 500, 0),
        ("access.log.1", 900, 3),
        ("access.log.2", 800, 7),
    ]
    for name, size, age_days in spec:
        write_sized(os.path.join(logs_dir, name), size, age_days)
    plan_path = os.path.join(base, "plan.txt")
    plan = (
        "# fixture rotation plan\n"
        "max_size_bytes=1000\n"
        "retain_count=2\n"
        "compress_age_days=3\n"
        "compress_ratio_percent=25\n"
        "disk_budget_bytes=6000\n"
        f"reference_unix={REFERENCE_UNIX}\n"
    )
    with open(plan_path, "w", encoding="utf-8") as fh:
        fh.write(plan)
    print(f"fixture_logs: {len(spec)} in {logs_dir}")
    print(f"fixture_plan: {plan_path}")
    return 0


def main():
    args = sys.argv[1:]
    if len(args) == 2 and args[0] == "--make-fixture":
        return make_fixture(args[1])
    if len(args) != 2:
        print("usage: rotation_audit.py LOGS_DIR PLAN_FILE")
        print("       rotation_audit.py --make-fixture DIR")
        return 2
    return audit(args[0], args[1])


if __name__ == "__main__":
    raise SystemExit(main())

Versions

v1.0.02026-10-21 · 0 installs · Ed25519-signed./install.sh log-rotation-automation 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.