v0.19.0 use updated config and toml packages, dep update, minor fixes
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-29 00:20:53 -04:00
1 parent b8982e4e44
commit cb0da23070
22 files changed
+692 -232

No files matched your search

+8 -17
View File
@@ -35,6 +35,7 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
}
if err := svc.Start(); err != nil {
svc.Shutdown()
return nil, nil, fmt.Errorf("failed to start service pipelines: %w", err)
}
@@ -47,24 +48,14 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
}
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle
func handleReload(ctx context.Context, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
logger.Info("msg", "Starting configuration hot reload")
// Get updated config from the lixenwraith/config manager
lcfg := config.GetConfigManager()
if lcfg == nil {
err := fmt.Errorf("config manager not available for reload")
logger.Error("msg", "Reload failed", "error", err)
if err := config.ValidateConfig(newCfg); err != nil {
logger.Error("msg", "Invalid reload configuration, keeping old service running", "error", err)
return nil, nil, nil, err
}
updatedCfgStruct, err := lcfg.AsStruct()
if err != nil {
logger.Error("msg", "Failed to get updated config for reload", "error", err, "action", "keeping current configuration")
return nil, nil, nil, err
}
newCfg := updatedCfgStruct.(*config.Config)
// 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)
@@ -74,6 +65,9 @@ func handleReload(ctx context.Context, oldSvc *service.Service, statusCancel con
}
// Gracefully shut down the old service
if statusCancel != nil {
statusCancel()
}
if oldSvc != nil {
logger.Info("msg", "Shutting down old service before activating new one")
oldSvc.Shutdown()
@@ -81,15 +75,12 @@ func handleReload(ctx context.Context, oldSvc *service.Service, statusCancel con
// 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("failed to start new service: %w", err)
}
// Manage status reporter lifecycle
if statusCancel != nil {
statusCancel()
}
var newStatusCancel context.CancelFunc
if newCfg.StatusReporter {
newStatusCancel = startStatusReporter(ctx, newService)
+3 -7
View File
@@ -14,7 +14,7 @@ Usage:
logwisp help | -h | --help
logwisp --version
Any configuration key is settable as a flag using its TOML path:
Scalar configuration keys are settable as flags using their TOML paths:
--<path>=<value> e.g. --logging.level=debug
Common options:
@@ -27,13 +27,9 @@ Logging:
--logging.output=<mode> file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error
--logging.file.directory=<path>
--logging.console.target=<target> stdout|stderr|split
Pipelines (N = 0-based index):
--pipelines.N.name=<name>
--pipelines.N.plugin_sources.N.type=<type> file|console|random|null
--pipelines.N.plugin_sinks.N.type=<type> console|file|http|tcp|null
--pipelines.N.flow.filters.N.patterns='["ERROR","WARN"]'
Pipelines, plugins and filters are configured in TOML; array-indexed CLI and
environment overrides are unsupported. -- ends option parsing.
Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
+40 -22
View File
@@ -2,10 +2,11 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"reflect"
"syscall"
"logwisp/internal/config"
@@ -29,15 +30,21 @@ func main() {
handleHelp(os.Args[1:])
// Load configuration with automatic CLI parsing
cfg, err := config.Load(os.Args[1:])
manager, err := config.Load(os.Args[1:])
if err != nil {
if strings.Contains(err.Error(), "not found") && cfg != nil && cfg.ConfigFile != "" {
fmt.Fprintf(os.Stderr, "Error: Config file not found: %s\n", cfg.ConfigFile)
if errors.Is(err, config.ErrConfigNotFound) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
os.Exit(1)
}
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)
}
// Initialize output handler
InitOutputHandler(cfg.Quiet)
@@ -84,9 +91,8 @@ func main() {
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
var configChanges <-chan string
lcfg := config.GetConfigManager()
if cfg.ConfigAutoReload && lcfg != nil {
configChanges = lcfg.Watch()
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")
@@ -101,13 +107,34 @@ func main() {
if svc != nil {
svc.Shutdown()
}
if lcfg != nil {
lcfg.StopAutoUpdate()
}
manager.Close()
logger.Info("msg", "Shutdown complete")
// Deferred logger shutdown will run after this
}()
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", 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)
if err == nil {
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
}
}
// --- 4. Main Application Event Loop ---
logger.Info("msg", "Application started, waiting for signals or config changes")
for {
@@ -115,12 +142,7 @@ func main() {
case sig := <-sigChan:
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
if err == nil {
svc = newSvc
cfg = newCfg
statusReporterCancel = newStatusCancel
}
reload(true)
} else {
logger.Info("msg", "Shutdown signal received", "signal", sig)
cancel() // Trigger service shutdown via context
@@ -132,12 +154,8 @@ func main() {
configChanges = nil // Stop selecting on this channel
continue
}
logger.Info("msg", "Configuration file change detected, triggering reload", "event", event)
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
if err == nil {
svc = newSvc
cfg = newCfg
statusReporterCancel = newStatusCancel
if collectConfigChanges(event, configChanges) {
reload(false)
}
case <-ctx.Done():
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"strings"
lconfig "github.com/lixenwraith/config"
)
// collectConfigChanges folds the queued hints into one snapshot read. Error
// events describe an unpublished edit and must not restart a healthy service.
func collectConfigChanges(first string, changes <-chan string) bool {
changed := reportConfigEvent(first)
// Bound the drain to the currently queued events so a busy writer cannot
// starve signal handling. Later events remain queued for the next iteration.
for remaining := len(changes); remaining > 0; remaining-- {
select {
case event, ok := <-changes:
if !ok {
return changed
}
changed = reportConfigEvent(event) || changed
default:
return changed
}
}
return changed
}
func reportConfigEvent(event string) bool {
if event == lconfig.EventFileDeleted || event == lconfig.EventPermissionsChanged ||
event == lconfig.EventReloadTimeout || strings.HasPrefix(event, lconfig.EventReloadError+":") {
logger.Warn("msg", "Configuration update not applied", "event", event, "action", "keeping current service")
return false
}
return event != ""
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"logwisp/internal/config"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
func testLogger(t *testing.T) {
t.Helper()
logger = log.NewLogger()
t.Cleanup(func() { logger = nil })
}
func TestWatchErrorsDoNotTriggerReloadAndChangesAreCoalesced(t *testing.T) {
testLogger(t)
for _, event := range []string{lconfig.EventFileDeleted, lconfig.EventPermissionsChanged, lconfig.EventReloadTimeout, lconfig.EventReloadError + ":invalid TOML"} {
if collectConfigChanges(event, nil) {
t.Fatalf("error event triggered service restart: %s", event)
}
}
changes := make(chan string, 3)
changes <- "pipelines"
changes <- "status_reporter"
changes <- lconfig.EventReloadError + ":partial edit"
close(changes)
if !collectConfigChanges("logging.level", changes) || len(changes) != 0 {
t.Fatal("pending path changes were not folded into a single reload")
}
if collectConfigChanges("", changes) {
t.Fatal("closed channel triggered a reload")
}
}
func loadTestConfig(t *testing.T, path string) *config.Config {
t.Helper()
for _, entry := range os.Environ() {
key, value, _ := strings.Cut(entry, "=")
if strings.HasPrefix(key, "LOGWISP_") {
t.Setenv(key, value)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
}
}
m, err := config.Load([]string{"-c", path, "--quiet", "--status_reporter=false"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(m.Close)
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) {
testLogger(t)
path, err := filepath.Abs(filepath.Join("..", "..", "config", "logwisp.toml"))
if err != nil {
t.Fatal(err)
}
cfg := loadTestConfig(t, path)
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)
if err != nil {
t.Fatal(err)
}
defer svc.Shutdown()
pl, err := svc.GetPipeline("default")
if err != nil || len(pl.Sources) != 1 || len(pl.Sinks) != 1 {
t.Fatalf("sample plugins failed to build: %v", err)
}
svc.Shutdown() // A later reload failure or process exit can close it again.
}
func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
testLogger(t)
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("[logging]\nlevel = \"info\"\n"), 0600); err != nil {
t.Fatal(err)
}
for _, mode := range []string{"empty-pipelines", "duplicate-names", "fractional-plugin-integer", "plugin-overflow"} {
t.Run(mode, func(t *testing.T) {
cfg := loadTestConfig(t, path)
switch mode {
case "empty-pipelines":
cfg.Pipelines = nil
case "duplicate-names":
cfg.Pipelines = append(cfg.Pipelines, cfg.Pipelines[0])
case "fractional-plugin-integer":
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = 1.5
case "plugin-overflow":
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0)
}
cancelled := false
svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true })
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)
}
})
}
}