Files
mesh-catalog/modules/logrotate/cmd/logrotate-tools/journal.go
T
jochen 2e082d1680 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).
2026-10-04 12:50:20 +02:00

98 lines
3.3 KiB
Go

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
}