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,131 @@
|
||||
package chlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Table = "logs.raw"
|
||||
|
||||
// Filter describes one bounded query against logs.raw. Since/Until are
|
||||
// mandatory: the table has no text index, so every query must be time-bounded.
|
||||
type Filter struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
|
||||
Namespace string
|
||||
Host string
|
||||
Pod string
|
||||
Container string
|
||||
App string
|
||||
Severity string
|
||||
Stream string
|
||||
Source string
|
||||
|
||||
Pattern string
|
||||
Regex bool
|
||||
IgnoreCase bool
|
||||
Fields map[string]string
|
||||
|
||||
Limit uint64
|
||||
}
|
||||
|
||||
// Selective reports whether the filter narrows the scan enough to be cheap:
|
||||
// any of namespace, host, or app restricts to a small slice of the table.
|
||||
func (f Filter) Selective() bool {
|
||||
return f.Namespace != "" || f.Host != "" || f.App != ""
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
SQL string
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
const selectColumns = "timestamp, host, source, namespace, pod, container, stream, severity, message, labels, fields"
|
||||
|
||||
func chTime(t time.Time) string {
|
||||
return strconv.FormatInt(t.UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// Build renders a fully parameterized query. All user-supplied values travel
|
||||
// as HTTP {name:Type} parameters, never interpolated into the SQL text.
|
||||
func Build(f Filter) (Query, error) {
|
||||
if f.Since.IsZero() || f.Until.IsZero() {
|
||||
return Query{}, fmt.Errorf("query must be time-bounded: since/until missing")
|
||||
}
|
||||
if !f.Since.Before(f.Until) {
|
||||
return Query{}, fmt.Errorf("empty time range: since %s is not before until %s",
|
||||
f.Since.UTC().Format(time.RFC3339), f.Until.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
params := map[string]string{
|
||||
"since_ms": chTime(f.Since),
|
||||
"until_ms": chTime(f.Until),
|
||||
}
|
||||
where := []string{
|
||||
"timestamp >= fromUnixTimestamp64Milli({since_ms:Int64})",
|
||||
"timestamp < fromUnixTimestamp64Milli({until_ms:Int64})",
|
||||
}
|
||||
|
||||
addEq := func(column, name, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
where = append(where, fmt.Sprintf("%s = {%s:String}", column, name))
|
||||
params[name] = value
|
||||
}
|
||||
addEq("namespace", "ns", f.Namespace)
|
||||
addEq("host", "host", f.Host)
|
||||
addEq("pod", "pod", f.Pod)
|
||||
addEq("container", "container", f.Container)
|
||||
addEq("stream", "stream", f.Stream)
|
||||
addEq("source", "source", f.Source)
|
||||
addEq("labels['app']", "app", f.App)
|
||||
|
||||
if f.Severity != "" {
|
||||
where = append(where, "lowerUTF8(severity) = {severity:String}")
|
||||
params["severity"] = strings.ToLower(f.Severity)
|
||||
}
|
||||
|
||||
if f.Pattern != "" {
|
||||
switch {
|
||||
case f.Regex:
|
||||
pat := f.Pattern
|
||||
if f.IgnoreCase {
|
||||
pat = "(?i)" + pat
|
||||
}
|
||||
where = append(where, "match(message, {pattern:String})")
|
||||
params["pattern"] = pat
|
||||
case f.IgnoreCase:
|
||||
where = append(where, "positionCaseInsensitive(message, {pattern:String}) > 0")
|
||||
params["pattern"] = f.Pattern
|
||||
default:
|
||||
where = append(where, "position(message, {pattern:String}) > 0")
|
||||
params["pattern"] = f.Pattern
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(f.Fields))
|
||||
for k := range f.Fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for i, k := range keys {
|
||||
kn := fmt.Sprintf("fk%d", i)
|
||||
vn := fmt.Sprintf("fv%d", i)
|
||||
where = append(where, fmt.Sprintf("fields[{%s:String}] = {%s:String}", kn, vn))
|
||||
params[kn] = k
|
||||
params[vn] = f.Fields[k]
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf("SELECT %s FROM %s WHERE %s ORDER BY timestamp ASC",
|
||||
selectColumns, Table, strings.Join(where, " AND "))
|
||||
if f.Limit > 0 {
|
||||
sql += " LIMIT {limit:UInt64}"
|
||||
params["limit"] = strconv.FormatUint(f.Limit, 10)
|
||||
}
|
||||
return Query{SQL: sql, Params: params}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user