Add chlog CLI with chcat/chtail/chgrep entrypoints
Single Go binary for the ClickHouse log store (logs.raw): chlog with cat/tail/grep subcommands, plus chcat/chtail/chgrep argv[0]-dispatched symlink entrypoints. Every query is time-bounded and fully parameterized; chgrep guards wide unfiltered scans. Ships nfpm RPM with completions and woodpecker PR/tag pipelines mirroring node-lookup.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package chlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
colReset = "\x1b[0m"
|
||||
colDim = "\x1b[2m"
|
||||
colCyan = "\x1b[36m"
|
||||
colGreen = "\x1b[32m"
|
||||
colRed = "\x1b[31m"
|
||||
colYel = "\x1b[33m"
|
||||
)
|
||||
|
||||
type Formatter func(w io.Writer, r Row) error
|
||||
|
||||
func NewFormatter(format string, color bool) (Formatter, error) {
|
||||
switch format {
|
||||
case "text":
|
||||
return textFormatter(color), nil
|
||||
case "json":
|
||||
return jsonFormatter, nil
|
||||
case "logfmt":
|
||||
return logfmtFormatter, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown format %q: want text, json or logfmt", format)
|
||||
}
|
||||
}
|
||||
|
||||
// origin renders the row's source identity: ns/pod for k8s rows, host for vm.
|
||||
func origin(r Row) string {
|
||||
if r.Pod != "" {
|
||||
return r.Namespace + "/" + r.Pod
|
||||
}
|
||||
if r.Namespace != "" {
|
||||
return r.Namespace
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func severityColor(sev string) string {
|
||||
switch strings.ToLower(sev) {
|
||||
case "error", "err", "fatal", "critical", "crit":
|
||||
return colRed
|
||||
case "warn", "warning":
|
||||
return colYel
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func textFormatter(color bool) Formatter {
|
||||
return func(w io.Writer, r Row) error {
|
||||
ts := r.Time().Format("2006-01-02T15:04:05.000Z")
|
||||
msg := r.Message
|
||||
if !color {
|
||||
_, err := fmt.Fprintf(w, "%s %s %s\n", ts, origin(r), msg)
|
||||
return err
|
||||
}
|
||||
if c := severityColor(r.Severity); c != "" {
|
||||
msg = c + msg + colReset
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%s%s%s %s%s%s %s\n",
|
||||
colDim, ts, colReset, colCyan, origin(r), colReset, msg)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type jsonRow struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Host string `json:"host"`
|
||||
Source string `json:"source"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Pod string `json:"pod,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func jsonFormatter(w io.Writer, r Row) error {
|
||||
b, err := json.Marshal(jsonRow{
|
||||
Timestamp: r.Time().Format(time.RFC3339Nano),
|
||||
Host: r.Host,
|
||||
Source: r.Source,
|
||||
Namespace: r.Namespace,
|
||||
Pod: r.Pod,
|
||||
Container: r.Container,
|
||||
Stream: r.Stream,
|
||||
Severity: r.Severity,
|
||||
Message: r.Message,
|
||||
Labels: r.Labels,
|
||||
Fields: r.Fields,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "%s\n", b)
|
||||
return err
|
||||
}
|
||||
|
||||
func logfmtValue(s string) string {
|
||||
if s == "" {
|
||||
return `""`
|
||||
}
|
||||
if strings.ContainsAny(s, " \t\"=\n") {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func logfmtFormatter(w io.Writer, r Row) error {
|
||||
var b strings.Builder
|
||||
pair := func(k, v string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(logfmtValue(v))
|
||||
}
|
||||
pair("ts", r.Time().Format(time.RFC3339Nano))
|
||||
pair("source", r.Source)
|
||||
pair("host", r.Host)
|
||||
pair("ns", r.Namespace)
|
||||
pair("pod", r.Pod)
|
||||
pair("container", r.Container)
|
||||
pair("stream", r.Stream)
|
||||
pair("severity", r.Severity)
|
||||
pair("app", r.Labels["app"])
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString("msg=")
|
||||
b.WriteString(logfmtValue(r.Message))
|
||||
b.WriteByte('\n')
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user