---
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:
- log-forensics diagnoses failures from logs and traces. This skill does not diagnose anything from log contents, and it never opens log lines for analysis.
- This skill is the file lifecycle only: rotation triggers, retention, compression, pruning, config for a rotator, and a dry-run audit that a rotation plan is safe. No diagnosis content.
- This skill covers the plan and the audit. Enabling a system rotator on a target machine, and checking how that machine's rotator behaves with open files, are target machine work covered in Workflow step 10 as a procedure, not as verified claims here.
- The only rotator syntax this skill treats as checked is the simple plan file syntax exercised by the checker in the Operating Rules section: one key=value pair per line, with the six keys the checker requires. A logrotate config for a target machine is a translation of those audited values, made and validated on that machine.
- Verification note, 2026-10-21: rotation_audit.py below was run with Python 3.12.3, the version `python3 --version` returned on the drafting machine, against a fixture built by the script itself with `--make-fixture`. The fixture run returned 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 logrotate binary was checked for on the drafting machine and was NOT found (`logrotate: command not found`), so no live logrotate run was performed and no logrotate config was executed or validated there. The script uses only the Python standard library (`os`, `re`, and `sys`, plus `datetime` for fixture timestamps), so no package version is quoted and no install step is needed. What was NOT verified: any logrotate directive, flag, or config syntax, the translation of the plan file into a logrotate config, any real compression ratio (25 percent in the fixture is a plan assumption entered as a number, not a measured result), any time based rotation trigger, and any behavior on live log files that a process still holds open. Only the plan file parser, the dry-run decisions, and the counts above were verified by a real run.

## Workflow

1. Inventory the log files before setting any rule.
   - List every file in each log directory under review, with its name, its size in bytes, and its modification time. Group the files by base log: an active file in the form name.log, and its rotated generations in the form name.log.N, where N is a whole number.
   - Files that fit neither form go on the inventory as ungrouped. The checker skips them, so a rule built from the checker output alone says nothing about them. Name them in the deliverable limits when they hold real size.
   - Record the current total in bytes for each directory and for each base log. Retention and budget math later in the routine uses these totals, not a guess at a typical size.
   - Note which active logs a running process may still hold open. The dry-run audit cannot test that. Rotation of an open file is a target machine question, settled in step 10 before enabling.

2. Measure growth rates from sizes and ages.
   - For each base log, take the active file size and the sizes and ages of its generations, and work out a rough growth rate in bytes per day. A generation of 1500 bytes that covers five days points to about 300 bytes per day for that log.
   - Where history is too short to show a rate, say so in the inventory and use the largest single file size seen as the planning size. A plan built on a stated rough rate can be corrected. A plan built on an unstated one cannot.
   - Flag the fastest growing log by name. Trigger and retention choices protect the disk from that log first, and the quiet logs follow the same rules for consistency.
   - Project one retention cycle at the measured rates: how many bytes each base log adds before its oldest kept generation would be pruned. That projection feeds the budget in step 4.

3. Pick rotation triggers: size, time, or both.
   - Size trigger: an active log rotates when it reaches a byte threshold. This protects the disk when growth is uneven or bursty, and it is the trigger the checker models, as max_size_bytes.
   - Time trigger: an active log rotates after a set number of days, at whatever size it has reached. This gives archives a regular shape when growth is steady. The checker does not model a pure time trigger for active files. A plan that uses one carries that part as unverified until it is checked on the target machine.
   - Both triggers: an active log rotates when either condition is met first. Record both values in the plan notes, audit the size condition with the checker, and mark the time condition plainly as pending target machine validation.
   - Set the size threshold from the growth rate and the budget, not from a round number habit. A threshold far above a day of growth for a fast log leaves the disk exposed. A threshold far below it creates many small generations and makes retention counts hard to reason about.
   - Write the chosen trigger type and value for each base log in the inventory. One shared threshold for all logs is acceptable when the inventory says so. A silent mix of triggers is not.

4. Set retention counts against a total disk budget.
   - Set disk_budget_bytes first: the total bytes the log directory may hold after rotation, compression, and pruning have all run. The budget comes from the space the directory can actually claim, with room left for the rest of the system.
   - Set retain_count per base log: the number of rotated generations kept. The checker applies one shared count to every group. A plan that needs different counts per log gets one checker run per log group, with the plan file adjusted for each run.
   - Check the plain math before any run: kept generations, at their projected sizes, plus active files, must fit the budget with margin. If the math fails on paper, lower the count or lower the size threshold and work it again. The checker run in step 8 confirms the math against real file sizes. It does not replace it.
   - Retention is a count of files, and counts hide size growth. A log that doubles in size keeps the same file count at twice the bytes. The budget check is the guard against that, which is why both numbers go in the plan.
   - Record what the retention count means in time as well: at the measured rate, how many days of history the kept generations cover. If that span is shorter than the history the work needs, raise the budget or accept a shorter span in writing. Pruning will not negotiate later.

