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 }