v0.30.1 configuration engine as wasm
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
This commit is contained in:
1 parent
883af0fcaa
commit
bfcc15613a
13 files changed
+320
-52
No files matched your search
@@ -5,6 +5,7 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -59,7 +60,8 @@ func FromPreset(name string, values map[string]string, isDir config.IsDir) (*Com
|
||||
}
|
||||
|
||||
// FromCommandLine starts from a pasted lw command line, quoted as a POSIX
|
||||
// shell reads it; it takes pipeline flags only, as a composition holds no settings
|
||||
// shell reads it; it takes pipeline flags only, at least one, as a composition
|
||||
// holds no settings
|
||||
func FromCommandLine(line string, isDir config.IsDir) (*Composition, error) {
|
||||
words, err := split(strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(line)) // pasted line ends
|
||||
if err != nil {
|
||||
@@ -86,6 +88,9 @@ func FromCommandLine(line string, isDir config.IsDir) (*Composition, error) {
|
||||
}
|
||||
specs = append(specs, config.Spec{Flag: flag, Value: value})
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
return nil, errors.New("no pipeline flags")
|
||||
}
|
||||
pipelines, err := config.SpecPipelines(specs, isDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -117,13 +122,13 @@ 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 fmt.Errorf("source %s: %w", s.ID, err)
|
||||
return config.At(fmt.Sprintf("pipelines[%d].plugin_sources[%s].config", i, s.ID), 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 fmt.Errorf("sink %s: %w", s.ID, err)
|
||||
return config.At(fmt.Sprintf("pipelines[%d].plugin_sinks[%s].config", i, s.ID), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,6 +482,75 @@ 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
|
||||
type Form struct {
|
||||
Name, Hint string
|
||||
Write func(*Composition) (string, error)
|
||||
}
|
||||
|
||||
var forms = []Form{
|
||||
{"command", "lw's flags", (*Composition).CommandLine},
|
||||
{"environment", "LOGWISP_ variables: one pipeline", (*Composition).Environment},
|
||||
{"file", "a configuration file's pipelines", func(c *Composition) (string, error) {
|
||||
data, err := c.File()
|
||||
return string(data), err
|
||||
}},
|
||||
}
|
||||
|
||||
// Forms are what a composition writes: lw --tui prints one, the website shows them
|
||||
func Forms() []Form { return slices.Clone(forms) }
|
||||
|
||||
// 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
|
||||
func FromJSON(data []byte) (*Composition, error) {
|
||||
var tables any
|
||||
if err := json.Unmarshal(data, &tables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
if err := config.Scan(map[string]any{"pipelines": dropNulls(tables)}, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromConfig(cfg)
|
||||
}
|
||||
|
||||
// dropNulls removes JSON's nulls from tables and lists, which the shell forms
|
||||
// would write as text
|
||||
func dropNulls(v any) any {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range v {
|
||||
if e == nil {
|
||||
delete(v, k)
|
||||
} else {
|
||||
v[k] = dropNulls(e)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
v = slices.DeleteFunc(v, func(e any) bool { return e == nil })
|
||||
for i, e := range v {
|
||||
v[i] = dropNulls(e)
|
||||
}
|
||||
return v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// JSON writes the pipelines as FromJSON reads them, keyed as File writes them
|
||||
func (c *Composition) JSON() ([]byte, error) {
|
||||
data, err := toml.Marshal(map[string]any{"pipelines": c.Pipelines})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := toml.NewParser(data).Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(root["pipelines"])
|
||||
}
|
||||
|
||||
// quote leaves a word of these characters bare, and single-quotes any other,
|
||||
// or one starting with '=', which zsh expands
|
||||
func quote(word string) string {
|
||||
|
||||
@@ -34,10 +34,11 @@ func TestEngineLinksNoHostCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A pasted command line, its line ends LF or CRLF, starts the composition that
|
||||
// wrote it: escaped and quoted values, nested keys, lists, moved filters,
|
||||
// stages whose flags' defaults differ from a file's, a value starting with '-'
|
||||
func TestPastedCommandLineStartsTheSameComposition(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) {
|
||||
c := &Composition{}
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
@@ -80,6 +81,36 @@ func TestPastedCommandLineStartsTheSameComposition(t *testing.T) {
|
||||
t.Fatalf("%q\n got %+v %v\nwant %+v", pasted, got, err, c)
|
||||
}
|
||||
}
|
||||
data, err := c.JSON()
|
||||
must(err)
|
||||
if got, err := FromJSON(data); err != nil || !reflect.DeepEqual(got, c) {
|
||||
t.Fatalf("%s\n got %+v %v\nwant %+v", data, got, err, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
with, err := FromJSON([]byte(`[{"name":"p","flow":{"rate_limit":null,"filters":[{"patterns":["a",null]}]},
|
||||
"plugin_sources":[{"type":"file","config":{"directory":"/x/","pattern":null}}],
|
||||
"plugin_sinks":[{"type":"console","config":{"color":null}}]}]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
without, err := FromJSON([]byte(`[{"name":"p","flow":{"filters":[{"patterns":["a"]}]},
|
||||
"plugin_sources":[{"type":"file","config":{"directory":"/x/"}}],"plugin_sinks":[{"type":"console"}]}]`))
|
||||
if err != nil || !reflect.DeepEqual(with, without) {
|
||||
t.Fatalf("%v\n got %+v\nwant %+v", err, with, without)
|
||||
}
|
||||
}
|
||||
|
||||
// The website's JSON refuses a misspelled key as a file does, at any depth
|
||||
func TestAMisspelledJSONKeyIsRefused(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The command line and environment carry no control character, which a
|
||||
|
||||
@@ -133,7 +133,7 @@ const (
|
||||
type FilterConfig struct {
|
||||
Type FilterType `toml:"type" default:"include" lw:"enum=include|exclude" help:"pass only matching entries, or drop them"`
|
||||
Logic FilterLogic `toml:"logic" default:"or" lw:"enum=or|and" help:"match any pattern, or all"`
|
||||
Patterns []string `toml:"patterns" lw:"hint=regex" help:"RE2 patterns"`
|
||||
Patterns []string `toml:"patterns,omitempty" lw:"hint=regex" help:"RE2 patterns"`
|
||||
}
|
||||
|
||||
// --- Sources ---
|
||||
|
||||
@@ -90,7 +90,7 @@ func ValidatePipelines(pipelines []PipelineConfig) error {
|
||||
single[role+typ] = p.Name
|
||||
}
|
||||
if _, err := decode(role, typ, m); err != nil {
|
||||
return at(path+".config", err)
|
||||
return At(path+".config", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -130,14 +130,14 @@ func checkFlow(f *FlowConfig, path string) error {
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(stages)) {
|
||||
if err := Settle(stages[key]); err != nil {
|
||||
return at(path+"."+key, err)
|
||||
return At(path+"."+key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// at prefixes err with the path of the table it was found in
|
||||
func at(path string, err error) error {
|
||||
// At prefixes err with 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)
|
||||
}
|
||||
|
||||
+7
-20
@@ -240,29 +240,16 @@ func (f *presetForm) apply(a *app) {
|
||||
a.use(c.Pipelines[0])
|
||||
}
|
||||
|
||||
// outputs are what o offers: run, or print one form
|
||||
var outputs = []struct {
|
||||
name, hint string
|
||||
form func(*compose.Composition) (string, error) // nil runs
|
||||
}{
|
||||
{"run", "start the pipelines now, with lw's other settings", nil},
|
||||
{"command line", "print lw's flags", (*compose.Composition).CommandLine},
|
||||
{"environment", "print LOGWISP_ variables: one pipeline", (*compose.Composition).Environment},
|
||||
{"file", "print a configuration file's pipelines", func(c *compose.Composition) (string, error) {
|
||||
data, err := c.File()
|
||||
return string(data), err
|
||||
}},
|
||||
}
|
||||
|
||||
// outputMenu is what o offers: run, or print one of the forms
|
||||
func (a *app) outputMenu() dialog {
|
||||
var options []ui.Option
|
||||
for _, o := range outputs {
|
||||
options = append(options, ui.Option{Name: o.name, Hint: o.hint})
|
||||
forms := compose.Forms()
|
||||
options := []ui.Option{{Name: "run", Hint: "start the pipelines now, with lw's other settings"}}
|
||||
for _, f := range forms {
|
||||
options = append(options, ui.Option{Name: f.Name, Hint: "print " + f.Hint})
|
||||
}
|
||||
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
|
||||
pick: func(a *app, i int) {
|
||||
o := outputs[i]
|
||||
if o.form == nil {
|
||||
if i == 0 {
|
||||
if a.problem != nil {
|
||||
a.status = "not valid yet: c shows the problem"
|
||||
return
|
||||
@@ -270,7 +257,7 @@ func (a *app) outputMenu() dialog {
|
||||
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
||||
return
|
||||
}
|
||||
text, err := o.form(a.comp)
|
||||
text, err := forms[i-1].Write(a.comp)
|
||||
if err != nil {
|
||||
a.status = err.Error()
|
||||
return
|
||||
|
||||
@@ -311,9 +311,6 @@ func slicesHasKey(keys []config.Key, name string) bool {
|
||||
// paste starts over from a pasted command line
|
||||
func (a *app) paste(text string) {
|
||||
c, err := compose.FromCommandLine(text, config.HostIsDir)
|
||||
if err == nil && len(c.Pipelines) == 0 {
|
||||
err = errors.New("no pipeline flags")
|
||||
}
|
||||
if err != nil {
|
||||
a.status = "paste: " + err.Error()
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user