v0.31.0 refinements
This commit is contained in:
1 parent
bfcc15613a
commit
6bd5e12a60
52 files changed
+1948
-683
No files matched your search
@@ -448,7 +448,7 @@ func (p *Policy) Enabled() bool { return p != nil }
|
||||
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
|
||||
|
||||
// Unrestricted reports whether an mtls policy admits any identity the CA
|
||||
// vouches for. Under scram the credentials file is the allow list.
|
||||
// vouches for. Under scram the users are the allow list.
|
||||
func (p *Policy) Unrestricted() bool {
|
||||
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
|
||||
}
|
||||
|
||||
@@ -139,9 +139,10 @@ func TestNewValidation(t *testing.T) {
|
||||
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
|
||||
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, config.Dialer, TCP, "password_file applies only"},
|
||||
{"scram without tls", listener(config.AuthOptions{}), nil, config.Listener, TCP, "requires tls.enabled"},
|
||||
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file"},
|
||||
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "credentials file is the allow list"},
|
||||
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "only to dialers"},
|
||||
{"scram without users", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file, or username and password_file"},
|
||||
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "the users are the allow list"},
|
||||
{"credentials and a user", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "not both"},
|
||||
{"user without password_file", scram(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "go together"},
|
||||
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, config.Listener, TCP, "requires tls.client_auth"},
|
||||
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, config.Listener, TCP, "only to HTTP listeners"},
|
||||
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
|
||||
@@ -176,8 +177,10 @@ func TestNewValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, config.Listener, HTTP); err != nil {
|
||||
t.Fatalf("scram listener rejected: %v", err)
|
||||
for _, o := range []*config.AuthOptions{listener(config.AuthOptions{TokenLifetimeMS: 60000}), scram(config.AuthOptions{Username: "edge-01", PasswordFile: pw})} {
|
||||
if _, err := New(o, f.serverTLS, nil, config.Listener, HTTP); err != nil {
|
||||
t.Fatalf("scram listener rejected: %v", err)
|
||||
}
|
||||
}
|
||||
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
|
||||
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, config.Listener, HTTP); err != nil {
|
||||
|
||||
+159
-7
@@ -16,8 +16,11 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/chain"
|
||||
@@ -27,6 +30,7 @@ import (
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
"github.com/lixenwraith/toml"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -162,10 +166,87 @@ func (c *Credentials) Marshal() ([]byte, error) {
|
||||
return append([]byte(header), body...), nil
|
||||
}
|
||||
|
||||
// ReadPassword reads a password file, trimming one trailing line break so a
|
||||
// file from an editor and one from `lw auth add-user` agree.
|
||||
func ReadPassword(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
// processDecoy is the decoy key of listeners without a credentials file:
|
||||
// one per process, so reloads keep unknown-user challenges stable
|
||||
var processDecoy = sync.OnceValue(func() []byte { key := make([]byte, 32); rand.Read(key); return key })
|
||||
|
||||
// oneUser is a listener's only user, derived in memory at the dialers' floor
|
||||
// cost, the default outside tests
|
||||
func oneUser(user, path string) (*Credentials, error) {
|
||||
pw, err := ReadPassword(path, user, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred, err := auth.NewCredential(user, pw, auth.WithTime(minArgonTime), auth.WithMemory(minArgonMemory))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: user %q: %w", user, err)
|
||||
}
|
||||
return &Credentials{DecoyKey: processDecoy(), Users: []*auth.Credential{cred}}, nil
|
||||
}
|
||||
|
||||
// readOnce keeps what a reload cannot read again, by path and user, and
|
||||
// whether a terminal was asked: one may be asked for several users
|
||||
var readOnce = struct {
|
||||
sync.Mutex
|
||||
m map[[2]string]asked
|
||||
}{m: map[[2]string]asked{}}
|
||||
|
||||
type asked struct {
|
||||
pw string
|
||||
tty bool
|
||||
}
|
||||
|
||||
// startupOver is set once lw's first service is built
|
||||
var startupOver atomic.Bool
|
||||
|
||||
// EndStartup stops reading descriptors, pipes, FIFOs and terminals: at a
|
||||
// reload a console source may be reading the same one, and a read would hold
|
||||
// the reload and its signals. What was read before stays for reloads.
|
||||
func EndStartup() { startupOver.Store(true) }
|
||||
|
||||
// ReadPassword reads user's password from path, trimming one trailing line
|
||||
// break so a file from an editor and one from `lw auth add-user` agree. A
|
||||
// regular file is read each time, for rotation by reload; /dev/fd/N, a pipe,
|
||||
// a FIFO or another device once, before EndStartup; a terminal is asked once,
|
||||
// where generate lets Enter alone draw the password.
|
||||
func ReadPassword(path, user string, generate bool) (string, error) {
|
||||
fd, inherited := descriptor(path)
|
||||
if fi, err := os.Stat(path); !inherited && (err != nil || fi.Mode().IsRegular()) {
|
||||
data, err := os.ReadFile(path)
|
||||
return checkPassword(path, data, err)
|
||||
}
|
||||
readOnce.Lock()
|
||||
defer readOnce.Unlock()
|
||||
key := [2]string{path, user}
|
||||
if a, ok := readOnce.m[key]; ok {
|
||||
return a.pw, nil
|
||||
}
|
||||
if startupOver.Load() {
|
||||
return "", fmt.Errorf("auth: password_file %s is read only at startup; name a regular file or restart lw", path)
|
||||
}
|
||||
for k, a := range readOnce.m {
|
||||
if k[0] == path && !a.tty {
|
||||
return "", fmt.Errorf("auth: password_file %s was read for user %q and holds one password", path, k[1])
|
||||
}
|
||||
}
|
||||
var (
|
||||
data []byte
|
||||
tty bool
|
||||
err error
|
||||
)
|
||||
if inherited {
|
||||
data, err = readDescriptor(fd, path)
|
||||
} else {
|
||||
data, tty, err = readDevice(path, user, generate)
|
||||
}
|
||||
pw, err := checkPassword(path, data, err)
|
||||
if err == nil {
|
||||
readOnce.m[key] = asked{pw, tty}
|
||||
}
|
||||
return pw, err
|
||||
}
|
||||
|
||||
func checkPassword(path string, data []byte, err error) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: password_file: %w", err)
|
||||
}
|
||||
@@ -176,6 +257,70 @@ func ReadPassword(path string) (string, error) {
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
// descriptor is N of /dev/fd/N, which ReadPassword closes: the path then
|
||||
// names another file. 0-2 stay open, or stdio would go to the next file.
|
||||
func descriptor(path string) (int, bool) {
|
||||
n, ok := strings.CutPrefix(path, "/dev/fd/")
|
||||
fd, err := strconv.Atoi(n)
|
||||
return fd, ok && err == nil && fd > 2
|
||||
}
|
||||
|
||||
// readDescriptor reads an inherited descriptor to its end and closes it,
|
||||
// which needs no fdescfs on FreeBSD. Go opens its own close-on-exec, so a
|
||||
// descriptor with the flag is lw's: reading or closing it would break lw.
|
||||
func readDescriptor(fd int, path string) ([]byte, error) {
|
||||
flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFD, 0)
|
||||
if errno != 0 || flags&syscall.FD_CLOEXEC != 0 {
|
||||
return nil, fmt.Errorf("%s was not passed to lw: redirect it, as in %d<FILE", path, fd)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
|
||||
}
|
||||
|
||||
// readDevice reads a pipe, FIFO or device to its end, or asks when it is a
|
||||
// terminal. The open file decides: on FreeBSD stat calls /dev/stdin a device
|
||||
// whatever it holds.
|
||||
func readDevice(path, user string, generate bool) ([]byte, bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, syscall.ENXIO) {
|
||||
err = fmt.Errorf("%s: no terminal to ask on; name a password file", path)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
if term.IsTerminal(int(f.Fd())) {
|
||||
data, err := ask(path, user, generate)
|
||||
return data, true, err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
|
||||
return data, false, err
|
||||
}
|
||||
|
||||
// ask prompts on the terminal at path without echo. A generated password is
|
||||
// shown there only: logs persist, and stderr is often a file.
|
||||
func ask(path, user string, generate bool) ([]byte, error) {
|
||||
tty, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tty.Close()
|
||||
hint := ""
|
||||
if generate {
|
||||
hint = " (Enter generates one)"
|
||||
}
|
||||
fmt.Fprintf(tty, "password for %s%s: ", user, hint)
|
||||
data, err := term.ReadPassword(int(tty.Fd()))
|
||||
fmt.Fprintln(tty)
|
||||
if err == nil && len(data) == 0 && generate {
|
||||
pw := rand.Text()
|
||||
fmt.Fprintf(tty, "generated password for %s, shown once: %s\n", user, pw)
|
||||
return []byte(pw), nil
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// --- Listener ---
|
||||
|
||||
type scramListener struct {
|
||||
@@ -215,7 +360,14 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
||||
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
|
||||
l.cb = cb[:]
|
||||
}
|
||||
if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil {
|
||||
secret := secretFile{"auth.credentials_file", o.CredentialsFile}
|
||||
if o.CredentialsFile != "" {
|
||||
l.creds, err = LoadCredentials(o.CredentialsFile)
|
||||
} else {
|
||||
secret = secretFile{"auth.password_file", o.PasswordFile}
|
||||
l.creds, err = oneUser(o.Username, o.PasswordFile)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.transport == HTTP {
|
||||
@@ -231,7 +383,7 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
||||
}
|
||||
}
|
||||
p.listener = l
|
||||
p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile})
|
||||
p.secrets = append(p.secrets, secret)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -543,7 +695,7 @@ type scramDialer struct {
|
||||
}
|
||||
|
||||
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
|
||||
password, err := ReadPassword(o.PasswordFile)
|
||||
password, err := ReadPassword(o.PasswordFile, o.Username, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+127
-15
@@ -19,6 +19,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -101,7 +102,9 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool {
|
||||
|
||||
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role config.Side, transport Transport) *Policy {
|
||||
t.Helper()
|
||||
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
|
||||
if o.Type = MethodSCRAM; o.Username == "" {
|
||||
o.CredentialsFile = f.creds
|
||||
}
|
||||
p, err := New(&o, tlsCfg, nil, role, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -348,21 +351,24 @@ func TestCertificateBindsToUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown user's challenge salt comes from the file's decoy key, so it is
|
||||
// the same after a restart and a prober learns nothing from restarts.
|
||||
// An unknown user's challenge salt comes from the decoy key, the file's or,
|
||||
// for one user without a file, the process's, so it is the same after a
|
||||
// rebuild and a prober learns nothing from restarts or reloads.
|
||||
func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
var salts []string
|
||||
for range 2 {
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
|
||||
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, o := range []config.AuthOptions{{}, {Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}} {
|
||||
var salts []string
|
||||
for range 2 {
|
||||
l := f.listener(t, o, f.serverTLS, config.Listener, TCP)
|
||||
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
salts = append(salts, c.Salt)
|
||||
}
|
||||
if salts[0] != salts[1] {
|
||||
t.Fatalf("%+v: unknown user salts differ across rebuilds: %q, %q", o, salts[0], salts[1])
|
||||
}
|
||||
salts = append(salts, c.Salt)
|
||||
}
|
||||
if salts[0] != salts[1] {
|
||||
t.Fatalf("unknown user salts differ across restarts: %q, %q", salts[0], salts[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,15 +621,121 @@ func TestRefusedHellosFreeTheirSlot(t *testing.T) {
|
||||
func TestReadPasswordTrimsOneLineBreak(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, contents := range []string{"secret", "secret\n", "secret\r\n"} {
|
||||
if pw, err := ReadPassword(f.write(t, "pw", contents)); err != nil || pw != "secret" {
|
||||
if pw, err := ReadPassword(f.write(t, "pw", contents), "edge-01", false); err != nil || pw != "secret" {
|
||||
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
|
||||
}
|
||||
}
|
||||
if _, err := ReadPassword(f.write(t, "pw", "\n")); err == nil {
|
||||
if _, err := ReadPassword(f.write(t, "pw", "\n"), "edge-01", false); err == nil {
|
||||
t.Error("an empty password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A descriptor's password is read once and the descriptor closed: a reload
|
||||
// gets it from memory, and another user naming it is refused.
|
||||
func TestPasswordFromADescriptorSurvivesReload(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fd, err := syscall.Dup(int(r.Fd())) // not close-on-exec, as a shell passes it
|
||||
r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
|
||||
var before, after syscall.Stat_t
|
||||
syscall.Fstat(fd, &before)
|
||||
w.WriteString("pipe-secret\n")
|
||||
w.Close()
|
||||
path := fmt.Sprintf("/dev/fd/%d", fd)
|
||||
for range 2 {
|
||||
if pw, err := ReadPassword(path, "pipe", true); err != nil || pw != "pipe-secret" {
|
||||
t.Fatalf("ReadPassword(%s) = %q, %v", path, pw, err)
|
||||
}
|
||||
}
|
||||
if syscall.Fstat(fd, &after) == nil && after.Ino == before.Ino {
|
||||
t.Error("the descriptor was left open")
|
||||
}
|
||||
if _, err := ReadPassword(path, "other", false); err == nil || !strings.Contains(err.Error(), `read for user "pipe"`) {
|
||||
t.Errorf("another user of the descriptor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After startup a descriptor, pipe or terminal is not read: a console source
|
||||
// may read the same one. One read at startup stays for reloads.
|
||||
func TestOnceReadSourcesAreReadAtStartupOnly(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fd, err := syscall.Dup(int(r.Fd()))
|
||||
r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { startupOver.Store(false); readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
|
||||
w.WriteString("pipe-secret\n")
|
||||
w.Close()
|
||||
path := fmt.Sprintf("/dev/fd/%d", fd)
|
||||
if _, err := ReadPassword(path, "early", false); err != nil {
|
||||
t.Fatalf("at startup: %v", err)
|
||||
}
|
||||
EndStartup()
|
||||
if _, err := ReadPassword(os.DevNull, "late", false); err == nil || !strings.Contains(err.Error(), "only at startup") {
|
||||
t.Fatalf("a device after startup: %v", err)
|
||||
}
|
||||
if pw, err := ReadPassword(path, "early", false); err != nil || pw != "pipe-secret" {
|
||||
t.Fatalf("a reload of the early user: %q, %v", pw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A device is classed by the file it opens, not by stat: FreeBSD stats
|
||||
// /dev/stdin as a device whatever it holds, so one that is no terminal is read.
|
||||
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
|
||||
if _, err := ReadPassword(os.DevNull, "dev", false); err == nil || !strings.Contains(err.Error(), "password must be") {
|
||||
t.Fatalf("%s: %v; want it read, and empty", os.DevNull, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A /dev/fd/N lw opened itself, close-on-exec as Go opens every file, was
|
||||
// never passed to it: it is refused and left open.
|
||||
func TestOwnDescriptorIsNeverRead(t *testing.T) {
|
||||
f, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := ReadPassword(fmt.Sprintf("/dev/fd/%d", f.Fd()), "pipe", false); err == nil || !strings.Contains(err.Error(), "not passed to lw") {
|
||||
t.Fatalf("lw's own descriptor: %v", err)
|
||||
}
|
||||
if _, err := f.Stat(); err != nil {
|
||||
t.Fatalf("lw's own descriptor was closed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A listener's one user, from username and password_file, logs in with its
|
||||
// password; another password and an unknown user fail alike.
|
||||
func TestOneUserListenerAdmitsItsPassword(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
o := config.AuthOptions{Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}
|
||||
l := f.listener(t, o, f.serverTLS, config.ChainListener, TCP)
|
||||
res, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "pipe", "pipe-secret"), nil)
|
||||
if err != nil || res.err != nil || res.adm.Identity.Name != "pipe" {
|
||||
t.Fatalf("exchange: dialer %v, listener %v", err, res.err)
|
||||
}
|
||||
var seen []string
|
||||
for _, d := range []*Policy{f.dialer(t, "pipe", "edge-01-secret"), f.dialer(t, "edge-01", "pipe-secret")} {
|
||||
res, _, err := f.connect(t, l, f.serverTLS, d, nil)
|
||||
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
|
||||
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
|
||||
}
|
||||
seen = append(seen, err.Error())
|
||||
}
|
||||
if seen[0] != seen[1] {
|
||||
t.Fatalf("wrong password and unknown user differ: %q vs %q", seen[0], seen[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A credentials file is validated whole, and Marshal round-trips. Each case
|
||||
// breaks one rule of an otherwise valid file.
|
||||
func TestCredentialsFileValidation(t *testing.T) {
|
||||
|
||||
@@ -181,13 +181,34 @@ func (c *Composition) pipeline(i int) (*config.PipelineConfig, error) {
|
||||
|
||||
// AddPipeline appends an empty pipeline under a name no other has
|
||||
func (c *Composition) AddPipeline(name string) error {
|
||||
if name == "" || slices.ContainsFunc(c.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name }) {
|
||||
return fmt.Errorf("pipeline %q: a name no other pipeline has", name)
|
||||
if err := c.free(name, -1); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Pipelines = append(c.Pipelines, config.PipelineConfig{Name: name, Flow: &config.FlowConfig{}})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenamePipeline gives pipeline i a name no other has
|
||||
func (c *Composition) RenamePipeline(i int, name string) error {
|
||||
p, err := c.pipeline(i)
|
||||
if err == nil {
|
||||
err = c.free(name, i)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Name = name
|
||||
return nil
|
||||
}
|
||||
|
||||
// free refuses a name that is empty or held by a pipeline other than i
|
||||
func (c *Composition) free(name string, i int) error {
|
||||
if j := slices.IndexFunc(c.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name }); name == "" || j >= 0 && j != i {
|
||||
return fmt.Errorf("pipeline %q: a name no other pipeline has", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Composition) RemovePipeline(i int) error {
|
||||
if _, err := c.pipeline(i); err != nil {
|
||||
return err
|
||||
@@ -233,6 +254,52 @@ func (c *Composition) Add(pi int, role, typ string) (Node, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Retype gives a source or sink another type of its role, in its place: a
|
||||
// chosen id stays and an automatic one follows the type. Options of the same
|
||||
// name and kind carry over to a type on the same network side, where they
|
||||
// mean the same; across sides host, port, tls and auth do not.
|
||||
func (c *Composition) Retype(pi int, n Node, typ string) (Node, error) {
|
||||
p, err := c.pipeline(pi)
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
to, ok := config.LookupPlugin(n.Role, typ)
|
||||
if !ok {
|
||||
return Node{}, fmt.Errorf("unknown %s type %q", n.Role, typ)
|
||||
}
|
||||
var id, old *string
|
||||
var opts *map[string]any
|
||||
var ids []string
|
||||
switch i := index(p, n); {
|
||||
case i >= 0 && n.Role == "source":
|
||||
id, old, opts = &p.PluginSources[i].ID, &p.PluginSources[i].Type, &p.PluginSources[i].Config
|
||||
for _, s := range p.PluginSources {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
case i >= 0 && n.Role == "sink":
|
||||
id, old, opts = &p.PluginSinks[i].ID, &p.PluginSinks[i].Type, &p.PluginSinks[i].Config
|
||||
for _, s := range p.PluginSinks {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
default:
|
||||
return Node{}, n.missing()
|
||||
}
|
||||
from, _ := config.LookupPlugin(n.Role, *old)
|
||||
kept := map[string]any{}
|
||||
for _, k := range from.Keys() {
|
||||
same := func(t config.Key) bool { return t.Name == k.Name && t.Kind == k.Kind }
|
||||
if v, set := (*opts)[k.Name]; set && from.Side == to.Side && slices.ContainsFunc(to.Keys(), same) {
|
||||
kept[k.Name] = v
|
||||
}
|
||||
}
|
||||
suffix, auto := strings.CutPrefix(*id, *old)
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(suffix, "_")); auto && (suffix == "" || suffix[0] == '_' && err == nil && n > 1) {
|
||||
*id = config.FreeID(typ, slices.DeleteFunc(ids, func(s string) bool { return s == *id }))
|
||||
}
|
||||
*old, *opts = typ, kept
|
||||
return Node{Role: n.Role, ID: *id}, nil
|
||||
}
|
||||
|
||||
// Remove deletes a source, sink or filter, or turns a flow stage off
|
||||
func (c *Composition) Remove(pi int, n Node) error {
|
||||
p, err := c.pipeline(pi)
|
||||
@@ -353,22 +420,22 @@ func (c *Composition) edit(pi int, n Node, change func(map[string]any) error) er
|
||||
return plugin(p.PluginSinks[i].Type, &p.PluginSinks[i].Config)
|
||||
case n.Role == "filters":
|
||||
filter := &p.Flow.Filters[i]
|
||||
if err := editStage(reflect.ValueOf(&filter).Elem(), change); err != nil {
|
||||
if err := editStage(reflect.ValueOf(&filter).Elem(), "", change); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Flow.Filters[i] = *filter
|
||||
return nil
|
||||
}
|
||||
if stage, ok := stages(p.Flow)[n.Role]; ok {
|
||||
return editStage(stage, change)
|
||||
return editStage(stage, n.Role, change)
|
||||
}
|
||||
return n.missing()
|
||||
}
|
||||
|
||||
// editStage edits a stage's keys as a spec sets them; the table they scan into
|
||||
// replaces the stage, which turns on
|
||||
func editStage(stage reflect.Value, change func(map[string]any) error) error {
|
||||
m := map[string]any{}
|
||||
// replaces the stage, which turns on as a spec turns it on
|
||||
func editStage(stage reflect.Value, key string, change func(map[string]any) error) error {
|
||||
m := config.StageOn(key, nil)
|
||||
if !stage.IsNil() {
|
||||
m = config.Values(stage.Interface())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
@@ -64,7 +65,7 @@ func TestWrittenFormsStartTheSameComposition(t *testing.T) {
|
||||
_, err = c.Add(0, "filters", "include")
|
||||
must(err)
|
||||
must(c.MoveFilter(0, 1, 0))
|
||||
must(c.Set(0, Node{Role: "rate_limit"}, "rate", "100"))
|
||||
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"))
|
||||
must(c.AddPipeline("-b"))
|
||||
@@ -190,3 +191,71 @@ func TestUnnamedPartsTakeFreeIDs(t *testing.T) {
|
||||
t.Fatalf("%v\n%s", err, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Retype keeps the part's place and a chosen id, renames an automatic one,
|
||||
// and carries the options of the same name and kind only within a side
|
||||
func TestRetypeKeepsThePlace(t *testing.T) {
|
||||
c := &Composition{}
|
||||
if err := c.AddPipeline("p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, typ := range []string{"tcp", "http", "tcp"} {
|
||||
if _, err := c.Add(0, "sink", typ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tcp2, chosen := Node{Role: "sink", ID: "tcp_2"}, Node{Role: "sink", ID: "web"}
|
||||
c.Pipelines[0].PluginSinks[1].ID = chosen.ID
|
||||
for _, err := range []error{c.Set(0, tcp2, "port", "9000"), c.Set(0, tcp2, "keep_alive", "false"), c.Set(0, chosen, "port", "80")} {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := c.Retype(0, tcp2, "http")
|
||||
if err != nil || got.ID != "http" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if _, err := c.Retype(0, chosen, "tcp_chain"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parts []string
|
||||
for _, s := range c.Pipelines[0].PluginSinks {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s %v", s.ID, s.Type, s.Config))
|
||||
}
|
||||
if want := []string{"tcp=tcp map[]", "web=tcp_chain map[]", "http=http map[port:9000]"}; !slices.Equal(parts, want) {
|
||||
t.Fatalf("%q, want %q", parts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A pipeline takes a name no other has, renamed as when added
|
||||
func TestPipelineNamesAreUnique(t *testing.T) {
|
||||
c := &Composition{}
|
||||
for _, name := range []string{"a", "b"} {
|
||||
if err := c.AddPipeline(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if c.AddPipeline("a") == nil || c.RenamePipeline(1, "a") == nil || c.RenamePipeline(1, "") == nil {
|
||||
t.Fatal("took a name another pipeline has")
|
||||
}
|
||||
if err := c.RenamePipeline(1, "b"); err != nil || c.RenamePipeline(1, "c") != nil || c.Pipelines[1].Name != "c" {
|
||||
t.Fatalf("%v, %+v", err, c.Pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
// A stage an edit turns on does what its spec would: a rate limit drops what
|
||||
// passes its rate, a heartbeat beats
|
||||
func TestAStageTurnsOnAsItsSpecDoes(t *testing.T) {
|
||||
c := &Composition{}
|
||||
if err := c.AddPipeline("p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, err := range []error{c.Set(0, Node{Role: "rate_limit"}, "entries_per_second", "5"), c.Set(0, Node{Role: "heartbeat"}, "interval_ms", "500")} {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if f := c.Pipelines[0].Flow; f.RateLimit.Policy != "drop" || !f.Heartbeat.Enabled {
|
||||
t.Fatalf("%+v %+v", f.RateLimit, f.Heartbeat)
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,8 @@ const (
|
||||
|
||||
// RateLimitConfig defines the configuration for pipeline-level rate limiting
|
||||
type RateLimitConfig struct {
|
||||
Rate float64 `toml:"rate" lw:"min=0,zero=off" help:"entries a second"`
|
||||
Burst float64 `toml:"burst" lw:"min=0,zero=the rate" help:"entries at once"`
|
||||
EntriesPerSecond float64 `toml:"entries_per_second" lw:"min=0,zero=off" help:"entries a second the limit lets through"`
|
||||
BurstEntries float64 `toml:"burst_entries" lw:"min=0,zero=one second's worth" help:"entries let through at once before the rate applies"`
|
||||
Policy string `toml:"policy" default:"pass" lw:"enum=pass|drop" help:"drop entries over the rate, or pass them"`
|
||||
MaxEntrySizeBytes int64 `toml:"max_entry_size_bytes" lw:"min=0,zero=unlimited" help:"drop larger entries"`
|
||||
}
|
||||
@@ -262,8 +262,8 @@ type HTTPSinkOptions struct {
|
||||
WriteTimeoutMS int64 `toml:"write_timeout_ms" lw:"min=0,zero=none" help:"milliseconds an event write may take"`
|
||||
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"clients at once"`
|
||||
ReplayLines int64 `toml:"replay_lines" lw:"min=0,zero=none" help:"recent entries a new stream gets first"`
|
||||
LoginPage bool `toml:"login_page" help:"serve the login page; scram behind auth.trusted_proxies"`
|
||||
ViewerPage bool `toml:"viewer_page" help:"serve the viewer to users logged in; needs login_page"`
|
||||
LoginPage bool `toml:"login_page" help:"scram behind auth.trusted_proxies only: a login page at /auth/login"`
|
||||
ViewerPage bool `toml:"viewer_page" help:"scram behind auth.trusted_proxies only: the viewer after a login; without scram it is always at /"`
|
||||
Auth *AuthOptions `toml:"auth"`
|
||||
ACL *ACLOptions `toml:"acl"`
|
||||
}
|
||||
@@ -307,9 +307,9 @@ type HTTPChainSinkOptions struct {
|
||||
|
||||
// AuthOptions selects how a network plugin authenticates its peer. It sits
|
||||
// beside `tls`: TLS answers "is this channel private", auth answers "may this
|
||||
// peer do this". Listeners verify (mtls: certificate identity; scram: password
|
||||
// via credentials_file); dialers prove themselves (scram) or pin the server
|
||||
// (mtls). AuthOptions.Check holds the rules of each side.
|
||||
// peer do this". Listeners verify (mtls: certificate identity; scram: the
|
||||
// credentials_file's users, or one username and password_file); dialers prove
|
||||
// themselves (scram) or pin the server (mtls). Check holds each side's rules.
|
||||
type AuthOptions struct {
|
||||
Type string `toml:"type" default:"none" lw:"enum=none|mtls|scram" help:"how peers authenticate"`
|
||||
Identity string `toml:"identity" lw:"enum=cn|san_dns|san_uri|san_email,zero=cn under mtls; no binding under scram" help:"certificate field naming the peer; under scram it must equal the user"`
|
||||
@@ -321,8 +321,8 @@ type AuthOptions struct {
|
||||
// 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"`
|
||||
Username string `toml:"username" lw:"dialer" help:"scram: the user this dialer logs in as"`
|
||||
PasswordFile string `toml:"password_file" lw:"dialer,hint=file" help:"scram: file holding the user's password"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ACLOptions is a listener's address rules, entries being addresses or CIDRs:
|
||||
|
||||
@@ -340,7 +340,7 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
||||
// its own; other sinks do not take the key.
|
||||
func TestTopLevelColorIsEachConsoleSinksDefault(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console,color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
||||
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console:color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+48
-21
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Preset is a pipeline built from a few keys: --preset NAME,key=value starts
|
||||
// Preset is a pipeline built from a few keys: --preset NAME:key=value starts
|
||||
// a pipeline with it. Later flags add sources, sinks and filters, or replace
|
||||
// its format, rate limit and heartbeat.
|
||||
type Preset struct {
|
||||
@@ -64,6 +64,10 @@ var (
|
||||
{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"},
|
||||
}
|
||||
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
||||
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"},
|
||||
}
|
||||
)
|
||||
|
||||
var presets = []Preset{
|
||||
@@ -88,8 +92,9 @@ var presets = []Preset{
|
||||
{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"},
|
||||
}, 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 for users, behind proxy; without users the viewer is always on"},
|
||||
{Name: "viewer", Default: "false", Help: "true: the login page and viewer, behind proxy; without user or users the viewer is always on"},
|
||||
}, aclParams, tlsParams),
|
||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||
sources, err := pathOrStdin(v, isDir)
|
||||
@@ -101,14 +106,18 @@ var presets = []Preset{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v["users"] != "" {
|
||||
sink["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||
auth, err := scramAuth(v)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case auth != nil && v["tls"] == "off" && v["proxy"] == "":
|
||||
return fmt.Errorf("user and users need tls (self, issuer or files), or proxy")
|
||||
case auth != nil:
|
||||
sink["auth"] = auth
|
||||
case v["proxy"] != "":
|
||||
return fmt.Errorf("proxy needs user or users: browsers log in with SCRAM")
|
||||
}
|
||||
if v["proxy"] != "" {
|
||||
auth, ok := sink["auth"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("proxy needs users: browsers log in with SCRAM")
|
||||
}
|
||||
auth["trusted_proxies"] = list(v["proxy"])
|
||||
}
|
||||
switch v["viewer"] {
|
||||
@@ -127,18 +136,17 @@ var presets = []Preset{
|
||||
return nil
|
||||
}},
|
||||
{"edge", "Forward files or standard input to an aggregator over TLS, authenticated",
|
||||
[]PresetParam{pathParam, fromParam,
|
||||
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"},
|
||||
{Name: "pin", Help: "sha256//BASE64 of the aggregator's key, which it logs with tls=self"},
|
||||
{Name: "server_name", Help: "name the aggregator's certificate carries; default: to's host"},
|
||||
{Name: "user", Help: "SCRAM user (lw auth add-user on the aggregator)"},
|
||||
{Name: "password_file", Help: "file holding the SCRAM password"},
|
||||
}, userParams, []PresetParam{
|
||||
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
||||
{Name: "key", Help: "client key file"},
|
||||
{Name: "node", Help: "origin label; default: this host's name"},
|
||||
},
|
||||
}),
|
||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||
sources, err := pathOrStdin(v, isDir)
|
||||
if err != nil {
|
||||
@@ -159,16 +167,15 @@ var presets = []Preset{
|
||||
tls[option] = v[key]
|
||||
}
|
||||
}
|
||||
var auth map[string]any
|
||||
auth, err := scramAuth(v)
|
||||
switch {
|
||||
case v["user"] != "" && v["password_file"] != "":
|
||||
auth = map[string]any{"type": "scram", "username": v["user"], "password_file": v["password_file"]}
|
||||
case v["user"] != "" || v["password_file"] != "":
|
||||
return fmt.Errorf("user and password_file go together")
|
||||
case err != nil:
|
||||
return err
|
||||
case auth != nil:
|
||||
case v["cert"] != "" && v["key"] != "":
|
||||
auth = map[string]any{"type": "mtls"}
|
||||
default:
|
||||
return fmt.Errorf("edge needs user and password_file, or cert and key: it never sends unauthenticated")
|
||||
return fmt.Errorf("edge needs user, or cert and key: it never sends unauthenticated")
|
||||
}
|
||||
sink := map[string]any{"host": host, "port": port, "tls": tls, "auth": auth}
|
||||
if v["node"] != "" {
|
||||
@@ -183,6 +190,7 @@ var presets = []Preset{
|
||||
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"},
|
||||
}, userParams, []PresetParam{
|
||||
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
||||
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
||||
formatParam("json"),
|
||||
@@ -203,13 +211,16 @@ var presets = []Preset{
|
||||
tls := src["tls"].(map[string]any)
|
||||
tls["client_auth"], tls["client_ca_file"] = true, v["client_ca"]
|
||||
}
|
||||
auth, err := scramAuth(v)
|
||||
switch {
|
||||
case v["users"] != "":
|
||||
src["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
||||
case err != nil:
|
||||
return err
|
||||
case auth != nil:
|
||||
src["auth"] = auth
|
||||
case v["client_ca"] != "":
|
||||
src["auth"] = map[string]any{"type": "mtls"}
|
||||
default:
|
||||
return fmt.Errorf("aggregator needs users or client_ca: it never receives unauthenticated")
|
||||
return fmt.Errorf("aggregator needs user, users or client_ca: it never receives unauthenticated")
|
||||
}
|
||||
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: "edges", Type: typ, Config: src})
|
||||
p.PluginSinks = pipeDefault("").PluginSinks
|
||||
@@ -356,6 +367,22 @@ func listener(v map[string]string, def string) (map[string]any, error) {
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// scramAuth is the scram table of user, whose password the terminal asks for
|
||||
// unless password_file names it, or of a listener's users file; nil without
|
||||
func scramAuth(v map[string]string) (map[string]any, error) {
|
||||
switch {
|
||||
case v["user"] != "" && v["users"] != "":
|
||||
return nil, fmt.Errorf("user or users, not both")
|
||||
case v["user"] != "":
|
||||
return map[string]any{"type": "scram", "username": v["user"], "password_file": cmp.Or(v["password_file"], "/dev/tty")}, nil
|
||||
case v["password_file"] != "":
|
||||
return nil, fmt.Errorf("password_file needs user")
|
||||
case v["users"] != "":
|
||||
return map[string]any{"type": "scram", "credentials_file": v["users"]}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func splitAddr(key, addr string) (string, int64, error) {
|
||||
host, p, err := net.SplitHostPort(addr)
|
||||
port, perr := strconv.ParseUint(p, 10, 16)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// sinks add to its own, a later format replaces its own.
|
||||
func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
got := loadPipelines(t, "preset", "tail,path=/var/log/app.log", "sink", "null", "format", "json",
|
||||
got := loadPipelines(t, "preset", "tail:path=/var/log/app.log", "sink", "null", "format", "json",
|
||||
"pipeline", "mine", "preset", "pipe")
|
||||
want := []PipelineConfig{{
|
||||
Name: "tail",
|
||||
@@ -40,21 +40,24 @@ func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
||||
func TestPresetKeysAreChecked(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
for spec, want := range map[string]string{
|
||||
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
||||
"tail,path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
||||
"tail": "preset tail needs path",
|
||||
"tail,path=x,format.type=json": "preset keys do not nest",
|
||||
"tail,path=x,path=y": `key "path" takes one value`,
|
||||
"serve,listen=8080": `listen "8080": want HOST:PORT`,
|
||||
"serve,tls=self,cert=c": "cert does not apply to tls=self",
|
||||
"serve,tls=files,cert=c": "tls=files needs key",
|
||||
"serve,viewer=yes": `viewer "yes"`,
|
||||
"serve,proxy=127.0.0.1": "proxy needs users",
|
||||
"serve,viewer=true": "viewer=true needs proxy",
|
||||
"edge,to=agg:9000": "it never sends unauthenticated",
|
||||
"edge,to=agg:9000,user=u": "user and password_file go together",
|
||||
"edge,to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
||||
"aggregator,users=u,tls=off": "the aggregator always serves TLS",
|
||||
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
||||
"tail:path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
||||
"tail": "preset tail needs path",
|
||||
"tail:path=x,format.type=json": "preset keys do not nest",
|
||||
"tail:path=x,path=y": `key "path" takes one value`,
|
||||
"serve:listen=8080": `listen "8080": want HOST:PORT`,
|
||||
"serve:tls=self,cert=c": "cert does not apply to tls=self",
|
||||
"serve:tls=files,cert=c": "tls=files needs key",
|
||||
"serve:viewer=yes": `viewer "yes"`,
|
||||
"serve:proxy=127.0.0.1": "proxy needs user or users",
|
||||
"serve:viewer=true": "viewer=true needs proxy",
|
||||
"serve:user=u": "user and users need tls (self, issuer or files), or proxy",
|
||||
"serve:tls=self,user=u,users=f": "user or users, not both",
|
||||
"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:user=u,users=f": "user or users, not both",
|
||||
"aggregator": "it never receives unauthenticated",
|
||||
} {
|
||||
_, err := Load(Args{Specs: specs("preset", spec)})
|
||||
@@ -64,6 +67,17 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth := p.PluginSinks[0].Config["auth"].(map[string]any); auth["password_file"] != "/dev/tty" {
|
||||
t.Fatalf("auth = %v, want password_file /dev/tty", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -117,8 +117,8 @@ func TestValidateConfigNamesThePath(t *testing.T) {
|
||||
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"}}}
|
||||
},
|
||||
"pipelines[0].flow.rate_limit.rate: must be a finite number": func(c *Config) {
|
||||
c.Pipelines[0].Flow.RateLimit = &RateLimitConfig{Rate: math.Inf(1)}
|
||||
"pipelines[0].flow.rate_limit.entries_per_second: must be a finite number": func(c *Config) {
|
||||
c.Pipelines[0].Flow.RateLimit = &RateLimitConfig{EntriesPerSecond: math.Inf(1)}
|
||||
},
|
||||
"color: must be one of auto, always, never, got \"\"": func(c *Config) {
|
||||
c.Color = ""
|
||||
|
||||
+33
-17
@@ -93,10 +93,9 @@ var specKinds = []specKind{
|
||||
Fill(&format)
|
||||
return []string{typedSpec(&format)}
|
||||
}},
|
||||
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
|
||||
{"rate-limit", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
||||
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
|
||||
return Scan(opts, p.Flow.RateLimit)
|
||||
p.Flow.RateLimit = &RateLimitConfig{}
|
||||
return Scan(StageOn("rate_limit", opts), p.Flow.RateLimit)
|
||||
}, func(p *PipelineConfig) []string {
|
||||
if p.Flow.RateLimit == nil {
|
||||
return nil
|
||||
@@ -106,8 +105,8 @@ var specKinds = []specKind{
|
||||
return []string{formatSpec("", Values(&rate))}
|
||||
}},
|
||||
{"heartbeat", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
||||
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
|
||||
return Scan(opts, p.Flow.Heartbeat)
|
||||
p.Flow.Heartbeat = &HeartbeatConfig{}
|
||||
return Scan(StageOn("heartbeat", opts), p.Flow.Heartbeat)
|
||||
}, func(p *PipelineConfig) []string {
|
||||
if p.Flow.Heartbeat == nil {
|
||||
return nil
|
||||
@@ -127,6 +126,18 @@ type pipelineSpec struct {
|
||||
value string
|
||||
}
|
||||
|
||||
// stageOn are the values naming a flow stage sets, in a spec or an engine
|
||||
// edit: the file's defaults (pass, disabled) would leave it doing nothing
|
||||
var stageOn = map[string]map[string]any{"rate_limit": {"policy": "drop"}, "heartbeat": {"enabled": true}}
|
||||
|
||||
// StageOn returns opts over the values that turn a flow stage on
|
||||
func StageOn(stage string, opts map[string]any) map[string]any {
|
||||
on := map[string]any{}
|
||||
maps.Copy(on, stageOn[stage])
|
||||
maps.Copy(on, opts)
|
||||
return on
|
||||
}
|
||||
|
||||
// Spec is one pipeline flag: its name without dashes, and its value.
|
||||
type Spec struct{ Flag, Value string }
|
||||
|
||||
@@ -282,13 +293,10 @@ func typedSpec(stage any) string {
|
||||
return formatSpec(typ, m)
|
||||
}
|
||||
|
||||
// formatSpec writes [TYPE,]key=value,...: tables as dotted keys, lists as a
|
||||
// repeated key
|
||||
// formatSpec writes TYPE[:key=value,...] or key=value,...: tables as dotted
|
||||
// keys, lists as a repeated key
|
||||
func formatSpec(typ string, tables ...map[string]any) string {
|
||||
var parts []string
|
||||
if typ != "" {
|
||||
parts = append(parts, escape(typ))
|
||||
}
|
||||
var add func(prefix string, m map[string]any)
|
||||
add = func(prefix string, m map[string]any) {
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
@@ -307,7 +315,11 @@ func formatSpec(typ string, tables ...map[string]any) string {
|
||||
for _, m := range tables {
|
||||
add("", m)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
opts := strings.Join(parts, ",")
|
||||
if typ == "" || opts == "" {
|
||||
return typ + opts
|
||||
}
|
||||
return typ + ":" + opts
|
||||
}
|
||||
|
||||
func text(v any) string {
|
||||
@@ -365,16 +377,20 @@ func useStdio(p *PipelineConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
|
||||
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
|
||||
// parseSpec reads TYPE[:key=value,...] or key=value,...: TYPE ends at the
|
||||
// first ':', dotted keys nest, a repeated key makes a list, and a backslash
|
||||
// escapes ',', '=' and '\'. Errors name the part.
|
||||
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
|
||||
rest, more := spec, true
|
||||
if typed {
|
||||
typ, rest, more = cutUnescaped(spec, ',')
|
||||
if _, _, eq := cutUnescaped(typ, '='); typ == "" || eq {
|
||||
typ, rest, _ = strings.Cut(spec, ":")
|
||||
more = rest != ""
|
||||
if head, _, comma := cutUnescaped(typ, ','); comma && !strings.Contains(head, "=") {
|
||||
return "", nil, fmt.Errorf("%s: TYPE ends at ':', as in %s:%s", spec, head, spec[len(head)+1:])
|
||||
}
|
||||
if typ == "" || strings.ContainsAny(typ, "=,\\") {
|
||||
return "", nil, fmt.Errorf("%s: missing TYPE", spec)
|
||||
}
|
||||
typ = unescape(typ)
|
||||
}
|
||||
opts = map[string]any{}
|
||||
for more {
|
||||
@@ -390,7 +406,7 @@ func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err er
|
||||
err = SetOption(opts, key, unescape(value))
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%s: %w", strings.TrimPrefix(typ+","+part, ","), err)
|
||||
return "", nil, fmt.Errorf("%s: %w", strings.TrimPrefix(typ+":"+part, ":"), err)
|
||||
}
|
||||
}
|
||||
return typ, opts, nil
|
||||
|
||||
@@ -36,13 +36,13 @@ func loadPipelines(t *testing.T, pairs ...string) []PipelineConfig {
|
||||
func TestPipelineSpecGrammar(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
got := loadPipelines(t,
|
||||
"source", `file,directory=/var/log/a\,b,pattern=*.log`,
|
||||
"source", `file,id=app,directory=x\=y`,
|
||||
"source", `file,directory=c:\\logs\\`,
|
||||
"sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
|
||||
"filter", "include,patterns=ERROR,patterns=WARN",
|
||||
"filter", `exclude,patterns=\d{1\,3}`,
|
||||
"format", `txt,timestamp_format=Jan 2\, 2006`,
|
||||
"source", `file:directory=/var/log/a\,b,pattern=*.log`,
|
||||
"source", `file:id=app,directory=x\=y`,
|
||||
"source", `file:directory=c:\\logs\\`,
|
||||
"sink", "http:port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
|
||||
"filter", "include:patterns=ERROR,patterns=WARN",
|
||||
"filter", `exclude:patterns=\d{1\,3}`,
|
||||
"format", `txt:timestamp_format=Jan 2\, 2006`,
|
||||
"pipeline", "b", "source", "null", "sink", "null", "sink", "null", "heartbeat", "interval_ms=1000")
|
||||
want := []PipelineConfig{{
|
||||
Name: "cli",
|
||||
@@ -76,13 +76,14 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
||||
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
for want, pairs := range map[string][]string{
|
||||
`--sink http,port: missing "="`: {"sink", "http,port"},
|
||||
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
|
||||
`--source file,type=x: TYPE already sets "type"`: {"source", "file,type=x"},
|
||||
`--sink http,tls.cert_file=c: "tls" is both a value and a table`: {"sink", "http,tls=1,tls.cert_file=c"},
|
||||
`--sink requires a value`: {"sink", ""},
|
||||
`--format txt: pipeline "cli" already has one`: {"format", "json", "format", "txt"},
|
||||
`--rate-limit rate=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "rate=1,polcy=drop"},
|
||||
`--sink http:port: missing "="`: {"sink", "http:port"},
|
||||
`--source file,directory=/x: TYPE ends at ':', as in file:directory=/x`: {"source", "file,directory=/x"},
|
||||
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
|
||||
`--source file:type=x: TYPE already sets "type"`: {"source", "file:type=x"},
|
||||
`--sink http:tls.cert_file=c: "tls" is both a value and a table`: {"sink", "http:tls=1,tls.cert_file=c"},
|
||||
`--sink requires a value`: {"sink", ""},
|
||||
`--format txt: pipeline "cli" already has one`: {"format", "json", "format", "txt"},
|
||||
`--rate-limit entries_per_second=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "entries_per_second=1,polcy=drop"},
|
||||
} {
|
||||
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
|
||||
t.Errorf("%q: err = %v, want %s", pairs, err, want)
|
||||
@@ -95,7 +96,7 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
||||
func TestSpecValueIsOneListEntry(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
sink := loadPipelines(t, "source", "null", "sink",
|
||||
`tcp,port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
|
||||
`tcp:port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
|
||||
`auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
|
||||
var opts TCPSinkOptions
|
||||
if err := Scan(sink.Config, &opts); err != nil {
|
||||
@@ -114,12 +115,12 @@ func TestEnvironmentPipeline(t *testing.T) {
|
||||
for name, value := range map[string]string{
|
||||
"LOGWISP_PIPELINE": "edge",
|
||||
"LOGWISP_SOURCE": "random",
|
||||
"LOGWISP_SOURCE_10": "null,id=ten",
|
||||
"LOGWISP_SOURCE_2": "null,id=two",
|
||||
"LOGWISP_SOURCE_10": "null:id=ten",
|
||||
"LOGWISP_SOURCE_2": "null:id=two",
|
||||
"LOGWISP_SINK_1": "console",
|
||||
"LOGWISP_FILTER": "",
|
||||
"LOGWISP_FORMAT_1": "raw",
|
||||
"LOGWISP_RATE_LIMIT": "rate=10",
|
||||
"LOGWISP_RATE_LIMIT": "entries_per_second=10",
|
||||
"LOGWISP_LOGGING_LEVEL": "debug",
|
||||
} {
|
||||
t.Setenv(name, value)
|
||||
@@ -136,7 +137,7 @@ func TestEnvironmentPipeline(t *testing.T) {
|
||||
p := cfg.Pipelines
|
||||
if len(p) != 1 || p[0].Name != "edge" || len(p[0].PluginSources) != 3 || p[0].PluginSources[1].ID != "two" ||
|
||||
p[0].PluginSources[2].ID != "ten" || len(p[0].PluginSinks) != 1 || p[0].Flow.Filters != nil || p[0].Flow.Format != nil ||
|
||||
*p[0].Flow.RateLimit != (RateLimitConfig{Rate: 10, Policy: "drop"}) || cfg.Logging.Level != "debug" {
|
||||
*p[0].Flow.RateLimit != (RateLimitConfig{EntriesPerSecond: 10, Policy: "drop"}) || cfg.Logging.Level != "debug" {
|
||||
t.Fatalf("environment pipeline: %+v %+v", p, cfg.Logging)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +175,7 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
||||
// stdout, as a filter does; two pipelines cannot both read stdin.
|
||||
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
got := loadPipelines(t, "filter", "include,patterns=ERROR")
|
||||
got := loadPipelines(t, "filter", "include:patterns=ERROR")
|
||||
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
||||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
||||
t.Fatalf("pipeline: %+v", p)
|
||||
@@ -190,7 +191,7 @@ func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||
// removal fails the reload instead of keeping the removed file's values.
|
||||
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
m, err := Load(Args{Specs: specs("source", "random,special=true", "sink", "null")})
|
||||
m, err := Load(Args{Specs: specs("source", "random:special=true", "sink", "null")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -217,11 +218,11 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
|
||||
// Written specs build the pipelines a file would: each flow stage with its
|
||||
// defaults, so the rate limit keeps its file policy and the heartbeat its off
|
||||
func TestWrittenSpecsBuildWhatAFileWould(t *testing.T) {
|
||||
p := PipelineConfig{Name: "p", Flow: &FlowConfig{Format: &FormatConfig{}, RateLimit: &RateLimitConfig{Rate: 5}, Heartbeat: &HeartbeatConfig{}},
|
||||
p := PipelineConfig{Name: "p", Flow: &FlowConfig{Format: &FormatConfig{}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5}, Heartbeat: &HeartbeatConfig{}},
|
||||
PluginSources: []PluginSourceConfig{{ID: "in", Type: "null", Config: map[string]any{}}},
|
||||
PluginSinks: []PluginSinkConfig{{ID: "out", Type: "null", Config: map[string]any{}}}}
|
||||
got, err := SpecPipelines(PipelineSpecs([]PipelineConfig{p}), nil)
|
||||
p.Flow = &FlowConfig{Format: &FormatConfig{Type: "raw"}, RateLimit: &RateLimitConfig{Rate: 5, Policy: "pass"},
|
||||
p.Flow = &FlowConfig{Format: &FormatConfig{Type: "raw"}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5, Policy: "pass"},
|
||||
Heartbeat: &HeartbeatConfig{IntervalMS: 1000, Format: "txt"}}
|
||||
if err != nil || !reflect.DeepEqual(got, []PipelineConfig{p}) {
|
||||
t.Fatalf("got %+v %v\nwant %+v", got, err, p)
|
||||
|
||||
@@ -296,7 +296,7 @@ func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
|
||||
}
|
||||
return nil
|
||||
case len(o.Allow) > 0 || len(o.AllowPatterns) > 0:
|
||||
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the credentials file is the allow list", "mtls")
|
||||
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the users are the allow list", "mtls")
|
||||
case side == Dialer && (o.CredentialsFile != "" || o.TokenLifetimeMS != 0 || len(o.TrustedProxies) > 0):
|
||||
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
|
||||
case side == Dialer && o.Identity != "":
|
||||
@@ -305,10 +305,12 @@ func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
|
||||
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", "scram")
|
||||
case side == Dialer:
|
||||
return nil
|
||||
case o.Username != "" || o.PasswordFile != "":
|
||||
return errors.New("auth: username and password_file apply only to dialers")
|
||||
case o.CredentialsFile == "":
|
||||
return fmt.Errorf("auth: type %q requires credentials_file", "scram")
|
||||
case o.CredentialsFile != "" && (o.Username != "" || o.PasswordFile != ""):
|
||||
return errors.New("auth: credentials_file, or username and password_file: not both")
|
||||
case (o.Username == "") != (o.PasswordFile == ""):
|
||||
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):
|
||||
@@ -425,6 +427,10 @@ func and(keys []string) string {
|
||||
return strings.Join(keys[:len(keys)-1], ", ") + " and " + keys[len(keys)-1]
|
||||
}
|
||||
|
||||
// pagesBehind is why a login or viewer page needs a proxy: only there do
|
||||
// browsers log in, and elsewhere the viewer needs no page
|
||||
const pagesBehind = "only for scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls, the viewer is always at /"
|
||||
|
||||
// Check keeps a stream and a status path ServeMux and a URL take literally,
|
||||
// apart, and off the authentication paths; a login page needs browsers that
|
||||
// log in, behind a proxy
|
||||
@@ -442,10 +448,12 @@ func (o *HTTPSinkOptions) Check() error {
|
||||
switch {
|
||||
case o.StreamPath == o.StatusPath:
|
||||
return fmt.Errorf("stream_path and status_path must differ")
|
||||
case (o.LoginPage || o.ViewerPage) && !behind:
|
||||
return errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
|
||||
case o.LoginPage && !behind:
|
||||
return &KeyError{"login_page", errors.New(pagesBehind)}
|
||||
case o.ViewerPage && !behind:
|
||||
return &KeyError{"viewer_page", errors.New(pagesBehind)}
|
||||
case o.ViewerPage && !o.LoginPage:
|
||||
return errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
|
||||
return &KeyError{"viewer_page", errors.New("needs login_page, where it sends a signed-out viewer")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ func NewRateLimiter(cfg config.RateLimitConfig, logger *log.Logger) (*RateLimite
|
||||
if err := config.Settle(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Rate == 0 {
|
||||
if cfg.EntriesPerSecond == 0 {
|
||||
return nil, nil // off
|
||||
}
|
||||
burst := cfg.Burst
|
||||
burst := cfg.BurstEntries
|
||||
if burst == 0 {
|
||||
burst = cfg.Rate
|
||||
burst = cfg.EntriesPerSecond
|
||||
}
|
||||
policy := config.PolicyPass
|
||||
if cfg.Policy == "drop" {
|
||||
@@ -40,7 +40,7 @@ func NewRateLimiter(cfg config.RateLimitConfig, logger *log.Logger) (*RateLimite
|
||||
}
|
||||
|
||||
l := &RateLimiter{
|
||||
bucket: tokenbucket.New(burst, cfg.Rate),
|
||||
bucket: tokenbucket.New(burst, cfg.EntriesPerSecond),
|
||||
policy: policy,
|
||||
logger: logger,
|
||||
maxEntrySizeBytes: cfg.MaxEntrySizeBytes,
|
||||
@@ -85,8 +85,8 @@ func (l *RateLimiter) GetStats() map[string]any {
|
||||
|
||||
stats := map[string]any{
|
||||
"enabled": true,
|
||||
"rate": l.bucket.Rate(),
|
||||
"burst": l.bucket.Capacity(),
|
||||
"entries_per_second": l.bucket.Rate(),
|
||||
"burst_entries": l.bucket.Capacity(),
|
||||
"dropped_total": l.droppedCount.Load(),
|
||||
"dropped_by_size_total": l.droppedBySizeCount.Load(),
|
||||
"policy": policyString(l.policy),
|
||||
|
||||
@@ -240,13 +240,14 @@ var warnedSecrets sync.Map
|
||||
|
||||
// SecretFileWarning describes a secret file every local user can read, once
|
||||
// per path per process. Group access is left alone: container secret mounts
|
||||
// commonly grant it on purpose.
|
||||
// commonly grant it on purpose. Terminals, pipes and descriptors hold no
|
||||
// secret at rest; lw closes /dev/fd/N after reading, and N then names another.
|
||||
func SecretFileWarning(key, path string) string {
|
||||
if path == "" {
|
||||
if path == "" || strings.HasPrefix(path, "/dev/fd/") {
|
||||
return ""
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi.Mode().Perm()&0o004 == 0 {
|
||||
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o004 == 0 {
|
||||
return ""
|
||||
}
|
||||
if _, seen := warnedSecrets.LoadOrStore(path, struct{}{}); seen {
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -200,3 +203,33 @@ func TestTLSOptionConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminals, pipes and descriptors hold no secret at rest: /dev/tty, mode
|
||||
// 0666 like /dev/null, is not warned about, nor /dev/fd/N whatever it names,
|
||||
// while a world-readable file is.
|
||||
func TestSecretFileWarningSkipsTerminalsAndPipes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fifo, file := filepath.Join(dir, "fifo"), filepath.Join(dir, "pw")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WriteFile(t, file, "secret\n")
|
||||
for _, path := range []string{fifo, file} {
|
||||
if err := os.Chmod(path, 0o644); err != nil { // past the umask
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
held, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
for _, path := range []string{os.DevNull, fifo, fmt.Sprintf("/dev/fd/%d", held.Fd())} {
|
||||
if w := SecretFileWarning("auth.password_file", path); w != "" {
|
||||
t.Errorf("%s: %s", path, w)
|
||||
}
|
||||
}
|
||||
if SecretFileWarning("auth.password_file", file) == "" {
|
||||
t.Error("a world-readable file was not warned about")
|
||||
}
|
||||
}
|
||||
+45
-66
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
ui "github.com/lixenwraith/terminal/tui"
|
||||
)
|
||||
|
||||
@@ -92,22 +91,9 @@ func (a *app) layout(w int) layout {
|
||||
func (a *app) drawCanvas(r ui.Region) {
|
||||
l := a.layout(r.W)
|
||||
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
|
||||
if chosen.y < a.scroll {
|
||||
a.scroll = chosen.y
|
||||
}
|
||||
if tall := 2 - a.col%2; chosen.y+tall > a.scroll+r.H {
|
||||
a.scroll = chosen.y + tall - r.H
|
||||
}
|
||||
a.scroll = max(0, min(a.scroll, l.h-r.H))
|
||||
|
||||
// Drawn whole off screen, then the rows in view copied
|
||||
cells := make([]terminal.Cell, r.W*l.h)
|
||||
full := ui.NewRegion(cells, r.W, 0, 0, r.W, l.h)
|
||||
full.FillStyle(a.th.Text)
|
||||
a.drawParts(full, l)
|
||||
for y := range min(r.H, l.h-a.scroll) {
|
||||
copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(a.scroll+y)*r.W:][:r.W])
|
||||
}
|
||||
a.scroll.SetDimensions(l.h, r.H)
|
||||
a.scroll.EnsureRange(chosen.y, 2-a.col%2)
|
||||
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
|
||||
}
|
||||
|
||||
func (a *app) drawParts(r ui.Region, l layout) {
|
||||
@@ -118,7 +104,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
||||
r.TextStyled(t.x, t.y, string(f.dot)+" "+c.title, tints[col].On(th.Text))
|
||||
}
|
||||
a.drawBox(r, l)
|
||||
g := wires{w: r.W, arms: make([]uint8, r.W*l.h)}
|
||||
g := ui.NewWires(r.W, l.h)
|
||||
for _, col := range []int{sourcesCol, sinksCol} {
|
||||
nodes := a.nodes(col)
|
||||
for i, s := range l.nodes[col] {
|
||||
@@ -130,7 +116,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
||||
a.drawNode(r, s, nodes[i], tints[col], chosen)
|
||||
}
|
||||
if l.wide && len(nodes) > 0 {
|
||||
g.join(l, col)
|
||||
join(g, l, col)
|
||||
}
|
||||
}
|
||||
if !l.wide {
|
||||
@@ -139,11 +125,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
||||
}
|
||||
return
|
||||
}
|
||||
for i, arms := range g.arms {
|
||||
if arms != 0 {
|
||||
r.TextStyled(i%r.W, i/r.W, string(f.wire[arms]), th.Border)
|
||||
}
|
||||
}
|
||||
r.DrawWires(g, f.Line, th.Border)
|
||||
if len(a.nodes(sourcesCol)) > 0 {
|
||||
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
|
||||
}
|
||||
@@ -152,30 +134,9 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
||||
}
|
||||
}
|
||||
|
||||
// wires are the canvas's cells by the arms they have: up 1, right 2, down 4,
|
||||
// left 8, so every junction draws from what meets there
|
||||
type wires struct {
|
||||
w int
|
||||
arms []uint8
|
||||
}
|
||||
|
||||
func (g wires) h(y, x0, x1 int) {
|
||||
for x := x0; x < x1; x++ {
|
||||
g.arms[y*g.w+x] |= 2
|
||||
g.arms[y*g.w+x+1] |= 8
|
||||
}
|
||||
}
|
||||
|
||||
func (g wires) v(x, y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
g.arms[y*g.w+x] |= 4
|
||||
g.arms[(y+1)*g.w+x] |= 1
|
||||
}
|
||||
}
|
||||
|
||||
// join wires a column's nodes to a trunk beside the flow box, and the trunk
|
||||
// to the box's side
|
||||
func (g wires) join(l layout, col int) {
|
||||
func join(g ui.Wires, l layout, col int) {
|
||||
ys := []int{l.entry}
|
||||
if col == sinksCol {
|
||||
ys[0] = l.exit
|
||||
@@ -186,18 +147,18 @@ func (g wires) join(l layout, col int) {
|
||||
if col == sourcesCol {
|
||||
trunk := l.box.x - gutter + 1
|
||||
for _, s := range l.nodes[col] {
|
||||
g.h(s.y, trunk-1, trunk)
|
||||
g.H(s.y, trunk-1, trunk)
|
||||
}
|
||||
g.v(trunk, slices.Min(ys), slices.Max(ys))
|
||||
g.h(l.entry, trunk, l.box.x-1)
|
||||
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||
g.H(l.entry, trunk, l.box.x-1)
|
||||
return
|
||||
}
|
||||
right := l.box.x + l.box.w
|
||||
trunk := right + 2
|
||||
g.h(l.exit, right, trunk)
|
||||
g.v(trunk, slices.Min(ys), slices.Max(ys))
|
||||
g.H(l.exit, right, trunk)
|
||||
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||
for _, s := range l.nodes[col] {
|
||||
g.h(s.y, trunk, s.x-1)
|
||||
g.H(s.y, trunk, s.x-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,14 +206,7 @@ func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
|
||||
func (a *app) drawBox(r ui.Region, l layout) {
|
||||
th, f := a.th, a.font
|
||||
b := l.box
|
||||
edge := []rune(f.box)
|
||||
across := strings.Repeat(string(edge[4]), max(0, b.w-2))
|
||||
r.TextStyled(b.x, b.y, string(edge[0])+across+string(edge[1]), th.Border)
|
||||
r.TextStyled(b.x, b.y+l.boxH-1, string(edge[2])+across+string(edge[3]), th.Border)
|
||||
for y := b.y + 1; y < b.y+l.boxH-1; y++ {
|
||||
r.TextStyled(b.x, y, string(edge[5]), th.Border)
|
||||
r.TextStyled(b.x+b.w-1, y, string(edge[5]), th.Border)
|
||||
}
|
||||
r.Sub(b.x, b.y, b.w, l.boxH).BoxStyle(f.box, th.Border)
|
||||
for i, n := range a.nodes(flowCol) {
|
||||
s := l.nodes[flowCol][i]
|
||||
chosen := a.col == flowCol && a.row[flowCol] == i
|
||||
@@ -345,11 +299,14 @@ func show(v any) string {
|
||||
// key, as config's key paths write them
|
||||
var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`)
|
||||
|
||||
func locate(err error) (pi int, n compose.Node, key string, ok bool) {
|
||||
m := problemPath.FindStringSubmatch(err.Error())
|
||||
if m == nil {
|
||||
return 0, n, "", false
|
||||
// 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] != "":
|
||||
@@ -362,7 +319,7 @@ func locate(err error) (pi int, n compose.Node, key string, ok bool) {
|
||||
}
|
||||
key = m[7]
|
||||
}
|
||||
return pi, n, key, true
|
||||
return pi, n, key, text, true
|
||||
}
|
||||
|
||||
// faulty reports whether the first problem names this node, or a filter
|
||||
@@ -371,6 +328,28 @@ func (a *app) faulty(n compose.Node) bool {
|
||||
if a.problem == nil {
|
||||
return false
|
||||
}
|
||||
pi, m, _, ok := locate(a.problem)
|
||||
pi, m, _, _, ok := locate(a.problem)
|
||||
return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID
|
||||
}
|
||||
|
||||
// where names a problem's part as the screen shows it: its pipeline when
|
||||
// another is shown, the part, and its key
|
||||
func (a *app) where(pi int, n compose.Node, key string) string {
|
||||
var out []string
|
||||
if pi != a.pi && pi < len(a.comp.Pipelines) {
|
||||
out = append(out, "pipeline "+a.comp.Pipelines[pi].Name)
|
||||
}
|
||||
switch n.Role {
|
||||
case "":
|
||||
case "source", "sink":
|
||||
out = append(out, n.Role+" "+n.ID)
|
||||
case "filters":
|
||||
out = append(out, fmt.Sprintf("filter %d", n.Index+1))
|
||||
default:
|
||||
out = append(out, n.Role)
|
||||
}
|
||||
if key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
return strings.Join(out, " · ")
|
||||
}
|
||||
+255
-128
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
@@ -16,23 +17,19 @@ type dialog interface {
|
||||
handle(a *app, ev terminal.Event)
|
||||
}
|
||||
|
||||
// frame draws a dialog's box, w by h at most, centered in r with its title,
|
||||
// and returns the inside
|
||||
// frame draws a dialog's box around an inside w by h, centered in r and
|
||||
// clipped to it, with its title, and returns the inside; sized to what it
|
||||
// holds, it ends on its last line
|
||||
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
|
||||
b := ui.Center(r, min(w, r.W), min(h, r.H))
|
||||
b.FillStyle(a.th.Text)
|
||||
wire := a.font.wire
|
||||
across := strings.Repeat(string(wire[10]), max(0, b.W-2))
|
||||
b.TextStyled(0, 0, string(wire[6])+across+string(wire[12]), a.th.Border)
|
||||
b.TextStyled(0, b.H-1, string(wire[3])+across+string(wire[9]), a.th.Border)
|
||||
for y := 1; y < b.H-1; y++ {
|
||||
b.TextStyled(0, y, string(wire[5]), a.th.Border)
|
||||
b.TextStyled(b.W-1, y, string(wire[5]), a.th.Border)
|
||||
}
|
||||
b.TextStyled(2, 0, " "+ui.Truncate(title, b.W-6)+" ", a.th.Accent)
|
||||
return b.Sub(2, 1, b.W-4, b.H-2)
|
||||
return ui.Center(r, min(w+4, r.W), min(h+2, r.H)).Frame(title, a.th)
|
||||
}
|
||||
|
||||
// inside is a dialog's widest inside on a screen w wide; one narrower than
|
||||
// stacked puts labels and keys above what they name
|
||||
func inside(w int) int { return max(1, min(72, w-4)) }
|
||||
|
||||
const stacked = 32
|
||||
|
||||
// chooser picks one option from a filtered list
|
||||
type chooser struct {
|
||||
title string
|
||||
@@ -43,8 +40,8 @@ type chooser struct {
|
||||
}
|
||||
|
||||
func (c *chooser) draw(a *app, r ui.Region) {
|
||||
in := a.frame(r, 76, len(c.list.Options)+4, c.title)
|
||||
in.OptionList(c.list, a.th)
|
||||
w := inside(r.W)
|
||||
a.frame(r, w, c.list.Rows(w), c.title).OptionList(c.list, a.th)
|
||||
}
|
||||
|
||||
func (c *chooser) handle(a *app, ev terminal.Event) {
|
||||
@@ -64,6 +61,11 @@ func (c *chooser) handle(a *app, ev terminal.Event) {
|
||||
a.dialog = nil
|
||||
c.pick(a, i)
|
||||
}
|
||||
case ev.Key == terminal.KeyRune && c.list.Menu:
|
||||
if i, ok := c.list.Pick(ev.Rune); ok {
|
||||
a.dialog = nil
|
||||
c.pick(a, i)
|
||||
}
|
||||
default:
|
||||
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
||||
}
|
||||
@@ -95,56 +97,104 @@ func (a *app) addMenu() dialog {
|
||||
return
|
||||
}
|
||||
a.choose(n)
|
||||
a.inspect, a.insp.cursor = true, 0
|
||||
for j, l := range a.lines() {
|
||||
if l.group != "" && l.node == n {
|
||||
a.insp.cursor = j
|
||||
}
|
||||
}
|
||||
a.inspectFirst(n)
|
||||
}}
|
||||
}
|
||||
|
||||
// presetMenu starts the pipeline over from a preset, or the pipelines from a
|
||||
// pasted command line; on a bare start, Esc starts empty
|
||||
func (a *app) presetMenu() dialog {
|
||||
presets := config.Presets()
|
||||
// typeMenu changes a source's or sink's type, in its place
|
||||
func (a *app) typeMenu(n compose.Node) dialog {
|
||||
p, _ := a.plugin(n)
|
||||
var options []ui.Option
|
||||
for _, p := range presets {
|
||||
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
||||
for _, q := range config.Plugins() {
|
||||
if q.Role == n.Role && q.Type != p.Type {
|
||||
options = append(options, ui.Option{Name: q.Type, Hint: q.Summary})
|
||||
}
|
||||
}
|
||||
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
||||
return &chooser{title: "Change " + p.Type, list: ui.NewOptionListState(options), pick: func(a *app, i int) {
|
||||
m, err := a.comp.Retype(a.pi, n, options[i].Name)
|
||||
if a.edit(err) {
|
||||
a.choose(m)
|
||||
a.inspectFirst(m)
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
// enumMenu sets an enum key to the option chosen, the cursor on its value
|
||||
func (a *app) enumMenu(l line, cur any) dialog {
|
||||
var options []ui.Option
|
||||
for _, e := range l.key.Enum {
|
||||
hint := ""
|
||||
if e == l.key.Default {
|
||||
hint = "the default"
|
||||
}
|
||||
options = append(options, ui.Option{Name: e, Hint: hint})
|
||||
}
|
||||
list := ui.NewOptionListState(options)
|
||||
list.Cursor = max(0, choice(l.key, cur))
|
||||
return &chooser{title: label(l), list: list, pick: func(a *app, i int) {
|
||||
a.edit(a.comp.Set(a.pi, l.node, l.key.Name, options[i].Name))
|
||||
}}
|
||||
}
|
||||
|
||||
// presetMenu replaces the pipeline shown from a preset, or the pipelines
|
||||
// from a pasted command line; on a bare start, Esc starts empty
|
||||
func (a *app) presetMenu() dialog {
|
||||
title := "Start from a preset, or paste an lw command line"
|
||||
if len(a.comp.Pipelines) > 0 {
|
||||
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
|
||||
}
|
||||
empty := func(a *app) {
|
||||
a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}})
|
||||
}
|
||||
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
||||
menu := a.pipelineMenu(title, (*app).use)
|
||||
menu.paste = func(a *app, text string) {
|
||||
a.dialog = nil
|
||||
if a.paste(text); len(a.comp.Pipelines) == 0 {
|
||||
a.dialog = menu // refused: the status says why
|
||||
}
|
||||
}
|
||||
menu.pick = func(a *app, i int) {
|
||||
switch {
|
||||
case i == len(presets):
|
||||
empty(a)
|
||||
case len(presets[i].Params) == 0:
|
||||
(&presetForm{preset: presets[i]}).apply(a)
|
||||
default:
|
||||
a.dialog = newPresetForm(presets[i])
|
||||
}
|
||||
}
|
||||
menu.back = func(a *app) {
|
||||
if len(a.comp.Pipelines) == 0 {
|
||||
empty(a)
|
||||
a.use(empty())
|
||||
}
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
// pipelineMenu offers the presets and an empty pipeline, which place puts
|
||||
// on screen
|
||||
func (a *app) pipelineMenu(title string, place func(a *app, p config.PipelineConfig)) *chooser {
|
||||
presets := config.Presets()
|
||||
var options []ui.Option
|
||||
for _, p := range presets {
|
||||
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
||||
}
|
||||
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
||||
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
||||
menu.pick = func(a *app, i int) {
|
||||
if i == len(presets) {
|
||||
place(a, empty())
|
||||
return
|
||||
}
|
||||
p := presets[i]
|
||||
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params, back: func(a *app) { a.dialog = menu },
|
||||
apply: func(a *app, values map[string]string) error {
|
||||
c, err := compose.FromPreset(p.Name, values, config.HostIsDir)
|
||||
if err == nil {
|
||||
place(a, c.Pipelines[0])
|
||||
}
|
||||
return err
|
||||
}}
|
||||
if f.fill(nil); len(p.Params) == 0 {
|
||||
f.submit(a)
|
||||
return
|
||||
}
|
||||
a.dialog = f
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
func empty() config.PipelineConfig {
|
||||
return config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}}
|
||||
}
|
||||
|
||||
// use puts a pipeline in place of the one shown, under its name, asking
|
||||
// first when that one has sources or sinks; the first pipeline keeps its own,
|
||||
// and its parts are work quit asks about
|
||||
@@ -167,51 +217,102 @@ func (a *app) use(p config.PipelineConfig) {
|
||||
a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace}
|
||||
}
|
||||
|
||||
// presetForm takes a preset's keys, its defaults as placeholders
|
||||
type presetForm struct {
|
||||
preset config.Preset
|
||||
fields []*ui.TextFieldState
|
||||
focus ui.Focus
|
||||
scroll int // the first field shown
|
||||
err string
|
||||
// append adds a pipeline after the others, under its name or the first free
|
||||
// one after it, and shows it
|
||||
func (a *app) append(p config.PipelineConfig) {
|
||||
var names []string
|
||||
for _, q := range a.comp.Pipelines {
|
||||
names = append(names, q.Name)
|
||||
}
|
||||
p.Name = config.FreeID(p.Name, names)
|
||||
a.comp.Pipelines = append(a.comp.Pipelines, p)
|
||||
a.pi, a.row, a.bar, a.changed = len(a.comp.Pipelines)-1, [3]int{}, false, true
|
||||
a.check()
|
||||
}
|
||||
|
||||
func newPresetForm(p config.Preset) *presetForm {
|
||||
f := &presetForm{preset: p, focus: ui.Focus{Len: len(p.Params)}}
|
||||
for range p.Params {
|
||||
f.fields = append(f.fields, ui.NewTextFieldState(""))
|
||||
}
|
||||
return f
|
||||
// form takes a few keys, their defaults as placeholders, then applies them;
|
||||
// a refusal stays in the form
|
||||
type form struct {
|
||||
title, summary string
|
||||
params []config.PresetParam
|
||||
fields []*ui.TextFieldState
|
||||
focus ui.Focus
|
||||
scroll ui.ViewportScroll // the fields' rows
|
||||
err string
|
||||
apply func(a *app, values map[string]string) error
|
||||
back func(a *app) // on Esc, after the form closes
|
||||
}
|
||||
|
||||
func (f *presetForm) draw(a *app, r ui.Region) {
|
||||
th := a.th
|
||||
in := a.frame(r, 76, len(f.fields)+6, "preset "+f.preset.Name)
|
||||
in.TextStyled(0, 0, ui.Truncate(f.preset.Summary, in.W), th.Muted)
|
||||
rows := max(1, in.H-3) // between the summary and the note
|
||||
f.scroll = max(0, min(f.scroll, f.focus.Index, len(f.fields)-rows), f.focus.Index-rows+1)
|
||||
for i := f.scroll; i < len(f.fields) && i-f.scroll < rows; i++ {
|
||||
p := f.preset.Params[i]
|
||||
v, _ := in.Field(2+i-f.scroll, 14, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
||||
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
||||
// fill gives the fields their first values
|
||||
func (f *form) fill(values map[string]string) {
|
||||
f.focus, f.fields = ui.Focus{Len: len(f.params)}, nil
|
||||
for _, p := range f.params {
|
||||
f.fields = append(f.fields, ui.NewTextFieldState(values[p.Name]))
|
||||
}
|
||||
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
|
||||
}
|
||||
|
||||
// draw lays the form out at the screen's width: the summary, the fields,
|
||||
// their labels beside them or, when stacked, above, then the focused
|
||||
// field's help or the refusal, in rows kept for the longest of them
|
||||
func (f *form) draw(a *app, r ui.Region) {
|
||||
th, w := a.th, inside(r.W)
|
||||
labelW, tall := 0, 2
|
||||
if w >= stacked {
|
||||
for _, p := range f.params {
|
||||
labelW = max(labelW, ui.RuneLen(p.Name)+2)
|
||||
}
|
||||
tall = 1
|
||||
}
|
||||
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)))
|
||||
}
|
||||
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)
|
||||
for i, l := range summary {
|
||||
in.TextStyled(0, i, l, th.Muted)
|
||||
}
|
||||
view := in.Sub(0, top, in.W, max(1, in.H-top-bottom))
|
||||
f.scroll.SetDimensions(len(f.fields)*tall, view.H)
|
||||
f.scroll.EnsureRange(f.focus.Index*tall, tall)
|
||||
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
|
||||
for i, p := range f.params {
|
||||
v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
||||
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
||||
}
|
||||
})
|
||||
note, style := f.params[f.focus.Index].Help, th.Muted
|
||||
if f.err != "" {
|
||||
note, style = f.err, th.Error
|
||||
}
|
||||
in.TextStyled(0, in.H-1, ui.Truncate(note, in.W), style)
|
||||
lines := wrap(note, in.W, noteRows) // on the last rows, the box ending on text
|
||||
for i, l := range lines {
|
||||
in.TextStyled(0, in.H-len(lines)+i, l, style)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *presetForm) handle(a *app, ev terminal.Event) {
|
||||
// wrap is s wrapped to w, at most n lines, the last cut with '…'; none when
|
||||
// s is empty
|
||||
func wrap(s string, w, n int) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return clip(ui.WrapText(s, w), n)
|
||||
}
|
||||
|
||||
func (f *form) handle(a *app, ev terminal.Event) {
|
||||
field := f.fields[f.focus.Index]
|
||||
switch {
|
||||
case ev.Type == terminal.EventPaste:
|
||||
field.Paste(ev.Text)
|
||||
case ev.Type != terminal.EventKey:
|
||||
case is(ev, terminal.KeyEscape, ""):
|
||||
a.dialog = a.presetMenu()
|
||||
a.dialog = nil
|
||||
if f.back != nil {
|
||||
f.back(a)
|
||||
}
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
f.apply(a)
|
||||
f.submit(a)
|
||||
case is(ev, terminal.KeyUp, ""):
|
||||
f.focus.Index = max(0, f.focus.Index-1)
|
||||
case is(ev, terminal.KeyDown, ""):
|
||||
@@ -222,39 +323,33 @@ func (f *presetForm) handle(a *app, ev terminal.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// apply builds the preset's pipeline, on this host: a path is a directory
|
||||
// when one is there
|
||||
func (f *presetForm) apply(a *app) {
|
||||
// submit applies the values typed, closing the form unless they are refused
|
||||
func (f *form) submit(a *app) {
|
||||
values := map[string]string{}
|
||||
for i, p := range f.preset.Params {
|
||||
for i, p := range f.params {
|
||||
if v := f.fields[i].Value(); v != "" {
|
||||
values[p.Name] = v
|
||||
}
|
||||
}
|
||||
c, err := compose.FromPreset(f.preset.Name, values, config.HostIsDir)
|
||||
if err != nil {
|
||||
f.err = err.Error()
|
||||
return
|
||||
}
|
||||
a.dialog = nil
|
||||
a.use(c.Pipelines[0])
|
||||
if err := f.apply(a, values); err != nil {
|
||||
f.err, a.dialog = err.Error(), f
|
||||
}
|
||||
}
|
||||
|
||||
// outputMenu is what o offers: run, or print one of the forms
|
||||
func (a *app) outputMenu() dialog {
|
||||
forms := compose.Forms()
|
||||
options := []ui.Option{{Name: "run", Hint: "start the pipelines now, with lw's other settings"}}
|
||||
options := []ui.Option{{Name: "run", Key: 'r', 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})
|
||||
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
|
||||
}
|
||||
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
|
||||
list := ui.NewOptionListState(options)
|
||||
list.Menu = true
|
||||
return &chooser{title: "Output, once the screen closes", list: list,
|
||||
pick: func(a *app, i int) {
|
||||
if i == 0 {
|
||||
if a.problem != nil {
|
||||
a.status = "not valid yet: c shows the problem"
|
||||
return
|
||||
}
|
||||
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
||||
a.run()
|
||||
return
|
||||
}
|
||||
text, err := forms[i-1].Write(a.comp)
|
||||
@@ -266,88 +361,120 @@ func (a *app) outputMenu() dialog {
|
||||
}}
|
||||
}
|
||||
|
||||
// notice shows lines until any key
|
||||
// notice shows text, wrapped to the screen, until a key other than one
|
||||
// that scrolls it
|
||||
type notice struct {
|
||||
title string
|
||||
lines []string
|
||||
body func(w int) []string // the lines at a width
|
||||
view ui.ViewportScroll
|
||||
}
|
||||
|
||||
// textNotice is a notice of plain text, its paragraphs split at '\n'
|
||||
func textNotice(title, body string) *notice {
|
||||
return ¬ice{title: title, body: func(w int) []string { return ui.WrapText(body, w) }}
|
||||
}
|
||||
|
||||
func (n *notice) draw(a *app, r ui.Region) {
|
||||
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
|
||||
lines, w := n.body(inside(r.W)), ui.RuneLen(n.title)+2
|
||||
for _, l := range lines {
|
||||
w = max(w, ui.RuneLen(l)+6)
|
||||
w = max(w, ui.RuneLen(l))
|
||||
}
|
||||
in := a.frame(r, w, len(lines)+4, n.title)
|
||||
for i, l := range lines {
|
||||
in.TextStyled(0, 1+i, l, a.th.Text)
|
||||
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
|
||||
rows := in.H
|
||||
if len(lines) > in.H { // the last row says where the view is
|
||||
rows = max(1, in.H-1)
|
||||
}
|
||||
}
|
||||
|
||||
// fit wraps lines to a dialog's inside on a screen w wide
|
||||
func fit(lines []string, w int) []string {
|
||||
var out []string
|
||||
for _, l := range lines {
|
||||
out = append(out, ui.WrapText(l, max(1, w-6))...)
|
||||
n.view.SetDimensions(len(lines), rows)
|
||||
for y := range min(rows, len(lines)-n.view.Offset) {
|
||||
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
|
||||
}
|
||||
if rows < in.H {
|
||||
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
|
||||
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *notice) handle(a *app, ev terminal.Event) {
|
||||
if ev.Type == terminal.EventKey {
|
||||
switch {
|
||||
case ev.Type != terminal.EventKey:
|
||||
case is(ev, terminal.KeyUp, "k"):
|
||||
n.view.ScrollBy(-1)
|
||||
case is(ev, terminal.KeyDown, "j"):
|
||||
n.view.ScrollBy(1)
|
||||
case is(ev, terminal.KeyPageUp, ""):
|
||||
n.view.PageUp()
|
||||
case is(ev, terminal.KeyPageDown, ""):
|
||||
n.view.PageDown()
|
||||
default:
|
||||
a.dialog = nil
|
||||
}
|
||||
}
|
||||
|
||||
// confirm asks a yes or no question
|
||||
// confirm asks a yes or no question: y or n answers, as does Enter on the
|
||||
// answer the arrows chose; Esc is no
|
||||
type confirm struct {
|
||||
question string
|
||||
yes func(a *app)
|
||||
state ui.ConfirmState
|
||||
}
|
||||
|
||||
func (c *confirm) draw(a *app, r ui.Region) {
|
||||
lines := fit([]string{c.question}, r.W)
|
||||
w := 0
|
||||
lines := ui.WrapText(c.question, inside(r.W))
|
||||
w := 12 // the answers
|
||||
for _, l := range lines {
|
||||
w = max(w, ui.RuneLen(l)+6)
|
||||
w = max(w, ui.RuneLen(l))
|
||||
}
|
||||
in := a.frame(r, w, len(lines)+5, "Confirm")
|
||||
in := a.frame(r, w, len(lines)+1, "Confirm")
|
||||
for i, l := range lines {
|
||||
in.TextStyled(0, 1+i, l, a.th.Text)
|
||||
in.TextStyled(0, i, l, a.th.Text)
|
||||
}
|
||||
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
|
||||
in.Radio(0, in.H-1, []string{"yes", "no"}, map[bool]int{true: 0, false: 1}[c.state.FocusYes], a.th)
|
||||
}
|
||||
|
||||
func (c *confirm) handle(a *app, ev terminal.Event) {
|
||||
switch {
|
||||
case is(ev, terminal.KeyNone, "yY"):
|
||||
a.dialog = nil
|
||||
if ev.Type != terminal.EventKey || !c.state.HandleKey(ev.Key, ev.Rune) {
|
||||
return
|
||||
}
|
||||
if a.dialog = nil; c.state.Result == ui.ConfirmYes {
|
||||
c.yes(a)
|
||||
case is(ev, terminal.KeyEscape, "nNq"):
|
||||
a.dialog = nil
|
||||
}
|
||||
}
|
||||
|
||||
// help lists the keys
|
||||
// help lists the keys, each beside its description or, narrow, above it
|
||||
func help(f font) dialog {
|
||||
keys := [][2]string{
|
||||
{"arrows, hjkl", "move"},
|
||||
{f.enter, "inspect the chosen part; edit a value"},
|
||||
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
|
||||
{f.enter, "inspect a part; edit a value; open a list; add"},
|
||||
{"tab", "next value"},
|
||||
{"esc", "back"},
|
||||
{"a", "add a source, sink or filter"},
|
||||
{"a", "add a source, sink or filter; on the bar, a pipeline"},
|
||||
{"c", "change a source's or sink's type"},
|
||||
{"d", "delete it; a value back to its default"},
|
||||
{f.space, "a flow stage or a switch on or off"},
|
||||
{"J K", "move a filter down or up"},
|
||||
{"[ ]", "previous or next pipeline"},
|
||||
{"p", "start over from a preset"},
|
||||
{"r", "run the pipelines"},
|
||||
{"o", "run, or print a command line, environment or file"},
|
||||
{"c", "the first problem"},
|
||||
{"!", "the first problem"},
|
||||
{"paste", "an lw command line replaces the pipelines"},
|
||||
{"q", "quit"},
|
||||
}
|
||||
n := ¬ice{title: "Keys"}
|
||||
for _, k := range keys {
|
||||
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
|
||||
}
|
||||
return n
|
||||
return ¬ice{title: "Keys", body: func(w int) []string {
|
||||
var lines []string
|
||||
for _, k := range keys {
|
||||
if w < stacked {
|
||||
lines = append(lines, k[0])
|
||||
for _, l := range ui.WrapText(k[1], w-2) {
|
||||
lines = append(lines, " "+l)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := k[0]
|
||||
for _, l := range ui.WrapText(k[1], w-14) {
|
||||
lines, key = append(lines, ui.PadRight(key, 14)+l), ""
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}}
|
||||
}
|
||||
+58
-37
@@ -21,15 +21,21 @@ type inspector struct {
|
||||
field *ui.TextFieldState // the value being typed, nil while none
|
||||
}
|
||||
|
||||
// line is one inspector row: a key, a group that folds, or a list's element
|
||||
// line is one inspector row: a key, a group that folds, a list's element, or
|
||||
// the row that adds an element or a filter
|
||||
type line struct {
|
||||
node compose.Node
|
||||
key config.Key // its Name is the dotted path in the node's options
|
||||
key config.Key // its Name is the dotted path in the node's options, "" on a filter's rows
|
||||
depth int
|
||||
group string // a group's title, "" for a value
|
||||
item int // a list element's index, the list's length on the row that adds one; -1 elsewhere
|
||||
}
|
||||
|
||||
// adds reports whether a row adds: a list's last row, or the filters'
|
||||
func (a *app) adds(l line) bool {
|
||||
return l.item >= 0 && (l.key.Name == "" || l.item == len(a.items(l.node, l.key.Name)))
|
||||
}
|
||||
|
||||
func (a *app) foldKey(n compose.Node, path string) string {
|
||||
return fmt.Sprintf("%d/%s/%s/%d/%s", a.pi, n.Role, n.ID, n.Index, path)
|
||||
}
|
||||
@@ -44,14 +50,15 @@ func (a *app) lines() []line {
|
||||
return a.keyLines(nil, n, a.keys(n), "", 0)
|
||||
}
|
||||
var out []line
|
||||
for i, f := range a.pipeline().Flow.Filters {
|
||||
filters := a.pipeline().Flow.Filters
|
||||
for i, f := range filters {
|
||||
fn := compose.Node{Role: "filters", Index: i}
|
||||
out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1})
|
||||
if a.insp.open[a.foldKey(n, strconv.Itoa(i))] {
|
||||
out = a.keyLines(out, fn, a.keys(n), "", 1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: len(filters)})
|
||||
}
|
||||
|
||||
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
|
||||
@@ -120,31 +127,31 @@ func (a *app) drawInspector(r ui.Region) {
|
||||
if a.inspect {
|
||||
hint = f.enter + " edit esc back"
|
||||
}
|
||||
r.TextStyled(0, 0, strings.Repeat(string(f.rule), r.W), th.Border)
|
||||
r.TextStyled(2, 0, " "+ui.Truncate(title, r.W-ui.RuneLen(hint)-10)+" ", th.Text)
|
||||
r.TextStyled(r.W-ui.RuneLen(hint)-3, 0, " "+hint+" ", th.Muted)
|
||||
r.Rule(0, title, hint, th)
|
||||
lines := a.lines()
|
||||
switch {
|
||||
case !ok:
|
||||
r.TextStyled(2, 2, "a adds a "+columns[a.col].role, th.Muted)
|
||||
return
|
||||
case len(lines) == 0 && n.Role == "filters":
|
||||
r.TextStyled(2, 2, "no filters: a adds one", th.Muted)
|
||||
return
|
||||
case len(lines) == 0:
|
||||
r.TextStyled(2, 2, "no options", th.Muted)
|
||||
return
|
||||
}
|
||||
a.insp.cursor = max(0, min(a.insp.cursor, len(lines)-1))
|
||||
rows := r.H - 2
|
||||
var help []string
|
||||
style := th.Muted
|
||||
if a.inspect {
|
||||
var text string
|
||||
text, style = a.help(lines[a.insp.cursor])
|
||||
help = clip(ui.WrapText(text, r.W-4), 2)
|
||||
}
|
||||
rows := r.H - 1 - len(help)
|
||||
cols, perCol := 1, rows
|
||||
if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows {
|
||||
cols, perCol = 2, half
|
||||
}
|
||||
if cols == 1 {
|
||||
a.insp.scroll = max(0, min(a.insp.scroll, a.insp.cursor, len(lines)-rows))
|
||||
a.insp.scroll = max(a.insp.scroll, a.insp.cursor-rows+1)
|
||||
} else {
|
||||
a.insp.scroll = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines))
|
||||
if cols == 2 {
|
||||
a.insp.scroll = 0
|
||||
}
|
||||
colW, labelW := r.W/cols, 0
|
||||
@@ -157,9 +164,8 @@ func (a *app) drawInspector(r ui.Region) {
|
||||
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
|
||||
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
|
||||
}
|
||||
if a.inspect && rows > 0 {
|
||||
help, style := a.help(lines[a.insp.cursor])
|
||||
r.TextStyled(2, r.H-1, ui.Truncate(help, r.W-4), style)
|
||||
for i, h := range help {
|
||||
r.TextStyled(2, r.H-len(help)+i, h, style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,12 +183,15 @@ func (a *app) nodeTitle(n compose.Node) string {
|
||||
// help is the focused row's 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, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
||||
return a.problem.Error(), a.th.Error
|
||||
if pi, n, key, text, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
||||
return text, a.th.Error
|
||||
}
|
||||
}
|
||||
if l.group != "" && l.key.Name == "" {
|
||||
return "J and K move this filter; d deletes it", a.th.Muted
|
||||
switch {
|
||||
case l.group != "" && l.key.Name == "":
|
||||
return "J and K move this filter; c changes its type; d deletes it", a.th.Muted
|
||||
case a.adds(l) && l.key.Name == "":
|
||||
return "adds a filter: include passes only the entries that match, exclude drops them", a.th.Muted
|
||||
}
|
||||
help := l.key.Help
|
||||
if d := placeholder(l.key); d != "" && l.group == "" {
|
||||
@@ -217,12 +226,12 @@ func (a *app) drawLine(r ui.Region, l line, labelW int, focused bool) {
|
||||
switch {
|
||||
case focused && a.insp.field != nil:
|
||||
v.TextInput(a.insp.field, placeholder(k), true, th)
|
||||
case l.item == len(items):
|
||||
case a.adds(l):
|
||||
v.TextStyled(0, 0, "+ add", th.Muted)
|
||||
case l.item >= 0:
|
||||
v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text)
|
||||
case len(k.Enum) > 0:
|
||||
v.Choice(0, 0, k.Enum, choice(k, cur), th)
|
||||
v.Radio(0, 0, k.Enum, choice(k, cur), th)
|
||||
case k.Kind == "bool":
|
||||
v.Toggle(0, 0, cur == true || cur == nil && k.Default == "true", th)
|
||||
case cur == nil:
|
||||
@@ -289,7 +298,8 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
||||
k := l.key
|
||||
cur := a.value(l.node, k.Name)
|
||||
focus := ui.Focus{Index: a.insp.cursor, Len: len(lines)}
|
||||
toggle := is(ev, terminal.KeyEnter, " ") || is(ev, terminal.KeySpace, "")
|
||||
enter, space := is(ev, terminal.KeyEnter, ""), is(ev, terminal.KeySpace, " ")
|
||||
left, right := is(ev, terminal.KeyLeft, "h"), is(ev, terminal.KeyRight, "l")
|
||||
switch {
|
||||
case focus.HandleKey(ev.Key, ev.Modifiers):
|
||||
a.insp.cursor = focus.Index
|
||||
@@ -297,31 +307,35 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
||||
a.insp.cursor = max(0, a.insp.cursor-1)
|
||||
case is(ev, terminal.KeyDown, "j"):
|
||||
a.insp.cursor = min(len(lines)-1, a.insp.cursor+1)
|
||||
case l.group != "" && (toggle || is(ev, terminal.KeyRight, "l") || is(ev, terminal.KeyLeft, "h")):
|
||||
case a.adds(l) && k.Name == "" && enter:
|
||||
a.dialog = a.addMenu()
|
||||
case l.group != "" && (enter || space || left || right):
|
||||
fold := a.foldKey(l.node, k.Name)
|
||||
if k.Name == "" {
|
||||
fold = a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))
|
||||
}
|
||||
a.insp.open[fold] = toggle && !a.insp.open[fold] || is(ev, terminal.KeyRight, "l")
|
||||
case l.group != "" && l.key.Name == "" && is(ev, terminal.KeyNone, "JK"):
|
||||
a.insp.open[fold] = (enter || space) && !a.insp.open[fold] || right
|
||||
case l.group != "" && k.Name == "" && is(ev, terminal.KeyNone, "JK"):
|
||||
a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune])
|
||||
case is(ev, terminal.KeyNone, "d"):
|
||||
a.clear(l)
|
||||
case len(k.Enum) > 0 && l.item < 0 && (toggle || is(ev, terminal.KeyLeft, "h") || is(ev, terminal.KeyRight, "l")):
|
||||
case len(k.Enum) > 0 && l.item < 0 && enter:
|
||||
a.dialog = a.enumMenu(l, cur)
|
||||
case len(k.Enum) > 0 && l.item < 0 && (space || left || right):
|
||||
i := choice(k, cur)
|
||||
switch next, moved := ui.StepChoice(ev.Key, ev.Rune, i, len(k.Enum)); {
|
||||
case moved:
|
||||
i = next
|
||||
case !toggle:
|
||||
case !space:
|
||||
return true // an arrow at either end
|
||||
default:
|
||||
i = (i + 1) % len(k.Enum)
|
||||
}
|
||||
a.edit(a.comp.Set(a.pi, l.node, k.Name, k.Enum[i]))
|
||||
case k.Kind == "bool" && l.item < 0 && toggle:
|
||||
case k.Kind == "bool" && l.item < 0 && (enter || space || left || right):
|
||||
on := cur == true || cur == nil && k.Default == "true"
|
||||
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(!on)))
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(right || !left && !on)))
|
||||
case enter:
|
||||
text := show(cmp.Or(cur, any("")))
|
||||
if items := a.items(l.node, k.Name); l.item >= 0 {
|
||||
text = ""
|
||||
@@ -337,13 +351,14 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// typeKey edits the value being typed: Enter sets it, Tab sets it and moves
|
||||
// on, Esc drops it
|
||||
// typeKey edits the value being typed: Enter sets it, Tab and the arrows up
|
||||
// and down set it and move on, Esc drops it
|
||||
func (a *app) typeKey(ev terminal.Event, l line) {
|
||||
up, down := is(ev, terminal.KeyUp, ""), is(ev, terminal.KeyDown, "")
|
||||
switch {
|
||||
case is(ev, terminal.KeyEscape, ""):
|
||||
a.insp.field = nil
|
||||
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, ""):
|
||||
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, "") || up || down:
|
||||
value := a.insp.field.Value()
|
||||
if a.commit(l, value) {
|
||||
a.insp.field = nil
|
||||
@@ -351,7 +366,12 @@ func (a *app) typeKey(ev terminal.Event, l line) {
|
||||
a.insp.cursor++ // stays on the row that adds one
|
||||
}
|
||||
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())}
|
||||
if focus.HandleKey(ev.Key, ev.Modifiers) {
|
||||
switch {
|
||||
case up:
|
||||
a.insp.cursor = max(0, a.insp.cursor-1)
|
||||
case down:
|
||||
a.insp.cursor = min(focus.Len-1, a.insp.cursor+1)
|
||||
case focus.HandleKey(ev.Key, ev.Modifiers):
|
||||
a.insp.cursor = focus.Index
|
||||
}
|
||||
}
|
||||
@@ -390,6 +410,7 @@ func (a *app) commit(l line, value string) bool {
|
||||
// value, which returns to its default
|
||||
func (a *app) clear(l line) {
|
||||
switch {
|
||||
case a.adds(l):
|
||||
case l.group != "" && l.key.Name == "":
|
||||
a.edit(a.comp.Remove(a.pi, l.node))
|
||||
case l.item >= 0:
|
||||
|
||||
+12
-12
@@ -55,14 +55,14 @@ var looks = map[terminal.ColorMode]look{
|
||||
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text},
|
||||
}
|
||||
|
||||
// font is the marks one character set draws: the widgets' and the canvas's
|
||||
// font is the marks one character set draws: the widgets' and the canvas's;
|
||||
// Glyphs.Line draws frames, rules and wires
|
||||
type font struct {
|
||||
ui.Glyphs
|
||||
wire []rune // a wire cell by its arms: up 1, right 2, down 4, left 8
|
||||
box []rune // the flow box: top left, top right, bottom left, bottom right, across, down
|
||||
arrow, down rune // into the flow box and a sink; between stacked parts
|
||||
bar, barOn rune // a node, and the chosen node
|
||||
dot, rule rune // before a column's title; the inspector's rule
|
||||
box ui.LineType // the flow box
|
||||
arrow, down rune // into the flow box and a sink; between stacked parts
|
||||
bar, barOn rune // a node, and the chosen node
|
||||
dot rune // before a column's title
|
||||
valid string
|
||||
invalid string
|
||||
enter string
|
||||
@@ -85,13 +85,13 @@ func (f font) fold(r rune) rune {
|
||||
|
||||
// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8
|
||||
var (
|
||||
unicodeFont = font{Glyphs: ui.GlyphsUnicode, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("╭╮╰╯╌╎"),
|
||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', rule: '─', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
|
||||
cp437Font = font{Glyphs: ui.GlyphsCP437, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("┌┐└┘─│"),
|
||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', rule: '─', valid: "√", invalid: "x", enter: "enter", space: "space",
|
||||
unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed,
|
||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
|
||||
cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle,
|
||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', valid: "√", invalid: "x", enter: "enter", space: "space",
|
||||
folds: map[rune]rune{'…': '.'}}
|
||||
asciiFont = font{Glyphs: ui.GlyphsASCII, wire: []rune(" |-+||++-+-+++++"), box: []rune("++++-|"),
|
||||
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', rule: '-', valid: "ok", invalid: "x", enter: "enter", space: "space",
|
||||
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
|
||||
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space",
|
||||
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
|
||||
)
|
||||
|
||||
|
||||
+190
-35
@@ -7,6 +7,7 @@ package tui
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
@@ -85,11 +86,12 @@ type app struct {
|
||||
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
|
||||
scroll int // the canvas's first row
|
||||
inspect bool // keys go to the inspector
|
||||
pi int // the pipeline shown
|
||||
col int // the chosen column
|
||||
row [3]int // the chosen node of each column
|
||||
scroll ui.ViewportScroll // the canvas's rows
|
||||
bar bool // keys go to the pipeline bar
|
||||
inspect bool // keys go to the inspector
|
||||
insp inspector
|
||||
dialog dialog // takes every key while open
|
||||
status string // the last edit's refusal
|
||||
@@ -217,6 +219,8 @@ func (a *app) handle(ev terminal.Event) {
|
||||
case ev.Type == terminal.EventPaste:
|
||||
a.paste(ev.Text)
|
||||
case ev.Type != terminal.EventKey:
|
||||
case a.bar:
|
||||
a.barKey(ev)
|
||||
case !a.inspect:
|
||||
a.canvasKey(ev)
|
||||
case !a.inspectKey(ev):
|
||||
@@ -238,13 +242,17 @@ func (a *app) canvasKey(ev terminal.Event) {
|
||||
a.col = min(2, a.col+1)
|
||||
case is(ev, terminal.KeyTab, ""):
|
||||
a.col = (a.col + 1) % 3
|
||||
case is(ev, terminal.KeyUp, "k") && a.row[a.col] == 0:
|
||||
a.bar = true
|
||||
case is(ev, terminal.KeyUp, "k"):
|
||||
a.row[a.col] = max(0, a.row[a.col]-1)
|
||||
a.row[a.col]--
|
||||
case is(ev, terminal.KeyDown, "j"):
|
||||
a.row[a.col]++
|
||||
a.node()
|
||||
case is(ev, terminal.KeyEnter, "") && ok:
|
||||
a.inspect, a.insp.cursor = true, 0
|
||||
case is(ev, terminal.KeyEnter, "") && (!ok || n.Role == "filters" && len(a.pipeline().Flow.Filters) == 0):
|
||||
a.dialog = a.addMenu() // an empty column, or no filters, as a
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
a.inspectFirst(n)
|
||||
case is(ev, terminal.KeyNone, "a"):
|
||||
a.dialog = a.addMenu()
|
||||
case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters":
|
||||
@@ -269,8 +277,12 @@ func (a *app) globalKey(ev terminal.Event) {
|
||||
a.dialog = a.outputMenu()
|
||||
case is(ev, terminal.KeyNone, "?"):
|
||||
a.dialog = help(a.font)
|
||||
case is(ev, terminal.KeyNone, "c"):
|
||||
case is(ev, terminal.KeyNone, "!"):
|
||||
a.showProblem()
|
||||
case is(ev, terminal.KeyNone, "c"):
|
||||
a.changeType()
|
||||
case is(ev, terminal.KeyNone, "r"):
|
||||
a.run()
|
||||
case is(ev, terminal.KeyNone, "]") && n > 0:
|
||||
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
||||
case is(ev, terminal.KeyNone, "[") && n > 0:
|
||||
@@ -280,6 +292,76 @@ func (a *app) globalKey(ev terminal.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// barKey takes the keys on the pipeline bar: switch, add, rename, delete
|
||||
func (a *app) barKey(ev terminal.Event) {
|
||||
n := len(a.comp.Pipelines)
|
||||
switch p := a.pipeline(); {
|
||||
case is(ev, terminal.KeyLeft, "h["):
|
||||
a.pi, a.row = (a.pi+n-1)%n, [3]int{}
|
||||
case is(ev, terminal.KeyRight, "l]"):
|
||||
a.pi, a.row = (a.pi+1)%n, [3]int{}
|
||||
case is(ev, terminal.KeyDown, "j") || is(ev, terminal.KeyEscape, ""):
|
||||
a.bar = false
|
||||
case is(ev, terminal.KeyNone, "a"):
|
||||
a.dialog = a.pipelineMenu("Add a pipeline", (*app).append)
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
f := &form{title: "Rename pipeline " + p.Name, params: []config.PresetParam{{Name: "name", Required: true, Help: "a name no other pipeline has"}},
|
||||
apply: func(a *app, v map[string]string) error {
|
||||
err := a.comp.RenamePipeline(a.pi, v["name"])
|
||||
if err == nil {
|
||||
a.edit(nil)
|
||||
}
|
||||
return err
|
||||
}}
|
||||
f.fill(map[string]string{"name": p.Name})
|
||||
a.dialog = f
|
||||
case is(ev, terminal.KeyNone, "d"):
|
||||
remove := func(a *app) {
|
||||
a.edit(a.comp.RemovePipeline(a.pi))
|
||||
a.pi, a.row, a.insp.open = max(0, a.pi-1), [3]int{}, map[string]bool{} // folds are keyed by index
|
||||
if len(a.comp.Pipelines) == 0 {
|
||||
a.bar, a.dialog = false, a.presetMenu()
|
||||
}
|
||||
}
|
||||
if len(p.PluginSources)+len(p.PluginSinks) == 0 {
|
||||
remove(a)
|
||||
return
|
||||
}
|
||||
a.dialog = &confirm{question: "Delete pipeline " + p.Name + "?", yes: remove}
|
||||
default:
|
||||
a.globalKey(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// changeType offers a source or sink the other types of its role; a filter
|
||||
// or the format changes type on its type row
|
||||
func (a *app) changeType() {
|
||||
n, ok := a.node()
|
||||
if l := a.lines(); a.inspect && ok && len(l) > 0 {
|
||||
n = l[min(a.insp.cursor, len(l)-1)].node
|
||||
}
|
||||
switch {
|
||||
case !ok || n.Role == "filters" && !a.inspect:
|
||||
a.note = "c changes the type of a source, sink, filter or format"
|
||||
case n.Role == "source" || n.Role == "sink":
|
||||
a.dialog = a.typeMenu(n)
|
||||
case n.Role == "filters" && n.Index < len(a.pipeline().Flow.Filters), n.Role == "format":
|
||||
a.choose(n)
|
||||
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == "type" })
|
||||
default:
|
||||
a.note = "c changes the type of a source, sink, filter or format"
|
||||
}
|
||||
}
|
||||
|
||||
// run leaves to start the pipelines, once they are valid
|
||||
func (a *app) run() {
|
||||
if a.problem != nil {
|
||||
a.status = "not valid yet: ! shows the problem"
|
||||
return
|
||||
}
|
||||
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
||||
}
|
||||
|
||||
// switchStage turns a flow stage on with its defaults, or off; a stage with
|
||||
// an enabled key switches that
|
||||
func (a *app) switchStage(n compose.Node) {
|
||||
@@ -308,7 +390,8 @@ func slicesHasKey(keys []config.Key, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// paste starts over from a pasted command line
|
||||
// paste starts over from a pasted command line, asking first when there are
|
||||
// edited pipelines to lose
|
||||
func (a *app) paste(text string) {
|
||||
c, err := compose.FromCommandLine(text, config.HostIsDir)
|
||||
if err != nil {
|
||||
@@ -320,7 +403,7 @@ func (a *app) paste(text string) {
|
||||
a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines))
|
||||
a.check()
|
||||
}
|
||||
if !a.changed {
|
||||
if !a.changed || len(a.comp.Pipelines) == 0 {
|
||||
replace(a)
|
||||
return
|
||||
}
|
||||
@@ -341,18 +424,25 @@ func (a *app) showProblem() {
|
||||
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 := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
|
||||
a.pi = pi
|
||||
a.choose(n)
|
||||
a.inspect = true
|
||||
a.insp.cursor = 0
|
||||
for i, l := range a.lines() {
|
||||
if l.node == n && l.key.Name == key {
|
||||
a.insp.cursor = i
|
||||
}
|
||||
}
|
||||
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == key })
|
||||
}
|
||||
a.dialog = ¬ice{title: "Problem", lines: ui.WrapText(a.problem.Error(), 60)}
|
||||
a.dialog = textNotice("Problem", a.problem.Error())
|
||||
}
|
||||
|
||||
// inspectFirst opens the inspector on a node's first required key, or on a
|
||||
// filter's group
|
||||
func (a *app) inspectFirst(n compose.Node) {
|
||||
a.inspectAt(func(l line) bool { return l.node == n && (l.key.Required || l.key.Name == "") })
|
||||
}
|
||||
|
||||
// inspectAt opens the inspector, which takes the keys from the bar, on the
|
||||
// first line at holds for, or on its top
|
||||
func (a *app) inspectAt(at func(line) bool) {
|
||||
a.inspect, a.bar = true, false
|
||||
a.insp.cursor = max(0, slices.IndexFunc(a.lines(), at))
|
||||
}
|
||||
|
||||
// choose puts the canvas cursor on a node, a filter on the filters stage
|
||||
@@ -374,13 +464,12 @@ func (a *app) choose(n compose.Node) {
|
||||
// it from 110 columns or below it, a status row, and any dialog over them
|
||||
func (a *app) draw(r ui.Region) {
|
||||
r.FillStyle(a.th.Text)
|
||||
status, style := a.bottom(r.W)
|
||||
if len(a.comp.Pipelines) > 0 {
|
||||
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
|
||||
a.node() // a delete can leave the row past its column's end
|
||||
p := a.pipeline()
|
||||
title := fmt.Sprintf("LogWisp pipeline %s %d of %d", p.Name, a.pi+1, len(a.comp.Pipelines))
|
||||
r.TextStyled(1, 0, ui.Truncate(title, r.W-2), a.th.Accent)
|
||||
body := r.Sub(0, 1, r.W, r.H-2)
|
||||
a.drawBar(r.Sub(0, 0, r.W, 1))
|
||||
body := r.Sub(0, 1, r.W, r.H-1-len(status))
|
||||
if r.W >= 110 {
|
||||
iw := min(60, r.W*2/5)
|
||||
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
|
||||
@@ -391,12 +480,56 @@ func (a *app) draw(r ui.Region) {
|
||||
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
|
||||
}
|
||||
}
|
||||
a.drawStatus(r.Sub(0, r.H-1, r.W, 1))
|
||||
for i, l := range status {
|
||||
r.TextStyled(1, r.H-len(status)+i, l, style)
|
||||
}
|
||||
if a.status == "" && a.note == "" && a.problem == nil {
|
||||
keys := a.hints(r.W)
|
||||
r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted)
|
||||
}
|
||||
if a.dialog != nil {
|
||||
a.dialog.draw(a, r)
|
||||
}
|
||||
}
|
||||
|
||||
// drawBar draws the title row: the pipelines by name, the one shown marked,
|
||||
// or it and its place when they do not fit; with the keys, the bar's verbs
|
||||
func (a *app) drawBar(r ui.Region) {
|
||||
th, f := a.th, a.font
|
||||
if a.bar {
|
||||
r.TextStyled(0, 0, string(f.Focus), th.Accent)
|
||||
hint := "a add " + f.enter + " rename d delete"
|
||||
if r.W >= 60 {
|
||||
r.TextStyled(r.W-ui.RuneLen(hint)-1, 0, hint, th.Muted)
|
||||
r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1)
|
||||
}
|
||||
}
|
||||
r.TextStyled(2, 0, "LogWisp", th.Accent)
|
||||
names, w := a.comp.Pipelines, 11
|
||||
for _, p := range names {
|
||||
w += ui.RuneLen(p.Name) + 4
|
||||
}
|
||||
if w > r.W {
|
||||
names = names[a.pi : a.pi+1]
|
||||
}
|
||||
x := 11
|
||||
for _, p := range names {
|
||||
mark, style := f.Off, th.Muted
|
||||
if p.Name == a.pipeline().Name {
|
||||
mark, style = f.On, th.Text
|
||||
if a.bar {
|
||||
style = th.Text.On(th.Selected)
|
||||
}
|
||||
}
|
||||
label := ui.Truncate(string(mark)+" "+p.Name, max(0, r.W-x))
|
||||
r.TextStyled(x, 0, label, style)
|
||||
x += ui.RuneLen(label) + 2
|
||||
}
|
||||
if len(names) < len(a.comp.Pipelines) {
|
||||
r.TextStyled(x, 0, fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)), th.Muted)
|
||||
}
|
||||
}
|
||||
|
||||
// paint draws the screen into cells, each rune one the font's terminal shows
|
||||
func (a *app) paint(cells []terminal.Cell, w, h int) {
|
||||
a.draw(ui.NewRegion(cells, w, 0, 0, w, h))
|
||||
@@ -405,23 +538,45 @@ func (a *app) paint(cells []terminal.Cell, w, h int) {
|
||||
}
|
||||
}
|
||||
|
||||
// drawStatus shows an edit's refusal, a note, or whether the pipelines are
|
||||
// valid, and the keys
|
||||
func (a *app) drawStatus(r ui.Region) {
|
||||
// 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
|
||||
func (a *app) bottom(w int) ([]string, ui.Style) {
|
||||
th, f := a.th, a.font
|
||||
text, style := f.valid+" valid", th.Accent
|
||||
text, style := "", th.Error
|
||||
switch {
|
||||
case a.status != "":
|
||||
text, style = a.status, th.Error
|
||||
text = a.status
|
||||
case a.note != "":
|
||||
text, style = a.note, th.Muted
|
||||
case a.problem != nil:
|
||||
text, style = f.invalid+" "+a.problem.Error(), th.Error
|
||||
pi, n, key, msg, _ := locate(a.problem)
|
||||
if where := a.where(pi, n, key); where != "" {
|
||||
msg = where + ": " + msg
|
||||
}
|
||||
text = f.invalid + " " + msg
|
||||
default:
|
||||
return []string{f.valid + " valid"}, th.Accent
|
||||
}
|
||||
keys := "a add d delete " + f.space + " on/off J/K move p preset o output ? help"
|
||||
if ui.RuneLen(keys)+24 > r.W {
|
||||
return clip(ui.WrapText(text, w-2), 3), style
|
||||
}
|
||||
|
||||
// hints are the status row's keys, shown while nothing else needs the row
|
||||
func (a *app) hints(w int) string {
|
||||
keys := "a add d delete " + a.font.space + " on/off J/K move p preset o output ? help"
|
||||
if ui.RuneLen(keys)+12 > w {
|
||||
keys = "? help"
|
||||
}
|
||||
r.TextStyled(1, 0, ui.Truncate(text, r.W-ui.RuneLen(keys)-4), style)
|
||||
r.TextStyled(r.W-ui.RuneLen(keys)-1, 0, keys, th.Muted)
|
||||
return keys
|
||||
}
|
||||
|
||||
// clip keeps n lines, the last cut short when more follow
|
||||
func clip(lines []string, n int) []string {
|
||||
if len(lines) > n {
|
||||
if n < 1 {
|
||||
return nil
|
||||
}
|
||||
lines = lines[:n]
|
||||
lines[n-1] = ui.Truncate(lines[n-1]+"…", ui.RuneLen(lines[n-1]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
+248
-12
@@ -197,8 +197,9 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
||||
}
|
||||
|
||||
// The inspector types a value and sets it, typed by the key's kind, and Tab
|
||||
// moves on; empty or d returns it to its default; arrows step a choice,
|
||||
// setting nothing past either end, and Space a switch
|
||||
// or Up moves on; empty or d returns it to its default; arrows step a choice,
|
||||
// setting nothing past either end, and set a switch; Enter opens a choice's
|
||||
// options, at its value, and Space flips a switch
|
||||
func TestInspectorSetsKeys(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
|
||||
press(a, terminal.KeyEnter)
|
||||
@@ -221,8 +222,24 @@ func TestInspectorSetsKeys(t *testing.T) {
|
||||
focusKey(t, a, "from")
|
||||
press(a, terminal.KeyLeft)
|
||||
focusKey(t, a, "raw")
|
||||
press(a, " ")
|
||||
if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "start" || o["raw"] != true {
|
||||
if press(a, " ", terminal.KeyLeft, terminal.KeyLeft); opts()["raw"] != false {
|
||||
t.Fatalf("Left left raw %v", opts()["raw"])
|
||||
}
|
||||
press(a, terminal.KeyRight, terminal.KeyRight)
|
||||
focusKey(t, a, "pattern")
|
||||
at = a.insp.cursor
|
||||
if press(a, terminal.KeyEnter, "x", terminal.KeyUp); a.insp.cursor != at-1 || opts()["pattern"] != "x" {
|
||||
t.Fatalf("Up left the cursor on row %d, pattern %v", a.insp.cursor, opts()["pattern"])
|
||||
}
|
||||
focusKey(t, a, "from")
|
||||
if press(a, terminal.KeyEnter); a.dialog.(*chooser).list.Cursor != 0 {
|
||||
t.Fatalf("the options open off the value: %+v", a.dialog)
|
||||
}
|
||||
if press(a, terminal.KeyDown, terminal.KeyEscape); opts()["from"] != "start" {
|
||||
t.Fatalf("Esc set %v", opts()["from"])
|
||||
}
|
||||
press(a, terminal.KeyEnter, terminal.KeyDown, terminal.KeyEnter)
|
||||
if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "end" || o["raw"] != true {
|
||||
t.Fatalf("%v", o)
|
||||
}
|
||||
focusKey(t, a, "directory")
|
||||
@@ -294,8 +311,9 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// o leaves with the chosen form, printed as the engine writes it, or with
|
||||
// the pipelines to run; a composition that does not validate stays
|
||||
// o leaves with the form its letter picks, printed as the engine writes it,
|
||||
// or with the pipelines to run, as r does; a composition that does not
|
||||
// validate stays
|
||||
func TestOutputLeavesWithTheForm(t *testing.T) {
|
||||
c := pipeline(t, []string{"null"}, []string{"null"})
|
||||
line, err := c.CommandLine()
|
||||
@@ -303,7 +321,7 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := mono(c)
|
||||
press(a, "o", "comm", terminal.KeyEnter)
|
||||
press(a, "o", "c")
|
||||
if !a.done || a.result.Exit != Print || a.result.Output != line {
|
||||
t.Fatalf("%+v", a.result)
|
||||
}
|
||||
@@ -312,8 +330,13 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
|
||||
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
|
||||
t.Fatalf("%+v", a.result)
|
||||
}
|
||||
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
press(a, "r")
|
||||
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
|
||||
t.Fatalf("r: %+v", a.result)
|
||||
}
|
||||
a = mono(pipeline(t, []string{"file"}, []string{"null"}))
|
||||
press(a, "o", terminal.KeyEnter)
|
||||
press(a, "o", terminal.KeyEnter, "r")
|
||||
if a.done || a.status == "" {
|
||||
t.Fatalf("ran an invalid composition: %+v", a.result)
|
||||
}
|
||||
@@ -351,7 +374,8 @@ func TestPresetsStartThePipeline(t *testing.T) {
|
||||
}
|
||||
|
||||
// A pasted command line replaces the pipelines, from the preset menu too,
|
||||
// asking first once they were edited; one that names none is refused
|
||||
// asking first once they were edited and while some remain; one that names
|
||||
// none is refused
|
||||
func TestPasteReplacesThePipelines(t *testing.T) {
|
||||
a := mono(&compose.Composition{})
|
||||
if press(a, []byte("lw\n")); a.status == "" {
|
||||
@@ -368,22 +392,42 @@ func TestPasteReplacesThePipelines(t *testing.T) {
|
||||
if a.pipeline().Name != "x" {
|
||||
t.Fatalf("replaced edited pipelines without asking: %+v", a.comp.Pipelines)
|
||||
}
|
||||
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
press(a, "k", "d", "y", []byte("lw --pipeline z --source null --sink null"))
|
||||
if a.dialog != nil || len(a.comp.Pipelines) != 1 || a.pipeline().Name != "z" {
|
||||
t.Fatalf("a paste after the last pipeline was deleted: %T %+v", a.dialog, a.comp.Pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
// The first problem marks the part it names, and c moves there, to the key
|
||||
// The first problem marks the part it names, and ! moves there, to the key
|
||||
func TestProblemNamesItsPart(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp"}))
|
||||
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"}) {
|
||||
t.Fatalf("problem %v", a.problem)
|
||||
}
|
||||
press(a, "c")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ! and c on the pipeline bar open the inspector, which then takes the keys:
|
||||
// Enter edits the key it shows, not the pipeline's name
|
||||
func TestTheInspectorTakesTheKeysFromTheBar(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
|
||||
press(a, "k", "!", terminal.KeyEscape, terminal.KeyEnter)
|
||||
if a.bar || a.insp.field == nil {
|
||||
t.Fatalf("Enter after ! on the bar: bar %v, %T", a.bar, a.dialog)
|
||||
}
|
||||
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
press(a, "k", "c", terminal.KeyEnter, terminal.KeyEnter)
|
||||
if a.bar || !a.inspect || a.dialog != nil {
|
||||
t.Fatalf("Enter after c on the bar: bar %v, %T", a.bar, a.dialog)
|
||||
}
|
||||
}
|
||||
|
||||
// 16 colors and none keep the terminal's own background; more paint the site's
|
||||
func TestOnlyFullColorPaintsTheBackground(t *testing.T) {
|
||||
for mode, l := range looks {
|
||||
@@ -451,7 +495,9 @@ func TestPresetFormShowsTheFocusedField(t *testing.T) {
|
||||
presets := config.Presets()
|
||||
p := slices.MaxFunc(presets, func(x, y config.Preset) int { return len(x.Params) - len(y.Params) })
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
a.dialog = newPresetForm(p)
|
||||
f := &form{title: p.Name, params: p.Params}
|
||||
f.fill(nil)
|
||||
a.dialog = f
|
||||
press(a, terminal.KeyBacktab)
|
||||
last := p.Params[len(p.Params)-1].Name
|
||||
if screen := strings.Join(text(render(a, 80, 12), 80), "\n"); !strings.Contains(screen, last) {
|
||||
@@ -459,6 +505,96 @@ func TestPresetFormShowsTheFocusedField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// presetForm is a preset's form, open on a
|
||||
func presetForm(a *app, name string) *form {
|
||||
presets := config.Presets()
|
||||
p := presets[slices.IndexFunc(presets, func(p config.Preset) bool { return p.Name == name })]
|
||||
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params}
|
||||
f.fill(nil)
|
||||
a.dialog = f
|
||||
return f
|
||||
}
|
||||
|
||||
// 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) {
|
||||
a := mono(pipeline(t, nil, nil))
|
||||
presetForm(a, "serve")
|
||||
press(a, terminal.KeyTab, terminal.KeyTab, terminal.KeyTab, "127.0.0.1:9000")
|
||||
if screen := strings.Join(text(render(a, 25, 20), 25), "\n"); !strings.Contains(screen, "listen") || !strings.Contains(screen, "127.0.0.1:9000") {
|
||||
t.Fatalf("listen and its value not whole at 25 columns:\n%s", screen)
|
||||
}
|
||||
}
|
||||
|
||||
// A dialog is sized to what it holds: its first and last rows inside the
|
||||
// box have text, a form without a summary included
|
||||
func TestDialogsHaveNoBlankEdgeRows(t *testing.T) {
|
||||
var a *app
|
||||
for name, open := range map[string]func(){
|
||||
"add": func() { press(a, "a") },
|
||||
"presets": func() { press(a, "p") },
|
||||
"output": func() { press(a, "o") },
|
||||
"confirm": func() { press(a, "q") },
|
||||
"help": func() { press(a, "?") },
|
||||
"problem": func() { press(a, "!") },
|
||||
"rename": func() { press(a, terminal.KeyUp, terminal.KeyEnter) },
|
||||
"form": func() { presetForm(a, "serve") },
|
||||
} {
|
||||
a = mono(pipeline(t, nil, nil))
|
||||
a.changed, a.dialog = true, nil
|
||||
open()
|
||||
rows := text(render(a, 80, 40), 80)
|
||||
bottom := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "└") })
|
||||
top := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "┌") })
|
||||
if a.dialog == nil || top < 0 || bottom < top+2 {
|
||||
t.Errorf("%s: no dialog:\n%s", name, strings.Join(rows, "\n"))
|
||||
continue
|
||||
}
|
||||
edge := []rune(rows[bottom])
|
||||
left, right := slices.Index(edge, '└'), slices.Index(edge, '┘')
|
||||
for _, y := range []int{top + 1, bottom - 1} {
|
||||
if inside := strings.Trim(string([]rune(rows[y])[left:right]), " │"); inside == "" {
|
||||
t.Errorf("%s: row %d blank:\n%s", name, y, strings.Join(rows[top:bottom+1], "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A question is answered by y or n, or by Enter on the answer the arrows
|
||||
// chose; no is chosen first
|
||||
func TestConfirmAnswersOnEnter(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
a.changed = true
|
||||
if press(a, "q", terminal.KeyEnter); a.done || a.dialog != nil {
|
||||
t.Fatalf("Enter on no: done %v, dialog %T", a.done, a.dialog)
|
||||
}
|
||||
if press(a, "q", terminal.KeyLeft, terminal.KeyEnter); !a.done {
|
||||
t.Fatal("Enter on yes did not quit")
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas scrolls to keep the chosen part in view, its bar solid
|
||||
func TestCanvasKeepsTheChosenInView(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
|
||||
a.col, a.row[sinksCol] = sinksCol, 7
|
||||
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) {
|
||||
t.Fatalf("the eighth sink is out of view:\n%s", screen)
|
||||
}
|
||||
}
|
||||
|
||||
// A notice taller than the screen scrolls with j and k and says where it is
|
||||
func TestNoticeScrolls(t *testing.T) {
|
||||
a := mono(pipeline(t, nil, nil))
|
||||
press(a, "?")
|
||||
screen := func() string { return strings.Join(text(render(a, 25, 20), 25), "\n") }
|
||||
if s := screen(); strings.Contains(s, "quit") || !strings.Contains(s, "j k: 1-") {
|
||||
t.Fatalf("help at 25 columns:\n%s", s)
|
||||
}
|
||||
if press(a, strings.Repeat("j", 60)); a.dialog == nil || !strings.Contains(screen(), "quit") {
|
||||
t.Fatalf("scrolled to the end:\n%s", screen())
|
||||
}
|
||||
}
|
||||
|
||||
// A dialog's text wraps to a narrow screen
|
||||
func TestDialogsWrapToTheScreen(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
@@ -472,3 +608,103 @@ func TestDialogsWrapToTheScreen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The inspector opens on a part's first required key, after a adds it or
|
||||
// Enter chooses it
|
||||
func TestInspectorOpensOnTheFirstRequiredKey(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"tcp_chain"}))
|
||||
a.col = sinksCol
|
||||
press(a, terminal.KeyEnter)
|
||||
at := func() string { return a.lines()[a.insp.cursor].key.Name }
|
||||
if got := at(); got != "host" {
|
||||
t.Fatalf("Enter on tcp_chain: %s", got)
|
||||
}
|
||||
press(a, terminal.KeyEscape, "a", "http", terminal.KeyEnter)
|
||||
if got := at(); got != "port" {
|
||||
t.Fatalf("a adding http: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The status shows the first problem whole, by its part and key, not by its
|
||||
// path, on as many rows as it takes
|
||||
func TestTheProblemShowsWhole(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"http"}))
|
||||
sink := compose.Node{Role: "sink", ID: "http"}
|
||||
for key, value := range map[string]string{"port": "8080", "viewer_page": "true"} {
|
||||
if err := a.comp.Set(0, sink, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a.check()
|
||||
for _, w := range []int{60, 87, 146} {
|
||||
screen := strings.Join(text(render(a, w, 30), w), "\n")
|
||||
if !strings.Contains(screen, "sink http · viewer_page: only") || !strings.Contains(screen, "at /") || strings.Contains(screen, "pipelines[0]") {
|
||||
t.Errorf("%d columns:\n%s", w, screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on a row that adds acts as a does: an empty column's slot, the
|
||||
// filters stage with none, the inspector's last filter row
|
||||
func TestEnterOnAnAddRowAdds(t *testing.T) {
|
||||
a := mono(pipeline(t, nil, []string{"null"}))
|
||||
if press(a, terminal.KeyEnter); a.dialog == nil {
|
||||
t.Fatal("Enter on the empty sources column")
|
||||
}
|
||||
press(a, "null", terminal.KeyEnter, terminal.KeyEscape, terminal.KeyRight, terminal.KeyDown, terminal.KeyEnter, "exc", terminal.KeyEnter)
|
||||
if f := a.pipeline().Flow.Filters; len(f) != 1 || f[0].Type != "exclude" {
|
||||
t.Fatalf("Enter on filters: %+v", f)
|
||||
}
|
||||
lines := a.lines()
|
||||
a.insp.cursor = len(lines) - 1
|
||||
press(a, terminal.KeyEnter, terminal.KeyEnter)
|
||||
if f := a.pipeline().Flow.Filters; len(f) != 2 {
|
||||
t.Fatalf("Enter on the add row: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// c changes a source's or sink's type in its place and opens it; on a filter
|
||||
// or the format it goes to the type row
|
||||
func TestChangeTypeKeepsThePlace(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null", "file"}))
|
||||
a.col = sinksCol
|
||||
press(a, "c", "tcp", terminal.KeyEnter)
|
||||
sinks := a.pipeline().PluginSinks
|
||||
if sinks[0].Type != "tcp" || sinks[0].ID != "tcp" || sinks[1].Type != "file" || !a.inspect || a.lines()[a.insp.cursor].key.Name != "port" {
|
||||
t.Fatalf("%+v, cursor on %+v", sinks, a.lines()[a.insp.cursor])
|
||||
}
|
||||
press(a, terminal.KeyEscape)
|
||||
a.choose(compose.Node{Role: "format"})
|
||||
if press(a, "c"); !a.inspect || a.lines()[a.insp.cursor].key.Name != "type" {
|
||||
t.Fatalf("c on format: %+v", a.lines()[a.insp.cursor])
|
||||
}
|
||||
}
|
||||
|
||||
// The pipeline bar, above the parts, switches pipelines, adds one under a free
|
||||
// name, renames one under a name no other has, and deletes one, asking first
|
||||
// when it has parts
|
||||
func TestThePipelineBarAddsRenamesDeletes(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
press(a, "k", "a", "empty", terminal.KeyEnter, "k", "a", "empty", terminal.KeyEnter)
|
||||
names := func() (out []string) {
|
||||
for _, p := range a.comp.Pipelines {
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := names(); !slices.Equal(got, []string{"p", "default", "default_2"}) || a.pi != 2 {
|
||||
t.Fatalf("added %q, showing %d", got, a.pi)
|
||||
}
|
||||
press(a, "k", terminal.KeyEnter, terminal.KeyCtrlU, "p", terminal.KeyEnter)
|
||||
if _, open := a.dialog.(*form); !open {
|
||||
t.Fatalf("renamed to a name another has: %q", names())
|
||||
}
|
||||
press(a, terminal.KeyCtrlU, "x", terminal.KeyEnter, "h", "h", "d")
|
||||
if got := names(); !slices.Equal(got, []string{"p", "default", "x"}) || a.dialog == nil {
|
||||
t.Fatalf("deleted without asking: %q", got)
|
||||
}
|
||||
press(a, "y", "l", "d")
|
||||
if got := names(); !slices.Equal(got, []string{"default"}) || a.dialog != nil {
|
||||
t.Fatalf("%q %T", got, a.dialog)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user