5. Choose compression, and when it applies.
   - Decide whether rotated generations compress at all. Compression trades processor time at rotation for disk space over the whole retention span. For logs kept only a short span, plain files may be the simpler choice, and the plan can set the compression age above the oldest kept age so nothing compresses.
   - Set compress_age_days: the age at which a kept generation would compress. Compressing the newest generation right away can race with anything still reading it, so a common plan shape is to leave the newest generation plain and compress older ones. In checker terms, that means an age of at least the span the newest generation covers.
   - Enter compress_ratio_percent as a planning assumption: the projected compressed size as a percent of the original. The fixture uses 25. That number is an assumption entered by the plan, not a measured property of any log. When a real sample is available, compress a copy of one old generation by hand, measure the result, and use that measured percent in the plan instead.
   - Compression applies only to generations that survive pruning. The checker decides pruning first, then compression, so no savings are claimed for a file that would be deleted.
   - Files already compressed under a different naming scheme are outside the checker name forms. List them in the inventory as ungrouped and handle their rule outside the audit.

6. Write the pruning rules in plain order.
   - Pruning rule, stated exactly as the checker applies it: within each base log group, order the generations by age, newest first. Keep the first retain_count. The rest would be pruned.
   - No other file is a pruning candidate under the plan. Active files never prune. Kept generations never prune. A rule that would delete an active file fails the audit on sight and stops the routine.
   - Age ties break by file name in the checker, so the decision order is fixed and repeatable. When two generations share an age and the tie decides which one is pruned, note the tie in the deliverable.
   - Pruning is the only destructive part of rotation, and it runs on a schedule once enabled. Every later step in this routine exists to check the pruning list before that schedule starts.

7. Write the rotator config as a plan file.
   - Write the plan in the checker plan file format: one key=value pair per line, lines starting with # ignored, blank lines ignored. The six required keys are max_size_bytes, retain_count, compress_age_days, compress_ratio_percent, disk_budget_bytes, and reference_unix. Values are whole numbers, bytes and days and percent and a Unix timestamp as the labels state.
   - reference_unix is the time ages are measured against for the audit run. For a fixture run it is fixed, so the output is repeatable. For an audit of a real directory, set it to the current time at the moment of the run and record that value with the output, so the ages in the report can be checked later.
   - Keep one plan file per directory or per log group under review. Name it for the directory it covers, and keep it with the deliverable so the audited values and the shipped values can be compared line by line.
   - Translating the plan into a system rotator config, such as a logrotate config, happens on the target machine, using that machine's own documentation for its rotator, because no rotator binary was present to validate a translation during drafting. The translation carries the audited values across: the same threshold, the same count, the same compression age, the same budget intent. Any value that changes in translation voids the audit for that value until the plan is updated and re-run.

8. Dry-run the plan with the checker before enabling anything.
   - Save the script from the Operating Rules section as rotation_audit.py. For a first run, build the worked fixture: `python3 rotation_audit.py --make-fixture fixture`, then run `python3 rotation_audit.py fixture/logs fixture/plan.txt` and compare the output with the expected output in the Operating Rules section.
   - Run it on a real plan with `python3 rotation_audit.py LOGS_DIR PLAN_FILE`, where LOGS_DIR is the log directory under review and PLAN_FILE is the plan file from step 7. The checker renames, compresses, and deletes nothing. It reads names, sizes, and modification times only.
   - Confirm that claim on the first real run: record the file count and total size of the directory before the run and after it, and check that both match. Trust in the dry-run comes from that check, not from the header text.
   - Keep the full checker output with the deliverable. Every count quoted later comes from that output.

9. Read the dry-run output line by line and fix the plan, not the logs.
   - Check the rotate lines: each active log at or over the threshold appears once with action=rotate, and no active log under the threshold does. A large active log marked keep means the threshold in the plan file is higher than the one the routine intended.
   - Check the prune lines against step 6 by hand for one group: count the generations, count the kept ones, and confirm the pruned names are the oldest ones. This hand check is the core safety check of the whole routine. Skip nothing here.
   - Check the compress lines: only kept generations at or over the compression age appear, and each projected size equals the file size times the ratio percent, divided by 100, rounded down to a whole number.
   - Check the totals: projected_bytes equals total_bytes minus pruned_bytes minus compress_savings_bytes, and the verdict is PASS only when projected_bytes fits disk_budget_bytes. A verdict of REVIEW means the plan, as written, leaves the directory over budget. Fix the plan values and re-run. Editing log files to make a plan pass is outside this routine.
   - Repeat the run after every plan change, and keep the last output. The deliverable carries the final run only, with earlier runs noted by their verdicts when they changed a decision.

10. Enable the plan in stages on the target machine.
    - Translate the audited plan into the target machine's rotator config, per step 7, and read the translated config back against the plan file value by value before the rotator is allowed to run it.
    - Where the target rotator offers its own dry-run or debug mode, use it there and compare its file list with the checker output for the same directory. The option names for that mode come from the target machine's documentation. None are quoted here, because none were verified during drafting.
    - Enable rotation first with pruning held back, when the target setup allows that split: rotation and compression run, generations accumulate past the count for one cycle, and the prune list from the next checker run is checked by hand before pruning joins the schedule. When the target setup cannot split the stages, the first scheduled run gets watched and its result compared with the audit before the second run is left alone.
    - Re-audit after the first real rotation: run the checker on the directory again with a fresh reference_unix, and confirm the new generation matches the projected size range and the totals still fit the budget.
    - Recheck the plan whenever a log is added, a growth rate changes sharply, or the budget changes. A plan is current only for the inventory it was built from.

## 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())
```
