logrotate: rotation on every machine, its base configuration owned

Rotation ran on one machine of four; the others carried package and fail2ban
rules nothing read, and one log had reached 4.9 GB. The module installs
logrotate, owns /etc/logrotate.conf whole (the distribution's base plus
compress/delaycompress, dropping a hand-set olddir that collides same-named
logs) and enables logrotate.timer. Seven tools from a Go bundle, the journal's
usage and vacuum among them (to-be 42 Phase 1).
This commit is contained in:
jochen
2026-10-04 12:50:20 +02:00
parent d9336d11d0
commit 2e082d1680
12 changed files with 1375 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# logrotate
Log rotation on every machine (novox/hq to-be 42 Phase 1, research 027).
## What it owns
- The `logrotate` package.
- `/etc/logrotate.conf`, written whole. It is the distribution's base (weekly, four kept, `create`,
the `.pac*` taboo, `include /etc/logrotate.d`, the `wtmp`/`btmp` rules) plus `compress` and
`delaycompress`. A rotated log is compressed one rotation late, so a program still writing to the
file it had open loses nothing. The manifest test dry-runs it with `logrotate -d` where logrotate is
installed. The host keeps the machine's previous file once.
- `logrotate.timer`, running and enabled: daily, catching up after downtime.
## What it improves
- Rotation ran on one machine of four. The other three had rules in `/etc/logrotate.d`, put there by
their packages (nginx, postgresql, samba, cups) and by the mesh's own `fail2ban` module, and nothing
that read them. On the anchor, a web server's access log had reached 4.9 GB and the intrusion
prevention log 239 MB.
- Rotated logs are compressed everywhere.
- The one machine that did rotate had `olddir /var/log/archive` set by hand. That flattens logs from
different directories into one, where two logs with the same name collide. It is dropped.
`/var/log/archive` and what is in it are left as found.
## What it leaves found
- Every file in `/etc/logrotate.d`. They belong to their packages and modules.
- The journal's own bounds (`journald.conf`). journald runs on its defaults everywhere: 10 % of the
filesystem, capped at 4 GB. The journal tools below read and vacuum it.
## Tools
| tool | | answers |
|---|---|---|
| `logrotate_status` | r | each log's last rotation (the status file, through sudo -n), the timer, and the last run; "never run" where it has not |
| `logrotate_configs` | r | the base's global settings, and each rule file with the logs it rotates |
| `logrotate_check` | r | `logrotate -d` on the whole configuration: errors and warnings, changing nothing |
| `logrotate_big_logs` | r | the largest files under `/var/log`, with the total and the journal's share; journal files listed on request |
| `logrotate_force` | a | `logrotate -f -v` on one rule file, with the base's globals in front so it rotates as the nightly run would, or on every log |
| `logrotate_journal_usage` | r | `journalctl --disk-usage` and the journald settings that bound it |
| `logrotate_journal_vacuum` | a | `journalctl --vacuum-size/--vacuum-time`, with what each directory freed |
Forcing one rule file alone would leave out what the base sets. A rule that names no count would then
keep no old logs at all. So the tool writes the base's globals to a file that root owns, which is the
only kind logrotate running as root will read, and passes it in front of the rule.
@@ -0,0 +1,97 @@
package main
// The journal: the other place a machine's logs fill its disk, kept by journald rather than
// logrotate. The tools say how much it holds and what bounds it, and vacuum it on demand. Read as
// root: an account outside the journal's groups sees only its own part, and is told so.
import (
"fmt"
"regexp"
"strings"
)
var (
usage = regexp.MustCompile(`take up (\S+) in the file system`)
freed = regexp.MustCompile(`Vacuuming done, freed (\S+) of archived journals from (\S+?)\.?$`)
sizeSpec = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?[KMGT]?$`)
timeSpec = regexp.MustCompile(`^[0-9]+(us|ms|s|sec|min|h|hour|hours|d|day|days|w|week|weeks|M|month|months|y|year|years)$`)
)
// JournalBounds are the journald settings that bound its size and age.
var JournalBounds = []string{"Storage", "Compress", "SystemMaxUse", "SystemKeepFree", "SystemMaxFileSize", "RuntimeMaxUse", "MaxRetentionSec", "MaxFileSec"}
// JournalUsage is the journal's size on disk and the settings that bound it, unset meaning
// journald's default (10% of the filesystem, at most 4G).
func (m *Machine) JournalUsage() (map[string]any, error) {
out, err := m.Root("journalctl", "--disk-usage")
if err != nil {
return nil, err
}
answer := map[string]any{"said": firstLine(out)}
if u := usage.FindStringSubmatch(out); u != nil {
answer["usage"] = u[1]
}
settings := map[string]string{}
if cat, err := m.Out("systemd-analyze", "cat-config", "systemd/journald.conf"); err == nil {
for _, l := range lines(cat) {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "#") || strings.HasPrefix(l, "[") {
continue
}
if k, v, ok := strings.Cut(l, "="); ok && contains(JournalBounds, k) {
settings[k] = v
}
}
}
answer["settings"] = settings
if len(settings) == 0 {
answer["note"] = "journald runs on its defaults: at most 10% of the filesystem, capped at 4G"
}
return answer, nil
}
// Vacuum removes archived journal files beyond a size or older than a time, and says what it freed.
func (m *Machine) Vacuum(size, age string) (map[string]any, error) {
if size == "" && age == "" {
return nil, fmt.Errorf("say a size to keep (e.g. 500M) or an age to keep (e.g. 4weeks), or both")
}
args := []string{}
if size != "" {
if !sizeSpec.MatchString(size) {
return nil, fmt.Errorf("size %q is a number with K, M, G or T", size)
}
args = append(args, "--vacuum-size="+size)
}
if age != "" {
if !timeSpec.MatchString(age) {
return nil, fmt.Errorf("time %q is a number with a unit: s, min, h, d, weeks, months, years", age)
}
args = append(args, "--vacuum-time="+age)
}
r, err := m.RootRan("journalctl", args...)
if err != nil {
return nil, err
}
if r.Status != 0 {
return nil, failure("journalctl", "sudo", r)
}
type freedFrom struct {
Directory string `json:"directory"`
Freed string `json:"freed"`
}
from := []freedFrom{}
deleted := 0
for _, l := range lines(r.Stdout + "\n" + r.Stderr) {
if f := freed.FindStringSubmatch(strings.TrimSpace(l)); f != nil {
from = append(from, freedFrom{f[2], f[1]})
}
if strings.HasPrefix(strings.TrimSpace(l), "Deleted archived journal") {
deleted++
}
}
answer := map[string]any{"freed": from, "files_deleted": deleted}
if after, err := m.JournalUsage(); err == nil {
answer["usage_after"] = after["usage"]
}
return answer, nil
}
@@ -0,0 +1,326 @@
package main
// Log rotation, on every machine (novox/hq to-be 42 Phase 1, research 027/01: "rotation running on
// one machine of four"). Three machines carried rules in /etc/logrotate.d — put there by their
// packages and by the mesh's own fail2ban module — and no logrotate to read them, so those logs
// grew without bound. The module installs logrotate, owns its base configuration and enables its
// timer; these tools read what it did, find what grows, force one rule set, and do the same for the
// journal, which is the other place a machine's logs fill its disk.
//
// The status file and much of /var/log are root's, so reading them goes through sudo -n.
import (
"fmt"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// The files logrotate reads and keeps.
const (
BaseConf = "/etc/logrotate.conf"
RulesDir = "/etc/logrotate.d"
StateFile = "/var/lib/logrotate.status"
LogRoot = "/var/log"
forcedConf = "/run/mesh-logrotate-force.conf"
)
// Rotation is one log and when logrotate last rotated it.
type Rotation struct {
Log string `json:"log"`
LastRotated string `json:"last_rotated"`
}
var stateLine = regexp.MustCompile(`^"(.*)" (\d+)-(\d+)-(\d+)(?:-(\d+):(\d+)(?::(\d+))?)?$`)
// ParseState reads logrotate's status file: `"<log>" Y-M-D-h:m:s` per line.
func ParseState(text string) []Rotation {
out := []Rotation{}
for _, l := range lines(text) {
s := stateLine.FindStringSubmatch(strings.TrimSpace(l))
if s == nil {
continue
}
n := make([]int, 6)
for i := range n {
n[i], _ = strconv.Atoi(s[i+2])
}
t := time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], n[5], 0, time.Local)
out = append(out, Rotation{Log: s[1], LastRotated: t.Format(time.RFC3339)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Log < out[j].Log })
return out
}
// Status is each log's last rotation and the timer that rotates them.
func (m *Machine) Status(match string) (map[string]any, error) {
out := map[string]any{"state_file": StateFile}
r, err := m.RootRan("cat", StateFile)
if err != nil {
return nil, err
}
switch {
case r.Status == 0:
rot := []Rotation{}
for _, x := range ParseState(r.Stdout) {
if match == "" || strings.Contains(x.Log, match) {
rot = append(rot, x)
}
}
out["logs"], out["state_file_present"] = rot, true
case strings.Contains(r.Stderr, "No such file"):
out["logs"], out["state_file_present"] = []Rotation{}, false
out["note"] = "logrotate has never run here"
default:
return nil, failure("cat", "sudo", r)
}
if t, err := m.unitProps("logrotate.timer", "LoadState", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil {
out["timer"] = t
}
if s, err := m.unitProps("logrotate.service", "LoadState", "Result", "ExecMainExitTimestamp", "ExecMainStatus"); err == nil && s["LoadState"] == "loaded" {
out["last_run"] = s
}
return out, nil
}
// Rule is one rule file and the logs it rotates.
type Rule struct {
File string `json:"file"`
Logs []string `json:"logs"`
Mesh bool `json:"mesh_owned,omitempty"`
}
// RulesIn reads the log patterns a logrotate file names: the paths before each `{`.
func RulesIn(text string) []string {
logs := []string{}
depth := 0
var pending []string
for _, l := range lines(text) {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "#") {
continue
}
if depth == 0 {
before, _, opens := strings.Cut(l, "{")
fields := strings.Fields(before)
if len(fields) > 0 && !strings.HasPrefix(fields[0], "/") && !strings.HasPrefix(fields[0], "\"") {
// A directive (olddir, include …), not a log.
fields = nil
}
for _, f := range fields {
if strings.HasPrefix(f, "/") || strings.HasPrefix(f, "\"/") {
pending = append(pending, strings.Trim(f, "\""))
}
}
if opens {
logs = append(logs, pending...)
pending = nil
depth++
if strings.Contains(l[strings.Index(l, "{"):], "}") {
depth--
}
}
continue
}
if strings.HasPrefix(l, "}") || strings.HasSuffix(l, "}") && !strings.Contains(l, "{") {
depth--
}
}
return logs
}
// Configs is the base configuration's own logs and every rule file with the logs it rotates.
func (m *Machine) Configs() (map[string]any, error) {
base, err := m.ReadFile(BaseConf)
if err != nil {
return nil, fmt.Errorf("reading %s: %w (logrotate is not installed, or the module has not been applied)", BaseConf, err)
}
names, err := m.Out("find", RulesDir, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-printf", "%f\n")
if err != nil {
return nil, err
}
rules := []Rule{{File: BaseConf, Logs: RulesIn(string(base)), Mesh: strings.HasPrefix(string(base), "# The mesh's (module logrotate")}}
sorted := lines(names)
sort.Strings(sorted)
for _, n := range sorted {
p := path.Join(RulesDir, n)
text, err := m.ReadFile(p)
if err != nil {
rules = append(rules, Rule{File: p, Logs: []string{"(unreadable: " + err.Error() + ")"}})
continue
}
rules = append(rules, Rule{File: p, Logs: RulesIn(string(text))})
}
return map[string]any{"globals": Globals(string(base)), "rules": rules}, nil
}
// Globals is the base configuration without its includes and its per-log blocks: what every rule
// file inherits. Forcing one rule file is done with these before it, so it rotates as it would in
// the whole run — without them, a rule that names no count would keep no old log at all.
func Globals(text string) []string {
out := []string{}
depth := 0
for _, l := range strings.Split(text, "\n") {
t := strings.TrimSpace(l)
switch {
case depth > 0:
if strings.Contains(t, "}") {
depth--
}
case strings.Contains(t, "{"):
if !strings.Contains(t, "}") {
depth++
}
case t == "" || strings.HasPrefix(t, "#"), strings.HasPrefix(t, "include"):
default:
out = append(out, t)
}
}
return out
}
// Check is a dry run of the whole configuration (logrotate -d, which changes nothing): its errors
// and warnings, so a broken rule is found before the night it was meant to run.
func (m *Machine) Check() (map[string]any, error) {
r, err := m.RootRan("logrotate", "-d", BaseConf)
if err != nil {
return nil, err
}
errs, warns := []string{}, []string{}
for _, l := range lines(r.Stdout + "\n" + r.Stderr) {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "error:"):
errs = append(errs, l)
case strings.HasPrefix(l, "warning:") && !strings.Contains(l, "debug mode does nothing"):
warns = append(warns, l)
}
}
return map[string]any{"ok": len(errs) == 0 && r.Status == 0, "status": r.Status, "errors": errs, "warnings": warns}, nil
}
// LogFile is one file under /var/log and its size.
type LogFile struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Size string `json:"size"`
Modified string `json:"modified"`
Journal bool `json:"journal"`
}
// BigLogs is the largest files under /var/log, on its own filesystem, read as root. Journal files
// are counted and, unless asked for, not listed: journald bounds them, and the journal tools speak
// for them.
func (m *Machine) BigLogs(limit int, journals bool) (map[string]any, error) {
r, err := m.RootRan("find", LogRoot, "-xdev", "-type", "f", "-printf", "%s\t%TY-%Tm-%Td %TH:%TM\t%p\n")
if err != nil {
return nil, err
}
if r.Status != 0 && strings.TrimSpace(r.Stdout) == "" {
return nil, failure("find", "sudo", r)
}
files := []LogFile{}
var total, journalBytes int64
for _, l := range lines(r.Stdout) {
f := strings.SplitN(l, "\t", 3)
if len(f) != 3 {
continue
}
n, _ := strconv.ParseInt(f[0], 10, 64)
total += n
journal := strings.HasSuffix(f[2], ".journal") || strings.HasSuffix(f[2], ".journal~")
if journal {
journalBytes += n
if !journals {
continue
}
}
files = append(files, LogFile{Path: f[2], Bytes: n, Size: human(n), Modified: f[1], Journal: journal})
}
sort.Slice(files, func(i, j int) bool { return files[i].Bytes > files[j].Bytes })
count := len(files)
if len(files) > limit {
files = files[:limit]
}
return map[string]any{"under": LogRoot, "files": count, "total_bytes": total, "total": human(total),
"journal_bytes": journalBytes, "journal": human(journalBytes), "journals_listed": journals, "largest": files}, nil
}
func human(n int64) string {
units := []string{"B", "K", "M", "G", "T"}
f := float64(n)
i := 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%d%s", n, units[0])
}
return fmt.Sprintf("%.1f%s", f, units[i])
}
var ruleName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@+-]*$`)
// Force rotates the logs of one rule file now (logrotate -f -v), with the base configuration's
// globals before it; or every log, given the base configuration's own name.
func (m *Machine) Force(config string, writeTemp func(string) (string, func(), error)) (map[string]any, error) {
var args []string
switch {
case config == path.Base(BaseConf) || config == BaseConf:
args = []string{"-f", "-v", BaseConf}
case ruleName.MatchString(config):
rule := path.Join(RulesDir, config)
if _, err := m.ReadFile(rule); err != nil {
return nil, fmt.Errorf("%s is not a rule file here: %w", rule, err)
}
base, err := m.ReadFile(BaseConf)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", BaseConf, err)
}
temp, done, err := writeTemp("# The globals of " + BaseConf + ", for forcing " + rule + " alone.\n" + strings.Join(Globals(string(base)), "\n") + "\n")
if err != nil {
return nil, err
}
defer done()
// logrotate running as root reads only a configuration root owns.
if _, err := m.Root("install", "-m", "0644", "-o", "root", "-g", "root", temp, forcedConf); err != nil {
return nil, err
}
defer m.Root("rm", "-f", forcedConf) //nolint:errcheck
args = []string{"-f", "-v", forcedConf, rule}
default:
return nil, fmt.Errorf("%q is neither a file of %s nor %s", config, RulesDir, path.Base(BaseConf))
}
r, err := m.RootRan("logrotate", args...)
if err != nil {
return nil, err
}
said := lines(r.Stdout + "\n" + r.Stderr)
rotated, errs := []string{}, []string{}
for _, l := range said {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "rotating log "):
rotated = append(rotated, strings.TrimSuffix(strings.Fields(strings.TrimPrefix(l, "rotating log "))[0], ","))
case strings.HasPrefix(l, "error:"):
errs = append(errs, l)
}
}
if len(said) > 200 {
said = said[len(said)-200:]
}
return map[string]any{"config": config, "ok": r.Status == 0 && len(errs) == 0, "rotated": rotated, "errors": errs, "log": said}, nil
}
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
@@ -0,0 +1,188 @@
package main
import (
"strings"
"testing"
)
const state = `logrotate state -- version 2
"/var/log/nginx/error.log" 2026-3-15-0:34:52
"/var/log/wtmp" 2024-6-27-11:0:0
"/var/log/old.log" 2026-1-2
`
func TestTheStatusFileIsReadPerLog(t *testing.T) {
r := ParseState(state)
if len(r) != 3 || r[0].Log != "/var/log/nginx/error.log" || !strings.HasPrefix(r[0].LastRotated, "2026-03-15T00:34:52") || !strings.HasPrefix(r[1].LastRotated, "2026-01-02T00:00:00") {
t.Fatalf("%+v", r)
}
m := machine(fake(func(c call) Ran {
if c.String() == "sudo -n cat "+StateFile {
return Ran{Stdout: state}
}
return Ran{Stdout: "ActiveState=active\n"}
}, nil), 1000)
s, err := m.Status("nginx")
if err != nil || len(s["logs"].([]Rotation)) != 1 || s["state_file_present"] != true {
t.Fatalf("%v %v", s, err)
}
}
func TestAMachineWhereLogrotateNeverRanSaysSo(t *testing.T) {
m := machine(fake(func(c call) Ran {
if c.name == "sudo" {
return Ran{Status: 1, Stderr: "cat: /var/lib/logrotate.status: No such file or directory\n"}
}
return Ran{Stdout: "LoadState=not-found\n"}
}, nil), 1000)
s, err := m.Status("")
if err != nil || s["state_file_present"] != false || !strings.Contains(s["note"].(string), "never run") {
t.Fatalf("%v %v", s, err)
}
refused := machine(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil), 1000)
if _, err := refused.Status(""); err == nil || !strings.Contains(err.Error(), "without a prompt") {
t.Fatalf("a refusal is an error: %v", err)
}
}
const samba = `/var/log/samba/log.smbd /var/log/samba/log.nmbd /var/log/samba/*.log {
notifempty
missingok
copytruncate
}
# a comment { with a brace
/var/log/one.log
/var/log/two.log {
postrotate
kill -HUP 1
endscript
}
`
func TestARuleFilesLogsAreThePathsBeforeEachBrace(t *testing.T) {
got := RulesIn(samba)
if strings.Join(got, " ") != "/var/log/samba/log.smbd /var/log/samba/log.nmbd /var/log/samba/*.log /var/log/one.log /var/log/two.log" {
t.Fatalf("%v", got)
}
}
func TestADirectiveIsNotALog(t *testing.T) {
got := RulesIn("weekly\nolddir /var/log/archive\ninclude /etc/logrotate.d\n/var/log/wtmp {\n monthly\n}\n")
if strings.Join(got, " ") != "/var/log/wtmp" {
t.Fatalf("%v", got)
}
}
func TestGlobalsAreTheBaseWithoutIncludesOrBlocks(t *testing.T) {
g := manifest(t).resource(t, "config")["content"].(string)
got := Globals(g)
if strings.Join(got, "|") != "weekly|rotate 4|create|compress|delaycompress|tabooext + .pacorig .pacnew .pacsave" {
t.Fatalf("%v", got)
}
}
func TestForcingOneRuleCarriesTheGlobalsInAFileRootOwns(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
if c.args[1] == "logrotate" {
return Ran{Stderr: "reading config file /run/mesh-logrotate-force.conf\nrotating log /var/log/samba/log.smbd, log->rotateCount is 4\nerror: error renaming x: Permission denied\n"}
}
return Ran{}
}, &calls), 1000)
files := map[string]string{BaseConf: "weekly\nrotate 4\ninclude /etc/logrotate.d\n/var/log/wtmp {\n monthly\n}\n", RulesDir + "/samba": samba}
m.ReadFile = func(p string) ([]byte, error) {
if s, ok := files[p]; ok {
return []byte(s), nil
}
return nil, errNoFile
}
var written string
removed := false
r, err := m.Force("samba", func(s string) (string, func(), error) {
written = s
return "/tmp/x.conf", func() { removed = true }, nil
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(written, "weekly\nrotate 4\n") || strings.Contains(written, "include") || strings.Contains(written, "wtmp") || !removed {
t.Fatalf("written %q removed %v", written, removed)
}
var seen []string
for _, c := range calls {
seen = append(seen, c.String())
}
want := []string{
"sudo -n install -m 0644 -o root -g root /tmp/x.conf " + forcedConf,
"sudo -n logrotate -f -v " + forcedConf + " " + RulesDir + "/samba",
"sudo -n rm -f " + forcedConf,
}
if strings.Join(seen, "\n") != strings.Join(want, "\n") {
t.Fatalf("ran:\n%s", strings.Join(seen, "\n"))
}
if r["ok"] != false || strings.Join(r["rotated"].([]string), ",") != "/var/log/samba/log.smbd" || len(r["errors"].([]string)) != 1 {
t.Fatalf("%v", r)
}
if _, err := m.Force("../../etc/shadow", nil); err == nil {
t.Fatal("a path was taken for a rule file")
}
if _, err := m.Force("absent", nil); err == nil {
t.Fatal("a rule file that is not there was forced")
}
}
func TestBigLogsAreSortedAndBounded(t *testing.T) {
m := machine(byLine(map[string]Ran{
"sudo -n find /var/log -xdev -type f -printf %s\t%TY-%Tm-%Td %TH:%TM\t%p\n": {Status: 1, Stdout: "10\t2026-10-04 10:00\t/var/log/a.log\n4294967296\t2026-10-04 11:00\t/var/log/journal/x/system.journal\n2048\t2026-10-01 09:00\t/var/log/b.log\n", Stderr: "find: something vanished\n"},
}, nil), 1000)
r, err := m.BigLogs(2, true)
if err != nil {
t.Fatal(err)
}
l := r["largest"].([]LogFile)
if r["files"] != 3 || len(l) != 2 || !l[0].Journal || l[0].Size != "4.0G" || l[1].Path != "/var/log/b.log" || l[1].Size != "2.0K" {
t.Fatalf("%v", r)
}
r, _ = m.BigLogs(5, false)
if l := r["largest"].([]LogFile); len(l) != 2 || l[0].Path != "/var/log/b.log" || r["journal"] != "4.0G" {
t.Fatalf("journals counted, not listed: %v", r)
}
}
func TestTheDryRunReportsErrorsAndNotItsOwnWarning(t *testing.T) {
m := machine(byLine(map[string]Ran{
"sudo -n logrotate -d /etc/logrotate.conf": {Stderr: "warning: logrotate in debug mode does nothing except printing debug messages!\nerror: /etc/logrotate.d/x:3 unknown option 'bogus'\nwarning: something real\n"},
}, nil), 1000)
r, err := m.Check()
if err != nil || r["ok"] != false || len(r["errors"].([]string)) != 1 || len(r["warnings"].([]string)) != 1 {
t.Fatalf("%v %v", r, err)
}
}
func TestTheJournalIsMeasuredAndVacuumedAsRoot(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
switch c.String() {
case "sudo -n journalctl --disk-usage":
return Ran{Stdout: "Archived and active journals take up 4G in the file system.\n"}
case "systemd-analyze cat-config systemd/journald.conf":
return Ran{Stdout: "# /etc/systemd/journald.conf\n[Journal]\n#SystemMaxUse=\nSystemMaxUse=1G\n"}
case "sudo -n journalctl --vacuum-size=500M --vacuum-time=4weeks":
return Ran{Stderr: "Deleted archived journal /var/log/journal/x/system@a.journal (128M).\nVacuuming done, freed 128M of archived journals from /var/log/journal/x.\n"}
}
return Ran{Status: 99}
}, &calls), 1000)
u, err := m.JournalUsage()
if err != nil || u["usage"] != "4G" || u["settings"].(map[string]string)["SystemMaxUse"] != "1G" {
t.Fatalf("%v %v", u, err)
}
v, err := m.Vacuum("500M", "4weeks")
if err != nil || v["files_deleted"] != 1 || v["usage_after"] != "4G" {
t.Fatalf("%v %v", v, err)
}
for _, bad := range [][2]string{{"", ""}, {"lots", ""}, {"", "forever"}, {"1G; rm", ""}} {
if _, err := m.Vacuum(bad[0], bad[1]); err == nil {
t.Errorf("%v accepted", bad)
}
}
}
@@ -0,0 +1,289 @@
package main
// The commands this bundle runs on its machine, and who runs them.
//
// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words —
// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only
// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the
// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the
// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an
// empty answer.
//
// The runner is injected, so every tool is tested over a fake one without the machine.
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"strings"
"time"
)
// Ran is what one command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not there, or that it was ended for taking too long.
Err string
}
// Runner runs one command, so the tools can be tested without the machine.
type Runner func(ctx context.Context, name string, args ...string) Ran
// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a
// command that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the
// process; well above anything a tool answers.
const outputLimit = 16 << 20
type bounded struct {
bytes.Buffer
cut bool
}
func (b *bounded) Write(p []byte) (int, error) {
if room := outputLimit - b.Len(); room < len(p) {
if room > 0 {
b.Buffer.Write(p[:room])
}
b.cut = true
return len(p), nil
}
return b.Buffer.Write(p)
}
// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
var out, errb bounded
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
if ctx.Err() == context.DeadlineExceeded {
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
return r
}
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
r.Status = exit.ExitCode()
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist):
r.Status, r.Err = 127, "ENOENT"
default:
r.Status, r.Err = 126, err.Error()
}
return r
}
// Escalated is the command as it is run: as given when this process is root, else through sudo
// without a prompt.
func Escalated(uid int, name string, args ...string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// Machine is this machine as the tools see it: a runner, who this process is, and its files.
type Machine struct {
Run Runner
UID int
User string
Account string
ReadFile func(path string) ([]byte, error)
Now func() time.Time
Sleep func(time.Duration)
}
// ThisMachine is the machine the runtime launched this bundle on.
func ThisMachine() *Machine {
user := os.Getenv("USER")
if user == "" {
user = os.Getenv("LOGNAME")
}
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
if account == "" {
account = user
}
return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep}
}
// Out runs a command that only reads, and fails with what went wrong named.
func (m *Machine) Out(name string, args ...string) (string, error) {
r := m.Run(context.Background(), name, args...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, name, r)
}
// Root runs a command that needs root, escalated when this process is not.
func (m *Machine) Root(name string, args ...string) (string, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, program, r)
}
// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer.
func (m *Machine) RootRan(name string, args ...string) (Ran, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Err != "" || (program == "sudo" && sudoRefused(r)) {
return r, failure(name, program, r)
}
return r, nil
}
func sudoRefused(r Ran) bool {
return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:")
}
// failure names what failed by how it failed: the program missing is a spawn error, sudo missing
// or refusing speaks for itself, and the rest is the command's own first line.
func failure(cmd, program string, r Ran) error {
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
if r.Err == "ENOENT" {
if program == "sudo" {
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
}
return fmt.Errorf("%s is not installed on this machine", cmd)
}
if r.Err != "" {
return fmt.Errorf("%s did not answer: %s", cmd, r.Err)
}
if program == "sudo" && sudoRefused(r) {
if strings.Contains(said, "command not found") {
return fmt.Errorf("%s is not installed on this machine", cmd)
}
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
}
if line := firstLine(said); line != "" {
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
}
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
}
func firstLine(text string) string {
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
func lines(text string) []string {
var out []string
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" {
out = append(out, l)
}
}
return out
}
// text is a string argument; required says whether it may be absent. It is never something a
// command would read as an option, which under sudo would be root's option.
func text(args map[string]any, key string, required bool) (string, error) {
raw, present := args[key]
if !present || raw == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := raw.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
s = strings.TrimSpace(s)
if required && s == "" {
return "", fmt.Errorf("%s is required", key)
}
if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") {
return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s)
}
return s, nil
}
// whole is a whole-number argument with a default, kept within bounds.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
raw, present := args[key]
if !present || raw == nil {
return def, nil
}
f, ok := raw.(float64)
if !ok || f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
// flag is a boolean argument, false when absent.
func flag(args map[string]any, key string) (bool, error) {
raw, present := args[key]
if !present || raw == nil {
return false, nil
}
b, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
// schema is a tool's input: its properties and the ones it requires.
func schema(properties map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return s
}
// unitProps reads a unit's properties as systemctl shows them.
func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) {
args := []string{"show", unit, "--no-pager"}
for _, p := range props {
args = append(args, "--property="+p)
}
out, err := m.Out("systemctl", args...)
if err != nil {
return nil, err
}
return keyValues(out, "="), nil
}
// keyValues reads `key<sep>value` lines; a line without the separator is skipped.
func keyValues(out, sep string) map[string]string {
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(l, sep)
if ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return kv
}
@@ -0,0 +1,107 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// call is one command a fake runner was asked to run.
type call struct {
name string
args []string
}
func (c call) String() string {
if len(c.args) == 0 {
return c.name
}
return c.name + " " + strings.Join(c.args, " ")
}
// fake is a runner answering by the command line it is given, recording every call.
func fake(answer func(c call) Ran, calls *[]call) Runner {
return func(_ context.Context, name string, args ...string) Ran {
c := call{name, append([]string(nil), args...)}
if calls != nil {
*calls = append(*calls, c)
}
return answer(c)
}
}
// byLine answers from a table keyed by the whole command line, and refuses anything else as a
// command the test did not expect.
func byLine(table map[string]Ran, calls *[]call) Runner {
return fake(func(c call) Ran {
if r, ok := table[c.String()]; ok {
return r
}
return Ran{Status: 99, Stderr: "unexpected command: " + c.String()}
}, calls)
}
func machine(run Runner, uid int) *Machine {
return &Machine{Run: run, UID: uid, User: "operator", Account: "operator",
ReadFile: func(string) ([]byte, error) { return nil, errNoFile },
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) },
Sleep: func(time.Duration) {}}
}
type noFile struct{}
func (noFile) Error() string { return "no such file" }
var errNoFile = noFile{}
func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" {
t.Fatalf("not root: %s %v", p, a)
}
if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" {
t.Fatalf("root: %s %v", p, a)
}
}
func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) {
cases := []struct {
r Ran
want string
}{
{Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"},
{Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"},
{Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"},
{Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"},
}
for _, c := range cases {
m := machine(fake(func(call) Ran { return c.r }, nil), 1000)
if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%+v: %v, want %q", c.r, err, c.want)
}
}
m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000)
if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") {
t.Errorf("a missing program: %v", err)
}
}
func TestAnArgumentIsNeverAnOption(t *testing.T) {
for _, bad := range []any{"-rf", "a\nb", 3.0} {
if _, err := text(map[string]any{"x": bad}, "x", true); err == nil {
t.Errorf("%v was accepted", bad)
}
}
if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" {
t.Errorf("a plain value: %q %v", s, err)
}
if _, err := text(map[string]any{}, "x", true); err == nil {
t.Error("a missing required value was accepted")
}
if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 {
t.Errorf("not bounded: %d", n)
}
if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil {
t.Error("below the least was accepted")
}
}
@@ -0,0 +1,128 @@
// logrotate's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's
// runtime launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads
// when each log was last rotated, the rule files and a dry run of them, and the largest logs; forces
// one rule file; and reads and vacuums the journal. Acts go through sudo -n.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
const binaryName = "logrotate-tools"
func bg() context.Context { return context.Background() }
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): logrotate.
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// writeTemp writes a file only this account can write, and gives back how to remove it.
func writeTemp(content string) (string, func(), error) {
f, err := os.CreateTemp("", "mesh-logrotate-*.conf")
if err != nil {
return "", nil, err
}
_, werr := f.WriteString(content)
cerr := f.Close()
done := func() { os.Remove(f.Name()) }
if werr != nil || cerr != nil {
done()
return "", nil, fmt.Errorf("writing %s: %v %v", f.Name(), werr, cerr)
}
return f.Name(), done, nil
}
func tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{
Name: "logrotate_status",
Description: "When logrotate last rotated each log (its status file, read through sudo -n), narrowed to logs whose path holds a word; with the timer's last and next run and the last run's result. A machine where it never ran says so.",
Input: schema(map[string]any{"match": map[string]any{"type": "string", "description": "only logs whose path holds this"}}),
Run: func(args map[string]any) (any, error) {
match, err := text(args, "match", false)
if err != nil {
return nil, err
}
return m.Status(match)
},
},
{
Name: "logrotate_configs",
Description: "The base configuration's global settings and every rule file of /etc/logrotate.d with the logs it rotates.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.Configs() },
},
{
Name: "logrotate_check",
Description: "A dry run of the whole configuration (logrotate -d through sudo -n, which changes nothing): its errors and warnings, so a broken rule is found before the night it runs.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.Check() },
},
{
Name: "logrotate_big_logs",
Description: "The largest files under /var/log on its own filesystem (read through sudo -n), with size and modification time; with the total and how much of it is the journal. Journal files are listed only when asked (journals: true).",
Input: schema(map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many (default 20, at most 200)"},
"journals": map[string]any{"type": "boolean", "description": "list the journal's files too"},
}),
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "limit", 20, 1, 200)
if err != nil {
return nil, err
}
j, err := flag(args, "journals")
if err != nil {
return nil, err
}
return m.BigLogs(n, j)
},
},
{
Name: "logrotate_force",
Description: "Rotate now (logrotate -f -v, through sudo -n) the logs of one rule file of /etc/logrotate.d, with the base configuration's " +
"global settings before it so it rotates as the nightly run would; or every log, given logrotate.conf. Answers what was rotated, the errors and the log.",
Input: schema(map[string]any{"config": map[string]any{"type": "string", "description": "a file name in /etc/logrotate.d, or logrotate.conf for every log"}}, "config"),
Run: func(args map[string]any) (any, error) {
config, err := text(args, "config", true)
if err != nil {
return nil, err
}
return m.Force(config, writeTemp)
},
},
{
Name: "logrotate_journal_usage",
Description: "How much the systemd journal holds on disk (journalctl --disk-usage, through sudo -n so every part is counted) and the journald settings that bound it.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.JournalUsage() },
},
{
Name: "logrotate_journal_vacuum",
Description: "Remove archived journal files (through sudo -n) beyond a total size, older than an age, or both; answers what each directory freed and the usage after.",
Input: schema(map[string]any{
"size": map[string]any{"type": "string", "description": "keep at most this much, e.g. 500M or 2G"},
"time": map[string]any{"type": "string", "description": "keep at most this old, e.g. 4weeks or 30d"},
}),
Run: func(args map[string]any) (any, error) {
size, err := text(args, "size", false)
if err != nil {
return nil, err
}
age, err := text(args, "time", false)
if err != nil {
return nil, err
}
return m.Vacuum(size, age)
},
},
}
}
@@ -0,0 +1,54 @@
package main
// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package, its base configuration
// whole, and its timer — and the base configuration proven by logrotate's own dry run where logrotate
// is installed, because a base configuration that does not parse stops every rotation on the machine.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestItDeclaresThePackageTheBaseAndTheTimer(t *testing.T) {
m := manifest(t)
if p := m.resource(t, "package"); p["package"] != "logrotate" {
t.Fatalf("%v", p)
}
c := m.resource(t, "config")
if c["path"] != BaseConf || c["into"] != nil || !strings.HasPrefix(c["content"].(string), "# The mesh's (module logrotate") {
t.Fatalf("%v", c)
}
if !strings.Contains(c["content"].(string), "\ninclude "+RulesDir+"\n") {
t.Fatal("the base must include the packages' rules, or nothing of theirs rotates")
}
if strings.Contains(c["content"].(string), "olddir") {
t.Fatal("olddir flattens logs of different directories into one, where two of one name collide")
}
timer := m.resource(t, "timer")
if timer["unit"] != "logrotate.timer" || timer["state"] != "running" || timer["boot"] != "enabled" {
t.Fatalf("%v", timer)
}
}
func TestTheBaseParses(t *testing.T) {
logrotate, err := exec.LookPath("logrotate")
if err != nil {
t.Skip("logrotate is not installed here; the base configuration is not dry-run")
}
dir := t.TempDir()
content := strings.ReplaceAll(manifest(t).resource(t, "config")["content"].(string), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil {
t.Fatal(err)
}
conf := filepath.Join(dir, "logrotate.conf")
if err := os.WriteFile(conf, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(logrotate, "-d", "-s", filepath.Join(dir, "state"), conf).CombinedOutput()
if err != nil || strings.Contains(string(out), "error:") {
t.Fatalf("logrotate -d: %v\n%s", err, out)
}
}
@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type resource map[string]any
type manifestShape struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Tools []string `json:"tools"`
Resources []resource `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) manifestShape {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifestShape) resource(t *testing.T, id string) resource {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
// bundle to the shape the builder compiles and the runtime loads.
func TestToolsAreTheManifests(t *testing.T) {
m := manifest(t)
names := map[string]bool{}
for _, tool := range tools(machine(nil, 1000)) {
if names[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
names[tool.Name] = true
}
for _, want := range m.Tools {
if !names[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(names, want)
}
if len(names) != 0 {
t.Errorf("served and not listed: %v", names)
}
var tools map[string]any
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" {
tools = a
}
}
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
t.Fatalf("the tools artifact: %v", tools)
}
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
t.Fatalf("loads: %v", tools["loads"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module logrotate
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+53
View File
@@ -0,0 +1,53 @@
{
"module": "logrotate",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"tools": [
"logrotate_status",
"logrotate_configs",
"logrotate_check",
"logrotate_big_logs",
"logrotate_force",
"logrotate_journal_usage",
"logrotate_journal_vacuum"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "logrotate"
},
{
"id": "config",
"type": "file",
"path": "/etc/logrotate.conf",
"mode": "0644",
"content": "# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits.\n# Written whole at every push; an edit here is overwritten. Each package's own rules are in\n# /etc/logrotate.d and stay the packages'.\n\n# Weekly, four weeks kept, a new empty log created after each rotation.\nweekly\nrotate 4\ncreate\n\n# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open\n# loses nothing to the compression.\ncompress\ndelaycompress\n\n# A package's replaced configuration is never read as a rule.\ntabooext + .pacorig .pacnew .pacsave\n\ninclude /etc/logrotate.d\n\n/var/log/wtmp {\n monthly\n create 0664 root utmp\n minsize 1M\n rotate 1\n}\n\n/var/log/btmp {\n missingok\n monthly\n create 0600 root utmp\n rotate 1\n}\n"
},
{
"id": "timer",
"type": "service",
"unit": "logrotate.timer",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/logrotate-tools",
"binary": "logrotate-tools",
"loads": [
"logrotate-tools"
]
}
]
}
}