v0.32.0 lwconf engine update: config by path, schema from code, wasm
This commit is contained in:
1 parent
c2e3d669da
commit
a1e9d21d06
33 files changed
+1278
-421
No files matched your search
@@ -154,7 +154,7 @@ func TestNewValidation(t *testing.T) {
|
||||
{"trusted proxies without a type", &config.AuthOptions{TrustedProxies: proxies}, nil, config.Listener, HTTP, "trusted_proxies is set"},
|
||||
{"trusted proxies under mtls", &config.AuthOptions{Type: MethodMTLS, TrustedProxies: proxies}, mtlsListenerTLS(), config.Listener, HTTP, "trusted_proxies applies only"},
|
||||
{"trusted proxies on a chain source", listener(config.AuthOptions{TrustedProxies: proxies}), nil, config.ChainListener, HTTP, "only to the http sink"},
|
||||
{"trusted proxies on tcp", listener(config.AuthOptions{TrustedProxies: proxies}), nil, config.Listener, TCP, "only to the http sink"},
|
||||
{"trusted proxies on tcp", listener(config.AuthOptions{TrustedProxies: proxies}), nil, config.Listener, TCP, "only to HTTP listeners"},
|
||||
{"trusted proxies with certificate binding", listener(config.AuthOptions{TrustedProxies: proxies, Identity: "cn"}), f.serverTLS, config.Listener, HTTP, "TLS-terminating proxy"},
|
||||
{"malformed trusted proxy", listener(config.AuthOptions{TrustedProxies: []string{"localhost"}}), nil, config.Listener, HTTP, "neither an address"},
|
||||
{"trusted proxies on a dialer", dialer(config.AuthOptions{TrustedProxies: proxies}), &tls.Config{}, config.Dialer, HTTP, "only to listeners"},
|
||||
|
||||
+148
-30
@@ -32,9 +32,9 @@ type Composition struct {
|
||||
// by its key in the flow table: filters with an index, format, rate_limit or
|
||||
// heartbeat
|
||||
type Node struct {
|
||||
Role string
|
||||
ID string
|
||||
Index int
|
||||
Role string `json:"role"`
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
// FromConfig starts from a loaded configuration's pipelines, its console sinks
|
||||
@@ -100,8 +100,10 @@ func FromCommandLine(line string, isDir config.IsDir) (*Composition, error) {
|
||||
}
|
||||
|
||||
// settle gives every flow stage its defaults, every plugin's options their
|
||||
// kinds and every source and sink an id; the rules are Validate's
|
||||
// kinds and every source and sink an id; the rules are Validate's. Its error
|
||||
// joins a plugin's each holding a value of the wrong kind, kept as given.
|
||||
func (c *Composition) settle() error {
|
||||
var errs []error
|
||||
for i := range c.Pipelines {
|
||||
p := &c.Pipelines[i]
|
||||
if p.Flow == nil {
|
||||
@@ -122,17 +124,17 @@ func (c *Composition) settle() error {
|
||||
for j, s := range p.PluginSources {
|
||||
p.PluginSources[j].Config = orEmpty(s.Config)
|
||||
if err := config.Coerce("source", s.Type, p.PluginSources[j].Config); err != nil {
|
||||
return config.At(fmt.Sprintf("pipelines[%d].plugin_sources[%s].config", i, s.ID), err)
|
||||
errs = append(errs, config.At(Node{Role: "source", ID: s.ID}.Path(i), err))
|
||||
}
|
||||
}
|
||||
for j, s := range p.PluginSinks {
|
||||
p.PluginSinks[j].Config = orEmpty(s.Config)
|
||||
if err := config.Coerce("sink", s.Type, p.PluginSinks[j].Config); err != nil {
|
||||
return config.At(fmt.Sprintf("pipelines[%d].plugin_sinks[%s].config", i, s.ID), err)
|
||||
errs = append(errs, config.At(Node{Role: "sink", ID: s.ID}.Path(i), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// name gives the parts a file left unnamed the ids their specs would get: the
|
||||
@@ -340,6 +342,17 @@ func index(p *config.PipelineConfig, n Node) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Path is the path of node n's options in pipeline pi, as Locate reads it
|
||||
func (n Node) Path(pi int) string {
|
||||
switch n.Role {
|
||||
case "source", "sink":
|
||||
return fmt.Sprintf("pipelines[%d].plugin_%ss[%s].config", pi, n.Role, n.ID)
|
||||
case "filters":
|
||||
return fmt.Sprintf("pipelines[%d].flow.filters[%d]", pi, n.Index)
|
||||
}
|
||||
return fmt.Sprintf("pipelines[%d].flow.%s", pi, n.Role)
|
||||
}
|
||||
|
||||
func (n Node) missing() error {
|
||||
if n.Role == "filters" {
|
||||
return fmt.Errorf("no filter %d", n.Index)
|
||||
@@ -476,9 +489,69 @@ func (c *Composition) Options(pi int, n Node) (map[string]any, bool, error) {
|
||||
return nil, false, n.missing()
|
||||
}
|
||||
|
||||
// Validate applies every rule lw applies at load, naming the refused key's path
|
||||
// Validate applies every rule lw applies at load: its error joins every
|
||||
// problem, one a part, which Problems splits and Locate places
|
||||
func (c *Composition) Validate() error { return config.ValidatePipelines(c.Pipelines) }
|
||||
|
||||
// Problems are the problems an error joins, in order
|
||||
func Problems(err error) []error {
|
||||
if joined, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
var out []error
|
||||
for _, e := range joined.Unwrap() {
|
||||
out = append(out, Problems(e)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{err}
|
||||
}
|
||||
|
||||
// Locate reads where a problem lies from its path: the pipeline, the node,
|
||||
// and the key in the node's options, a list's without its index; text is
|
||||
// what is wrong. ok is false for a problem no pipeline holds.
|
||||
func Locate(err error) (pi int, n Node, key, text string, ok bool) {
|
||||
ke, _ := errors.AsType[*config.KeyError](err)
|
||||
rest, found := "", false
|
||||
if ke != nil {
|
||||
rest, found = strings.CutPrefix(ke.Key, "pipelines[")
|
||||
}
|
||||
end := strings.IndexByte(rest, ']')
|
||||
if !found || end < 0 {
|
||||
return 0, n, "", err.Error(), false
|
||||
}
|
||||
if pi, err = strconv.Atoi(rest[:end]); err != nil {
|
||||
return 0, n, "", ke.Error(), false
|
||||
}
|
||||
rest = strings.TrimPrefix(rest[end+1:], ".")
|
||||
for role, list := range map[string]string{"source": "plugin_sources[", "sink": "plugin_sinks["} {
|
||||
if after, ok := strings.CutPrefix(rest, list); ok {
|
||||
id, more := cutID(after)
|
||||
n.Role, n.ID, rest = role, id, strings.TrimPrefix(strings.TrimPrefix(more, "config"), ".")
|
||||
}
|
||||
}
|
||||
if stage, ok := strings.CutPrefix(rest, "flow."); ok {
|
||||
name, more, _ := strings.Cut(stage, ".")
|
||||
name, index, _ := strings.Cut(name, "[")
|
||||
n.Role, rest = name, more
|
||||
n.Index, _ = strconv.Atoi(strings.TrimSuffix(index, "]"))
|
||||
}
|
||||
key, _, _ = strings.Cut(rest, "[")
|
||||
return pi, n, key, ke.Err.Error(), true
|
||||
}
|
||||
|
||||
// cutID cuts a plugin path at its id's ']', the first that ends the path or
|
||||
// starts its type or config: ids are free text, and keys hold list indexes
|
||||
func cutID(path string) (id, rest string) {
|
||||
for i := range len(path) {
|
||||
if after := path[i+1:]; path[i] == ']' && (after == "" || after == ".type" || after == ".config" || strings.HasPrefix(after, ".config.")) {
|
||||
return path[:i], strings.TrimPrefix(after, ".")
|
||||
}
|
||||
}
|
||||
return path, ""
|
||||
}
|
||||
|
||||
// CommandLine writes the composition as an lw command line, a flag a line,
|
||||
// quoted for a POSIX shell
|
||||
func (c *Composition) CommandLine() (string, error) {
|
||||
@@ -549,28 +622,77 @@ func (c *Composition) File() ([]byte, error) {
|
||||
return toml.Marshal(map[string]any{"pipelines": c.Pipelines})
|
||||
}
|
||||
|
||||
// Form is a way to write a composition, named for what it is: each loads back
|
||||
// to the same pipelines
|
||||
// Form is a way to write a composition, named for what it is, and to read it
|
||||
// back to the same pipelines
|
||||
type Form struct {
|
||||
Name, Hint string
|
||||
Write func(*Composition) (string, error)
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"`
|
||||
Write func(*Composition) (string, error) `json:"-"`
|
||||
Read func(string, config.IsDir) (*Composition, error) `json:"-"`
|
||||
}
|
||||
|
||||
var forms = []Form{
|
||||
{"command", "lw's flags", (*Composition).CommandLine},
|
||||
{"environment", "LOGWISP_ variables: one pipeline", (*Composition).Environment},
|
||||
{"command", "lw's flags", (*Composition).CommandLine, FromCommandLine},
|
||||
{"environment", "LOGWISP_ variables: one pipeline", (*Composition).Environment, FromEnvironment},
|
||||
{"file", "a configuration file's pipelines", func(c *Composition) (string, error) {
|
||||
data, err := c.File()
|
||||
return string(data), err
|
||||
}},
|
||||
}, func(text string, _ config.IsDir) (*Composition, error) { return FromFile([]byte(text)) }},
|
||||
}
|
||||
|
||||
// Forms are what a composition writes: lw --tui prints one, the website shows them
|
||||
// Forms are what a composition writes and reads: lw --tui prints one, the
|
||||
// website shows them and reads them pasted
|
||||
func Forms() []Form { return slices.Clone(forms) }
|
||||
|
||||
// FromEnvironment starts from NAME='value' lines as an environment file holds
|
||||
// them, quoted as a POSIX shell reads them: '#' starts a comment line, a word
|
||||
// without '=', such as export, assigns nothing, and a later assignment wins
|
||||
func FromEnvironment(text string, isDir config.IsDir) (*Composition, error) {
|
||||
vars := map[string]string{}
|
||||
for line := range strings.Lines(text) {
|
||||
if line = strings.TrimSpace(line); line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
words, err := split(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, w := range words {
|
||||
if name, value, ok := strings.Cut(w, "="); ok {
|
||||
vars[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
var environ []string
|
||||
for name, value := range vars {
|
||||
environ = append(environ, name+"="+value)
|
||||
}
|
||||
pipelines, err := config.EnvPipelines(environ, isDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Composition{Pipelines: pipelines}
|
||||
return c, c.settle()
|
||||
}
|
||||
|
||||
// FromFile starts from a configuration file's pipelines, refusing a misspelled
|
||||
// key as lw does; its settings are the host's and its console sinks keep the
|
||||
// color they name
|
||||
func FromFile(data []byte) (*Composition, error) {
|
||||
cfg, err := config.ParseFile(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.Pipelines) == 0 {
|
||||
return nil, errors.New("no [[pipelines]]")
|
||||
}
|
||||
return FromConfig(&config.Config{Pipelines: cfg.Pipelines})
|
||||
}
|
||||
|
||||
// FromJSON starts from pipelines as the website keeps them: a JSON list of
|
||||
// tables in a file's shape, a misspelled key refused as a file's is, a null
|
||||
// a value left unset
|
||||
// a value left unset. A value of the wrong kind returns the composition too,
|
||||
// which Validate names among the rest.
|
||||
func FromJSON(data []byte) (*Composition, error) {
|
||||
var tables any
|
||||
if err := json.Unmarshal(data, &tables); err != nil {
|
||||
@@ -710,14 +832,14 @@ func copyPipeline(p config.PipelineConfig) config.PipelineConfig {
|
||||
// Schema is what lw --schema prints, and the website builds its catalogue
|
||||
// from. A setting's flag is --KEY, a pipeline flag's --FLAG.
|
||||
type Schema struct {
|
||||
Version string `json:"version"`
|
||||
Settings []Setting `json:"settings"`
|
||||
Flags []config.SpecFlag `json:"pipeline_flags"`
|
||||
Flow []config.Stage `json:"flow"`
|
||||
Tables map[string][]config.Key `json:"tables"`
|
||||
Sources []Plugin `json:"sources"`
|
||||
Sinks []Plugin `json:"sinks"`
|
||||
Presets []config.Preset `json:"presets"`
|
||||
Version string `json:"version"`
|
||||
Settings []Setting `json:"settings"`
|
||||
Flags []config.SpecFlag `json:"pipeline_flags"`
|
||||
Flow []config.Stage `json:"flow"`
|
||||
Sources []Plugin `json:"sources"`
|
||||
Sinks []Plugin `json:"sinks"`
|
||||
Presets []config.Preset `json:"presets"`
|
||||
Forms []Form `json:"forms"`
|
||||
}
|
||||
|
||||
// Setting is a settings key with its environment variable
|
||||
@@ -737,12 +859,8 @@ func NewSchema() Schema {
|
||||
Version: version.Short(),
|
||||
Flags: config.SpecFlags(),
|
||||
Flow: config.FlowStages(),
|
||||
Tables: map[string][]config.Key{
|
||||
"tls": config.KeysOf[config.TLSOptions](),
|
||||
"auth": config.KeysOf[config.AuthOptions](),
|
||||
"acl": config.KeysOf[config.ACLOptions](),
|
||||
},
|
||||
Presets: config.Presets(),
|
||||
Forms: Forms(),
|
||||
}
|
||||
for _, k := range config.SettingKeys() {
|
||||
s.Settings = append(s.Settings, Setting{k, "LOGWISP_" + strings.ToUpper(strings.ReplaceAll(k.Name, ".", "_"))})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -35,11 +36,12 @@ func TestEngineLinksNoHostCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A pasted command line, its line ends LF or CRLF, and the website's JSON each
|
||||
// start the composition that wrote them: escaped and quoted values, nested
|
||||
// keys, lists, moved filters, stages whose flags' defaults differ from a
|
||||
// file's, a value starting with '-', JSON's numbers for integers
|
||||
func TestWrittenFormsStartTheSameComposition(t *testing.T) {
|
||||
// Every form a composition writes, its line ends LF or CRLF, and the
|
||||
// website's JSON read back to it: escaped and quoted values, nested keys,
|
||||
// lists, moved filters, stages whose flags' defaults differ from a file's, a
|
||||
// value starting with '-', JSON's numbers for integers. The environment holds
|
||||
// one pipeline, so each form is written from one or both.
|
||||
func TestWrittenFormsReadBack(t *testing.T) {
|
||||
c := &Composition{}
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
@@ -68,18 +70,29 @@ func TestWrittenFormsStartTheSameComposition(t *testing.T) {
|
||||
must(c.Set(0, Node{Role: "rate_limit"}, "entries_per_second", "100"))
|
||||
must(c.Set(0, Node{Role: "heartbeat"}, "interval_ms", "5000"))
|
||||
must(c.Set(0, Node{Role: "heartbeat"}, "enabled", "true"))
|
||||
one := &Composition{Pipelines: slices.Clone(c.Pipelines)}
|
||||
must(c.AddPipeline("-b"))
|
||||
for _, role := range []string{"source", "sink"} {
|
||||
_, err = c.Add(1, role, "null")
|
||||
must(err)
|
||||
}
|
||||
must(c.Set(1, Node{Role: "heartbeat"}, "interval_ms", "2000"))
|
||||
line, err := c.CommandLine()
|
||||
must(err)
|
||||
for _, pasted := range []string{line, strings.ReplaceAll(line, "\n", "\r\n")} {
|
||||
got, err := FromCommandLine(pasted, nil)
|
||||
if err != nil || !reflect.DeepEqual(got, c) {
|
||||
t.Fatalf("%q\n got %+v %v\nwant %+v", pasted, got, err, c)
|
||||
for _, f := range Forms() {
|
||||
written := 0
|
||||
for _, c := range []*Composition{c, one} {
|
||||
text, err := f.Write(c)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
written++
|
||||
for _, pasted := range []string{text, strings.ReplaceAll(text, "\n", "\r\n")} {
|
||||
if got, err := f.Read(pasted, nil); err != nil || !reflect.DeepEqual(got, c) {
|
||||
t.Fatalf("%s %q\n got %+v %v\nwant %+v", f.Name, pasted, got, err, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if written == 0 {
|
||||
t.Errorf("%s wrote neither composition", f.Name)
|
||||
}
|
||||
}
|
||||
data, err := c.JSON()
|
||||
@@ -89,6 +102,52 @@ func TestWrittenFormsStartTheSameComposition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pasted environment text reads as a shell runs it: a comment line assigns
|
||||
// nothing, export is no assignment, and a later assignment wins
|
||||
func TestPastedEnvironmentReadsAsAShellRunsIt(t *testing.T) {
|
||||
c, err := FromEnvironment("export LOGWISP_SOURCE='null'\n# LOGWISP_SOURCE='tcp_chain'\n\nLOGWISP_SINK='tcp:port=1'\nLOGWISP_SINK='null'\n", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := c.Pipelines[0]
|
||||
if len(c.Pipelines) != 1 || len(p.PluginSources) != 1 || p.PluginSources[0].Type != "null" || len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "null" {
|
||||
t.Fatalf("pipelines %+v", c.Pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
// Locate places a problem by its path: a plugin's key, a key of its table, a
|
||||
// rule between its table's keys, a list entry, the plugin itself under an id
|
||||
// holding ']', a filter's list entry, a stage and the pipeline; and nothing
|
||||
// outside one
|
||||
func TestLocatePlacesAProblem(t *testing.T) {
|
||||
sink := func(id string) Node { return Node{Role: "sink", ID: id} }
|
||||
for path, want := range map[string]struct {
|
||||
pi int
|
||||
n Node
|
||||
key string
|
||||
}{
|
||||
"pipelines[1].plugin_sinks[out].config.port": {1, sink("out"), "port"},
|
||||
"pipelines[0].plugin_sinks[out].config.tls.min_version": {0, sink("out"), "tls.min_version"},
|
||||
"pipelines[0].plugin_sinks[out].config.tls": {0, sink("out"), "tls"},
|
||||
"pipelines[0].plugin_sinks[out].config.acl.allow[1]": {0, sink("out"), "acl.allow"},
|
||||
"pipelines[0].plugin_sinks[a]b].config": {0, sink("a]b"), ""},
|
||||
"pipelines[0].plugin_sources[in].type": {0, Node{Role: "source", ID: "in"}, "type"},
|
||||
"pipelines[0].flow.filters[2].patterns[1]": {0, Node{Role: "filters", Index: 2}, "patterns"},
|
||||
"pipelines[0].flow.rate_limit.policy": {0, Node{Role: "rate_limit"}, "policy"},
|
||||
"pipelines[3].name": {3, Node{}, "name"},
|
||||
} {
|
||||
pi, n, key, text, ok := Locate(fmt.Errorf("checked: %w", &config.KeyError{Key: path, Err: errors.New("wrong")}))
|
||||
if !ok || pi != want.pi || n != want.n || key != want.key || text != "wrong" {
|
||||
t.Errorf("%s: %d %+v %q %q %v", path, pi, n, key, text, ok)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{errors.New("no pipelines configured"), &config.KeyError{Key: "color", Err: errors.New("wrong")}} {
|
||||
if _, _, _, text, ok := Locate(err); ok || text != err.Error() {
|
||||
t.Errorf("%v: placed, text %q", err, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A null in the website's JSON is a value left unset, which every form then
|
||||
// leaves out, in a table or a list
|
||||
func TestANullInJSONIsUnset(t *testing.T) {
|
||||
@@ -105,13 +164,18 @@ func TestANullInJSONIsUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The website's JSON refuses a misspelled key as a file does, at any depth
|
||||
func TestAMisspelledJSONKeyIsRefused(t *testing.T) {
|
||||
// The website's JSON and a pasted file refuse a misspelled key as lw does,
|
||||
// at any depth
|
||||
func TestAMisspelledKeyIsRefused(t *testing.T) {
|
||||
for _, data := range []string{`[{"name":"p","plugin_source":[]}]`, `[{"name":"p","flow":{"rate_limt":{}}}]`} {
|
||||
if _, err := FromJSON([]byte(data)); err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||
t.Errorf("%s: %v", data, err)
|
||||
}
|
||||
}
|
||||
file := "[[pipelines]]\nname = \"p\"\nnmae = \"q\"\n[[pipelines.plugin_sources]]\ntype = \"null\"\n"
|
||||
if _, err := FromFile([]byte(file)); err == nil || !strings.Contains(err.Error(), `unknown key "pipelines[0].nmae"`) {
|
||||
t.Errorf("file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The command line and environment carry no control character, which a
|
||||
|
||||
@@ -319,8 +319,8 @@ type AuthOptions struct {
|
||||
CredentialsFile string `toml:"credentials_file" lw:"listener,hint=file" help:"scram: verifiers, from lw auth add-user"`
|
||||
// exp has whole seconds, a sub-second expires_in reads as unknown, and 10 s
|
||||
// leaves room for renewal ahead of expiry
|
||||
TokenLifetimeMS int64 `toml:"token_lifetime_ms" lw:"listener,min=10000,max=86400000,zero=15 minutes" help:"scram on HTTP: bearer token lifetime"`
|
||||
TrustedProxies []string `toml:"trusted_proxies" lw:"listener,hint=cidr" help:"scram on the http sink: proxies ending the browsers' TLS; logins are then unbound"`
|
||||
TokenLifetimeMS int64 `toml:"token_lifetime_ms" lw:"listener,http,min=10000,max=86400000,zero=15 minutes" help:"scram on HTTP: bearer token lifetime"`
|
||||
TrustedProxies []string `toml:"trusted_proxies" lw:"listener,http,hint=cidr" help:"scram on the http sink: proxies ending the browsers' TLS; logins are then unbound"`
|
||||
Username string `toml:"username" help:"scram: the user a dialer logs in as, or a listener's one user"`
|
||||
PasswordFile string `toml:"password_file" lw:"hint=file" help:"scram: file holding the password; /dev/fd/N or a pipe is read once, /dev/tty asks"`
|
||||
}
|
||||
@@ -335,7 +335,7 @@ type ACLOptions struct {
|
||||
ProxyProtocol string `toml:"proxy_protocol" default:"off" lw:"enum=off|optional|required" help:"read a PROXY header from proxy_from"`
|
||||
ProxyFrom []string `toml:"proxy_from" lw:"hint=cidr" help:"proxies that may send a PROXY header"`
|
||||
MaxConnectionsPerClient int64 `toml:"max_connections_per_client" lw:"min=0,zero=unlimited" help:"connections a client may hold"`
|
||||
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client" lw:"min=0,zero=unlimited" help:"HTTP: requests a client may make each second"`
|
||||
RequestsPerSecondPerClient float64 `toml:"requests_per_second_per_client" lw:"http,min=0,zero=unlimited" help:"HTTP: requests a client may make each second"`
|
||||
}
|
||||
|
||||
// --- TLS Options ---
|
||||
|
||||
@@ -51,7 +51,7 @@ func Load(args Args) (*Manager, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
specs = envPipelineSpecs()
|
||||
specs = envPipelineSpecs(os.Environ())
|
||||
}
|
||||
if _, err := buildPipelines(specs, HostIsDir); err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +63,7 @@ func Load(args Args) (*Manager, error) {
|
||||
initial.Logging.Level = "warn"
|
||||
initial.StatusReporter = false
|
||||
}
|
||||
var invalid error // the validator's problems, which lw prints a line each
|
||||
cfg, err := lconfig.NewBuilder().
|
||||
WithTarget(initial).
|
||||
WithEnvPrefix("LOGWISP_").
|
||||
@@ -70,10 +71,10 @@ func Load(args Args) (*Manager, error) {
|
||||
WithFile(configPath).
|
||||
WithTypedValidator(func(cfg *Config) error {
|
||||
effective := *cfg // the builder keeps cfg; only the copy is replaced
|
||||
if err := m.usePipelines(&effective); err != nil {
|
||||
return err
|
||||
if invalid = m.usePipelines(&effective); invalid == nil {
|
||||
invalid = ValidateConfig(&effective)
|
||||
}
|
||||
return ValidateConfig(&effective)
|
||||
return invalid
|
||||
}).
|
||||
WithSecurityOptions(lconfig.SecurityOptions{
|
||||
PreventPathTraversal: true,
|
||||
@@ -81,7 +82,10 @@ func Load(args Args) (*Manager, error) {
|
||||
}).
|
||||
Build()
|
||||
if err != nil {
|
||||
if !errors.Is(err, lconfig.ErrConfigNotFound) {
|
||||
switch {
|
||||
case invalid != nil:
|
||||
return nil, fmt.Errorf("invalid configuration:\n%w", invalid)
|
||||
case !errors.Is(err, lconfig.ErrConfigNotFound):
|
||||
return nil, fmt.Errorf("failed to load or validate config: %w", err)
|
||||
}
|
||||
if isExplicit {
|
||||
|
||||
+132
-57
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -24,22 +25,48 @@ type Preset struct {
|
||||
}
|
||||
|
||||
// PresetParam is one key. A list takes several values, ',' between them or
|
||||
// the key repeated; any other key takes one.
|
||||
// the key repeated; any other key takes one. Enum and Hint are those of the
|
||||
// option the key sets.
|
||||
type PresetParam struct {
|
||||
Name string `json:"name"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Help string `json:"help"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
List bool `json:"list,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Help string `json:"help"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
List bool `json:"list,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Usage is the key's help after its choices, as lw preset and lw --tui show it
|
||||
func (p PresetParam) Usage() string {
|
||||
if len(p.Enum) == 0 {
|
||||
return p.Help
|
||||
}
|
||||
return strings.Join(p.Enum, "|") + ": " + p.Help
|
||||
}
|
||||
|
||||
// mirror gives a key the enum and hint of option key of table T, which it
|
||||
// sets; a switch takes true or false
|
||||
func mirror[T any](p PresetParam, key string) PresetParam {
|
||||
opts := options(reflect.TypeFor[T]())
|
||||
i := slices.IndexFunc(opts, func(o option) bool { return o.Name == key })
|
||||
if i < 0 {
|
||||
panic(fmt.Sprintf("preset key %s: %s has no key %q", p.Name, reflect.TypeFor[T]().Name(), key))
|
||||
}
|
||||
p.Enum, p.Hint = opts[i].Enum, opts[i].Hint
|
||||
if opts[i].Kind == "bool" {
|
||||
p.Enum = []string{"true", "false"}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// IsDir tells whether a preset's path is a directory, whose files it follows,
|
||||
// or a file pattern. Off the target host it is the user's answer, or nil: a
|
||||
// or a file pattern. Off the target host it is the user's Answers, or nil: a
|
||||
// path ending in '/' is then a directory, one with a glob a pattern, and any
|
||||
// other fails with ErrPathKind.
|
||||
type IsDir func(path string) (bool, error)
|
||||
|
||||
var ErrPathKind = errors.New("path: a directory or a file pattern?")
|
||||
var ErrPathKind error = &KeyError{"path", errors.New("a directory or a file pattern?")}
|
||||
|
||||
// HostIsDir asks this host: a path that does not exist is a pattern
|
||||
func HostIsDir(path string) (bool, error) {
|
||||
@@ -47,29 +74,67 @@ func HostIsDir(path string) (bool, error) {
|
||||
return err == nil && fi.IsDir(), nil
|
||||
}
|
||||
|
||||
// Answers is IsDir off the host: whether each path answered is a directory,
|
||||
// and nil's rule for the others
|
||||
func Answers(dirs map[string]bool) IsDir {
|
||||
return func(path string) (bool, error) {
|
||||
if dir, ok := dirs[path]; ok {
|
||||
return dir, nil
|
||||
}
|
||||
if dir := strings.HasSuffix(path, "/"); dir || strings.ContainsAny(path, `*?[\`) {
|
||||
return dir, nil
|
||||
}
|
||||
return false, ErrPathKind
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
fromParam = mirror[FileSourceOptions](PresetParam{Name: "from", Default: "end", Help: "where reading a file starts"}, "from")
|
||||
formatParam = func(def string) PresetParam {
|
||||
return mirror[FormatConfig](PresetParam{Name: "format", Default: def, Help: "entry format"}, "type")
|
||||
}
|
||||
tlsParams = []PresetParam{
|
||||
mirror[TLSOptions](PresetParam{Name: "cert", Help: "certificate file, for tls=files"}, "cert_file"),
|
||||
mirror[TLSOptions](PresetParam{Name: "key", Help: "key file, for tls=files"}, "key_file"),
|
||||
mirror[TLSOptions](PresetParam{Name: "issuer_cert", Help: "CA certificate file (lw tls ca), for tls=issuer"}, "issuer_cert_file"),
|
||||
mirror[TLSOptions](PresetParam{Name: "issuer_key", Help: "CA key file, for tls=issuer"}, "issuer_key_file"),
|
||||
{Name: "hosts", List: true, Help: "names and addresses, beyond this host's, of a self or issuer certificate; ',' between them"},
|
||||
}
|
||||
aclParams = []PresetParam{
|
||||
{Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family (either behind proxy=); default: all; ',' between them"},
|
||||
{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"},
|
||||
mirror[ACLOptions](PresetParam{Name: "allow", List: true, Help: "addresses or CIDRs that may connect, of listen's family (either behind proxy=); default: all; ',' between them"}, "allow"),
|
||||
mirror[ACLOptions](PresetParam{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"}, "deny"),
|
||||
}
|
||||
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
||||
userParams = []PresetParam{
|
||||
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp_chain or http_chain", Enum: transports()}
|
||||
usersParam = func(who string) PresetParam {
|
||||
return mirror[AuthOptions](PresetParam{Name: "users", Help: "credentials file (lw auth add-user): " + who + " log in with SCRAM"}, "credentials_file")
|
||||
}
|
||||
userParams = []PresetParam{
|
||||
{Name: "user", Help: "SCRAM user: an edge's login, or a listener's only user in place of users"},
|
||||
{Name: "password_file", Help: "file holding user's password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks"},
|
||||
mirror[AuthOptions](PresetParam{Name: "password_file", Help: "file holding user's password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks"}, "password_file"),
|
||||
}
|
||||
)
|
||||
|
||||
// tlsParam is a listener's tls key: a tlsModes name, off only where the
|
||||
// listener may serve plain text
|
||||
func tlsParam(def string, off bool) PresetParam {
|
||||
p := PresetParam{Name: "tls", Default: def, Help: "a certificate made at startup (self-signed, or from issuer_*), or files"}
|
||||
if off {
|
||||
p.Help = "plain text, " + p.Help
|
||||
}
|
||||
for _, m := range tlsModes {
|
||||
if m.name != "off" || off {
|
||||
p.Enum = append(p.Enum, m.name)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The listeners' tls keys: the aggregator always serves TLS, as edges send
|
||||
// credentials and logs
|
||||
var serveTLS, aggregatorTLS = tlsParam("off", true), tlsParam("self", false)
|
||||
|
||||
var presets = []Preset{
|
||||
{"pipe", "Standard input to standard output, line for line: lw without a file",
|
||||
[]PresetParam{formatParam("raw")},
|
||||
@@ -90,11 +155,11 @@ var presets = []Preset{
|
||||
{"serve", "Serve files or standard input live: a browser viewer at /, SSE at /stream",
|
||||
slices.Concat([]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"},
|
||||
serveTLS,
|
||||
usersParam("readers"),
|
||||
}, userParams, []PresetParam{
|
||||
{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 login page and viewer, behind proxy; without user or users the viewer is always on"},
|
||||
mirror[AuthOptions](PresetParam{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"}, "trusted_proxies"),
|
||||
mirror[HTTPSinkOptions](PresetParam{Name: "viewer", Default: "false", Help: "the login page and viewer, behind proxy; without user or users the viewer is always on"}, "viewer_page"),
|
||||
}, aclParams, tlsParams),
|
||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||
sources, err := pathOrStdin(v, isDir)
|
||||
@@ -102,7 +167,7 @@ var presets = []Preset{
|
||||
return err
|
||||
}
|
||||
p.PluginSources = sources
|
||||
sink, err := listener(v, "off")
|
||||
sink, err := listener(v, serveTLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,12 +204,12 @@ var presets = []Preset{
|
||||
slices.Concat([]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"},
|
||||
mirror[TLSOptions](PresetParam{Name: "ca", Help: "CA file that verifies the aggregator; default: system roots"}, "ca_file"),
|
||||
{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"},
|
||||
}, userParams, []PresetParam{
|
||||
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
||||
{Name: "key", Help: "client key file"},
|
||||
mirror[TLSOptions](PresetParam{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"}, "cert_file"),
|
||||
mirror[TLSOptions](PresetParam{Name: "key", Help: "client key file"}, "key_file"),
|
||||
{Name: "node", Help: "origin label; default: this host's name"},
|
||||
}),
|
||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||
@@ -188,11 +253,11 @@ var presets = []Preset{
|
||||
slices.Concat([]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"},
|
||||
aggregatorTLS,
|
||||
usersParam("edges"),
|
||||
}, userParams, []PresetParam{
|
||||
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
||||
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
||||
mirror[TLSOptions](PresetParam{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"}, "client_ca_file"),
|
||||
mirror[FileSinkOptions](PresetParam{Name: "out", Help: "directory for the received entries; default: standard output"}, "directory"),
|
||||
formatParam("json"),
|
||||
}, aclParams, tlsParams),
|
||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||
@@ -200,10 +265,7 @@ var presets = []Preset{
|
||||
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")
|
||||
src, err := listener(v, aggregatorTLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -288,13 +350,10 @@ func pathOrStdin(v map[string]string, isDir IsDir) ([]PluginSourceConfig, error)
|
||||
if path == "" {
|
||||
return pipeDefault("").PluginSources, nil
|
||||
}
|
||||
dir, err := strings.HasSuffix(path, "/"), error(nil)
|
||||
switch {
|
||||
case isDir != nil:
|
||||
dir, err = isDir(path)
|
||||
case !dir && !strings.ContainsAny(path, `*?[\`):
|
||||
err = ErrPathKind
|
||||
if isDir == nil {
|
||||
isDir = Answers(nil)
|
||||
}
|
||||
dir, err := isDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -308,20 +367,23 @@ func pathOrStdin(v map[string]string, isDir IsDir) ([]PluginSourceConfig, error)
|
||||
// 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 {
|
||||
var tlsModes = []tlsMode{
|
||||
{name: "off"},
|
||||
{name: "self", set: map[string]any{"self_signed": true}, made: true},
|
||||
{name: "issuer", needs: map[string]string{"issuer_cert": "issuer_cert_file", "issuer_key": "issuer_key_file"}, made: true},
|
||||
{name: "files", needs: map[string]string{"cert": "cert_file", "key": "key_file"}},
|
||||
}
|
||||
|
||||
type tlsMode struct {
|
||||
name string
|
||||
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, acl and tls,
|
||||
// whose mode decides which certificate keys apply.
|
||||
func listener(v map[string]string, def string) (map[string]any, error) {
|
||||
// listener starts a listening plugin's options: host, port, acl and tls, in
|
||||
// a mode its key offers, which decides the certificate keys that apply
|
||||
func listener(v map[string]string, key PresetParam) (map[string]any, error) {
|
||||
host, port, err := splitAddr("listen", v["listen"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -335,11 +397,12 @@ func listener(v map[string]string, def string) (map[string]any, error) {
|
||||
if len(acl) > 0 {
|
||||
opts["acl"] = acl
|
||||
}
|
||||
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)), ", "))
|
||||
mode := cmp.Or(v["tls"], key.Default)
|
||||
i := slices.IndexFunc(tlsModes, func(m tlsMode) bool { return m.name == mode })
|
||||
if i < 0 || !slices.Contains(key.Enum, mode) {
|
||||
return nil, fmt.Errorf("tls %q (valid: %s)", mode, strings.Join(key.Enum, ", "))
|
||||
}
|
||||
m := tlsModes[i]
|
||||
for _, other := range tlsModes {
|
||||
for k := range other.needs {
|
||||
if _, needed := m.needs[k]; needed && v[k] == "" {
|
||||
@@ -392,13 +455,25 @@ func splitAddr(key, addr string) (string, int64, error) {
|
||||
return host, int64(port), nil
|
||||
}
|
||||
|
||||
// chainType is a transport's chain plugin type, both a source and a sink
|
||||
func chainType(transport string) (string, error) {
|
||||
if transport != "tcp" && transport != "http" {
|
||||
return "", fmt.Errorf("transport %q (valid: tcp, http)", transport)
|
||||
if !slices.Contains(transports(), transport) {
|
||||
return "", fmt.Errorf("transport %q (valid: %s)", transport, strings.Join(transports(), ", "))
|
||||
}
|
||||
return transport + "_chain", nil
|
||||
}
|
||||
|
||||
// transports name the chain plugins: the catalogue's TRANSPORT_chain types
|
||||
func transports() []string {
|
||||
var out []string
|
||||
for _, p := range plugins {
|
||||
if t, ok := strings.CutSuffix(p.Type, "_chain"); ok && p.Role == "sink" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func list(s string) []any {
|
||||
var out []any
|
||||
for e := range strings.SplitSeq(s, ",") {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -56,7 +57,7 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
||||
"edge:to=agg:9000": "it never sends unauthenticated",
|
||||
"edge:to=agg:9000,password_file=p": "password_file needs user",
|
||||
"edge:to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
||||
"aggregator:users=u,tls=off": "the aggregator always serves TLS",
|
||||
"aggregator:users=u,tls=off": `tls "off" (valid: `,
|
||||
"aggregator:user=u,users=f": "user or users, not both",
|
||||
"aggregator": "it never receives unauthenticated",
|
||||
} {
|
||||
@@ -67,6 +68,49 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A preset key that sets an option takes its choices and hint, a switch true
|
||||
// or false; a listener's tls offers off only where it may serve plain text
|
||||
func TestPresetKeysTakeTheirOptionsChoices(t *testing.T) {
|
||||
param := func(preset, key string) PresetParam {
|
||||
ps := Presets()
|
||||
p := ps[slices.IndexFunc(ps, func(p Preset) bool { return p.Name == preset })]
|
||||
return p.Params[slices.IndexFunc(p.Params, func(k PresetParam) bool { return k.Name == key })]
|
||||
}
|
||||
for _, c := range []struct {
|
||||
preset, key string
|
||||
table reflect.Type
|
||||
option string
|
||||
}{
|
||||
{"tail", "from", reflect.TypeFor[FileSourceOptions](), "from"},
|
||||
{"pipe", "format", reflect.TypeFor[FormatConfig](), "type"},
|
||||
{"serve", "proxy", reflect.TypeFor[AuthOptions](), "trusted_proxies"},
|
||||
{"aggregator", "users", reflect.TypeFor[AuthOptions](), "credentials_file"},
|
||||
} {
|
||||
opts := options(c.table)
|
||||
o := opts[slices.IndexFunc(opts, func(o option) bool { return o.Name == c.option })]
|
||||
if p := param(c.preset, c.key); !slices.Equal(p.Enum, o.Enum) || p.Hint != o.Hint || len(o.Enum) == 0 && o.Hint == "" {
|
||||
t.Errorf("%s %s: enum %v hint %q, want %s's %v %q", c.preset, c.key, p.Enum, p.Hint, c.option, o.Enum, o.Hint)
|
||||
}
|
||||
}
|
||||
var modes []string
|
||||
for _, m := range tlsModes {
|
||||
modes = append(modes, m.name)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
preset, key string
|
||||
enum []string
|
||||
}{
|
||||
{"serve", "viewer", []string{"true", "false"}},
|
||||
{"edge", "transport", transports()},
|
||||
{"serve", "tls", modes},
|
||||
{"aggregator", "tls", slices.DeleteFunc(slices.Clone(modes), func(m string) bool { return m == "off" })},
|
||||
} {
|
||||
if p := param(c.preset, c.key); !slices.Equal(p.Enum, c.enum) {
|
||||
t.Errorf("%s %s: enum %v, want %v", c.preset, c.key, p.Enum, c.enum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A preset user without password_file is asked for on the terminal
|
||||
func TestPresetUserWithoutPasswordFileAsks(t *testing.T) {
|
||||
p, err := ExpandPreset("edge", map[string]string{"to": "agg:9000", "user": "u"}, nil)
|
||||
@@ -78,16 +122,19 @@ func TestPresetUserWithoutPasswordFileAsks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A preset path is what isDir answers, a directory named with brackets too;
|
||||
// without isDir, off the target host, it is a directory when it ends in '/',
|
||||
// a pattern when it holds a glob, and otherwise ErrPathKind asks
|
||||
// A preset path is what isDir answers, a directory named with brackets too,
|
||||
// and off the target host the user's answer, a single file too; without one
|
||||
// it is a directory when it ends in '/', a pattern when it holds a glob, and
|
||||
// otherwise ErrPathKind asks
|
||||
func TestPresetPathKind(t *testing.T) {
|
||||
yes := func(string) (bool, error) { return true, nil }
|
||||
file := Answers(map[string]bool{"/srv/app.log": false})
|
||||
for _, c := range []struct {
|
||||
path string
|
||||
isDir IsDir
|
||||
want string
|
||||
}{{"/srv/app/", nil, "/srv/app/ *"}, {"/srv/app/*.log", nil, "/srv/app *.log"}, {"/srv/app[1]", yes, "/srv/app[1] *"}, {"/srv/app", nil, ""}} {
|
||||
}{{"/srv/app/", nil, "/srv/app/ *"}, {"/srv/app/*.log", nil, "/srv/app *.log"}, {"/srv/app[1]", yes, "/srv/app[1] *"}, {"/srv/app", nil, ""},
|
||||
{"/srv/app.log", file, "/srv app.log"}, {"/srv/app", file, ""}, {"/srv/app/", file, "/srv/app/ *"}} {
|
||||
p, err := ExpandPreset("tail", map[string]string{"path": c.path}, c.isDir)
|
||||
if c.want == "" {
|
||||
if !errors.Is(err, ErrPathKind) {
|
||||
|
||||
+38
-13
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -57,8 +58,25 @@ var plugins = []Plugin{
|
||||
// Plugins lists the catalogue
|
||||
func Plugins() []Plugin { return slices.Clone(plugins) }
|
||||
|
||||
// Keys describes the plugin's options
|
||||
func (p Plugin) Keys() []Key { return keys(p.options) }
|
||||
// Keys describes the plugin's options, its tables holding the keys of its
|
||||
// side, and HTTP's keys only when it speaks HTTP
|
||||
func (p Plugin) Keys() []Key { return p.applying(keys(p.options)) }
|
||||
|
||||
func (p Plugin) applying(all []Key) []Key {
|
||||
side, out := "listener", []Key{}
|
||||
if p.Side == Dialer {
|
||||
side = "dialer"
|
||||
}
|
||||
for _, k := range all {
|
||||
if (k.Side == "" || k.Side == side) && (!k.HTTP || p.HTTP) {
|
||||
if k.Keys != nil {
|
||||
k.Keys = p.applying(k.Keys)
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupPlugin finds the catalogue row of a source or sink type
|
||||
func LookupPlugin(role, typ string) (Plugin, bool) {
|
||||
@@ -82,7 +100,8 @@ func (e *KeyError) Unwrap() error { return e.Err }
|
||||
// option is one key of an options table, from its field's tags: toml names
|
||||
// it, default: holds the default as typed on a command line, help: says what
|
||||
// it does, and lw: lists its rules: required, enum=a|b, min=N, max=N,
|
||||
// zero=MEANING (zero is a value, not the default), hint=KIND, listener or dialer.
|
||||
// zero=MEANING (zero is a value, not the default), hint=KIND, listener or
|
||||
// dialer, and http (only plugins that speak HTTP take it).
|
||||
type option struct {
|
||||
Key
|
||||
index int
|
||||
@@ -107,6 +126,8 @@ func options(t reflect.Type) []option {
|
||||
o.Required = true
|
||||
case "listener", "dialer":
|
||||
o.Side = name
|
||||
case "http":
|
||||
o.HTTP = true
|
||||
case "enum":
|
||||
o.Enum = strings.Split(value, "|")
|
||||
case "min", "max":
|
||||
@@ -133,7 +154,7 @@ func options(t reflect.Type) []option {
|
||||
}
|
||||
|
||||
// Key describes one option, for lw --schema and the composer: its tags, and
|
||||
// its kind, a table's being "table" (tls, auth or acl)
|
||||
// its kind, a table's being "table" (tls, auth or acl) with the table's keys
|
||||
type Key struct {
|
||||
Name string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -146,17 +167,21 @@ type Key struct {
|
||||
Zero string `json:"zero,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
HTTP bool `json:"http,omitempty"`
|
||||
Keys []Key `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
var kinds = map[reflect.Kind]string{reflect.String: "string", reflect.Int64: "integer",
|
||||
reflect.Float64: "number", reflect.Bool: "bool", reflect.Slice: "list", reflect.Pointer: "table"}
|
||||
|
||||
// Stage is a flow stage: its key in the flow table, whether it is a list (the
|
||||
// filters), and its options
|
||||
// filters), its options, and the values that turn it on, which a file's
|
||||
// defaults leave off
|
||||
type Stage struct {
|
||||
Key string `json:"key"`
|
||||
List bool `json:"list,omitempty"`
|
||||
Keys []Key `json:"keys"`
|
||||
Key string `json:"key"`
|
||||
List bool `json:"list,omitempty"`
|
||||
Keys []Key `json:"keys"`
|
||||
On map[string]any `json:"on,omitempty"`
|
||||
}
|
||||
|
||||
// FlowStages lists the flow table's stages, in the order entries pass them
|
||||
@@ -165,20 +190,20 @@ func FlowStages() []Stage {
|
||||
t := reflect.TypeFor[FlowConfig]()
|
||||
for _, o := range options(t) {
|
||||
ft := t.Field(o.index).Type
|
||||
out = append(out, Stage{o.Name, ft.Kind() == reflect.Slice, keys(ft.Elem())})
|
||||
out = append(out, Stage{o.Name, ft.Kind() == reflect.Slice, keys(ft.Elem()), maps.Clone(stageOn[o.Name])})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KeysOf describes the options of a table: tls, auth or acl
|
||||
func KeysOf[T any]() []Key { return keys(reflect.TypeFor[T]()) }
|
||||
|
||||
func keys(t reflect.Type) []Key {
|
||||
var out []Key
|
||||
out := []Key{}
|
||||
for _, o := range options(t) {
|
||||
if o.Kind == "" {
|
||||
panic(fmt.Sprintf("%s.%s: no kind", t.Name(), o.Name))
|
||||
}
|
||||
if ft := t.Field(o.index).Type; ft.Kind() == reflect.Pointer {
|
||||
o.Keys = keys(ft.Elem())
|
||||
}
|
||||
out = append(out, o.Key)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -79,7 +80,8 @@ func TestCoerceGivesTheDeclaredKinds(t *testing.T) {
|
||||
}
|
||||
|
||||
// ValidateConfig decodes every plugin and flow stage without opening a file,
|
||||
// and names the path of what it refuses
|
||||
// and names the path of what it refuses: a rule between a table's keys by
|
||||
// the table's
|
||||
func TestValidateConfigNamesThePath(t *testing.T) {
|
||||
valid := func() *Config {
|
||||
return &Config{Color: "auto", Pipelines: []PipelineConfig{{
|
||||
@@ -97,9 +99,13 @@ func TestValidateConfigNamesThePath(t *testing.T) {
|
||||
"pipelines[0].plugin_sinks[out].config.port: must be from 1 to 65535, got 70000": func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks[0].Config["port"] = int64(70000)
|
||||
},
|
||||
"pipelines[0].plugin_sinks[out].config: tls: client_auth requires client_ca_file": func(c *Config) {
|
||||
"pipelines[0].plugin_sinks[out].config.tls: client_auth requires client_ca_file": func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks[0].Config["tls"].(map[string]any)["client_auth"] = true
|
||||
},
|
||||
"pipelines[0].plugin_sinks[out].config.acl: requests_per_second_per_client applies only to HTTP listeners": func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks[0].Type = "tcp"
|
||||
c.Pipelines[0].PluginSinks[0].Config["acl"] = map[string]any{"requests_per_second_per_client": 2.5}
|
||||
},
|
||||
"pipelines[0].plugin_sinks[out]: duplicate id": func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks = append(c.Pipelines[0].PluginSinks, PluginSinkConfig{ID: "out", Type: "null"})
|
||||
},
|
||||
@@ -109,7 +115,7 @@ func TestValidateConfigNamesThePath(t *testing.T) {
|
||||
"pipelines[0].plugin_sinks[out].config.port: not an integer": func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks[0].Config["port"] = "http"
|
||||
},
|
||||
`pipelines[0].plugin_sinks[up].config: auth: type "mtls" cannot be used with tls.insecure_skip_verify`: func(c *Config) {
|
||||
`pipelines[0].plugin_sinks[up].config.auth: type "mtls" cannot be used with tls.insecure_skip_verify`: func(c *Config) {
|
||||
c.Pipelines[0].PluginSinks[0] = PluginSinkConfig{ID: "up", Type: "tcp_chain", Config: map[string]any{"host": "h", "port": int64(1),
|
||||
"tls": map[string]any{"enabled": true, "insecure_skip_verify": true}, "auth": map[string]any{"type": "mtls"}}}
|
||||
},
|
||||
@@ -133,3 +139,52 @@ func TestValidateConfigNamesThePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A configuration that does not load names every problem, a line each, each
|
||||
// part's first: the settings, each flow stage, each plugin, each pipeline
|
||||
func TestLoadReportsEveryProblemALine(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, err := Load(Args{Overrides: []string{"--color=bogus"}, Specs: specs("pipeline", "a", "source", "file",
|
||||
"sink", "tcp:port=70000", "rate-limit", "policy=bogus", "filter", "include:patterns=(",
|
||||
"pipeline", "a", "sink", "null")})
|
||||
want := strings.Join([]string{"invalid configuration:",
|
||||
`color: must be one of auto, always, never, got "bogus"`,
|
||||
`pipelines[0].flow.rate_limit.policy: must be one of pass, drop, got "bogus"`,
|
||||
"pipelines[0].flow.filters[0].patterns[0]: \"(\": error parsing regexp: missing closing ): `(`",
|
||||
"pipelines[0].plugin_sources[file].config.directory: must be set",
|
||||
"pipelines[0].plugin_sinks[tcp].config.port: must be from 1 to 65535, got 70000",
|
||||
`pipelines[1].name: duplicate name "a"`,
|
||||
}, "\n")
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("got\n%v\nwant\n%s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin lists the table keys that apply to it: those of its side, a chain
|
||||
// source's being a listener's, and the HTTP ones only when it speaks HTTP
|
||||
func TestTableKeysFollowThePlugin(t *testing.T) {
|
||||
has := func(role, typ, path string) bool {
|
||||
p, _ := LookupPlugin(role, typ)
|
||||
keys := p.Keys()
|
||||
for part := range strings.SplitSeq(path, ".") {
|
||||
i := slices.IndexFunc(keys, func(k Key) bool { return k.Name == part })
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
keys = keys[i].Keys
|
||||
}
|
||||
return true
|
||||
}
|
||||
for path, want := range map[string]bool{
|
||||
"sink tcp_chain tls.pin_sha256": true, "sink tcp_chain tls.self_signed": false,
|
||||
"source tcp_chain tls.self_signed": true, "source tcp_chain tls.pin_sha256": false,
|
||||
"source tcp_chain auth.node_binding": true, "sink tcp_chain auth.credentials_file": false,
|
||||
"sink tcp acl.requests_per_second_per_client": false, "sink tcp auth.token_lifetime_ms": false,
|
||||
"sink http acl.requests_per_second_per_client": true, "sink http auth.trusted_proxies": true,
|
||||
} {
|
||||
f := strings.Fields(path)
|
||||
if has(f[0], f[1], f[2]) != want {
|
||||
t.Errorf("%s: listed %v, want %v", path, !want, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -183,12 +182,15 @@ func cliSpecs(in []Spec) ([]pipelineSpec, error) {
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// envPipelineSpecs reads the one-pipeline environment form. An empty variable
|
||||
// counts as unset, so container templates may leave optional ones blank.
|
||||
func envPipelineSpecs() []pipelineSpec {
|
||||
// envPipelineSpecs reads the one-pipeline environment form from environ,
|
||||
// NAME=value each. An empty variable counts as unset, so container templates
|
||||
// may leave optional ones blank.
|
||||
func envPipelineSpecs(environ []string) []pipelineSpec {
|
||||
var specs []pipelineSpec
|
||||
if name := os.Getenv(specVariable("pipeline")); name != "" {
|
||||
specs = append(specs, pipelineSpec{specVariable("pipeline"), nil, name})
|
||||
for _, kv := range environ {
|
||||
if name, value, _ := strings.Cut(kv, "="); name == specVariable("pipeline") && value != "" {
|
||||
specs = append(specs, pipelineSpec{name, nil, value})
|
||||
}
|
||||
}
|
||||
for i := range specKinds {
|
||||
kind := &specKinds[i]
|
||||
@@ -198,7 +200,7 @@ func envPipelineSpecs() []pipelineSpec {
|
||||
name, spec string
|
||||
}
|
||||
var found []numbered
|
||||
for _, kv := range os.Environ() {
|
||||
for _, kv := range environ {
|
||||
name, spec, _ := strings.Cut(kv, "=")
|
||||
suffix, ok := strings.CutPrefix(name, base+"_")
|
||||
n, err := strconv.ParseUint(suffix, 10, 64)
|
||||
@@ -216,6 +218,16 @@ func envPipelineSpecs() []pipelineSpec {
|
||||
return specs
|
||||
}
|
||||
|
||||
// EnvPipelines builds the pipeline of LOGWISP_ variables, NAME=value each, as
|
||||
// Load does without pipeline flags
|
||||
func EnvPipelines(environ []string, isDir IsDir) ([]PipelineConfig, error) {
|
||||
specs := envPipelineSpecs(environ)
|
||||
if len(specs) == 0 {
|
||||
return nil, errors.New("no LOGWISP_ pipeline variables")
|
||||
}
|
||||
return buildPipelines(specs, isDir)
|
||||
}
|
||||
|
||||
// SpecPipelines builds the pipelines of command-line specs, as Load does
|
||||
func SpecPipelines(specs []Spec, isDir IsDir) ([]PipelineConfig, error) {
|
||||
s, err := cliSpecs(specs)
|
||||
|
||||
+107
-79
@@ -24,26 +24,20 @@ import (
|
||||
|
||||
// ValidateConfig checks a configuration without reading a file it names:
|
||||
// the settings, then each pipeline's flow stages, and its sources and sinks
|
||||
// decoded in full, naming the path of a refused key. lw --check opens them.
|
||||
// decoded in full. Its error joins every problem, one a part, each a
|
||||
// *KeyError naming the path of the refused key. lw --check opens the files.
|
||||
func ValidateConfig(cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
if err := checkTags(reflect.ValueOf(cfg).Elem(), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
errs := []error{checkTags(reflect.ValueOf(cfg).Elem(), "")}
|
||||
if l := cfg.Logging; l != nil {
|
||||
if err := checkTags(reflect.ValueOf(l).Elem(), "logging."); err != nil {
|
||||
return err
|
||||
}
|
||||
errs = append(errs, checkTags(reflect.ValueOf(l).Elem(), "logging."))
|
||||
if l.Console != nil {
|
||||
if err := checkTags(reflect.ValueOf(l.Console).Elem(), "logging.console."); err != nil {
|
||||
return err
|
||||
}
|
||||
errs = append(errs, checkTags(reflect.ValueOf(l.Console).Elem(), "logging.console."))
|
||||
}
|
||||
}
|
||||
|
||||
return ValidatePipelines(cfg.Pipelines)
|
||||
return errors.Join(append(errs, ValidatePipelines(cfg.Pipelines))...)
|
||||
}
|
||||
|
||||
// ValidatePipelines is ValidateConfig's check of the pipelines
|
||||
@@ -51,97 +45,101 @@ func ValidatePipelines(pipelines []PipelineConfig) error {
|
||||
if len(pipelines) == 0 {
|
||||
return fmt.Errorf("no pipelines configured")
|
||||
}
|
||||
// Reject duplicate pipeline names (service map is keyed by name)
|
||||
names := make(map[string]struct{}, len(pipelines))
|
||||
for i, p := range pipelines {
|
||||
if _, dup := names[p.Name]; dup {
|
||||
return fmt.Errorf("pipeline[%d]: duplicate name %q", i, p.Name)
|
||||
}
|
||||
names[p.Name] = struct{}{}
|
||||
}
|
||||
var errs []error
|
||||
at := func(path string, err error) { errs = append(errs, At(path, err)) }
|
||||
names := map[string]bool{} // the service is keyed by name
|
||||
single := map[string]string{} // role and type of a single-instance plugin: its pipeline
|
||||
for i, p := range pipelines {
|
||||
if err := lconfig.NonEmpty(p.Name); err != nil {
|
||||
return fmt.Errorf("pipeline[%d].name: %w", i, err)
|
||||
path := fmt.Sprintf("pipelines[%d]", i)
|
||||
switch {
|
||||
case strings.TrimSpace(p.Name) == "":
|
||||
at(path+".name", errors.New("must be set"))
|
||||
case names[p.Name]:
|
||||
at(path+".name", fmt.Errorf("duplicate name %q", p.Name))
|
||||
}
|
||||
names[p.Name] = true
|
||||
if len(p.PluginSources) == 0 {
|
||||
return fmt.Errorf("pipeline[%d]: no sources defined", i)
|
||||
at(path+".plugin_sources", errors.New("no sources defined"))
|
||||
}
|
||||
if len(p.PluginSinks) == 0 {
|
||||
return fmt.Errorf("pipeline[%d]: no sinks defined", i)
|
||||
at(path+".plugin_sinks", errors.New("no sinks defined"))
|
||||
}
|
||||
if err := checkFlow(p.Flow, fmt.Sprintf("pipelines[%d].flow", i)); err != nil {
|
||||
return err
|
||||
if err := checkFlow(p.Flow, path+".flow"); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
check := func(role, list, id, typ string, m map[string]any) error {
|
||||
path := fmt.Sprintf("pipelines[%d].%s[%s]", i, list, id)
|
||||
check := func(role, list, id, typ string, m map[string]any) {
|
||||
path := fmt.Sprintf("%s.%s[%s]", path, list, id)
|
||||
plugin, ok := LookupPlugin(role, typ)
|
||||
switch {
|
||||
case ids[list+id]:
|
||||
return fmt.Errorf("%s: duplicate id", path)
|
||||
at(path, errors.New("duplicate id"))
|
||||
case !ok:
|
||||
return fmt.Errorf("%s.type: unknown %s type %q", path, role, typ)
|
||||
at(path+".type", fmt.Errorf("unknown %s type %q", role, typ))
|
||||
case plugin.Single && single[role+typ] != "":
|
||||
return fmt.Errorf("pipeline %q: one %s %s may run, and pipeline %q has it", p.Name, typ, role, single[role+typ])
|
||||
at(path, fmt.Errorf("one %s %s may run, and pipeline %q has it", typ, role, single[role+typ]))
|
||||
default:
|
||||
ids[list+id] = true
|
||||
if plugin.Single {
|
||||
single[role+typ] = p.Name
|
||||
}
|
||||
if _, err := decode(role, typ, m); err != nil {
|
||||
at(path+".config", err)
|
||||
}
|
||||
}
|
||||
ids[list+id] = true
|
||||
if plugin.Single {
|
||||
single[role+typ] = p.Name
|
||||
}
|
||||
if _, err := decode(role, typ, m); err != nil {
|
||||
return At(path+".config", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, s := range p.PluginSources {
|
||||
if err := check("source", "plugin_sources", s.ID, s.Type, s.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
check("source", "plugin_sources", s.ID, s.Type, s.Config)
|
||||
}
|
||||
for _, s := range p.PluginSinks {
|
||||
if err := check("sink", "plugin_sinks", s.ID, s.Type, s.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
check("sink", "plugin_sinks", s.ID, s.Type, s.Config)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// checkFlow settles copies of a pipeline's flow stages, which keep their
|
||||
// defaults for the dump
|
||||
// defaults for the dump: a problem a stage
|
||||
func checkFlow(f *FlowConfig, path string) error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
stages := map[string]any{}
|
||||
if f.Heartbeat != nil {
|
||||
stages["heartbeat"] = new(*f.Heartbeat)
|
||||
}
|
||||
if f.RateLimit != nil {
|
||||
stages["rate_limit"] = new(*f.RateLimit)
|
||||
}
|
||||
if f.Format != nil {
|
||||
stages["format"] = new(*f.Format)
|
||||
}
|
||||
for i, filter := range f.Filters {
|
||||
stages[fmt.Sprintf("filters[%d]", i)] = &filter
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(stages)) {
|
||||
if err := Settle(stages[key]); err != nil {
|
||||
return At(path+"."+key, err)
|
||||
var errs []error
|
||||
settle := func(key string, stage any) {
|
||||
if err := Settle(stage); err != nil {
|
||||
errs = append(errs, At(path+"."+key, err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if f.RateLimit != nil {
|
||||
settle("rate_limit", new(*f.RateLimit))
|
||||
}
|
||||
for i, filter := range f.Filters {
|
||||
settle(fmt.Sprintf("filters[%d]", i), &filter)
|
||||
}
|
||||
if f.Format != nil {
|
||||
settle("format", new(*f.Format))
|
||||
}
|
||||
if f.Heartbeat != nil {
|
||||
settle("heartbeat", new(*f.Heartbeat))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// At prefixes err with the path of the table it was found in
|
||||
// At addresses err to the path of the table it was found in
|
||||
func At(path string, err error) error {
|
||||
if ke, ok := errors.AsType[*KeyError](err); ok {
|
||||
return fmt.Errorf("%s.%s: %w", path, ke.Key, ke.Err)
|
||||
return &KeyError{path + "." + ke.Key, ke.Err}
|
||||
}
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
return &KeyError{path, err}
|
||||
}
|
||||
|
||||
// table addresses a rule between a table's keys, which its Check names by
|
||||
// a "tls: " prefix, to the table
|
||||
func table(key string, err error) error {
|
||||
if _, ok := errors.AsType[*KeyError](err); err == nil || ok {
|
||||
return err
|
||||
}
|
||||
return &KeyError{key, errors.New(strings.TrimPrefix(err.Error(), key+": "))}
|
||||
}
|
||||
|
||||
// networked is the options of a plugin that listens or dials
|
||||
@@ -175,12 +173,12 @@ func (p Plugin) checkNetwork(n networked) error {
|
||||
return &KeyError{"host", err}
|
||||
}
|
||||
if err := t.Check(p.Side != Dialer); err != nil {
|
||||
return err
|
||||
return table("tls", err)
|
||||
}
|
||||
if err := acl.Check(host, p.HTTP, a != nil && len(a.TrustedProxies) > 0); err != nil {
|
||||
return err
|
||||
return table("acl", err)
|
||||
}
|
||||
return a.Check(p.Side, p.HTTP, t.State())
|
||||
return table("auth", a.Check(p.Side, p.HTTP, t.State()))
|
||||
}
|
||||
|
||||
// Check holds the rules between tls keys on a listener or a dialer; a table
|
||||
@@ -311,9 +309,9 @@ func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
|
||||
return errors.New("auth: username and password_file go together")
|
||||
case o.CredentialsFile == "" && o.Username == "":
|
||||
return fmt.Errorf("auth: type %q requires credentials_file, or username and password_file", "scram")
|
||||
case o.TokenLifetimeMS > 0 && !http:
|
||||
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
|
||||
case len(o.TrustedProxies) > 0 && (side != Listener || !http):
|
||||
case !http && httpKey(o) != "":
|
||||
return fmt.Errorf("auth: %s applies only to HTTP listeners", httpKey(o))
|
||||
case len(o.TrustedProxies) > 0 && side != Listener:
|
||||
return errors.New("auth: trusted_proxies applies only to the http sink")
|
||||
case len(o.TrustedProxies) > 0 && o.Identity != "":
|
||||
return errors.New("auth: identity binds a client certificate, which a TLS-terminating proxy does not pass on; drop it or trusted_proxies")
|
||||
@@ -367,14 +365,13 @@ func (o *ACLOptions) Check(host string, http, proxied bool) error {
|
||||
return err
|
||||
}
|
||||
header := o.ProxyProtocol == "optional" || o.ProxyProtocol == "required"
|
||||
rps := o.RequestsPerSecondPerClient
|
||||
switch {
|
||||
case header && len(o.ProxyFrom) == 0:
|
||||
return fmt.Errorf("acl: proxy_protocol %s needs proxy_from, the proxies that may send the header", o.ProxyProtocol)
|
||||
case !header && len(o.ProxyFrom) > 0:
|
||||
return errors.New("acl: proxy_from needs proxy_protocol optional or required")
|
||||
case rps > 0 && !http:
|
||||
return errors.New("acl: requests_per_second_per_client applies only to HTTP listeners")
|
||||
case !http && httpKey(o) != "":
|
||||
return fmt.Errorf("acl: %s applies only to HTTP listeners", httpKey(o))
|
||||
case o.MaxConnectionsPerClient > 0 && proxied:
|
||||
return errors.New("acl: max_connections_per_client counts connections, behind trusted_proxies the proxy's; cap clients at the proxy")
|
||||
}
|
||||
@@ -399,6 +396,17 @@ func (o *ACLOptions) Check(host string, http, proxied bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpKey names the first key of table v tagged http that is set
|
||||
func httpKey(v any) string {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
for _, o := range options(rv.Type()) {
|
||||
if o.HTTP && !rv.Field(o.index).IsZero() {
|
||||
return o.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParsePrefix reads an address or CIDR of network's family ("tcp": either)
|
||||
func ParsePrefix(entry, network string) (netip.Prefix, error) {
|
||||
entry = strings.TrimSpace(entry)
|
||||
@@ -575,13 +583,33 @@ func checkFileKeys(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := toml.NewParser(data).Parse()
|
||||
root, err := fileTable(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkKeys(root, reflect.TypeOf(Config{}), "")
|
||||
}
|
||||
|
||||
// ParseFile reads a configuration file's text, refusing the keys lw refuses
|
||||
// there, and opens nothing it names
|
||||
func ParseFile(data []byte) (*Config, error) {
|
||||
root, err := fileTable(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
return cfg, Scan(root, cfg)
|
||||
}
|
||||
|
||||
// fileTable is a file's root table without the keys lw ignores there
|
||||
func fileTable(data []byte) (map[string]any, error) {
|
||||
root, err := toml.NewParser(data).Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(root, "config_file") // runtime metadata, documented as ignored in the file
|
||||
for _, key := range []string{"version", "check", "dump"} {
|
||||
delete(root, key) // command-line switches, which earlier lw --dump output wrote
|
||||
}
|
||||
return checkKeys(root, reflect.TypeOf(Config{}), "")
|
||||
return root, nil
|
||||
}
|
||||
+8
-35
@@ -2,7 +2,6 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -164,7 +163,7 @@ func join(g ui.Wires, l layout, col int) {
|
||||
|
||||
// drawNode draws a source or sink: a tinted bar, its type and whether it
|
||||
// listens or dials, then a summary of its options. The chosen node's bar is
|
||||
// solid; a node the first problem names has its bar in the error style.
|
||||
// solid; a node a problem names has its bar in the error style.
|
||||
func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) {
|
||||
th := a.th
|
||||
bg, bar := th.Text, a.font.bar
|
||||
@@ -295,41 +294,15 @@ func show(v any) string {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// problemPath reads the part a validation error names: its pipeline, node and
|
||||
// key, as config's key paths write them
|
||||
var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`)
|
||||
|
||||
// locate reads a problem's part, and its text past the path
|
||||
func locate(err error) (pi int, n compose.Node, key, text string, ok bool) {
|
||||
at := problemPath.FindStringSubmatchIndex(err.Error())
|
||||
if at == nil {
|
||||
return 0, n, "", err.Error(), false
|
||||
}
|
||||
m := problemPath.FindStringSubmatch(err.Error())
|
||||
text = strings.TrimPrefix(err.Error()[at[1]:], ": ")
|
||||
pi, _ = strconv.Atoi(m[1])
|
||||
switch {
|
||||
case m[2] != "":
|
||||
n = compose.Node{Role: map[string]string{"plugin_sources": "source", "plugin_sinks": "sink"}[m[2]], ID: m[3]}
|
||||
key = m[4]
|
||||
case m[5] != "":
|
||||
n = compose.Node{Role: m[5]}
|
||||
if m[6] != "" {
|
||||
n.Index, _ = strconv.Atoi(m[6])
|
||||
}
|
||||
key = m[7]
|
||||
}
|
||||
return pi, n, key, text, true
|
||||
}
|
||||
|
||||
// faulty reports whether the first problem names this node, or a filter
|
||||
// when n is the filters stage
|
||||
// faulty reports whether a problem names this node, or a filter when n is
|
||||
// the filters stage
|
||||
func (a *app) faulty(n compose.Node) bool {
|
||||
if a.problem == nil {
|
||||
return false
|
||||
for _, p := range compose.Problems(a.problem) {
|
||||
if pi, m, _, _, ok := compose.Locate(p); ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
pi, m, _, _, ok := locate(a.problem)
|
||||
return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID
|
||||
return false
|
||||
}
|
||||
|
||||
// where names a problem's part as the screen shows it: its pipeline when
|
||||
|
||||
@@ -265,7 +265,7 @@ func (f *form) draw(a *app, r ui.Region) {
|
||||
}
|
||||
summary, noteRows := wrap(f.summary, w, 2), len(wrap(f.err, w, 3))
|
||||
for _, p := range f.params {
|
||||
noteRows = max(noteRows, len(wrap(p.Help, w, 3)))
|
||||
noteRows = max(noteRows, len(wrap(p.Usage(), w, 3)))
|
||||
}
|
||||
top, bottom := len(summary)+min(1, len(summary)), noteRows+min(1, noteRows)
|
||||
in := a.frame(r, w, top+len(f.fields)*tall+bottom, f.title)
|
||||
@@ -281,7 +281,7 @@ func (f *form) draw(a *app, r ui.Region) {
|
||||
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
||||
}
|
||||
})
|
||||
note, style := f.params[f.focus.Index].Help, th.Muted
|
||||
note, style := f.params[f.focus.Index].Usage(), th.Muted
|
||||
if f.err != "" {
|
||||
note, style = f.err, th.Error
|
||||
}
|
||||
@@ -456,7 +456,7 @@ func help(f font) dialog {
|
||||
{"p", "start over from a preset"},
|
||||
{"r", "run the pipelines"},
|
||||
{"o", "run, or print a command line, environment or file"},
|
||||
{"!", "the first problem"},
|
||||
{"!", "every problem, moving to the first"},
|
||||
{"paste", "an lw command line replaces the pipelines"},
|
||||
{"q", "quit"},
|
||||
}
|
||||
|
||||
@@ -69,8 +69,7 @@ func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix str
|
||||
case "table":
|
||||
out = append(out, line{node: n, key: k, depth: depth, group: name, item: -1})
|
||||
if a.insp.open[a.foldKey(n, k.Name)] {
|
||||
p, _ := a.plugin(n)
|
||||
out = a.keyLines(out, n, sided(a.tables[name], p.Side), k.Name+".", depth+1)
|
||||
out = a.keyLines(out, n, k.Keys, k.Name+".", depth+1)
|
||||
}
|
||||
case "list":
|
||||
out = append(out, line{node: n, key: k, depth: depth, group: name, item: -1})
|
||||
@@ -180,10 +179,10 @@ func (a *app) nodeTitle(n compose.Node) string {
|
||||
return n.Role + " · flow"
|
||||
}
|
||||
|
||||
// help is the focused row's problem, or its key's help and default
|
||||
// help is the focused row's first problem, or its key's help and default
|
||||
func (a *app) help(l line) (string, ui.Style) {
|
||||
if a.problem != nil {
|
||||
if pi, n, key, text, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
||||
for _, p := range compose.Problems(a.problem) {
|
||||
if pi, n, key, text, ok := compose.Locate(p); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
||||
return text, a.th.Error
|
||||
}
|
||||
}
|
||||
@@ -264,7 +263,7 @@ func (a *app) groupSummary(l line) string {
|
||||
return show(v)
|
||||
case map[string]any:
|
||||
var out []string
|
||||
for _, k := range a.tables[l.group] {
|
||||
for _, k := range l.key.Keys {
|
||||
if s, ok := v[k.Name]; ok {
|
||||
out = append(out, k.Name+" "+show(s))
|
||||
}
|
||||
|
||||
+13
-21
@@ -85,7 +85,6 @@ type app struct {
|
||||
look look
|
||||
th ui.Theme
|
||||
font font
|
||||
tables map[string][]config.Key
|
||||
pi int // the pipeline shown
|
||||
col int // the chosen column
|
||||
row [3]int // the chosen node of each column
|
||||
@@ -96,14 +95,14 @@ type app struct {
|
||||
dialog dialog // takes every key while open
|
||||
status string // the last edit's refusal
|
||||
note string // what the last action did
|
||||
problem error // the composition's first problem
|
||||
problem error // the composition's problems, joined
|
||||
changed bool
|
||||
done bool
|
||||
result Result
|
||||
}
|
||||
|
||||
func newApp(c *compose.Composition, l look, f font) *app {
|
||||
a := &app{comp: c, look: l, th: l.Theme, font: f, tables: compose.NewSchema().Tables}
|
||||
a := &app{comp: c, look: l, th: l.Theme, font: f}
|
||||
a.th.Glyphs = f.Glyphs
|
||||
a.insp.open = map[string]bool{}
|
||||
if len(c.Pipelines) == 0 {
|
||||
@@ -167,10 +166,10 @@ func (a *app) plugin(n compose.Node) (config.Plugin, bool) {
|
||||
return config.LookupPlugin(n.Role, typ)
|
||||
}
|
||||
|
||||
// keys are the options a node takes: a plugin's of its side, a stage's
|
||||
// keys are the options a node takes: a plugin's, a stage's
|
||||
func (a *app) keys(n compose.Node) []config.Key {
|
||||
if p, ok := a.plugin(n); ok {
|
||||
return sided(p.Keys(), p.Side)
|
||||
return p.Keys()
|
||||
}
|
||||
for _, s := range config.FlowStages() {
|
||||
if s.Key == n.Role {
|
||||
@@ -180,18 +179,6 @@ func (a *app) keys(n compose.Node) []config.Key {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sided drops the keys of the other network side
|
||||
func sided(keys []config.Key, side config.Side) []config.Key {
|
||||
name := map[config.Side]string{config.Listener: "listener", config.ChainListener: "listener", config.Dialer: "dialer"}[side]
|
||||
var out []config.Key
|
||||
for _, k := range keys {
|
||||
if k.Side == "" || k.Side == name {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// edit applies one engine edit: a refusal is the status, a change revalidates
|
||||
func (a *app) edit(err error) bool {
|
||||
if err != nil {
|
||||
@@ -418,13 +405,13 @@ func (a *app) quit() {
|
||||
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }}
|
||||
}
|
||||
|
||||
// showProblem chooses the part the first problem names and shows it whole
|
||||
// showProblem chooses the part the first problem names and shows them all
|
||||
func (a *app) showProblem() {
|
||||
if a.problem == nil {
|
||||
a.note = "the pipelines are valid"
|
||||
return
|
||||
}
|
||||
if pi, n, key, _, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
|
||||
if pi, n, key, _, ok := compose.Locate(compose.Problems(a.problem)[0]); ok && pi < len(a.comp.Pipelines) {
|
||||
a.pi = pi
|
||||
a.choose(n)
|
||||
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == key })
|
||||
@@ -539,7 +526,8 @@ func (a *app) paint(cells []terminal.Cell, w, h int) {
|
||||
}
|
||||
|
||||
// bottom is the status rows, wrapped to three at most: an edit's refusal, a
|
||||
// note, the first problem by its part, or the keys while all is valid
|
||||
// note, the first problem by its part and how many follow, or the keys while
|
||||
// all is valid
|
||||
func (a *app) bottom(w int) ([]string, ui.Style) {
|
||||
th, f := a.th, a.font
|
||||
text, style := "", th.Error
|
||||
@@ -549,10 +537,14 @@ func (a *app) bottom(w int) ([]string, ui.Style) {
|
||||
case a.note != "":
|
||||
text, style = a.note, th.Muted
|
||||
case a.problem != nil:
|
||||
pi, n, key, msg, _ := locate(a.problem)
|
||||
problems := compose.Problems(a.problem)
|
||||
pi, n, key, msg, _ := compose.Locate(problems[0])
|
||||
if where := a.where(pi, n, key); where != "" {
|
||||
msg = where + ": " + msg
|
||||
}
|
||||
if len(problems) > 1 {
|
||||
msg += fmt.Sprintf(" (%d more: ! shows them)", len(problems)-1)
|
||||
}
|
||||
text = f.invalid + " " + msg
|
||||
default:
|
||||
return []string{f.valid + " valid"}, th.Accent
|
||||
|
||||
@@ -399,18 +399,28 @@ func TestPasteReplacesThePipelines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The first problem marks the part it names, and ! moves there, to the key
|
||||
// Every problem marks the part it names and its key's row, the status counts
|
||||
// those after the first, and ! moves to the first, to the key
|
||||
func TestProblemNamesItsPart(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp"}))
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp", "http"}))
|
||||
sink := compose.Node{Role: "sink", ID: "tcp"}
|
||||
if !a.faulty(sink) || a.faulty(compose.Node{Role: "source", ID: "null"}) || a.faulty(compose.Node{Role: "sink", ID: "null"}) {
|
||||
if !a.faulty(sink) || !a.faulty(compose.Node{Role: "sink", ID: "http"}) ||
|
||||
a.faulty(compose.Node{Role: "source", ID: "null"}) || a.faulty(compose.Node{Role: "sink", ID: "null"}) {
|
||||
t.Fatalf("problem %v", a.problem)
|
||||
}
|
||||
if status, _ := a.bottom(200); !strings.Contains(strings.Join(status, " "), "sink tcp · port: must be set (1 more") {
|
||||
t.Fatalf("status %q", status)
|
||||
}
|
||||
press(a, "!")
|
||||
n, _ := a.node()
|
||||
if l := a.lines()[a.insp.cursor]; n != sink || !a.inspect || l.key.Name != "port" {
|
||||
t.Fatalf("on %+v, row %+v", n, l)
|
||||
}
|
||||
a.choose(compose.Node{Role: "sink", ID: "http"})
|
||||
lines := a.lines()
|
||||
if text, style := a.help(lines[slices.IndexFunc(lines, func(l line) bool { return l.key.Name == "port" })]); text != "must be set" || style != a.th.Error {
|
||||
t.Fatalf("http's port row: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ! and c on the pipeline bar open the inspector, which then takes the keys:
|
||||
@@ -515,6 +525,19 @@ func presetForm(a *app, name string) *form {
|
||||
return f
|
||||
}
|
||||
|
||||
// A preset form's note shows the focused key's choices before its help
|
||||
func TestPresetFormShowsTheChoices(t *testing.T) {
|
||||
a := mono(pipeline(t, nil, nil))
|
||||
presetForm(a, "tail")
|
||||
press(a, terminal.KeyBacktab)
|
||||
ps := config.Presets()
|
||||
tail := ps[slices.IndexFunc(ps, func(p config.Preset) bool { return p.Name == "tail" })].Params
|
||||
format := tail[slices.IndexFunc(tail, func(p config.PresetParam) bool { return p.Name == "format" })]
|
||||
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.Contains(screen, format.Usage()) || len(format.Enum) == 0 {
|
||||
t.Fatalf("format's choices not shown:\n%s", screen)
|
||||
}
|
||||
}
|
||||
|
||||
// A preset form is usable at 25 columns: labels stack above their values,
|
||||
// which take the row, so typed text shows whole
|
||||
func TestPresetFormFitsANarrowScreen(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user