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…
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.
172 lines
5.9 KiB
Go
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
|
|
}
|