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,150 @@
|
||||
package chlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleRow() Row {
|
||||
return Row{
|
||||
Timestamp: "2026-08-23 05:00:01.234",
|
||||
Host: "node1",
|
||||
Source: "k8s",
|
||||
Namespace: "logging",
|
||||
Pod: "vector-abc",
|
||||
Container: "vector",
|
||||
Stream: "stdout",
|
||||
Severity: "Error",
|
||||
Message: "something broke",
|
||||
Labels: map[string]string{"app": "vector"},
|
||||
Fields: map[string]string{"req_id": "42"},
|
||||
}
|
||||
}
|
||||
|
||||
func render(t *testing.T, format string, color bool, r Row) string {
|
||||
t.Helper()
|
||||
f, err := NewFormatter(format, color)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := f(&b, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestTextNoColor(t *testing.T) {
|
||||
out := render(t, "text", false, sampleRow())
|
||||
want := "2026-08-23T05:00:01.234Z logging/vector-abc something broke\n"
|
||||
if out != want {
|
||||
t.Errorf("got %q, want %q", out, want)
|
||||
}
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Error("no-color output contains ANSI escapes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextColor(t *testing.T) {
|
||||
out := render(t, "text", true, sampleRow())
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Error("color output missing ANSI escapes")
|
||||
}
|
||||
if !strings.Contains(out, "logging/vector-abc") || !strings.Contains(out, "something broke") {
|
||||
t.Errorf("content missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextVMRowUsesHost(t *testing.T) {
|
||||
r := sampleRow()
|
||||
r.Source = "vm"
|
||||
r.Namespace = ""
|
||||
r.Pod = ""
|
||||
out := render(t, "text", false, r)
|
||||
if !strings.Contains(out, " node1 ") {
|
||||
t.Errorf("vm row should show host: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormat(t *testing.T) {
|
||||
out := render(t, "json", false, sampleRow())
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("not valid JSON: %v: %q", err, out)
|
||||
}
|
||||
if m["timestamp"] != "2026-08-23T05:00:01.234Z" {
|
||||
t.Errorf("timestamp = %v", m["timestamp"])
|
||||
}
|
||||
if m["message"] != "something broke" || m["namespace"] != "logging" {
|
||||
t.Errorf("fields wrong: %v", m)
|
||||
}
|
||||
labels, _ := m["labels"].(map[string]any)
|
||||
if labels["app"] != "vector" {
|
||||
t.Errorf("labels = %v", m["labels"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogfmtFormat(t *testing.T) {
|
||||
out := render(t, "logfmt", false, sampleRow())
|
||||
for _, want := range []string{
|
||||
"ts=2026-08-23T05:00:01.234Z",
|
||||
"ns=logging",
|
||||
"pod=vector-abc",
|
||||
"severity=Error",
|
||||
"app=vector",
|
||||
`msg="something broke"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("logfmt missing %q: %q", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(out, "\n") {
|
||||
t.Error("missing trailing newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogfmtQuoting(t *testing.T) {
|
||||
r := sampleRow()
|
||||
r.Message = `plain`
|
||||
out := render(t, "logfmt", false, r)
|
||||
if !strings.Contains(out, "msg=plain") {
|
||||
t.Errorf("unquoted simple value expected: %q", out)
|
||||
}
|
||||
r.Message = "has \"quotes\" and = signs"
|
||||
out = render(t, "logfmt", false, r)
|
||||
if !strings.Contains(out, `msg="has \"quotes\" and = signs"`) {
|
||||
t.Errorf("quoted value wrong: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFormat(t *testing.T) {
|
||||
if _, err := NewFormatter("yaml", false); err == nil {
|
||||
t.Fatal("expected error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRowKeyStableAndDistinct(t *testing.T) {
|
||||
a := sampleRow()
|
||||
b := sampleRow()
|
||||
if a.Key() != b.Key() {
|
||||
t.Error("identical rows should share a key")
|
||||
}
|
||||
b.Message = "different"
|
||||
if a.Key() == b.Key() {
|
||||
t.Error("different rows should not share a key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRowTimeParses(t *testing.T) {
|
||||
r := sampleRow()
|
||||
want := time.Date(2026, 8, 23, 5, 0, 1, 234000000, time.UTC)
|
||||
if !r.Time().Equal(want) {
|
||||
t.Errorf("Time() = %s, want %s", r.Time(), want)
|
||||
}
|
||||
r.Timestamp = "2026-08-23 05:00:01"
|
||||
if r.Time().IsZero() {
|
||||
t.Error("timestamp without fraction should still parse")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user