systemd: the journal verb reads a window, and failed is the seat's verb
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 covered: a later merge that contains it was delivered: novox/mesh-catalog@cb733d1b7cea (merged as 2fa39eac into main, walk plan-17914094762…
mesh/delivery-group group feat/journal-window-on-the-seat delivered: every member is 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 covered: a later merge that contains it was delivered: novox/mesh-catalog@cb733d1b7cea (merged as 2fa39eac into main, walk plan-17914094762…
mesh/delivery-group group feat/journal-window-on-the-seat delivered: every member is delivered
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.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user