v0.33.0 tui tap for live view and console enhancements

This commit is contained in:
lixen committed 2026-10-10 21:32:08 -04:00
1 parent a1e9d21d06
commit 87f5576ccf
41 files changed
+4606 -449

No files matched your search

+10 -16
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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)
}
+216
View File
@@ -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
}
+309
View File
@@ -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
}