Files
mesh-catalog/modules/systemd/cmd/systemd-tools/journal.go
T
jschoubben 3ebcb15a4b systemd: serve kernel-log, the kernel's messages of one boot (issue 1975)
journalctl --dmesg --boot=N under the same escalation, window, priority, fixed-string match, 2000-line cap
and redaction as the journal verb (shapeLines is the one filter both use); a boot is 0 or a negative whole
number, refused otherwise before anything runs. No scope: the kernel belongs to no manager.

Builds: issue 1975, the operator's request of 2026-11-12 (the lead's handover; no design line names the verb)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:17:28 +01:00

197 lines
6.9 KiB
Go

package main
// What the journal verb may be asked (the operator's direction 2026-10-07, recorded in novox/hq): a time
// window, a priority and a fixed text, beside the unit and how many lines.
//
// **Nothing a caller says reaches a shell, and nothing is read as an option.** journalctl is run with an
// argv of its own words (execRunner), under `sudo -n` for the system journal (issue 255) — so a value that
// journalctl read as an option would be root's option. Every value is therefore held to the forms
// journalctl reads for it and given as `--name=value`, one word: a relative "-30min" is the value of
// --since, never a flag. A value in any other form is refused naming the forms, before anything runs.
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
const (
// MostLines is the most lines one answer carries: well below what the runtime carries back in one reply.
MostLines = 2000
// DefaultLines is how many when the caller does not say.
DefaultLines = 100
// MatchScan is how many of the window's last lines a match is looked for in.
MatchScan = 50000
// LongestLine is where one line is cut, so a single runaway line cannot fill the answer.
LongestLine = 4096
// LongestMatch is the longest text a match may be.
LongestMatch = 256
// MostBoots is how far back a kernel-log read may name a boot.
MostBoots = 9999
)
// JournalQuery is what a journal read is narrowed by.
type JournalQuery struct {
Lines int
Since string
Until string
Match string
Priority string
// Boot is which boot a kernel-log read is of: "0" this boot, "-1" the one before, and so on (empty is "0").
Boot string
}
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
var relative = regexp.MustCompile(`^(now|today|yesterday|tomorrow|[+-]?([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y))+|([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y) ?)+ago)$`)
// localDate is a date, or a date and a time, in the machine's own zone, as journalctl reads it.
var localDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$`)
// priorities are journalctl's priority names, and the words people use for them.
var priorities = map[string]int{
"emerg": 0, "emergency": 0, "panic": 0, "alert": 1, "crit": 2, "critical": 2, "err": 3, "error": 3,
"warning": 4, "warn": 4, "notice": 5, "info": 6, "debug": 7,
}
// when is one bound of the window as journalctl is given it, and the absolute time it names when it is
// one: an RFC 3339 time becomes seconds since the epoch, which every journalctl reads whatever its version.
func when(name, v string, ceil bool) (string, *time.Time, error) {
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
s := t.Unix()
if ceil && t.Nanosecond() > 0 {
s++
}
return "@" + strconv.FormatInt(s, 10), &t, nil
}
if relative.MatchString(v) || localDate.MatchString(v) {
return v, nil, nil
}
return "", nil, fmt.Errorf("%s %q: an RFC 3339 time (2026-10-07T09:30:00Z), a time relative to now "+
"(-30min, -2h, 1h ago, yesterday) or a local date (2026-10-07 09:30)", name, v)
}
// valid is the query with its defaults applied and every value held to its forms.
func (q JournalQuery) valid() (JournalQuery, error) {
switch {
case q.Lines == 0:
q.Lines = DefaultLines
case q.Lines < 0:
return q, fmt.Errorf("lines %d: at least 1", q.Lines)
case q.Lines > MostLines:
q.Lines = MostLines
}
var since, until *time.Time
var err error
if q.Since != "" {
if _, since, err = when("since", q.Since, false); err != nil {
return q, err
}
}
if q.Until != "" {
if _, until, err = when("until", q.Until, true); err != nil {
return q, err
}
}
if since != nil && until != nil && until.Before(*since) {
return q, fmt.Errorf("the window ends (%s) before it starts (%s)", q.Until, q.Since)
}
if q.Priority != "" {
p := strings.ToLower(q.Priority)
if n, ok := priorities[p]; ok {
q.Priority = strconv.Itoa(n)
} else if len(p) != 1 || p[0] < '0' || p[0] > '7' {
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
}
}
if q.Boot != "" {
n, err := strconv.Atoi(q.Boot)
if err != nil || n > 0 || n < -MostBoots {
return q, fmt.Errorf("boot %q: 0 for this boot, -1 for the one before, and so on (down to -%d)", q.Boot, MostBoots)
}
q.Boot = strconv.Itoa(n)
}
if len(q.Match) > LongestMatch {
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
}
if strings.ContainsAny(q.Match, "\n\r\x00") {
return q, fmt.Errorf("a match is one line of text")
}
return q, nil
}
// argv is journalctl's words for a valid query: each value inside the word of its own option.
func (q JournalQuery) argv(unit string, lines int) []string {
return q.window([]string{"--no-pager", "--output=short-iso", "--unit=" + unit}, lines)
}
// kernelArgv is journalctl's words for a valid query of the kernel's messages of one boot (-k, as dmesg
// reads them, but also of an earlier boot and in a window).
func (q JournalQuery) kernelArgv(lines int) []string {
boot := q.Boot
if boot == "" {
boot = "0"
}
return q.window([]string{"--no-pager", "--output=short-iso", "--dmesg", "--boot=" + boot}, lines)
}
// window adds the window, the priority and the line count to the words that choose what is read.
func (q JournalQuery) window(args []string, lines int) []string {
if q.Since != "" {
v, _, _ := when("since", q.Since, false)
args = append(args, "--since="+v)
}
if q.Until != "" {
v, _, _ := when("until", q.Until, true)
args = append(args, "--until="+v)
}
if q.Priority != "" {
args = append(args, "--priority="+q.Priority)
}
return append(args, "--lines="+strconv.Itoa(lines))
}
// journalQuery is the query a call's arguments say. A number may come as a JSON number or as its text:
// the seat's schema carries every argument as a string.
func journalQuery(a map[string]any) (JournalQuery, error) {
q := JournalQuery{}
switch v := a["lines"].(type) {
case nil:
case float64:
if v != float64(int(v)) {
return q, fmt.Errorf("lines %v: a whole number", v)
}
q.Lines = int(v)
case string:
if strings.TrimSpace(v) != "" {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return q, fmt.Errorf("lines %q: a whole number", v)
}
q.Lines = n
}
default:
return q, fmt.Errorf("lines: a whole number")
}
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority, "boot": &q.Boot} {
switch v := a[name].(type) {
case nil:
case string:
if name == "match" {
*into = v
} else {
*into = strings.TrimSpace(v)
}
case float64:
if name != "priority" && name != "boot" {
return q, fmt.Errorf("%s: text", name)
}
*into = strconv.FormatFloat(v, 'f', -1, 64)
default:
return q, fmt.Errorf("%s: text", name)
}
}
return q, nil
}