Files
mesh-catalog/modules/systemd/cmd/systemd-tools/journal.go
T
jochen 66106d93ac
mesh/delivery-group group feat/journal-window-on-the-seat delivering: 0 of 2 delivered
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery held for a person: its group feat/journal-window-on-the-seat's composed check did not pass for the heads that merged; a person decides that…
systemd: the journal verb reads a window, and failed is the seat's verb
An incident is read for the minutes it happened in (the operator's direction
2026-10-07): journal takes since, until, priority and a fixed-string match.
Every value is one word of journalctl's argv, held to the forms journalctl
reads, so nothing reaches a shell or is read as an option under sudo. What
the unit printed of a secret is redacted, as docker_logs does, before the
match is applied, so a match cannot find one.

systemd_failed becomes the seat's failed, with an optional scope. Needs the
controller's seat with these verbs (mesh-controller, same branch): an older
controller refuses a claim serving a verb its seat does not promise.
2026-10-07 19:15:20 +02:00

172 lines
5.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
)
// JournalQuery is what a journal read is narrowed by.
type JournalQuery struct {
Lines int
Since string
Until string
Match string
Priority 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 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 {
args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit}
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} {
switch v := a[name].(type) {
case nil:
case string:
if name == "match" {
*into = v
} else {
*into = strings.TrimSpace(v)
}
case float64:
if name != "priority" {
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
}