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).
98 lines
3.3 KiB
Go
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
|
|
}
|