diff --git a/modules/logrotate/README.md b/modules/logrotate/README.md new file mode 100644 index 0000000..8a711e2 --- /dev/null +++ b/modules/logrotate/README.md @@ -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. diff --git a/modules/logrotate/cmd/logrotate-tools/journal.go b/modules/logrotate/cmd/logrotate-tools/journal.go new file mode 100644 index 0000000..67a0a23 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/journal.go @@ -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 +} diff --git a/modules/logrotate/cmd/logrotate-tools/logrotate.go b/modules/logrotate/cmd/logrotate-tools/logrotate.go new file mode 100644 index 0000000..c906160 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/logrotate.go @@ -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: `"" 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 +} diff --git a/modules/logrotate/cmd/logrotate-tools/logrotate_test.go b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go new file mode 100644 index 0000000..94b7b76 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go @@ -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) + } + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/machine.go b/modules/logrotate/cmd/logrotate-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/machine.go @@ -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 `keyvalue` 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 +} diff --git a/modules/logrotate/cmd/logrotate-tools/machine_test.go b/modules/logrotate/cmd/logrotate-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/machine_test.go @@ -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") + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/main.go b/modules/logrotate/cmd/logrotate-tools/main.go new file mode 100644 index 0000000..26936ca --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/main.go @@ -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) + }, + }, + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/manifest_test.go b/modules/logrotate/cmd/logrotate-tools/manifest_test.go new file mode 100644 index 0000000..ce88707 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/manifest_test.go @@ -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) + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/shape_test.go b/modules/logrotate/cmd/logrotate-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/shape_test.go @@ -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"]) + } +} diff --git a/modules/logrotate/go.mod b/modules/logrotate/go.mod new file mode 100644 index 0000000..41ecab0 --- /dev/null +++ b/modules/logrotate/go.mod @@ -0,0 +1,5 @@ +module logrotate + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/logrotate/go.sum b/modules/logrotate/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/logrotate/go.sum @@ -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= diff --git a/modules/logrotate/module.json b/modules/logrotate/module.json new file mode 100644 index 0000000..23d3767 --- /dev/null +++ b/modules/logrotate/module.json @@ -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" + ] + } + ] + } +}