v0.35.0 tui fixes
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:
lixen committed 2026-10-11 04:11:57 -04:00
1 parent 1abffcec6c
commit f4f8cc0986
24 files changed
+965 -295

No files matched your search

+4 -3
View File
@@ -209,13 +209,14 @@ func defaults() *Config {
}
}
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line.
// Its parts take the ids lw gives their type, so a retyped part follows it.
func pipeDefault(name string) PipelineConfig {
return PipelineConfig{
Name: name,
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{}}},
PluginSources: []PluginSourceConfig{{ID: FreeID("console", nil), Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: FreeID("console", nil), Type: "console", Config: map[string]any{}}},
}
}
+12 -8
View File
@@ -61,16 +61,17 @@ func mirror[T any](p PresetParam, key string) PresetParam {
}
// 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 Answers, or nil: a
// path ending in '/' is then a directory, one with a glob a pattern, and any
// other fails with ErrPathKind.
// or a file pattern; a path ending in '/' is a directory without asking. Off
// the target host it is the user's Answers, or nil: a path with a glob is then
// a pattern, and any other fails with ErrPathKind.
type IsDir func(path string) (bool, error)
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
// HostIsDir asks this host, a leading ~ being the home directory: a path that
// does not exist is a pattern
func HostIsDir(path string) (bool, error) {
fi, err := os.Stat(path)
fi, err := os.Stat(ExpandHome(path))
return err == nil && fi.IsDir(), nil
}
@@ -81,8 +82,8 @@ func Answers(dirs map[string]bool) IsDir {
if dir, ok := dirs[path]; ok {
return dir, nil
}
if dir := strings.HasSuffix(path, "/"); dir || strings.ContainsAny(path, `*?[\`) {
return dir, nil
if strings.ContainsAny(path, `*?[\`) {
return false, nil
}
return false, ErrPathKind
}
@@ -353,7 +354,10 @@ func pathOrStdin(v map[string]string, isDir IsDir) ([]PluginSourceConfig, error)
if isDir == nil {
isDir = Answers(nil)
}
dir, err := isDir(path)
dir, err := strings.HasSuffix(path, "/"), error(nil)
if !dir {
dir, err = isDir(path)
}
if err != nil {
return nil, err
}
+16 -8
View File
@@ -2,6 +2,8 @@ package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
@@ -19,14 +21,14 @@ func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
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: "console", 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"}}},
PluginSources: []PluginSourceConfig{{ID: "console", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "console", Type: "console", Config: map[string]any{"color": "auto"}}},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
@@ -122,11 +124,16 @@ func TestPresetUserWithoutPasswordFileAsks(t *testing.T) {
}
}
// 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
// A preset path ending in '/' is a directory, there yet or not; any other is
// what isDir answers, a directory named with brackets too: this host's,
// reading ~ as home, or off it the user's answer, a single file too; without
// one it is a pattern when it holds a glob, and otherwise ErrPathKind asks
func TestPresetPathKind(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.Mkdir(filepath.Join(home, "logs"), 0o700); err != nil {
t.Fatal(err)
}
yes := func(string) (bool, error) { return true, nil }
file := Answers(map[string]bool{"/srv/app.log": false})
for _, c := range []struct {
@@ -134,7 +141,8 @@ func TestPresetPathKind(t *testing.T) {
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.log", file, "/srv app.log"}, {"/srv/app", file, ""}, {"/srv/app/", file, "/srv/app/ *"}} {
{"/srv/app.log", file, "/srv app.log"}, {"/srv/app", file, ""}, {"/srv/app/", file, "/srv/app/ *"},
{"~/logs", HostIsDir, "~/logs *"}, {home + "/later/", HostIsDir, home + "/later/ *"}} {
p, err := ExpandPreset("tail", map[string]string{"path": c.path}, c.isDir)
if c.want == "" {
if !errors.Is(err, ErrPathKind) {
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"maps"
"math"
"os"
"reflect"
"slices"
"strconv"
@@ -234,7 +235,8 @@ func settingKeys(v reflect.Value, prefix string) []Key {
// Decode reads a plugin's config map into its options: unknown keys, kinds,
// the defaults, the tag rules, the tls, auth and acl rules of its side, then the
// options' own Check. It reads no file.
// options' own Check; a leading ~ in a path is the home directory. It reads no
// file.
func Decode[T any](role, typ string, m map[string]any) (*T, error) {
v, err := decode(role, typ, m)
if err != nil {
@@ -263,6 +265,7 @@ func decode(role, typ string, m map[string]any) (any, error) {
if err := Settle(v.Interface()); err != nil {
return nil, err
}
expandPaths(v.Elem())
if n, ok := v.Interface().(networked); ok {
if err := p.checkNetwork(n); err != nil {
return nil, err
@@ -287,6 +290,32 @@ func Settle(v any) error {
// Fill gives a table its defaults and checks nothing: Settle's first step
func Fill(v any) { settle(reflect.ValueOf(v).Elem()) }
// expandPaths reads ~ (ExpandHome) in the dir and file keys of a table, its
// tables' too, where lw reads them; the forms keep ~ as typed
func expandPaths(v reflect.Value) {
for _, o := range options(v.Type()) {
switch f := v.Field(o.index); {
case f.Kind() == reflect.Pointer && !f.IsNil() && f.Elem().Kind() == reflect.Struct:
expandPaths(f.Elem())
case o.Hint == "dir" || o.Hint == "file":
f.SetString(ExpandHome(f.String()))
}
}
}
// ExpandHome reads a leading ~ or ~/ as the home directory; ~user, and a
// host without a home directory (js/wasm), keep the path as it is
func ExpandHome(path string) string {
rest, ok := strings.CutPrefix(path, "~")
if !ok || rest != "" && !os.IsPathSeparator(rest[0]) {
return path
}
if home, err := os.UserHomeDir(); err == nil {
return home + rest
}
return path
}
// preset sets every default before decoding, so a switch that defaults on
// stays on unless set off; it does so in the tables m sets
func preset(v reflect.Value, m map[string]any) {
+32
View File
@@ -2,6 +2,7 @@ package config
import (
"math"
"os"
"reflect"
"slices"
"strings"
@@ -199,3 +200,34 @@ func TestTableKeysFollowThePlugin(t *testing.T) {
}
}
}
// A leading ~ or ~/ in a dir or file key is the home directory where lw reads
// it, in a plugin's options, its tables too, while the configuration keeps it
// as typed, the settings' too (lw's log reads it at startup); ~user, other
// keys and a host without a home directory keep it
func TestTildeIsHomeInPathKeys(t *testing.T) {
isolateConfig(t)
home := os.Getenv("HOME")
src, err := Decode[FileSourceOptions]("source", "file", map[string]any{"directory": "~/logs", "pattern": "~*"})
if err != nil || src.Directory != home+"/logs" || src.Pattern != "~*" {
t.Fatalf("file source: %+v %v", src, err)
}
tls := map[string]any{"enabled": true, "ca_file": "~", "cert_file": "~/c", "key_file": "~other/k"}
sink, err := Decode[TCPChainSinkOptions]("sink", "tcp_chain", map[string]any{"host": "h", "port": int64(1), "tls": tls})
if err != nil || sink.TLS.CAFile != home || sink.TLS.CertFile != home+"/c" || sink.TLS.KeyFile != "~other/k" {
t.Fatalf("tls table: %+v %v", sink.TLS, err)
}
m, err := Load(Args{Overrides: []string{"--logging.file.directory=~/lw"}, Specs: specs("source", "file:directory=~/logs")})
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil || cfg.Logging.File.Directory != "~/lw" || cfg.Pipelines[0].PluginSources[0].Config["directory"] != "~/logs" {
t.Fatalf("as typed: %+v %v %v", cfg.Logging.File, cfg.Pipelines, err)
}
t.Setenv("HOME", "")
if src, err := Decode[FileSourceOptions]("source", "file", map[string]any{"directory": "~/logs"}); err != nil || src.Directory != "~/logs" {
t.Fatalf("without a home directory: %+v %v", src, err)
}
}
+4 -3
View File
@@ -172,12 +172,13 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
}
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does; two pipelines cannot both read stdin.
// stdout, as a filter does, each part under its type's own id; two pipelines
// cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "filter", "include:patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "console" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "console" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})