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:
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module logrotate
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user