v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4606
-449
No files matched your search
+10
-16
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/authz"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/service"
|
||||
"github.com/lixenwraith/logwisp/internal/version"
|
||||
|
||||
@@ -34,7 +35,7 @@ import (
|
||||
|
||||
// bootstrapInitial handles initial service startup with status reporter
|
||||
func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service, context.CancelFunc, error) {
|
||||
svc, err := bootstrapService(ctx, cfg)
|
||||
svc, err := bootstrapService(ctx, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
|
||||
}
|
||||
@@ -57,8 +58,9 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
|
||||
// no pipeline runs until a later reload succeeds.
|
||||
var errServiceStopped = errors.New("old service stopped, new one failed to start")
|
||||
|
||||
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle
|
||||
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
|
||||
// handleReload orchestrates the entire hot-reload process including status
|
||||
// reporter lifecycle; tap is the screen's, nil for none
|
||||
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc, tap *core.Tap) (*service.Service, *config.Config, context.CancelFunc, error) {
|
||||
logger.Info("msg", "Starting configuration hot reload")
|
||||
|
||||
if err := config.ValidateConfig(newCfg); err != nil {
|
||||
@@ -68,13 +70,12 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
|
||||
|
||||
// Bootstrap a new service to ensure it's valid before touching the old one
|
||||
logger.Debug("msg", "Bootstrapping new service with updated config")
|
||||
newService, err := bootstrapService(ctx, newCfg)
|
||||
newService, err := bootstrapService(ctx, newCfg, tap)
|
||||
if err != nil {
|
||||
logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Gracefully shut down the old service
|
||||
if statusCancel != nil {
|
||||
statusCancel()
|
||||
}
|
||||
@@ -83,14 +84,12 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
|
||||
oldSvc.Shutdown()
|
||||
}
|
||||
|
||||
// Start the new service
|
||||
if err := newService.Start(); err != nil {
|
||||
newService.Shutdown()
|
||||
logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err)
|
||||
return nil, nil, nil, fmt.Errorf("%w: %w", errServiceStopped, err)
|
||||
}
|
||||
|
||||
// Manage status reporter lifecycle
|
||||
var newStatusCancel context.CancelFunc
|
||||
if newCfg.StatusReporter {
|
||||
newStatusCancel = startStatusReporter(ctx, newService)
|
||||
@@ -100,9 +99,9 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
|
||||
return newService, newCfg, newStatusCancel, nil
|
||||
}
|
||||
|
||||
// bootstrapService creates and initializes the main log transport service and its pipelines
|
||||
func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, error) {
|
||||
// Create service with logger dependency injection
|
||||
// bootstrapService builds the service and its pipelines, which offer what
|
||||
// their flows decide to tap from their first entry; nil for none
|
||||
func bootstrapService(ctx context.Context, cfg *config.Config, tap *core.Tap) (*service.Service, error) {
|
||||
svc, err := service.NewService(ctx, cfg, logger)
|
||||
if err != nil {
|
||||
logger.Error("msg", "Failed to initialize service",
|
||||
@@ -110,6 +109,7 @@ func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
svc.SetTap(tap)
|
||||
|
||||
logger.Info("msg", "LogWisp started",
|
||||
"version", version.Short(),
|
||||
@@ -149,27 +149,23 @@ func dumpConfig(cfg *config.Config, w io.Writer) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// initializeLogger sets up the global logger based on the application's configuration
|
||||
func initializeLogger(cfg *config.Config) error {
|
||||
logger = log.NewLogger()
|
||||
logCfg := log.DefaultConfig()
|
||||
|
||||
if cfg.Quiet {
|
||||
// In quiet mode, disable ALL logging output
|
||||
logCfg.Level = 255 // A level that disables all output
|
||||
logCfg.EnableFile = false
|
||||
logCfg.EnableConsole = false
|
||||
return logger.ApplyConfig(logCfg)
|
||||
}
|
||||
|
||||
// Determine log level
|
||||
levelValue, err := log.Level(cfg.Logging.Level)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid log level: %w", err)
|
||||
}
|
||||
logCfg.Level = levelValue
|
||||
|
||||
// Configure log format
|
||||
if cfg.Logging.Format != "" {
|
||||
logCfg.Format = cfg.Logging.Format
|
||||
}
|
||||
@@ -177,7 +173,6 @@ func initializeLogger(cfg *config.Config) error {
|
||||
logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization)
|
||||
}
|
||||
|
||||
// Configure based on output mode
|
||||
switch cfg.Logging.Output {
|
||||
case "none":
|
||||
logCfg.EnableFile = false
|
||||
@@ -210,7 +205,6 @@ func initializeLogger(cfg *config.Config) error {
|
||||
return logger.ApplyConfig(logCfg)
|
||||
}
|
||||
|
||||
// configureFileLogging sets up file-based logging parameters from the configuration
|
||||
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
|
||||
if cfg.Logging.File != nil {
|
||||
logCfg.Directory = cfg.Logging.File.Directory
|
||||
|
||||
+10
-11
@@ -53,7 +53,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 1. Initial setup ---
|
||||
// Emulates nohup
|
||||
signal.Ignore(syscall.SIGHUP)
|
||||
|
||||
@@ -100,7 +99,6 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// --- 2. Bootstrap initial service ---
|
||||
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
|
||||
if err != nil {
|
||||
logger.Error("msg", "Failed to initialize service", "error", err)
|
||||
@@ -112,7 +110,6 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "lw: reading standard input; Ctrl-D ends it (lw --help for usage)")
|
||||
}
|
||||
|
||||
// --- 3. Setup signals and shutdown ---
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
|
||||
|
||||
@@ -156,7 +153,7 @@ func main() {
|
||||
if !fromDisk && cfg.StatusReporter == next.StatusReporter && reflect.DeepEqual(cfg.Pipelines, next.Pipelines) {
|
||||
return
|
||||
}
|
||||
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel)
|
||||
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel, nil)
|
||||
switch {
|
||||
case err == nil:
|
||||
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
|
||||
@@ -166,7 +163,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Main Application Event Loop ---
|
||||
logger.Info("msg", "Application started, waiting for signals or config changes")
|
||||
for {
|
||||
select {
|
||||
@@ -176,13 +172,13 @@ func main() {
|
||||
reload(true)
|
||||
} else {
|
||||
logger.Info("msg", "Shutdown signal received", "signal", sig)
|
||||
cancel() // Trigger service shutdown via context
|
||||
cancel()
|
||||
}
|
||||
|
||||
case event, ok := <-configChanges:
|
||||
if !ok {
|
||||
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
|
||||
configChanges = nil // Stop selecting on this channel
|
||||
configChanges = nil
|
||||
continue
|
||||
}
|
||||
if collectConfigChanges(event, configChanges) {
|
||||
@@ -194,7 +190,7 @@ func main() {
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
return // Exit the loop and trigger deferred shutdown
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,8 +232,9 @@ func loadFailed(err error) int {
|
||||
}
|
||||
|
||||
// composeOnScreen is lw --tui: it starts from the pipelines lw would load, or
|
||||
// the preset menu in place of the built-in pipe, then prints a form and exits,
|
||||
// or leaves inv to run the pipelines composed in place of the loaded ones
|
||||
// the preset menu in place of the built-in pipe, may run them on screen, then
|
||||
// prints a form and exits, or leaves inv to run the pipelines composed in
|
||||
// place of the loaded ones
|
||||
func composeOnScreen(inv *invocation) (code int, exit bool) {
|
||||
fail := func(err error) (int, bool) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -257,7 +254,9 @@ func composeOnScreen(inv *invocation) (code int, exit bool) {
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
res, err := tui.Run(start, cfg.Color)
|
||||
runner := &screenRunner{load: inv.load}
|
||||
res, err := tui.Run(start, cfg.Color, runner)
|
||||
runner.close()
|
||||
switch {
|
||||
case err != nil:
|
||||
return fail(err)
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) {
|
||||
if len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "default" {
|
||||
t.Fatalf("sample array-of-tables decoded incorrectly: %+v", cfg.Pipelines)
|
||||
}
|
||||
svc, err := bootstrapService(context.Background(), cfg)
|
||||
svc, err := bootstrapService(context.Background(), cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
|
||||
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0)
|
||||
}
|
||||
cancelled := false
|
||||
svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true })
|
||||
svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true }, nil)
|
||||
if err == nil || svc != nil || next != nil || cancelled {
|
||||
t.Fatalf("invalid reload affected running state: svc=%v cfg=%v cancelled=%v error=%v", svc, next, cancelled, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/authz"
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/pipeline"
|
||||
"github.com/lixenwraith/logwisp/internal/service"
|
||||
"github.com/lixenwraith/logwisp/internal/sink/console"
|
||||
"github.com/lixenwraith/logwisp/internal/tui"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// The records a tap holds for the screen between ticks: a run's, lw's log's
|
||||
const (
|
||||
runTapRecords = 1024
|
||||
logTapRecords = 256
|
||||
)
|
||||
|
||||
// screenRunner runs lw --tui's pipelines while the screen owns the terminal.
|
||||
// Start loads them as r would, through the file, environment and overrides,
|
||||
// and replaces what runs as a reload does.
|
||||
type screenRunner struct {
|
||||
load config.Args
|
||||
mu sync.Mutex // svc, tap and log, which State reads from another goroutine
|
||||
svc *service.Service
|
||||
tap *core.Tap
|
||||
log *core.Tap
|
||||
}
|
||||
|
||||
func (r *screenRunner) Start(specs []config.Spec, ask tui.Ask) (*core.Tap, error) {
|
||||
cfg, err := r.config(specs)
|
||||
if err == nil {
|
||||
err = r.startLogger(cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return r.tap, err
|
||||
}
|
||||
authz.SetAsker(ask)
|
||||
tap := core.NewTap(runTapRecords)
|
||||
tap.All.Store(true) // until the view's first draw says what its point shows
|
||||
if r.svc == nil {
|
||||
svc, err := bootstrapService(context.Background(), cfg, tap)
|
||||
if err != nil {
|
||||
return nil, placed(cfg, err)
|
||||
}
|
||||
if err := svc.Start(); err != nil {
|
||||
svc.Shutdown()
|
||||
return nil, placed(cfg, err)
|
||||
}
|
||||
r.set(svc, tap)
|
||||
return tap, nil
|
||||
}
|
||||
svc, _, _, err := handleReload(context.Background(), cfg, r.svc, nil, tap)
|
||||
switch {
|
||||
case errors.Is(err, errServiceStopped):
|
||||
r.set(nil, nil)
|
||||
return nil, placed(cfg, err)
|
||||
case err != nil:
|
||||
return r.tap, placed(cfg, err)
|
||||
}
|
||||
r.set(svc, tap)
|
||||
return tap, nil
|
||||
}
|
||||
|
||||
// placed addresses a start error to the part it names, by the path the
|
||||
// screen places a configuration problem by
|
||||
func placed(cfg *config.Config, err error) error {
|
||||
part, ok := errors.AsType[*pipeline.PartError](err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
pi := slices.IndexFunc(cfg.Pipelines, func(p config.PipelineConfig) bool { return p.Name == part.Pipeline })
|
||||
if pi < 0 {
|
||||
return err
|
||||
}
|
||||
return config.At(compose.Node{Role: part.Role, ID: part.ID}.Path(pi), err)
|
||||
}
|
||||
|
||||
func (r *screenRunner) set(svc *service.Service, tap *core.Tap) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.svc, r.tap = svc, tap
|
||||
}
|
||||
|
||||
func (r *screenRunner) Stop() {
|
||||
r.mu.Lock()
|
||||
svc := r.svc
|
||||
r.svc, r.tap = nil, nil
|
||||
r.mu.Unlock()
|
||||
if svc != nil {
|
||||
svc.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *screenRunner) State() tui.RunState {
|
||||
r.mu.Lock()
|
||||
svc, st := r.svc, tui.RunState{Log: r.log}
|
||||
r.mu.Unlock()
|
||||
if svc == nil {
|
||||
return st
|
||||
}
|
||||
select {
|
||||
case <-svc.Done():
|
||||
st.Ended = true
|
||||
default:
|
||||
}
|
||||
st.Counts = map[string]tui.Counts{}
|
||||
pipelines, _ := svc.GetGlobalStats()["pipelines"].(map[string]any)
|
||||
for name, v := range pipelines {
|
||||
p, _ := v.(map[string]any)
|
||||
flow, _ := p["flow"].(map[string]any)
|
||||
var c tui.Counts
|
||||
c.In, _ = flow["total_processed"].(uint64)
|
||||
c.Filtered, _ = flow["total_dropped"].(uint64)
|
||||
c.SinksDropped, _ = p["total_dropped_by_sink"].(uint64)
|
||||
st.Counts[name] = c
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// close ends view mode before lw runs on or exits: nothing runs, lw's log is
|
||||
// shut down, and terminals answer passwords again
|
||||
func (r *screenRunner) close() {
|
||||
r.Stop()
|
||||
authz.SetAsker(nil)
|
||||
shutdownLogger()
|
||||
logger = nil
|
||||
}
|
||||
|
||||
// config is specs as they would run on screen: no status reporter, as the
|
||||
// screen shows the counts, and the terminal's streams the screen's
|
||||
func (r *screenRunner) config(specs []config.Spec) (*config.Config, error) {
|
||||
load := r.load
|
||||
load.Specs = specs
|
||||
m, err := config.Load(load)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.Close()
|
||||
cfg, err := m.Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.StatusReporter = false
|
||||
return cfg, onScreen(cfg)
|
||||
}
|
||||
|
||||
// onScreen gives the screen the streams that are the terminal: a console sink
|
||||
// writing to it runs as a null sink with its id (the tap shows what it would
|
||||
// print; both make the pipeline wait, not drop), and a console source would
|
||||
// read the screen's keys
|
||||
func onScreen(cfg *config.Config) error {
|
||||
for i := range cfg.Pipelines {
|
||||
p := &cfg.Pipelines[i]
|
||||
for _, s := range p.PluginSources {
|
||||
if s.Type == "console" && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return config.At(compose.Node{Role: "source", ID: s.ID}.Path(i),
|
||||
errors.New("console reads the keyboard this screen uses: pipe input in (cmd | lw -T), or run with r"))
|
||||
}
|
||||
}
|
||||
for j, s := range p.PluginSinks {
|
||||
target, _ := s.Config["target"].(string)
|
||||
if s.Type == "console" && term.IsTerminal(int(console.Stream(target).Fd())) {
|
||||
p.PluginSinks[j] = config.PluginSinkConfig{ID: s.ID, Type: "null"}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLogger starts lw's log on the first run. While its console is the
|
||||
// terminal, its records and the logger's own diagnostics go to the screen's
|
||||
// lw stream instead: the one place that routes lw's log on screen.
|
||||
func (r *screenRunner) startLogger(cfg *config.Config) error {
|
||||
if logger != nil {
|
||||
return nil
|
||||
}
|
||||
if err := initializeLogger(cfg); err != nil {
|
||||
logger = nil
|
||||
return err
|
||||
}
|
||||
c := logger.GetConfig()
|
||||
streams := map[string][]*os.File{"stdout": {os.Stdout}, "stderr": {os.Stderr}, "split": {os.Stdout, os.Stderr}}[c.ConsoleTarget]
|
||||
if c.EnableConsole && slices.ContainsFunc(streams, func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) }) {
|
||||
w := lwStream{core.NewTap(logTapRecords)}
|
||||
logger.SetConsoleWriter(w)
|
||||
logger.SetErrorHandler(func(msg string) { w.Write([]byte(msg)) })
|
||||
r.mu.Lock()
|
||||
r.log = w.tap
|
||||
r.mu.Unlock()
|
||||
}
|
||||
return logger.Start()
|
||||
}
|
||||
|
||||
// lwStream offers each record lw's log writes as an entry of source "lw"
|
||||
type lwStream struct{ tap *core.Tap }
|
||||
|
||||
func (w lwStream) Write(p []byte) (int, error) {
|
||||
now := time.Now()
|
||||
line := strings.TrimRight(string(p), "\r\n") // a copy: the logger reuses p
|
||||
level, _, _ := core.LevelWord(line, "")
|
||||
w.tap.Offer(core.Tapped{Source: "lw", Event: core.TransportEvent{Time: now,
|
||||
Entry: core.LogEntry{Time: now, Source: "lw", Level: level, Message: line, RawSize: int64(len(p))}}})
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/authz"
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/plugin"
|
||||
"github.com/lixenwraith/logwisp/internal/session"
|
||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||
|
||||
"github.com/lixenwraith/log"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// onTerminal makes *stream a pseudo-terminal's master, which is a terminal,
|
||||
// until the test ends
|
||||
func onTerminal(t *testing.T, stream **os.File) {
|
||||
t.Helper()
|
||||
pty, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
||||
if err != nil || !term.IsTerminal(int(pty.Fd())) {
|
||||
t.Skip("no pseudo-terminal here")
|
||||
}
|
||||
was := *stream
|
||||
*stream = pty
|
||||
t.Cleanup(func() { *stream = was; pty.Close() })
|
||||
}
|
||||
|
||||
// screenRunnerFor is a runner on lw's settings in args, with specs to start
|
||||
func screenRunnerFor(t *testing.T, args ...string) (*screenRunner, func(pipeline ...string) []config.Spec) {
|
||||
t.Helper()
|
||||
testutil.ClearEnvPrefix(t, "LOGWISP_")
|
||||
path := filepath.Join(t.TempDir(), "empty.toml")
|
||||
testutil.WriteFile(t, path, "")
|
||||
inv, err := parseCommandLine(append([]string{"-c", path}, args...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &screenRunner{load: inv.load}
|
||||
t.Cleanup(func() { r.Stop(); authz.SetAsker(nil) })
|
||||
return r, func(pipeline ...string) []config.Spec {
|
||||
inv, err := parseCommandLine(append([]string{"--pipeline", "p"}, pipeline...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv.load.Specs
|
||||
}
|
||||
}
|
||||
|
||||
// A console sink writing to the terminal runs as a null sink with its id, which
|
||||
// keeps the console's back-pressure, so the screen counts no sink drops a run
|
||||
// would not have; one writing elsewhere keeps writing there
|
||||
func TestConsoleSinksOnTheTerminalRunAsNullSinks(t *testing.T) {
|
||||
onTerminal(t, &os.Stdout)
|
||||
cfg := &config.Config{Pipelines: []config.PipelineConfig{{Name: "p", PluginSinks: []config.PluginSinkConfig{
|
||||
{ID: "out", Type: "console"}, {ID: "err", Type: "console", Config: map[string]any{"target": "stderr"}}}}}}
|
||||
null, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer null.Close()
|
||||
stderr := os.Stderr
|
||||
os.Stderr = null
|
||||
defer func() { os.Stderr = stderr }()
|
||||
if err := onScreen(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := cfg.Pipelines[0].PluginSinks
|
||||
if s[0].ID != "out" || s[0].Type != "null" || s[0].Config != nil || s[1].Type != "console" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
build, _ := plugin.GetSink(s[0].Type)
|
||||
stand, err := build(s[0].ID, s[0].Config, log.NewLogger(), session.NewProxy(session.NewManager(time.Minute), s[0].ID))
|
||||
if err != nil || !slices.Contains(stand.Capabilities(), core.CapBackpressure) {
|
||||
t.Fatalf("the stand-in drops where the console waits: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A console source would read the screen's keys: it needs piped input, and
|
||||
// the refusal names its part
|
||||
func TestAConsoleSourceNeedsPipedInput(t *testing.T) {
|
||||
cfg := &config.Config{Pipelines: []config.PipelineConfig{{Name: "p", PluginSources: []config.PluginSourceConfig{{ID: "in", Type: "console"}}}}}
|
||||
in, out, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
stdin := os.Stdin
|
||||
os.Stdin = in
|
||||
err = onScreen(cfg)
|
||||
os.Stdin = stdin
|
||||
in.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("piped: %v", err)
|
||||
}
|
||||
onTerminal(t, &os.Stdin)
|
||||
err = onScreen(cfg)
|
||||
if pi, n, _, why, ok := compose.Locate(err); !ok || pi != 0 || n != (compose.Node{Role: "source", ID: "in"}) || !strings.HasPrefix(why, "console reads the keyboard") {
|
||||
t.Fatalf("on a terminal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A start error names the part that failed, as a problem's path does, both
|
||||
// when the part does not build and when it does not start
|
||||
func TestAStartErrorNamesItsPart(t *testing.T) {
|
||||
testLogger(t)
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
held, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
for name, sink := range map[string]string{
|
||||
"does not build": fmt.Sprintf("tcp:host=127.0.0.1,port=9,tls.enabled=true,tls.cert_file=%s.pem,tls.key_file=%s.key", missing, missing),
|
||||
"does not start": fmt.Sprintf("tcp:host=127.0.0.1,port=%d", held.Addr().(*net.TCPAddr).Port),
|
||||
} {
|
||||
r, specs := screenRunnerFor(t, "--quiet")
|
||||
_, err := r.Start(specs("--source", "null", "--sink", "null", "--sink", sink), nil)
|
||||
if pi, n, _, _, ok := compose.Locate(err); !ok || pi != 0 || n != (compose.Node{Role: "sink", ID: "tcp"}) {
|
||||
t.Errorf("a sink that %s: %+v in %v", name, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While the screen runs, a password the terminal would be asked for is asked
|
||||
// through Start's ask; once the screen closes, the terminal is asked again.
|
||||
// It runs alone: an earlier test's startup end stops every terminal read.
|
||||
func TestTheScreenAsksInTheTerminalsPlace(t *testing.T) {
|
||||
if os.Getenv("LW_TEST_SCREEN_ASKS") == "" {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestTheScreenAsksInTheTerminalsPlace$", "-test.v")
|
||||
cmd.Env = append(os.Environ(), "LW_TEST_SCREEN_ASKS=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
switch {
|
||||
case strings.Contains(string(out), "--- SKIP"):
|
||||
t.Skip("no pseudo-terminal here")
|
||||
case err != nil || !strings.Contains(string(out), "--- PASS"):
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if probe, err := os.Open("/dev/ptmx"); err != nil || !term.IsTerminal(int(probe.Fd())) {
|
||||
t.Skip("no pseudo-terminal here")
|
||||
} else {
|
||||
probe.Close()
|
||||
}
|
||||
testLogger(t)
|
||||
r, specs := screenRunnerFor(t, "--quiet")
|
||||
asked := make(chan string, 2)
|
||||
ask := func(user string, generate bool) ([]byte, error) {
|
||||
asked <- user
|
||||
return []byte("from-the-screen"), nil
|
||||
}
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
defer close(started)
|
||||
r.Start(specs("--preset", "edge:path="+t.TempDir()+",to=127.0.0.1:9,pin=sha256//"+strings.Repeat("A", 43)+"=,user=on-screen,password_file=/dev/ptmx"), ask)
|
||||
}()
|
||||
select {
|
||||
case user := <-asked:
|
||||
if user != "on-screen" {
|
||||
t.Fatalf("asked for %s", user)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the terminal was asked, not the screen")
|
||||
}
|
||||
<-started
|
||||
r.close()
|
||||
go authz.ReadPassword("/dev/ptmx", "after-the-screen", false)
|
||||
select {
|
||||
case user := <-asked:
|
||||
t.Fatalf("the closed screen was asked for %s", user)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// A run on screen has no status reporter, whatever the file says: the bar
|
||||
// shows the counts, and a rerun keeps no cancel for one
|
||||
func TestARunOnScreenHasNoStatusReporter(t *testing.T) {
|
||||
r, specs := screenRunnerFor(t, "--status_reporter=true")
|
||||
cfg, err := r.config(specs("--source", "null", "--sink", "null"))
|
||||
if err != nil || cfg.StatusReporter {
|
||||
t.Fatalf("status reporter %v: %v", cfg != nil && cfg.StatusReporter, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The state counts each pipeline's entries in and filtered by its flow apart
|
||||
// from those its sinks dropped
|
||||
func TestTheStateCountsInFilteredAndSinkDrops(t *testing.T) {
|
||||
testLogger(t)
|
||||
r, specs := screenRunnerFor(t, "--quiet")
|
||||
if _, err := r.Start(specs("--source", "random:interval_ms=1", "--filter", "exclude:patterns=.", "--sink", "null"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(10 * time.Millisecond) {
|
||||
c := r.State().Counts["p"]
|
||||
if c.In > 0 && c.Filtered > 0 && c.SinksDropped == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("counts %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A run's entries reach its tap. A rerun replaces what runs as a reload does:
|
||||
// pipelines that do not build leave the earlier ones running, with their tap;
|
||||
// a start that fails once those stopped leaves nothing.
|
||||
func TestARerunKeepsWhatRunsUntilTheNewPipelinesBuild(t *testing.T) {
|
||||
testLogger(t)
|
||||
r, specs := screenRunnerFor(t, "--quiet")
|
||||
tap, err := r.Start(specs("--source", "random:interval_ms=1", "--sink", "null"), nil)
|
||||
if err != nil || tap == nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
select {
|
||||
case rec := <-tap.Records():
|
||||
if rec.Pipeline != "p" || rec.Stage != "" {
|
||||
t.Fatalf("%+v", rec)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no entry reached the run's tap")
|
||||
}
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
bad := fmt.Sprintf("tcp:host=127.0.0.1,port=9,tls.enabled=true,tls.cert_file=%s.pem,tls.key_file=%s.key", missing, missing)
|
||||
if kept, err := r.Start(specs("--source", "null", "--sink", bad), nil); kept != tap || err == nil || len(r.State().Counts) != 1 {
|
||||
t.Fatalf("a rerun that does not build: %v, kept %v", err, kept == tap)
|
||||
}
|
||||
held, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
taken := fmt.Sprintf("tcp:host=127.0.0.1,port=%d", held.Addr().(*net.TCPAddr).Port)
|
||||
if kept, err := r.Start(specs("--source", "null", "--sink", taken), nil); kept != nil || err == nil || len(r.State().Counts) != 0 {
|
||||
t.Fatalf("a rerun that does not start: %v, %v", err, r.State())
|
||||
}
|
||||
}
|
||||
|
||||
// A run's tap takes what the flow drops from its first entry, before a view
|
||||
// says whether its point shows drops
|
||||
func TestARunsTapTakesDropsFromItsStart(t *testing.T) {
|
||||
testLogger(t)
|
||||
r, specs := screenRunnerFor(t, "--quiet")
|
||||
tap, err := r.Start(specs("--source", "random:interval_ms=1", "--filter", "exclude:patterns=.", "--sink", "null"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
select {
|
||||
case rec := <-tap.Records():
|
||||
if rec.Stage != "filters" {
|
||||
t.Fatalf("%+v", rec)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no dropped entry reached the run's tap")
|
||||
}
|
||||
}
|
||||
|
||||
// lw's log, written to the terminal, shows in the screen's lw stream instead;
|
||||
// written elsewhere, it stays there
|
||||
func TestLwsLogShowsOnTheScreen(t *testing.T) {
|
||||
null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer null.Close()
|
||||
stderr := os.Stderr
|
||||
os.Stderr = null
|
||||
elsewhere := screenLog(t)
|
||||
os.Stderr = stderr
|
||||
if elsewhere != nil {
|
||||
t.Fatal("an lw stream for a log written to a file")
|
||||
}
|
||||
onTerminal(t, &os.Stderr)
|
||||
log := screenLog(t)
|
||||
if log == nil {
|
||||
t.Fatal("no lw stream for a log on the terminal")
|
||||
}
|
||||
select {
|
||||
case rec := <-log.Records():
|
||||
if rec.Event.Entry.Source != "lw" || rec.Event.Entry.Message == "" {
|
||||
t.Fatalf("%+v", rec)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("lw's log did not reach its stream")
|
||||
}
|
||||
}
|
||||
|
||||
// screenLog is the lw stream of a run whose log writes info to stderr
|
||||
func screenLog(t *testing.T) *core.Tap {
|
||||
t.Helper()
|
||||
was := logger
|
||||
logger = nil
|
||||
r, specs := screenRunnerFor(t, "--logging.output=stderr", "--logging.level=info")
|
||||
t.Cleanup(func() { r.close(); logger = was })
|
||||
if _, err := r.Start(specs("--source", "null", "--sink", "null"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r.State().Log
|
||||
}
|
||||
Reference in new issue
Block a user