Files
logwisp/internal/config/spec_test.go
T
Lixen 6a168212de
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
v0.38.0 lwconf updates for deployment
lwconf: sparse stages, every problem of a part, pastes read by form
2026-10-22 17:34:20 -04:00

237 lines
9.9 KiB
Go

package config
import (
"errors"
"os"
"reflect"
"strings"
"testing"
"github.com/lixenwraith/logwisp/internal/testutil"
)
// specs pairs flags with values: specs("sink", "null") is --sink null
func specs(pairs ...string) []Spec {
var out []Spec
for i := 0; i < len(pairs); i += 2 {
out = append(out, Spec{pairs[i], pairs[i+1]})
}
return out
}
func loadPipelines(t *testing.T, pairs ...string) []PipelineConfig {
t.Helper()
m, err := Load(Args{Specs: specs(pairs...)})
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
return cfg.Pipelines
}
func TestPipelineSpecGrammar(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t,
"source", `file:directory=/var/log/a\,b,pattern=*.log`,
"source", `file:id=app,directory=x\=y`,
"source", `file:directory=c:\\logs\\`,
"sink", "http:port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
"filter", "include:patterns=ERROR,patterns=WARN",
"filter", `exclude:patterns=\d{1\,3}`,
"format", `txt:timestamp_format=Jan 2\, 2006`,
"pipeline", "b", "source", "null", "sink", "null", "sink", "null", "heartbeat", "interval_ms=1000")
want := []PipelineConfig{{
Name: "cli",
Flow: &FlowConfig{
Filters: []FilterConfig{
{Type: "include", Patterns: []string{"ERROR", "WARN"}},
{Type: "exclude", Patterns: []string{`\d{1,3}`}},
},
Format: &FormatConfig{Type: "txt", TimestampFormat: "Jan 2, 2006"},
},
PluginSources: []PluginSourceConfig{
{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log/a,b", "pattern": "*.log"}},
{ID: "app", Type: "file", Config: map[string]any{"directory": "x=y"}},
{ID: "file_2", Type: "file", Config: map[string]any{"directory": `c:\logs\`}},
},
PluginSinks: []PluginSinkConfig{{ID: "http", Type: "http", Config: map[string]any{
"port": int64(8080),
"auth": map[string]any{"type": "scram", "credentials_file": "users.toml"},
"tls": map[string]any{"enabled": true, "cert_file": "c", "key_file": "k"},
}}},
}, {
Name: "b",
Flow: &FlowConfig{Heartbeat: &HeartbeatConfig{Enabled: true, IntervalMS: 1000}},
PluginSources: []PluginSourceConfig{{ID: "null", Type: "null", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{
{ID: "null", Type: "null", Config: map[string]any{}},
{ID: "null_2", Type: "null", Config: map[string]any{}},
},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
}
for want, pairs := range map[string][]string{
`--sink http:port: missing "="`: {"sink", "http:port"},
`--source file,directory=/x: TYPE ends at ':', as in file:directory=/x`: {"source", "file,directory=/x"},
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
`--source file:type=x: TYPE already sets "type"`: {"source", "file:type=x"},
`--sink http:tls.cert_file=c: "tls" is both a value and a table`: {"sink", "http:tls=1,tls.cert_file=c"},
`--sink requires a value`: {"sink", ""},
`--format txt: pipeline "cli" already has one`: {"format", "json", "format", "txt"},
`--rate-limit entries_per_second=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "entries_per_second=1,polcy=drop"},
} {
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
t.Errorf("%q: err = %v, want %s", pairs, err, want)
}
}
}
// A spec value is one list entry, commas included: a regex quantifier or a DN
// must not split into several, wider entries.
func TestSpecValueIsOneListEntry(t *testing.T) {
isolateConfig(t)
sink := loadPipelines(t, "source", "null", "sink",
`tcp:port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
`auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
var opts TCPSinkOptions
if err := Scan(sink.Config, &opts); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Auth.AllowPatterns, []string{`^edge-\d{1,3}$`}) ||
!reflect.DeepEqual(opts.Auth.Allow, []string{"CN=a,O=b", "CN=c"}) {
t.Fatalf("allow_patterns %q, allow %q", opts.Auth.AllowPatterns, opts.Auth.Allow)
}
}
// One pipeline from LOGWISP_*: numbered variables follow the bare one in
// numeric order, only repeatable kinds are numbered, and empty means unset.
func TestEnvironmentPipeline(t *testing.T) {
isolateConfig(t)
for name, value := range map[string]string{
"LOGWISP_PIPELINE": "edge",
"LOGWISP_SOURCE": "random",
"LOGWISP_SOURCE_10": "null:id=ten",
"LOGWISP_SOURCE_2": "null:id=two",
"LOGWISP_SINK_1": "console",
"LOGWISP_FILTER": "",
"LOGWISP_FORMAT_1": "raw",
"LOGWISP_RATE_LIMIT": "entries_per_second=10",
"LOGWISP_LOGGING_LEVEL": "debug",
} {
t.Setenv(name, value)
}
m, err := Load(Args{})
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
p := cfg.Pipelines
if len(p) != 1 || p[0].Name != "edge" || len(p[0].PluginSources) != 3 || p[0].PluginSources[1].ID != "two" ||
p[0].PluginSources[2].ID != "ten" || len(p[0].PluginSinks) != 1 || p[0].Flow.Filters != nil || p[0].Flow.Format != nil ||
*p[0].Flow.RateLimit != (RateLimitConfig{EntriesPerSecond: 10, Policy: "drop"}) || cfg.Logging.Level != "debug" {
t.Fatalf("environment pipeline: %+v %+v", p, cfg.Logging)
}
}
func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
isolateConfig(t)
t.Setenv("LOGWISP_SOURCE", "random")
t.Setenv("LOGWISP_SINK", "console")
got := loadPipelines(t, "source", "null", "sink", "null")
if len(got) != 1 || len(got[0].PluginSources) != 1 || got[0].PluginSources[0].Type != "null" ||
len(got[0].PluginSinks) != 1 || got[0].PluginSinks[0].Type != "null" {
t.Fatalf("environment specs mixed into command-line pipelines: %+v", got)
}
}
// The file's pipelines are dropped unvalidated; its other keys still apply.
func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
isolateConfig(t)
testutil.WriteFile(t, "file.toml", "[logging]\nlevel = \"warn\"\n[[pipelines]]\nname = \"file\"\n")
m, err := Load(Args{File: "file.toml", Specs: specs("source", "null", "sink", "null"), Overrides: []string{"--status_reporter=false"}})
if err != nil {
t.Fatal(err)
}
defer m.Close()
if unknown := m.config.UnknownCLIKeys(); len(unknown) != 0 {
t.Fatalf("spec flags reached the schema parser: %v", unknown)
}
cfg, err := m.Snapshot()
if err != nil || len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "cli" || cfg.Logging.Level != "warn" || cfg.StatusReporter {
t.Fatalf("snapshot: %+v %v", cfg, err)
}
}
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does, each part under its type's own id; two pipelines
// cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "filter", "include:patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "console" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "console" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})
if err == nil || !strings.Contains(err.Error(), `one console source may run, and pipeline "a" has it`) {
t.Fatalf("second stdin reader: %v", err)
}
}
// Without a file a reload still rebuilds; once the discovered default
// appears it is read, and its pipelines still yield to the specs. Its later
// removal fails the reload instead of keeping the removed file's values.
func TestReloadKeepsSpecPipelines(t *testing.T) {
isolateConfig(t)
m, err := Load(Args{Specs: specs("source", "random:special=true", "sink", "null")})
if err != nil {
t.Fatal(err)
}
defer m.Close()
first, err := m.Reload()
if err != nil || first.StatusReporter {
t.Fatalf("reload without a file: %+v %v", first, err)
}
first.Pipelines[0].PluginSources[0].Config["special"] = "mutated"
testutil.WriteFile(t, "logwisp.toml", "status_reporter = true\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload()
if err != nil || !next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != true {
t.Fatalf("reload: %+v %v", next, err)
}
if err := os.Remove("logwisp.toml"); err != nil {
t.Fatal(err)
}
if _, err := m.Reload(); !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("reload after the file was removed: %v", err)
}
}
// Written specs build the pipelines a file would, with the keys it set: a
// typed stage names its type, the rate limit keeps its file policy and the
// heartbeat its off, and an id is written only where a spec would not take it
func TestWrittenSpecsBuildWhatAFileWould(t *testing.T) {
p := PipelineConfig{Name: "p", Flow: &FlowConfig{Format: &FormatConfig{}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5}, Heartbeat: &HeartbeatConfig{}},
PluginSources: []PluginSourceConfig{{ID: "in", Type: "null", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "null_2", Type: "null", Config: map[string]any{}}, {ID: "null", Type: "null", Config: map[string]any{}}}}
specs := PipelineSpecs([]PipelineConfig{p})
got, err := SpecPipelines(specs, nil)
p.Flow = &FlowConfig{Format: &FormatConfig{Type: "raw"}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5, Policy: "pass"}, Heartbeat: &HeartbeatConfig{}}
if err != nil || !reflect.DeepEqual(got, []PipelineConfig{p}) {
t.Fatalf("got %+v %v\nwant %+v", got, err, p)
}
if want := []Spec{{"pipeline", "p"}, {"source", "null:id=in"}, {"sink", "null:id=null_2"}, {"sink", "null"}, {"format", "raw"},
{"rate-limit", "entries_per_second=5,policy=pass"}, {"heartbeat", "enabled=false"}}; !reflect.DeepEqual(specs, want) {
t.Fatalf("specs %v, want %v", specs, want)
}
}