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).
189 lines
6.8 KiB
Go
189 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|