v0.23.0 color, presets, certificates made at startup, cli.go, sink flush
This commit is contained in:
1 parent
15745682bc
commit
bf410e1225
50 files changed
+2649
-426
No files matched your search
@@ -7,8 +7,13 @@ type Config struct {
|
||||
// Top-level flags for application control
|
||||
ShowVersion bool `toml:"version"`
|
||||
Check bool `toml:"check"` // build every plugin, report, exit: lw --check
|
||||
Dump bool `toml:"dump"` // print the effective configuration, exit: lw --dump
|
||||
Quiet bool `toml:"quiet"`
|
||||
|
||||
// Console sinks without their own color: "auto" (a terminal, NO_COLOR
|
||||
// unset, TERM not dumb), "always", "never"
|
||||
Color string `toml:"color"`
|
||||
|
||||
// Runtime behavior flags
|
||||
StatusReporter bool `toml:"status_reporter"`
|
||||
ConfigAutoReload bool `toml:"auto_reload"`
|
||||
@@ -85,7 +90,7 @@ type PipelineConfig struct {
|
||||
type FlowConfig struct {
|
||||
Heartbeat *HeartbeatConfig `toml:"heartbeat"`
|
||||
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||
Filters []FilterConfig `toml:"filters"`
|
||||
Filters []FilterConfig `toml:"filters,omitempty"`
|
||||
Format *FormatConfig `toml:"format"`
|
||||
}
|
||||
|
||||
@@ -105,9 +110,9 @@ type HeartbeatConfig struct {
|
||||
// FormatConfig is a polymorphic struct representing log entry formatting options
|
||||
type FormatConfig struct {
|
||||
Type string `toml:"type"` // "json", "txt", "raw"
|
||||
Flags int64 `toml:"flags"`
|
||||
TimestampFormat string `toml:"timestamp_format"`
|
||||
SanitizerPolicy string `toml:"sanitizer_policy"` // "raw", "json", "txt", "shell"
|
||||
Flags int64 `toml:"flags,omitempty"`
|
||||
TimestampFormat string `toml:"timestamp_format,omitempty"`
|
||||
SanitizerPolicy string `toml:"sanitizer_policy,omitempty"` // "raw", "json", "txt", "shell"
|
||||
}
|
||||
|
||||
// --- Rate Limit Options ---
|
||||
@@ -245,6 +250,7 @@ type ConsoleSinkOptions struct {
|
||||
Target string `toml:"target"` // "stdout", "stderr"
|
||||
BufferSize int64 `toml:"buffer_size"`
|
||||
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
|
||||
Color string `toml:"color"` // level names in color: "auto", "always", "never"; default: the top-level color
|
||||
}
|
||||
|
||||
// FileSinkOptions defines settings for a file-based sink
|
||||
@@ -366,9 +372,10 @@ type AuthOptions struct {
|
||||
// --- TLS Options ---
|
||||
|
||||
// TLSOptions is one shape for both roles. Listeners present cert_file and
|
||||
// key_file and verify clients with client_auth and client_ca_file; dialers
|
||||
// verify the server with ca_file and server_name and may present
|
||||
// cert_file and key_file.
|
||||
// key_file, or a certificate issued at startup (self_signed, or from the
|
||||
// issuer files), and verify clients with client_auth and client_ca_file;
|
||||
// dialers verify the server with ca_file and server_name, or pin_sha256, and
|
||||
// may present cert_file and key_file.
|
||||
type TLSOptions struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
|
||||
@@ -376,6 +383,14 @@ type TLSOptions struct {
|
||||
CertFile string `toml:"cert_file"`
|
||||
KeyFile string `toml:"key_file"`
|
||||
|
||||
// Listeners without cert_file: a certificate on a key made at startup,
|
||||
// self-signed or signed by the issuer CA, for hosts plus the host option,
|
||||
// os.Hostname(), localhost and the loopback addresses
|
||||
SelfSigned bool `toml:"self_signed"`
|
||||
IssuerCertFile string `toml:"issuer_cert_file"`
|
||||
IssuerKeyFile string `toml:"issuer_key_file"`
|
||||
Hosts []string `toml:"hosts"`
|
||||
|
||||
// Listener-side peer verification (mTLS)
|
||||
ClientAuth bool `toml:"client_auth"`
|
||||
ClientCAFile string `toml:"client_ca_file"`
|
||||
@@ -384,6 +399,7 @@ type TLSOptions struct {
|
||||
CAFile string `toml:"ca_file"` // empty = system trust store
|
||||
ServerName string `toml:"server_name"` // default: config host
|
||||
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
|
||||
PinSHA256 string `toml:"pin_sha256"` // sha256//BASE64 of the server key, ';' between several
|
||||
|
||||
// Minimum protocol version: "1.2" | "1.3" (default "1.3")
|
||||
MinVersion string `toml:"min_version"`
|
||||
|
||||
+33
-41
@@ -6,7 +6,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
|
||||
@@ -26,14 +25,18 @@ type Manager struct {
|
||||
specs []pipelineSpec // command-line or environment pipelines, kept across reloads
|
||||
}
|
||||
|
||||
// Args is a parsed command line; cmd/lw owns its grammar.
|
||||
type Args struct {
|
||||
File string // -c: the configuration file, "" for the environment or discovery
|
||||
Specs []Spec // pipeline flags, in order
|
||||
Overrides []string // --path=value settings, for lixenwraith/config
|
||||
}
|
||||
|
||||
// Load reads the startup sources and validates the initial configuration.
|
||||
// Watching starts only when Watch is called after successful service startup.
|
||||
func Load(args []string) (*Manager, error) {
|
||||
configPath, isExplicit, configArgs, err := resolveConfigPath(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
specs, configArgs, err := takePipelineSpecs(configArgs)
|
||||
func Load(args Args) (*Manager, error) {
|
||||
configPath, isExplicit := resolveConfigPath(args.File)
|
||||
specs, err := cliSpecs(args.Specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,7 +56,7 @@ func Load(args []string) (*Manager, error) {
|
||||
cfg, err := lconfig.NewBuilder().
|
||||
WithTarget(initial).
|
||||
WithEnvPrefix("LOGWISP_").
|
||||
WithArgs(configArgs).
|
||||
WithArgs(args.Overrides).
|
||||
WithFile(configPath).
|
||||
WithTypedValidator(func(cfg *Config) error {
|
||||
effective := *cfg // the builder keeps cfg; only the copy is replaced
|
||||
@@ -96,6 +99,17 @@ func (m *Manager) Snapshot() (*Config, error) {
|
||||
if err := m.usePipelines(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The top-level color is each console sink's default; the maps are fresh
|
||||
for _, p := range cfg.Pipelines {
|
||||
for i, s := range p.PluginSinks {
|
||||
if _, set := s.Config["color"]; s.Type == "console" && !set {
|
||||
if s.Config == nil {
|
||||
p.PluginSinks[i].Config = map[string]any{}
|
||||
}
|
||||
p.PluginSinks[i].Config["color"] = cfg.Color
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := checkFileKeys(m.path); err != nil {
|
||||
return nil, fmt.Errorf("config file %q: %w", m.path, err)
|
||||
}
|
||||
@@ -145,6 +159,7 @@ func (m *Manager) Close() { m.config.StopAutoUpdate() }
|
||||
func defaults() *Config {
|
||||
return &Config{
|
||||
StatusReporter: true,
|
||||
Color: "auto",
|
||||
Logging: &LogConfig{
|
||||
Output: "stderr",
|
||||
Level: "info",
|
||||
@@ -174,51 +189,28 @@ func pipeDefault(name string) PipelineConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveConfigPath consumes file-selection flags before schema CLI parsing.
|
||||
// The last selection wins, and -- terminates option handling.
|
||||
func resolveConfigPath(args []string) (path string, isExplicit bool, remaining []string, err error) {
|
||||
remaining = make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
remaining = append(remaining, args[i:]...)
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case arg == "-c" || arg == "--config":
|
||||
if i+1 == len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") {
|
||||
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
||||
}
|
||||
i++
|
||||
path, isExplicit = args[i], true
|
||||
case strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "-c="):
|
||||
_, path, _ = strings.Cut(arg, "=")
|
||||
if path == "" {
|
||||
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
||||
}
|
||||
isExplicit = true
|
||||
default:
|
||||
remaining = append(remaining, arg)
|
||||
}
|
||||
}
|
||||
if isExplicit {
|
||||
return path, true, remaining, nil
|
||||
// resolveConfigPath picks the file: -c, then LOGWISP_CONFIG_FILE and
|
||||
// LOGWISP_CONFIG_DIR, then the first of the user's and the working directory's
|
||||
// logwisp.toml. Only the discovered default may be missing.
|
||||
func resolveConfigPath(file string) (path string, isExplicit bool) {
|
||||
if file != "" {
|
||||
return file, true
|
||||
}
|
||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||
path = configFile
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
path = filepath.Join(configDir, configFile)
|
||||
}
|
||||
return path, true, remaining, nil
|
||||
return path, true
|
||||
}
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
return filepath.Join(configDir, "logwisp.toml"), true, remaining, nil
|
||||
return filepath.Join(configDir, "logwisp.toml"), true
|
||||
}
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
path = filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, false, remaining, nil
|
||||
return path, false
|
||||
}
|
||||
}
|
||||
return "logwisp.toml", false, remaining, nil
|
||||
return "logwisp.toml", false
|
||||
}
|
||||
@@ -12,29 +12,20 @@ import (
|
||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||
)
|
||||
|
||||
func TestResolveConfigArguments(t *testing.T) {
|
||||
// -c wins over the environment, which wins over discovery; only the
|
||||
// discovered default is not explicit, so only it may be missing.
|
||||
func TestResolveConfigPath(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
for _, args := range [][]string{{"-c", "chosen.toml"}, {"--config", "chosen.toml"}, {"--config=chosen.toml"}, {"-c=chosen.toml"}, {"-c", "old.toml", "--config=chosen.toml"}} {
|
||||
path, explicit, remaining, err := resolveConfigPath(append(args, "--quiet"))
|
||||
if err != nil || path != "chosen.toml" || !explicit || !reflect.DeepEqual(remaining, []string{"--quiet"}) {
|
||||
t.Fatalf("%v: %q %v %v %v", args, path, explicit, remaining, err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
|
||||
if _, _, _, err := resolveConfigPath(args); err == nil {
|
||||
t.Fatalf("missing path accepted: %v", args)
|
||||
}
|
||||
}
|
||||
args := []string{"--", "-c", "ignored.toml"}
|
||||
path, explicit, remaining, err := resolveConfigPath(args)
|
||||
if err != nil || explicit || path != "logwisp.toml" || !reflect.DeepEqual(remaining, args) {
|
||||
t.Fatalf("terminator ignored: %q %v %v %v", path, explicit, remaining, err)
|
||||
if path, explicit := resolveConfigPath(""); path != "logwisp.toml" || explicit {
|
||||
t.Fatalf("discovered default: %q %v", path, explicit)
|
||||
}
|
||||
t.Setenv("LOGWISP_CONFIG_DIR", "configs")
|
||||
t.Setenv("LOGWISP_CONFIG_FILE", "env.toml")
|
||||
path, explicit, _, err = resolveConfigPath(nil)
|
||||
if err != nil || !explicit || path != filepath.Join("configs", "env.toml") {
|
||||
t.Fatalf("environment path: %q %v %v", path, explicit, err)
|
||||
if path, explicit := resolveConfigPath(""); path != filepath.Join("configs", "env.toml") || !explicit {
|
||||
t.Fatalf("environment path: %q %v", path, explicit)
|
||||
}
|
||||
if path, explicit := resolveConfigPath("chosen.toml"); path != "chosen.toml" || !explicit {
|
||||
t.Fatalf("-c path: %q %v", path, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +34,7 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
||||
testutil.WriteFile(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n")
|
||||
t.Setenv("LOGGING_LEVEL", "invalid-bare-variable")
|
||||
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
||||
m, err := Load([]string{"--config", "selected.toml", "--logging.level=warn", "--quiet"})
|
||||
m, err := Load(Args{File: "selected.toml", Overrides: []string{"--logging.level=warn", "--quiet"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -61,7 +52,7 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
||||
if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
|
||||
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
|
||||
}
|
||||
env, err := Load([]string{"-c=selected.toml"})
|
||||
env, err := Load(Args{File: "selected.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,19 +65,19 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
||||
|
||||
func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
m, err := Load(nil)
|
||||
m, err := Load(Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Close()
|
||||
if _, err := Load([]string{"-c", "missing.toml"}); !errors.Is(err, ErrConfigNotFound) {
|
||||
if _, err := Load(Args{File: "missing.toml"}); !errors.Is(err, ErrConfigNotFound) {
|
||||
t.Fatalf("missing explicit path: %v", err)
|
||||
}
|
||||
if _, err := Load([]string{"--logging.file.max_size_mb=1.5"}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||
if _, err := Load(Args{Overrides: []string{"--logging.file.max_size_mb=1.5"}}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||
t.Fatalf("invalid CLI hidden by missing default: %v", err)
|
||||
}
|
||||
t.Setenv("LOGWISP_STATUS_REPORTER", "invalid")
|
||||
if _, err := Load(nil); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||
if _, err := Load(Args{}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||
t.Fatalf("invalid environment hidden by missing default: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +85,7 @@ func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
||||
func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
testutil.WriteFile(t, "reload.toml", "status_reporter = true\n")
|
||||
m, err := Load([]string{"-c", "reload.toml"})
|
||||
m, err := Load(Args{File: "reload.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -137,7 +128,7 @@ func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
||||
func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
testutil.WriteFile(t, "watched.toml", "auto_reload = true\n")
|
||||
m, err := Load([]string{"-c", "watched.toml"})
|
||||
m, err := Load(Args{File: "watched.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -181,7 +172,7 @@ server_name = "relay.example"
|
||||
type = "none"
|
||||
allow = ["one", "two"]
|
||||
`)
|
||||
m, err := Load([]string{"-c", "plugins.toml"})
|
||||
m, err := Load(Args{File: "plugins.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -221,7 +212,7 @@ type = "tcp"
|
||||
[pipelines.plugin_sinks.config]
|
||||
port = 9000
|
||||
`+table+"\n")
|
||||
m, err := Load([]string{"-c", "plugins.toml"})
|
||||
m, err := Load(Args{File: "plugins.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -257,7 +248,7 @@ type = "tcp"
|
||||
[pipelines.plugin_sinks.config]
|
||||
port = 9000
|
||||
`+body+"\n")
|
||||
m, err := Load([]string{"-c", "typo.toml"})
|
||||
m, err := Load(Args{File: "typo.toml"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -280,7 +271,7 @@ func TestNoConfigFileMeansQuietLogging(t *testing.T) {
|
||||
if c.file != "" {
|
||||
testutil.WriteFile(t, "logwisp.toml", c.file)
|
||||
}
|
||||
m, err := Load(nil)
|
||||
m, err := Load(Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -292,7 +283,7 @@ func TestNoConfigFileMeansQuietLogging(t *testing.T) {
|
||||
}
|
||||
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
||||
os.Remove("logwisp.toml")
|
||||
m, err := Load(nil)
|
||||
m, err := Load(Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -309,7 +300,7 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
||||
testutil.WriteFile(t, "logwisp.toml", "[[pipelines]]\nname = \"p\"\n"+
|
||||
"[[pipelines.plugin_sources]]\nid = \"in\"\ntype = \"null\"\n[[pipelines.plugin_sinks]]\nid = \"out\"\ntype = \"null\"\n"+
|
||||
"[[pipelines.flow.filters]]\ntype = \"exclude\"\npatterns = \"password=\\\\S{8,64}\"\n")
|
||||
m, err := Load(nil)
|
||||
m, err := Load(Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -319,3 +310,25 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
||||
t.Fatalf("patterns: %v %v", cfg.Pipelines[0].Flow.Filters, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The top-level color is the default of every console sink, which may set
|
||||
// its own; other sinks do not take the key.
|
||||
func TestTopLevelColorIsEachConsoleSinksDefault(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console,color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
cfg, err := m.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var colors []any
|
||||
for _, s := range cfg.Pipelines[0].PluginSinks {
|
||||
colors = append(colors, s.Config["color"])
|
||||
}
|
||||
if !reflect.DeepEqual(colors, []any{"always", "never", nil}) {
|
||||
t.Fatalf("console sink colors: %v", colors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Preset is a pipeline built from a few keys: --preset NAME,key=value starts
|
||||
// a pipeline with it. Later flags add sources, sinks and filters, or replace
|
||||
// its format, rate limit and heartbeat.
|
||||
type Preset struct {
|
||||
Name, Summary string
|
||||
Params []PresetParam
|
||||
build func(p *PipelineConfig, v map[string]string) error
|
||||
}
|
||||
|
||||
// PresetParam is one key. A list takes several values, ',' between them or
|
||||
// the key repeated; any other key takes one.
|
||||
type PresetParam struct {
|
||||
Name, Default, Help string
|
||||
Required, List bool
|
||||
}
|
||||
|
||||
// Keys several presets share
|
||||
var (
|
||||
pathParam = PresetParam{Name: "path", Help: "file, directory (its files) or glob to follow; default: standard input"}
|
||||
fromParam = PresetParam{Name: "from", Default: "end", Help: "start|end: where reading a file starts"}
|
||||
formatParam = func(def string) PresetParam { return PresetParam{Name: "format", Default: def, Help: "raw|txt|json"} }
|
||||
tlsParams = []PresetParam{
|
||||
{Name: "cert", Help: "certificate file, for tls=files"},
|
||||
{Name: "key", Help: "key file, for tls=files"},
|
||||
{Name: "issuer_cert", Help: "CA certificate file (lw tls ca), for tls=issuer"},
|
||||
{Name: "issuer_key", Help: "CA key file, for tls=issuer"},
|
||||
{Name: "hosts", List: true, Help: "names and addresses, beyond this host's, of a self or issuer certificate; ',' between them"},
|
||||
}
|
||||
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
||||
)
|
||||
|
||||
var presets = []Preset{
|
||||
{"pipe", "Standard input to standard output, line for line: lw without a file",
|
||||
[]PresetParam{formatParam("raw")},
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
stdio := pipeDefault("")
|
||||
p.PluginSources, p.PluginSinks = stdio.PluginSources, stdio.PluginSinks
|
||||
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||
return nil
|
||||
}},
|
||||
{"tail", "Follow files to standard output, like tail -F",
|
||||
[]PresetParam{{Name: "path", Required: true, Help: "file, directory (its files) or glob to follow"}, fromParam, formatParam("raw")},
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
p.PluginSources, p.PluginSinks = pathOrStdin(v), pipeDefault("").PluginSinks
|
||||
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||
return nil
|
||||
}},
|
||||
{"serve", "Serve files or standard input as a live HTTP stream (SSE)",
|
||||
append([]PresetParam{pathParam, fromParam, formatParam("json"),
|
||||
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"},
|
||||
{Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||
{Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"},
|
||||
{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"},
|
||||
{Name: "viewer", Default: "false", Help: "true: the built-in login and viewer pages, behind proxy"},
|
||||
}, tlsParams...),
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
p.PluginSources = pathOrStdin(v)
|
||||
sink, err := listener(v, "off")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v["users"] != "" {
|
||||
sink["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||
}
|
||||
if v["proxy"] != "" {
|
||||
auth, ok := sink["auth"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("proxy needs users: browsers log in with SCRAM")
|
||||
}
|
||||
auth["trusted_proxies"] = list(v["proxy"])
|
||||
}
|
||||
switch v["viewer"] {
|
||||
case "true":
|
||||
sink["login_page"], sink["viewer_page"] = true, true
|
||||
case "false":
|
||||
default:
|
||||
return fmt.Errorf("viewer %q (valid: true, false)", v["viewer"])
|
||||
}
|
||||
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "http", Type: "http", Config: sink})
|
||||
return nil
|
||||
}},
|
||||
{"edge", "Forward files or standard input to an aggregator over TLS, authenticated",
|
||||
[]PresetParam{pathParam, fromParam,
|
||||
{Name: "to", Required: true, Help: "the aggregator's HOST:PORT; [IPV6]:PORT"},
|
||||
transportParam,
|
||||
{Name: "ca", Help: "CA file that verifies the aggregator; default: system roots"},
|
||||
{Name: "pin", Help: "sha256//BASE64 of the aggregator's key, which it logs with tls=self"},
|
||||
{Name: "server_name", Help: "name the aggregator's certificate carries; default: to's host"},
|
||||
{Name: "user", Help: "SCRAM user (lw auth add-user on the aggregator)"},
|
||||
{Name: "password_file", Help: "file holding the SCRAM password"},
|
||||
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
||||
{Name: "key", Help: "client key file"},
|
||||
{Name: "node", Help: "origin label; default: this host's name"},
|
||||
},
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
p.PluginSources = pathOrStdin(v)
|
||||
typ, err := chainType(v["transport"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host, port, err := splitAddr("to", v["to"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tls := map[string]any{"enabled": true}
|
||||
for key, option := range map[string]string{"ca": "ca_file", "pin": "pin_sha256", "server_name": "server_name", "cert": "cert_file", "key": "key_file"} {
|
||||
if v[key] != "" {
|
||||
tls[option] = v[key]
|
||||
}
|
||||
}
|
||||
var auth map[string]any
|
||||
switch {
|
||||
case v["user"] != "" && v["password_file"] != "":
|
||||
auth = map[string]any{"type": "scram", "username": v["user"], "password_file": v["password_file"]}
|
||||
case v["user"] != "" || v["password_file"] != "":
|
||||
return fmt.Errorf("user and password_file go together")
|
||||
case v["cert"] != "" && v["key"] != "":
|
||||
auth = map[string]any{"type": "mtls"}
|
||||
default:
|
||||
return fmt.Errorf("edge needs user and password_file, or cert and key: it never sends unauthenticated")
|
||||
}
|
||||
sink := map[string]any{"host": host, "port": port, "tls": tls, "auth": auth}
|
||||
if v["node"] != "" {
|
||||
sink["node"] = v["node"]
|
||||
}
|
||||
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: "edge", Type: typ, Config: sink})
|
||||
return nil
|
||||
}},
|
||||
{"aggregator", "Receive from edges over TLS, authenticated, to files or standard output",
|
||||
append([]PresetParam{
|
||||
{Name: "listen", Default: "0.0.0.0:9000", Help: "HOST:PORT; [IPV6]:PORT; [::] for IPv6 only"},
|
||||
transportParam,
|
||||
{Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||
{Name: "users", Help: "credentials file (lw auth add-user): edges log in with SCRAM"},
|
||||
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
||||
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
||||
formatParam("json"),
|
||||
}, tlsParams...),
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
typ, err := chainType(v["transport"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v["tls"] == "off" {
|
||||
return fmt.Errorf("tls=off: edges send credentials and logs, the aggregator always serves TLS")
|
||||
}
|
||||
src, err := listener(v, "self")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v["client_ca"] != "" {
|
||||
tls := src["tls"].(map[string]any)
|
||||
tls["client_auth"], tls["client_ca_file"] = true, v["client_ca"]
|
||||
}
|
||||
switch {
|
||||
case v["users"] != "":
|
||||
src["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||
case v["client_ca"] != "":
|
||||
src["auth"] = map[string]any{"type": "mtls"}
|
||||
default:
|
||||
return fmt.Errorf("aggregator needs users or client_ca: it never receives unauthenticated")
|
||||
}
|
||||
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: "edges", Type: typ, Config: src})
|
||||
p.PluginSinks = pipeDefault("").PluginSinks
|
||||
if v["out"] != "" {
|
||||
p.PluginSinks = []PluginSinkConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": v["out"], "name": "aggregate"}}}
|
||||
}
|
||||
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
// Presets lists the presets, for lw preset
|
||||
func Presets() []Preset { return slices.Clone(presets) }
|
||||
|
||||
// ExpandPreset builds the pipeline a preset makes of values, as --preset does
|
||||
func ExpandPreset(name string, values map[string]string) (PipelineConfig, error) {
|
||||
p := PipelineConfig{Flow: &FlowConfig{}}
|
||||
each := map[string][]string{}
|
||||
for k, v := range values {
|
||||
each[k] = []string{v}
|
||||
}
|
||||
err := applyPreset(&p, name, each)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// applyPreset fills defaults and checks keys before the preset's build
|
||||
func applyPreset(p *PipelineConfig, name string, values map[string][]string) error {
|
||||
i := slices.IndexFunc(presets, func(r Preset) bool { return r.Name == name })
|
||||
if i < 0 {
|
||||
var names []string
|
||||
for _, r := range presets {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
return fmt.Errorf("no preset %q (valid: %s)", name, strings.Join(names, ", "))
|
||||
}
|
||||
r := presets[i]
|
||||
v, keys := map[string]string{}, []string{}
|
||||
for _, k := range r.Params {
|
||||
v[k.Name] = k.Default
|
||||
keys = append(keys, k.Name)
|
||||
}
|
||||
for k, each := range values {
|
||||
j := slices.IndexFunc(r.Params, func(p PresetParam) bool { return p.Name == k })
|
||||
switch {
|
||||
case j < 0:
|
||||
return fmt.Errorf("preset %s has no key %q (valid: %s)", name, k, strings.Join(keys, ", "))
|
||||
case len(each) > 1 && !r.Params[j].List:
|
||||
return fmt.Errorf("preset %s key %q takes one value", name, k)
|
||||
}
|
||||
v[k] = cmp.Or(strings.Join(each, ","), v[k])
|
||||
}
|
||||
for _, k := range r.Params {
|
||||
if k.Required && v[k.Name] == "" {
|
||||
return fmt.Errorf("preset %s needs %s", name, k.Name)
|
||||
}
|
||||
}
|
||||
p.Name = cmp.Or(p.Name, name)
|
||||
return r.build(p, v)
|
||||
}
|
||||
|
||||
func pathOrStdin(v map[string]string) []PluginSourceConfig {
|
||||
if v["path"] == "" {
|
||||
return pipeDefault("").PluginSources
|
||||
}
|
||||
return []PluginSourceConfig{fileSource(v["path"], v["from"])}
|
||||
}
|
||||
|
||||
// fileSource follows path: a directory's files, or the files a glob or a
|
||||
// file name matches in its directory.
|
||||
func fileSource(path, from string) PluginSourceConfig {
|
||||
dir, pattern := path, "*"
|
||||
if fi, err := os.Stat(path); err != nil || !fi.IsDir() {
|
||||
dir, pattern = filepath.Dir(path), filepath.Base(path)
|
||||
}
|
||||
return PluginSourceConfig{ID: "file", Type: "file", Config: map[string]any{"directory": dir, "pattern": pattern, "from": from}}
|
||||
}
|
||||
|
||||
// tlsModes is what each preset tls mode sets, and the preset keys it needs
|
||||
// by the tls option each fills; made marks a certificate made at startup,
|
||||
// which takes hosts.
|
||||
var tlsModes = map[string]struct {
|
||||
set map[string]any
|
||||
needs map[string]string
|
||||
made bool
|
||||
}{
|
||||
"off": {},
|
||||
"self": {set: map[string]any{"self_signed": true}, made: true},
|
||||
"issuer": {needs: map[string]string{"issuer_cert": "issuer_cert_file", "issuer_key": "issuer_key_file"}, made: true},
|
||||
"files": {needs: map[string]string{"cert": "cert_file", "key": "key_file"}},
|
||||
}
|
||||
|
||||
// listener starts a listening plugin's options: host, port and tls, whose
|
||||
// mode decides which certificate keys apply.
|
||||
func listener(v map[string]string, def string) (map[string]any, error) {
|
||||
host, port, err := splitAddr("listen", v["listen"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := map[string]any{"host": host, "port": port}
|
||||
mode := cmp.Or(v["tls"], def)
|
||||
m, ok := tlsModes[mode]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tls %q (valid: %s)", mode, strings.Join(slices.Sorted(maps.Keys(tlsModes)), ", "))
|
||||
}
|
||||
for _, other := range tlsModes {
|
||||
for k := range other.needs {
|
||||
if _, needed := m.needs[k]; needed && v[k] == "" {
|
||||
return nil, fmt.Errorf("tls=%s needs %s", mode, k)
|
||||
} else if !needed && v[k] != "" {
|
||||
return nil, fmt.Errorf("%s does not apply to tls=%s", k, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v["hosts"] != "" && !m.made {
|
||||
return nil, fmt.Errorf("hosts does not apply to tls=%s", mode)
|
||||
}
|
||||
if mode == "off" {
|
||||
return opts, nil
|
||||
}
|
||||
tls := map[string]any{"enabled": true}
|
||||
maps.Copy(tls, m.set)
|
||||
for k, option := range m.needs {
|
||||
tls[option] = v[k]
|
||||
}
|
||||
if v["hosts"] != "" {
|
||||
tls["hosts"] = list(v["hosts"])
|
||||
}
|
||||
opts["tls"] = tls
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func splitAddr(key, addr string) (string, int64, error) {
|
||||
host, p, err := net.SplitHostPort(addr)
|
||||
port, perr := strconv.ParseUint(p, 10, 16)
|
||||
if err != nil || perr != nil || port == 0 {
|
||||
return "", 0, fmt.Errorf("%s %q: want HOST:PORT, an IPv6 address in brackets", key, addr)
|
||||
}
|
||||
return host, int64(port), nil
|
||||
}
|
||||
|
||||
func chainType(transport string) (string, error) {
|
||||
if transport != "tcp" && transport != "http" {
|
||||
return "", fmt.Errorf("transport %q (valid: tcp, http)", transport)
|
||||
}
|
||||
return transport + "_chain", nil
|
||||
}
|
||||
|
||||
func list(s string) []any {
|
||||
var out []any
|
||||
for e := range strings.SplitSeq(s, ",") {
|
||||
if e = strings.TrimSpace(e); e != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A preset starts its pipeline and names it unless --pipeline did; later
|
||||
// sinks add to its own, a later format replaces its own.
|
||||
func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
got := loadPipelines(t, "preset", "tail,path=/var/log/app.log", "sink", "null", "format", "json",
|
||||
"pipeline", "mine", "preset", "pipe")
|
||||
want := []PipelineConfig{{
|
||||
Name: "tail",
|
||||
Flow: &FlowConfig{Format: &FormatConfig{Type: "json"}},
|
||||
PluginSources: []PluginSourceConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log", "pattern": "app.log", "from": "end"}}},
|
||||
PluginSinks: []PluginSinkConfig{
|
||||
{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}},
|
||||
{ID: "null", Type: "null", Config: map[string]any{}},
|
||||
},
|
||||
}, {
|
||||
Name: "mine",
|
||||
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
|
||||
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
|
||||
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}}},
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if _, err := Load(Args{Specs: specs("sink", "null", "preset", "pipe")}); err == nil || !strings.Contains(err.Error(), "must start its pipeline") {
|
||||
t.Fatalf("preset after a sink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Keys are checked against the preset's own, required ones enforced, and
|
||||
// the edge and aggregator refuse to run without authentication.
|
||||
func TestPresetKeysAreChecked(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
for spec, want := range map[string]string{
|
||||
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
||||
"tail,path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
||||
"tail": "preset tail needs path",
|
||||
"tail,path=x,format.type=json": "preset keys do not nest",
|
||||
"tail,path=x,path=y": `key "path" takes one value`,
|
||||
"serve,listen=8080": `listen "8080": want HOST:PORT`,
|
||||
"serve,tls=self,cert=c": "cert does not apply to tls=self",
|
||||
"serve,tls=files,cert=c": "tls=files needs key",
|
||||
"serve,viewer=yes": `viewer "yes"`,
|
||||
"serve,proxy=127.0.0.1": "proxy needs users",
|
||||
"edge,to=agg:9000": "it never sends unauthenticated",
|
||||
"edge,to=agg:9000,user=u": "user and password_file go together",
|
||||
"edge,to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
||||
"aggregator,users=u,tls=off": "the aggregator always serves TLS",
|
||||
"aggregator": "it never receives unauthenticated",
|
||||
} {
|
||||
_, err := Load(Args{Specs: specs("preset", spec)})
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: err = %v, want %s", spec, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
-49
@@ -16,11 +16,28 @@ type specKind struct {
|
||||
flag string
|
||||
typed bool // SPEC starts with TYPE
|
||||
many bool // repeatable within a pipeline; the environment adds _1.._N
|
||||
first bool // starts its pipeline, and names it by TYPE unless --pipeline did
|
||||
apply func(p *PipelineConfig, typ string, opts map[string]any) error
|
||||
}
|
||||
|
||||
var specKinds = []specKind{
|
||||
{"source", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
{"preset", true, false, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
values := map[string][]string{}
|
||||
for k, v := range opts {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
values[k] = []string{v}
|
||||
case []any: // a repeated key
|
||||
for _, e := range v {
|
||||
values[k] = append(values[k], e.(string))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("preset keys do not nest: %q", k)
|
||||
}
|
||||
}
|
||||
return applyPreset(p, typ, values)
|
||||
}},
|
||||
{"source", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
ids := make([]string, len(p.PluginSources))
|
||||
for i, s := range p.PluginSources {
|
||||
ids[i] = s.ID
|
||||
@@ -29,7 +46,7 @@ var specKinds = []specKind{
|
||||
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: id, Type: typ, Config: opts})
|
||||
return err
|
||||
}},
|
||||
{"sink", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
{"sink", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
ids := make([]string, len(p.PluginSinks))
|
||||
for i, s := range p.PluginSinks {
|
||||
ids[i] = s.ID
|
||||
@@ -38,24 +55,24 @@ var specKinds = []specKind{
|
||||
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: id, Type: typ, Config: opts})
|
||||
return err
|
||||
}},
|
||||
{"filter", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
{"filter", true, true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
opts["type"] = typ
|
||||
f := FilterConfig{}
|
||||
err := Scan(opts, &f)
|
||||
p.Flow.Filters = append(p.Flow.Filters, f)
|
||||
return err
|
||||
}},
|
||||
{"format", true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
{"format", true, false, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
|
||||
opts["type"] = typ
|
||||
p.Flow.Format = &FormatConfig{}
|
||||
return Scan(opts, p.Flow.Format)
|
||||
}},
|
||||
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
|
||||
{"rate-limit", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||
{"rate-limit", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
|
||||
return Scan(opts, p.Flow.RateLimit)
|
||||
}},
|
||||
{"heartbeat", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||
{"heartbeat", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
|
||||
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
|
||||
return Scan(opts, p.Flow.Heartbeat)
|
||||
}},
|
||||
@@ -68,33 +85,36 @@ type pipelineSpec struct {
|
||||
value string
|
||||
}
|
||||
|
||||
// takePipelineSpecs consumes the pipeline flags, which lixenwraith/config
|
||||
// would report as unrecognized.
|
||||
func takePipelineSpecs(args []string) (specs []pipelineSpec, rest []string, err error) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--" {
|
||||
return specs, append(rest, args[i:]...), nil
|
||||
}
|
||||
name, value, inline := strings.Cut(args[i], "=")
|
||||
// Spec is one pipeline flag: its name without dashes, and its value.
|
||||
type Spec struct{ Flag, Value string }
|
||||
|
||||
// SpecFlags names the pipeline flags, for the command-line parser.
|
||||
func SpecFlags() []string {
|
||||
flags := []string{"pipeline"}
|
||||
for _, k := range specKinds {
|
||||
flags = append(flags, k.flag)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// cliSpecs looks up the kind of each command-line spec.
|
||||
func cliSpecs(in []Spec) ([]pipelineSpec, error) {
|
||||
var specs []pipelineSpec
|
||||
for _, s := range in {
|
||||
var kind *specKind
|
||||
if name != "--pipeline" {
|
||||
k := slices.IndexFunc(specKinds, func(k specKind) bool { return "--"+k.flag == name })
|
||||
if s.Flag != "pipeline" {
|
||||
k := slices.IndexFunc(specKinds, func(k specKind) bool { return k.flag == s.Flag })
|
||||
if k < 0 {
|
||||
rest = append(rest, args[i])
|
||||
continue
|
||||
return nil, fmt.Errorf("--%s is no pipeline flag", s.Flag)
|
||||
}
|
||||
kind = &specKinds[k]
|
||||
}
|
||||
if !inline && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
i++
|
||||
value = args[i]
|
||||
if s.Value == "" {
|
||||
return nil, fmt.Errorf("--%s requires a value", s.Flag)
|
||||
}
|
||||
if value == "" {
|
||||
return nil, nil, fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
specs = append(specs, pipelineSpec{name, kind, value})
|
||||
specs = append(specs, pipelineSpec{"--" + s.Flag, kind, s.Value})
|
||||
}
|
||||
return specs, rest, nil
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// envPipelineSpecs reads the one-pipeline environment form. An empty variable
|
||||
@@ -131,48 +151,58 @@ func envPipelineSpecs() []pipelineSpec {
|
||||
}
|
||||
|
||||
// buildPipelines turns specs, in order, into pipelines; specs before the first
|
||||
// --pipeline go to one named "cli". A pipeline without a source reads stdin,
|
||||
// one without a sink writes stdout. Every call returns fresh maps.
|
||||
// --pipeline go to one named "cli". Every call returns fresh maps.
|
||||
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
|
||||
var pipelines []PipelineConfig
|
||||
var seen map[*specKind]bool
|
||||
for _, s := range specs {
|
||||
if s.kind == nil || pipelines == nil {
|
||||
name := "cli"
|
||||
if s.kind == nil {
|
||||
name = s.value
|
||||
}
|
||||
pipelines = append(pipelines, PipelineConfig{Name: name, Flow: &FlowConfig{}})
|
||||
if s.kind == nil {
|
||||
pipelines = append(pipelines, PipelineConfig{Name: s.value, Flow: &FlowConfig{}})
|
||||
seen = map[*specKind]bool{}
|
||||
if s.kind == nil {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
p := &pipelines[len(pipelines)-1]
|
||||
if seen[s.kind] && !s.kind.many {
|
||||
return nil, fmt.Errorf("%s %s: pipeline %q already has one", s.name, s.value, p.Name)
|
||||
}
|
||||
seen[s.kind] = true
|
||||
typ, opts, err := parseSpec(s.value, s.kind.typed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %w", s.name, err)
|
||||
}
|
||||
if pipelines == nil {
|
||||
name := "cli"
|
||||
if s.kind.first {
|
||||
name = typ
|
||||
}
|
||||
pipelines = append(pipelines, PipelineConfig{Name: name, Flow: &FlowConfig{}})
|
||||
seen = map[*specKind]bool{}
|
||||
}
|
||||
p := &pipelines[len(pipelines)-1]
|
||||
switch {
|
||||
case seen[s.kind] && !s.kind.many:
|
||||
return nil, fmt.Errorf("%s %s: pipeline %q already has one", s.name, s.value, p.Name)
|
||||
case s.kind.first && len(seen) > 0:
|
||||
return nil, fmt.Errorf("%s %s: must start its pipeline", s.name, s.value)
|
||||
}
|
||||
seen[s.kind] = true
|
||||
if err := s.kind.apply(p, typ, opts); err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
|
||||
}
|
||||
}
|
||||
for i := range pipelines {
|
||||
pipe := pipeDefault(pipelines[i].Name)
|
||||
if len(pipelines[i].PluginSources) == 0 {
|
||||
pipelines[i].PluginSources = pipe.PluginSources
|
||||
}
|
||||
if len(pipelines[i].PluginSinks) == 0 {
|
||||
pipelines[i].PluginSinks = pipe.PluginSinks
|
||||
}
|
||||
useStdio(&pipelines[i])
|
||||
}
|
||||
return pipelines, nil
|
||||
}
|
||||
|
||||
// useStdio makes a pipeline without a source read stdin, and one without a
|
||||
// sink write stdout, as a filter does.
|
||||
func useStdio(p *PipelineConfig) {
|
||||
pipe := pipeDefault(p.Name)
|
||||
if len(p.PluginSources) == 0 {
|
||||
p.PluginSources = pipe.PluginSources
|
||||
}
|
||||
if len(p.PluginSinks) == 0 {
|
||||
p.PluginSinks = pipe.PluginSinks
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
|
||||
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
|
||||
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
|
||||
|
||||
@@ -10,9 +10,18 @@ import (
|
||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||
)
|
||||
|
||||
func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
|
||||
// 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)
|
||||
m, err := Load(Args{Specs: specs(pairs...)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,15 +36,14 @@ func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
|
||||
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.cert_file=c",
|
||||
"--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",
|
||||
"--", "--source", "random")
|
||||
"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.cert_file=c",
|
||||
"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{
|
||||
@@ -67,17 +75,17 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
for want, args := range map[string][]string{
|
||||
`--sink http,port: missing "="`: {"--sink", "http,port"},
|
||||
`--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 rate=1,polcy=drop: unknown key "polcy"`: {"--rate-limit", "rate=1,polcy=drop"},
|
||||
for want, pairs := range map[string][]string{
|
||||
`--sink http,port: missing "="`: {"sink", "http,port"},
|
||||
`--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 rate=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "rate=1,polcy=drop"},
|
||||
} {
|
||||
if _, err := Load(args); err == nil || err.Error() != want {
|
||||
t.Errorf("%q: err = %v, want %s", args, err, want)
|
||||
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
|
||||
t.Errorf("%q: err = %v, want %s", pairs, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +94,7 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
||||
// must not split into several, wider entries.
|
||||
func TestSpecValueIsOneListEntry(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
sink := loadPipelines(t, "--source", "null", "--sink",
|
||||
sink := loadPipelines(t, "source", "null", "sink",
|
||||
`tcp,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 {
|
||||
@@ -115,7 +123,7 @@ func TestEnvironmentPipeline(t *testing.T) {
|
||||
} {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
m, err := Load(nil)
|
||||
m, err := Load(Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -136,7 +144,7 @@ func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
t.Setenv("LOGWISP_SOURCE", "random")
|
||||
t.Setenv("LOGWISP_SINK", "console")
|
||||
got := loadPipelines(t, "--source", "null", "--sink", "null")
|
||||
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)
|
||||
@@ -147,7 +155,7 @@ func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
|
||||
func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
testutil.WriteFile(t, "file.toml", "[logging]\nlevel = \"warn\"\n[[pipelines]]\nname = \"file\"\n")
|
||||
m, err := Load([]string{"-c", "file.toml", "--source", "null", "--sink", "null", "--status_reporter=false"})
|
||||
m, err := Load(Args{File: "file.toml", Specs: specs("source", "null", "sink", "null"), Overrides: []string{"--status_reporter=false"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -165,12 +173,12 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
||||
// stdout, as a filter does; two pipelines cannot both read stdin.
|
||||
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
got := loadPipelines(t, "--filter", "include,patterns=ERROR")
|
||||
got := loadPipelines(t, "filter", "include,patterns=ERROR")
|
||||
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
||||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
||||
t.Fatalf("pipeline: %+v", p)
|
||||
}
|
||||
_, err := Load([]string{"--pipeline", "a", "--sink", "null", "--pipeline", "b", "--sink", "null"})
|
||||
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})
|
||||
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
|
||||
t.Fatalf("second stdin reader: %v", err)
|
||||
}
|
||||
@@ -181,7 +189,7 @@ func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||
// removal fails the reload instead of keeping the removed file's values.
|
||||
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
m, err := Load([]string{"--source", "random,special=true", "--sink", "null"})
|
||||
m, err := Load(Args{Specs: specs("source", "random,special=true", "sink", "null")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ func ValidateConfig(cfg *Config) error {
|
||||
names[p.Name] = struct{}{}
|
||||
}
|
||||
|
||||
if err := lconfig.OneOf("auto", "always", "never")(cfg.Color); err != nil {
|
||||
return fmt.Errorf("color: %w", err)
|
||||
}
|
||||
|
||||
if err := validateLogConfig(cfg.Logging); err != nil {
|
||||
return fmt.Errorf("logging: %w", err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user