v0.19.0 use updated config and toml packages, dep update, minor fixes
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
b8982e4e44
commit
cb0da23070
22 files changed
+692
-232
No files matched your search
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user