Files
lixen a63b512733
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s
v0.36.0 doc sweep update
2026-10-14 11:33:04 -04:00

276 lines
7.3 KiB
Go

// Command lw is LogWisp: it runs the pipelines its file, LOGWISP_ variables
// and flags describe, rebuilt on SIGHUP, SIGUSR1 or an auto_reload, or
// composes them on screen with --tui. Its auth, tls, preset and config commands
// manage credentials and certificates, print a preset's pipeline as TOML and
// write the annotated configuration file.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"reflect"
"strings"
"syscall"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/tui"
"github.com/lixenwraith/logwisp/internal/version"
"github.com/lixenwraith/log"
"golang.org/x/term"
)
var logger *log.Logger
func main() {
inv, err := parseCommandLine(os.Args[1:])
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(2) // every command-line error is a usage error
case inv.command != nil:
os.Exit(inv.command.run(inv.args, os.Stdout, os.Stderr))
case inv.help:
printHelp(os.Stdout)
os.Exit(0)
case inv.on["version"]:
fmt.Println(version.String())
os.Exit(0)
case inv.on["schema"]:
out := json.NewEncoder(os.Stdout)
out.SetIndent("", " ")
if err := out.Encode(compose.NewSchema()); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if inv.on["tui"] {
if code, exit := composeOnScreen(&inv); exit {
os.Exit(code)
}
}
// Emulates nohup
signal.Ignore(syscall.SIGHUP)
manager, err := config.Load(inv.load)
if err != nil {
os.Exit(loadFailed(err))
}
defer manager.Close()
cfg, err := manager.Snapshot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Failed to read config: %v\n", err)
os.Exit(1)
}
InitOutputHandler(cfg.Quiet)
if inv.on["check"] {
os.Exit(checkConfig(cfg))
}
if inv.on["dump"] {
os.Exit(dumpConfig(cfg, os.Stdout))
}
if err := initializeLogger(cfg); err != nil {
FatalError(1, "Failed to initialize logger: %v\n", err)
}
defer shutdownLogger()
if err := logger.Start(); err != nil {
FatalError(1, "Failed to start logger: %v\n", err)
}
configFile := cfg.ConfigFile
if _, err := os.Stat(configFile); err != nil {
configFile = "none" // flags, environment and built-in defaults only
}
logger.Info("msg", "LogWisp starting",
"version", version.String(),
"config_file", configFile,
"log_output", cfg.Logging.Output,
"status_reporter", cfg.StatusReporter,
"auto_reload", cfg.ConfigAutoReload)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
if err != nil {
logger.Error("msg", "Failed to initialize service", "error", err)
shutdownLogger()
os.Exit(1)
}
if !cfg.Quiet && readsStdin(cfg) && term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprintln(os.Stderr, "lw: reading standard input; Ctrl-D ends it (lw --help for usage)")
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
var configChanges <-chan string
if cfg.ConfigAutoReload {
configChanges = manager.Watch()
logger.Info("msg", "Config auto-reload enabled", "config_file", cfg.ConfigFile)
} else {
logger.Info("msg", "Config auto-reload disabled")
}
defer func() {
logger.Info("msg", "Shutdown initiated")
if statusReporterCancel != nil {
statusReporterCancel()
}
if svc != nil {
svc.Shutdown()
}
manager.Close()
logger.Info("msg", "Shutdown complete")
}()
// Closed when every pipeline's input ended (stdin); nil while none runs
done := svc.Done()
reload := func(fromDisk bool) {
var next *config.Config
var err error
if fromDisk {
next, err = manager.Reload()
} else {
next, err = manager.Snapshot()
}
if err != nil {
logger.Error("msg", "Failed to read reload configuration", "error", oneLine(err), "action", "keeping current service")
return
}
// Watch events are hints and may be duplicated or concern startup-only
// settings. Signals always rebuild, including for certificate rotation.
if !fromDisk && cfg.StatusReporter == next.StatusReporter && reflect.DeepEqual(cfg.Pipelines, next.Pipelines) {
return
}
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel, nil)
switch {
case err == nil:
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
done = svc.Done()
case errors.Is(err, errServiceStopped):
done = nil // the stopped service's pipelines did not reach the end of input
}
}
logger.Info("msg", "Application started, waiting for signals or config changes")
for {
select {
case sig := <-sigChan:
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
reload(true)
} else {
logger.Info("msg", "Shutdown signal received", "signal", sig)
cancel()
}
case event, ok := <-configChanges:
if !ok {
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
configChanges = nil
continue
}
if collectConfigChanges(event, configChanges) {
reload(false)
}
case <-done:
logger.Info("msg", "Every pipeline finished: its input ended")
return
case <-ctx.Done():
return
}
}
}
func shutdownLogger() {
if logger != nil {
if err := logger.Shutdown(core.LoggerShutdownTimeout); err != nil {
// Best effort - can't log the shutdown error
Error("Logger shutdown error: %v\n", err)
}
}
}
// readsStdin reports a console source: typed into, it waits for the keyboard
func readsStdin(cfg *config.Config) bool {
for _, p := range cfg.Pipelines {
for _, src := range p.PluginSources {
if src.Type == "console" {
return true
}
}
}
return false
}
// oneLine is an error for a log record, which is a line: the problems
// validation joins, "; " between them
func oneLine(err error) string { return strings.ReplaceAll(err.Error(), "\n", "; ") }
// loadFailed reports a configuration that did not load and returns lw's exit
// code: a named file that is missing is a usage error
func loadFailed(err error) int {
if errors.Is(err, config.ErrConfigNotFound) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return 2
}
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
return 1
}
// composeOnScreen is lw --tui: it starts from the pipelines lw would load, or
// 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)
return 1, true
}
manager, err := config.Load(inv.load)
if err != nil {
return loadFailed(err), true
}
cfg, err := manager.Snapshot()
builtIn := manager.BuiltIn()
manager.Close()
start := &compose.Composition{}
if err == nil && !builtIn {
start, err = compose.FromConfig(cfg)
}
if err != nil {
return fail(err)
}
runner := &screenRunner{load: inv.load}
res, err := tui.Run(start, cfg.Color, runner)
runner.close()
switch {
case err != nil:
return fail(err)
case res.Exit == tui.Print:
fmt.Print(res.Output)
case res.Exit == tui.Start:
inv.load.Specs = config.PipelineSpecs(res.Pipelines)
return 0, false
}
return 0, true
}