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
+689
-229
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
name: Test
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- run: go test -timeout 60s -race ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go build ./cmd/logwisp
|
||||||
|
- run: GOOS=freebsd GOARCH=amd64 go build -o /tmp/logwisp-freebsd ./cmd/logwisp
|
||||||
@@ -35,6 +35,7 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := svc.Start(); err != nil {
|
if err := svc.Start(); err != nil {
|
||||||
|
svc.Shutdown()
|
||||||
return nil, nil, fmt.Errorf("failed to start service pipelines: %w", err)
|
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
|
// 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")
|
logger.Info("msg", "Starting configuration hot reload")
|
||||||
|
|
||||||
// Get updated config from the lixenwraith/config manager
|
if err := config.ValidateConfig(newCfg); err != nil {
|
||||||
lcfg := config.GetConfigManager()
|
logger.Error("msg", "Invalid reload configuration, keeping old service running", "error", err)
|
||||||
if lcfg == nil {
|
|
||||||
err := fmt.Errorf("config manager not available for reload")
|
|
||||||
logger.Error("msg", "Reload failed", "error", err)
|
|
||||||
return nil, nil, nil, 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
|
// Bootstrap a new service to ensure it's valid before touching the old one
|
||||||
logger.Debug("msg", "Bootstrapping new service with updated config")
|
logger.Debug("msg", "Bootstrapping new service with updated config")
|
||||||
newService, err := bootstrapService(ctx, newCfg)
|
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
|
// Gracefully shut down the old service
|
||||||
|
if statusCancel != nil {
|
||||||
|
statusCancel()
|
||||||
|
}
|
||||||
if oldSvc != nil {
|
if oldSvc != nil {
|
||||||
logger.Info("msg", "Shutting down old service before activating new one")
|
logger.Info("msg", "Shutting down old service before activating new one")
|
||||||
oldSvc.Shutdown()
|
oldSvc.Shutdown()
|
||||||
@@ -81,15 +75,12 @@ func handleReload(ctx context.Context, oldSvc *service.Service, statusCancel con
|
|||||||
|
|
||||||
// Start the new service
|
// Start the new service
|
||||||
if err := newService.Start(); err != nil {
|
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)
|
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)
|
return nil, nil, nil, fmt.Errorf("failed to start new service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage status reporter lifecycle
|
// Manage status reporter lifecycle
|
||||||
if statusCancel != nil {
|
|
||||||
statusCancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
var newStatusCancel context.CancelFunc
|
var newStatusCancel context.CancelFunc
|
||||||
if newCfg.StatusReporter {
|
if newCfg.StatusReporter {
|
||||||
newStatusCancel = startStatusReporter(ctx, newService)
|
newStatusCancel = startStatusReporter(ctx, newService)
|
||||||
|
|||||||
+3
-7
@@ -14,7 +14,7 @@ Usage:
|
|||||||
logwisp help | -h | --help
|
logwisp help | -h | --help
|
||||||
logwisp --version
|
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
|
--<path>=<value> e.g. --logging.level=debug
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
@@ -27,13 +27,9 @@ Logging:
|
|||||||
--logging.output=<mode> file|stdout|stderr|split|all|none
|
--logging.output=<mode> file|stdout|stderr|split|all|none
|
||||||
--logging.level=<level> debug|info|warn|error
|
--logging.level=<level> debug|info|warn|error
|
||||||
--logging.file.directory=<path>
|
--logging.file.directory=<path>
|
||||||
--logging.console.target=<target> stdout|stderr|split
|
|
||||||
|
|
||||||
Pipelines (N = 0-based index):
|
Pipelines, plugins and filters are configured in TOML; array-indexed CLI and
|
||||||
--pipelines.N.name=<name>
|
environment overrides are unsupported. -- ends option parsing.
|
||||||
--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"]'
|
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
|
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
|
||||||
|
|||||||
+40
-22
@@ -2,10 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"reflect"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"logwisp/internal/config"
|
"logwisp/internal/config"
|
||||||
@@ -29,15 +30,21 @@ func main() {
|
|||||||
handleHelp(os.Args[1:])
|
handleHelp(os.Args[1:])
|
||||||
|
|
||||||
// Load configuration with automatic CLI parsing
|
// Load configuration with automatic CLI parsing
|
||||||
cfg, err := config.Load(os.Args[1:])
|
manager, err := config.Load(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") && cfg != nil && cfg.ConfigFile != "" {
|
if errors.Is(err, config.ErrConfigNotFound) {
|
||||||
fmt.Fprintf(os.Stderr, "Error: Config file not found: %s\n", cfg.ConfigFile)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
|
||||||
os.Exit(1)
|
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
|
// Initialize output handler
|
||||||
InitOutputHandler(cfg.Quiet)
|
InitOutputHandler(cfg.Quiet)
|
||||||
@@ -84,9 +91,8 @@ func main() {
|
|||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
|
||||||
|
|
||||||
var configChanges <-chan string
|
var configChanges <-chan string
|
||||||
lcfg := config.GetConfigManager()
|
if cfg.ConfigAutoReload {
|
||||||
if cfg.ConfigAutoReload && lcfg != nil {
|
configChanges = manager.Watch()
|
||||||
configChanges = lcfg.Watch()
|
|
||||||
logger.Info("msg", "Config auto-reload enabled", "config_file", cfg.ConfigFile)
|
logger.Info("msg", "Config auto-reload enabled", "config_file", cfg.ConfigFile)
|
||||||
} else {
|
} else {
|
||||||
logger.Info("msg", "Config auto-reload disabled")
|
logger.Info("msg", "Config auto-reload disabled")
|
||||||
@@ -101,13 +107,34 @@ func main() {
|
|||||||
if svc != nil {
|
if svc != nil {
|
||||||
svc.Shutdown()
|
svc.Shutdown()
|
||||||
}
|
}
|
||||||
if lcfg != nil {
|
manager.Close()
|
||||||
lcfg.StopAutoUpdate()
|
|
||||||
}
|
|
||||||
logger.Info("msg", "Shutdown complete")
|
logger.Info("msg", "Shutdown complete")
|
||||||
// Deferred logger shutdown will run after this
|
// 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 ---
|
// --- 4. Main Application Event Loop ---
|
||||||
logger.Info("msg", "Application started, waiting for signals or config changes")
|
logger.Info("msg", "Application started, waiting for signals or config changes")
|
||||||
for {
|
for {
|
||||||
@@ -115,12 +142,7 @@ func main() {
|
|||||||
case sig := <-sigChan:
|
case sig := <-sigChan:
|
||||||
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
|
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
|
||||||
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
|
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
|
||||||
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
|
reload(true)
|
||||||
if err == nil {
|
|
||||||
svc = newSvc
|
|
||||||
cfg = newCfg
|
|
||||||
statusReporterCancel = newStatusCancel
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("msg", "Shutdown signal received", "signal", sig)
|
logger.Info("msg", "Shutdown signal received", "signal", sig)
|
||||||
cancel() // Trigger service shutdown via context
|
cancel() // Trigger service shutdown via context
|
||||||
@@ -132,12 +154,8 @@ func main() {
|
|||||||
configChanges = nil // Stop selecting on this channel
|
configChanges = nil // Stop selecting on this channel
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logger.Info("msg", "Configuration file change detected, triggering reload", "event", event)
|
if collectConfigChanges(event, configChanges) {
|
||||||
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
|
reload(false)
|
||||||
if err == nil {
|
|
||||||
svc = newSvc
|
|
||||||
cfg = newCfg
|
|
||||||
statusReporterCancel = newStatusCancel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-ctx.Done():
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -12,9 +12,9 @@
|
|||||||
### available on every network source and sink; see the [...tls] blocks below,
|
### available on every network source and sink; see the [...tls] blocks below,
|
||||||
### and the [...auth] blocks to authorize peers by certificate identity.
|
### and the [...auth] blocks to authorize peers by certificate identity.
|
||||||
###
|
###
|
||||||
### Environment overrides are currently read WITHOUT the LOGWISP_ prefix
|
### Environment overrides use the LOGWISP_ prefix
|
||||||
### (QUIET, LOGGING_LEVEL, ...). LOGWISP_CONFIG_FILE and LOGWISP_CONFIG_DIR
|
### (LOGWISP_QUIET, LOGWISP_LOGGING_LEVEL, ...). Bare names are ignored.
|
||||||
### are the exceptions and do carry it. Array-indexed paths such as
|
### LOGWISP_CONFIG_FILE and LOGWISP_CONFIG_DIR select the file. Paths such as
|
||||||
### pipelines.0.name cannot be set from the CLI or environment at all.
|
### pipelines.0.name cannot be set from the CLI or environment at all.
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
quiet = false # Suppress console output
|
quiet = false # Suppress console output
|
||||||
status_reporter = true # Periodic status logging (30s, DEBUG level)
|
status_reporter = true # Periodic status logging (30s, DEBUG level)
|
||||||
auto_reload = false # Config auto-reload on file change
|
auto_reload = false # Watch file; read at startup (signals always reread it)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
### Logging (LogWisp's internal operational logging)
|
### Logging (LogWisp's internal operational logging)
|
||||||
|
|||||||
+7
-1
@@ -164,7 +164,9 @@ only for a peer that was already admitted. See the
|
|||||||
|
|
||||||
Reload (signal or file watch) rebuilds the entire service:
|
Reload (signal or file watch) rebuilds the entire service:
|
||||||
|
|
||||||
1. Re-read the config through the config manager.
|
1. Signals reread the selected file; watches consume a newly published snapshot.
|
||||||
|
The application owns its manager and detached snapshots; no global mutable
|
||||||
|
target is shared with the running service. Validate every candidate again.
|
||||||
2. Build a **new** service from it. If construction fails, the old service keeps
|
2. Build a **new** service from it. If construction fails, the old service keeps
|
||||||
running untouched.
|
running untouched.
|
||||||
3. Shut the old service down, start the new one, and restart the status
|
3. Shut the old service down, start the new one, and restart the status
|
||||||
@@ -173,6 +175,10 @@ Reload (signal or file watch) rebuilds the entire service:
|
|||||||
Because this is a full rebuild, listening sockets close and reopen and all
|
Because this is a full rebuild, listening sockets close and reopen and all
|
||||||
clients are disconnected. Application logging is configured once at startup and
|
clients are disconnected. Application logging is configured once at startup and
|
||||||
is **not** re-applied on reload.
|
is **not** re-applied on reload.
|
||||||
|
Watch errors do not rebuild services. Queued changes are combined, and an unchanged
|
||||||
|
pipeline/status snapshot is skipped. Signals always rebuild for certificate rotation.
|
||||||
|
Failed candidates release their session workers. Once the old service has stopped,
|
||||||
|
a bind/start failure requires another corrected reload or process restart.
|
||||||
|
|
||||||
## Resource Management
|
## Resource Management
|
||||||
|
|
||||||
|
|||||||
+11
-19
@@ -26,20 +26,15 @@ Any scalar configuration key is settable as a flag using its TOML path:
|
|||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `-c <path>` | Configuration file | `./logwisp.toml` |
|
| `-c <path>` | Configuration file | `./logwisp.toml` |
|
||||||
| `--config=<path>` | Configuration file (equals form only) | `./logwisp.toml` |
|
| `--config <path>` / `--config=<path>` | Configuration file | `./logwisp.toml` |
|
||||||
| `--quiet` | Suppress all application output | `false` |
|
| `--quiet` | Suppress all application output | `false` |
|
||||||
| `--status_reporter=<bool>` | Periodic status logging | `true` |
|
| `--status_reporter=<bool>` | Periodic status logging | `true` |
|
||||||
| `--auto_reload=<bool>` | Reload config when the file changes | `false` |
|
| `--auto_reload=<bool>` | Reload config when the file changes | `false` |
|
||||||
| `--version` | Print version and exit | — |
|
| `--version` | Print version and exit | — |
|
||||||
| `-h`, `--help`, `help` | Print usage and exit | — |
|
| `-h`, `--help`, `help` | Print usage and exit | — |
|
||||||
|
|
||||||
> `--config <path>` with a space is not recognized. The path resolver
|
The last file-selection flag wins. `-c=<path>` also works. A missing or empty
|
||||||
> understands `-c <path>` and `--config=<path>` only; the space form is treated
|
path returns an error, and `--` ends option parsing.
|
||||||
> as an unknown flag, warned about, and ignored, after which LogWisp silently
|
|
||||||
> falls back to `./logwisp.toml`.
|
|
||||||
>
|
|
||||||
> `-c` as the final argument, with no path after it, crashes with an index
|
|
||||||
> panic rather than reporting a usage error.
|
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
@@ -74,22 +69,19 @@ that the current loader does not implement.
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Configuration paths map to environment variables by replacing `.` with `_` and
|
Configuration paths map to environment variables by replacing `.` with `_`,
|
||||||
uppercasing:
|
uppercasing and adding `LOGWISP_`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export QUIET=true
|
export LOGWISP_QUIET=true
|
||||||
export LOGGING_LEVEL=debug
|
export LOGWISP_LOGGING_LEVEL=debug
|
||||||
export LOGGING_FILE_DIRECTORY=/var/log/logwisp
|
export LOGWISP_LOGGING_FILE_DIRECTORY=/var/log/logwisp
|
||||||
```
|
```
|
||||||
|
|
||||||
> The `LOGWISP_` prefix is **not** currently applied to these — see
|
Bare names used by older versions are now ignored. Rename them to the prefixed
|
||||||
> [Configuration](configuration.md#environment-variables). Bare names like
|
forms when upgrading; see [Configuration](configuration.md#environment-variables).
|
||||||
> `QUIET` are what LogWisp actually reads, which is worth knowing both to make
|
|
||||||
> overrides work and to avoid accidental collisions.
|
|
||||||
|
|
||||||
The two variables that do carry the prefix are read directly by the path
|
The path resolver reads these variables directly:
|
||||||
resolver:
|
|
||||||
|
|
||||||
| Variable | Effect |
|
| Variable | Effect |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
|||||||
+37
-28
@@ -20,20 +20,19 @@ limit and formatter) disappears entirely.
|
|||||||
|
|
||||||
The path is resolved before any other configuration is read:
|
The path is resolved before any other configuration is read:
|
||||||
|
|
||||||
1. `-c <path>` on the command line
|
1. `-c <path>`, `--config <path>`, or their `=<path>` forms (last one wins)
|
||||||
2. `--config=<path>` on the command line
|
2. `$LOGWISP_CONFIG_FILE`, joined onto `$LOGWISP_CONFIG_DIR` when both are set
|
||||||
3. `$LOGWISP_CONFIG_FILE`, joined onto `$LOGWISP_CONFIG_DIR` when both are set
|
3. `$LOGWISP_CONFIG_DIR/logwisp.toml`
|
||||||
4. `$LOGWISP_CONFIG_DIR/logwisp.toml`
|
4. `~/.config/logwisp/logwisp.toml`, if it exists
|
||||||
5. `~/.config/logwisp/logwisp.toml`, if it exists
|
5. `./logwisp.toml`
|
||||||
6. `./logwisp.toml`
|
|
||||||
|
|
||||||
Missing file behaviour differs by how it was chosen. An explicitly requested
|
Missing file behaviour differs by how it was chosen. An explicitly requested
|
||||||
file that does not exist is a fatal error (exit code 2); a missing discovered
|
file that does not exist is a fatal error (exit code 2); a missing discovered
|
||||||
default is not an error, and LogWisp starts on built-in defaults.
|
default is not an error, and LogWisp starts on built-in defaults.
|
||||||
|
|
||||||
> `--config <path>` with a space is **not** recognized as a config path. It is
|
File-selection flags are consumed before schema overrides are parsed. A missing
|
||||||
> parsed as an unknown flag, warned about, and ignored — LogWisp then silently
|
or empty path is a usage error; `--` stops option parsing. The selected path is
|
||||||
> falls back to `./logwisp.toml`. Use `-c <path>` or `--config=<path>`.
|
runtime metadata and cannot be redirected by a `config_file` key inside the file.
|
||||||
|
|
||||||
## Global Settings
|
## Global Settings
|
||||||
|
|
||||||
@@ -186,28 +185,23 @@ suppress them, and they reach every sink in the pipeline.
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Environment overrides are derived from the TOML path: `.` becomes `_` and the
|
Environment overrides are derived from the TOML path: `.` becomes `_`, the
|
||||||
result is uppercased.
|
result is uppercased, and `LOGWISP_` is prepended.
|
||||||
|
|
||||||
| TOML path | Environment variable |
|
| TOML path | Environment variable |
|
||||||
|-----------|---------------------|
|
|-----------|---------------------|
|
||||||
| `quiet` | `QUIET` |
|
| `quiet` | `LOGWISP_QUIET` |
|
||||||
| `status_reporter` | `STATUS_REPORTER` |
|
| `status_reporter` | `LOGWISP_STATUS_REPORTER` |
|
||||||
| `logging.level` | `LOGGING_LEVEL` |
|
| `logging.level` | `LOGWISP_LOGGING_LEVEL` |
|
||||||
| `logging.file.directory` | `LOGGING_FILE_DIRECTORY` |
|
| `logging.file.directory` | `LOGWISP_LOGGING_FILE_DIRECTORY` |
|
||||||
|
|
||||||
> **The `LOGWISP_` prefix is not currently applied.** The configuration loader
|
Migration: the old custom transform accidentally read bare names such as `QUIET`
|
||||||
> requests it, but supplying a custom path-to-variable transform replaces the
|
and `LOGGING_LEVEL`. Rename those variables to their prefixed forms; bare names
|
||||||
> prefixing step rather than composing with it, so LogWisp reads bare
|
are now ignored. `LOGWISP_CONFIG_FILE` and `LOGWISP_CONFIG_DIR` still select the
|
||||||
> `QUIET`, `LOGGING_LEVEL`, and so on from the environment. Treat this as
|
file directly.
|
||||||
> current behaviour to be aware of — bare names like `QUIET` can collide with
|
|
||||||
> unrelated variables — rather than as intended design.
|
|
||||||
>
|
|
||||||
> The two exceptions are `LOGWISP_CONFIG_FILE` and `LOGWISP_CONFIG_DIR`, which
|
|
||||||
> are read directly by the path resolver and **do** carry the prefix.
|
|
||||||
|
|
||||||
Only scalar paths that exist in the configuration schema can be set this way.
|
Only scalar paths that exist in the configuration schema can be set this way.
|
||||||
Array elements cannot: `PIPELINES_0_NAME` has no effect.
|
Array elements cannot: `LOGWISP_PIPELINES_0_NAME` has no effect.
|
||||||
|
|
||||||
## Command-Line Overrides
|
## Command-Line Overrides
|
||||||
|
|
||||||
@@ -258,11 +252,21 @@ same port fail at listener bind time, when the pipeline starts.
|
|||||||
auto_reload = true
|
auto_reload = true
|
||||||
```
|
```
|
||||||
|
|
||||||
or send `SIGHUP` / `SIGUSR1`.
|
or send `SIGHUP` / `SIGUSR1`. Signals reread the selected file even when watching
|
||||||
|
is disabled and always rebuild, allowing certificate rotation without TOML edits.
|
||||||
|
CLI and environment overrides are captured at startup and keep their precedence.
|
||||||
|
|
||||||
Reload rebuilds the whole service: a new service is constructed from the new
|
Reload rebuilds the whole service: a new service is constructed from the new
|
||||||
configuration first, and only if that succeeds is the old one shut down. A
|
configuration first, and only if that succeeds is the old one shut down. Each
|
||||||
configuration error therefore leaves the running service untouched.
|
candidate is a detached snapshot and runs top-level validation again. Parse,
|
||||||
|
conversion, validation and construction errors leave the running service intact.
|
||||||
|
Listener bind/start failures occur after shutdown of the old service and can leave
|
||||||
|
the application without working pipelines; correct the file and signal again.
|
||||||
|
|
||||||
|
Watch errors (including deletion, permission changes and timeout) are logged
|
||||||
|
without rebuilding. Queued path changes are combined, and unchanged pipelines
|
||||||
|
and status settings do not trigger another rebuild. Removed file keys fall back
|
||||||
|
to the remaining sources on the next successful load.
|
||||||
|
|
||||||
| Reloaded | Not reloaded |
|
| Reloaded | Not reloaded |
|
||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
@@ -283,3 +287,8 @@ client is disconnected. Chain sinks reconnect on their own backoff schedule.
|
|||||||
| Boolean | `bool` | `true` / `false`, or a bare flag for `true` |
|
| Boolean | `bool` | `true` / `false`, or a bare flag for `true` |
|
||||||
| Array | `[]T` | Not settable outside the file |
|
| Array | `[]T` | Not settable outside the file |
|
||||||
| Table | struct | Nested path with `.` (flags) or `_` (environment) |
|
| Table | struct | Nested path with `.` (flags) or `_` (environment) |
|
||||||
|
|
||||||
|
Integer fields reject fractions, overflow and negative unsigned values. Non-finite
|
||||||
|
floats and integer-to-float precision loss are rejected too. This applies to plugin
|
||||||
|
maps when their constructors decode them. Only TOML configuration files are accepted;
|
||||||
|
JSON log formatting and network payloads are unaffected.
|
||||||
+1
-1
@@ -64,7 +64,7 @@ sudo mkdir -p /etc/logwisp
|
|||||||
sudo cp config/logwisp.toml /etc/logwisp/logwisp.toml
|
sudo cp config/logwisp.toml /etc/logwisp/logwisp.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
LogWisp searches, in order: `-c <path>`, `--config=<path>`,
|
LogWisp searches, in order: `-c <path>` / `--config <path>` (also `=<path>`),
|
||||||
`$LOGWISP_CONFIG_DIR`/`$LOGWISP_CONFIG_FILE`, `~/.config/logwisp/logwisp.toml`,
|
`$LOGWISP_CONFIG_DIR`/`$LOGWISP_CONFIG_FILE`, `~/.config/logwisp/logwisp.toml`,
|
||||||
`./logwisp.toml`. See [Configuration](configuration.md).
|
`./logwisp.toml`. See [Configuration](configuration.md).
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -45,9 +45,10 @@ or send a signal:
|
|||||||
kill -HUP $(pidof logwisp)
|
kill -HUP $(pidof logwisp)
|
||||||
```
|
```
|
||||||
|
|
||||||
Reload constructs a new service from the new configuration **before** tearing
|
Signals reread the selected TOML file even when `auto_reload` is disabled.
|
||||||
the old one down, so a broken configuration leaves the running service intact
|
Reload validates a detached snapshot and constructs a new service **before**
|
||||||
and logs the failure:
|
tearing the old one down, so invalid configuration leaves the running service
|
||||||
|
intact and logs the failure:
|
||||||
|
|
||||||
```
|
```
|
||||||
ERROR msg="Failed to bootstrap new service, keeping old service running" error=...
|
ERROR msg="Failed to bootstrap new service, keeping old service running" error=...
|
||||||
@@ -63,6 +64,9 @@ What reload does *not* do:
|
|||||||
construction, so rotation requires `SIGHUP`.
|
construction, so rotation requires `SIGHUP`.
|
||||||
|
|
||||||
Plan reloads on a busy relay the way you would plan a restart.
|
Plan reloads on a busy relay the way you would plan a restart.
|
||||||
|
Listener bind/start failures happen after the old service stops; these can leave
|
||||||
|
the application without working pipelines until a corrected configuration reloads.
|
||||||
|
File-watch errors do not restart services, and queued changes are combined.
|
||||||
|
|
||||||
### Checking a configuration
|
### Checking a configuration
|
||||||
|
|
||||||
@@ -264,8 +268,8 @@ and remember `http_chain` waits up to `flush_interval_ms`. For TLS problems see
|
|||||||
|
|
||||||
**Environment variable override has no effect**
|
**Environment variable override has no effect**
|
||||||
|
|
||||||
LogWisp currently reads these **without** the `LOGWISP_` prefix — `QUIET`,
|
LogWisp reads `LOGWISP_QUIET`, `LOGWISP_LOGGING_LEVEL`, and other prefixed
|
||||||
`LOGGING_LEVEL`, and so on. Array-indexed paths cannot be set from the
|
names. Bare names used by older versions must be renamed. Array-indexed paths cannot be set from the
|
||||||
environment or the command line at all.
|
environment or the command line at all.
|
||||||
|
|
||||||
## Security Operations
|
## Security Operations
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ module logwisp
|
|||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98
|
github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167
|
||||||
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3
|
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167 h1:WOB0DbCxGEXksprSpatdsHWeV2+koKDNc5OtNE3knJA=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167/go.mod h1:vGA41x85GxmJqYTCwg7cOlHUtqBh7AK/wPTRXuFQIYI=
|
||||||
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98 h1:VEFo2WsYgM5YYfzAfvXRKigCWr3zEz6h4M59N5TXMpk=
|
|
||||||
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98/go.mod h1:J9ydxY7he4Dz+S59xKs/kDQ2YAv+zOLF05b3Rr2/ogE=
|
|
||||||
github.com/lixenwraith/log v0.1.1-0.20260724174821-e688c5a07207 h1:EnDMcnFkgTwTGo1ojoJ85/b6aH3yxlBUAg6AefaBcrI=
|
|
||||||
github.com/lixenwraith/log v0.1.1-0.20260724174821-e688c5a07207/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc=
|
|
||||||
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 h1:5ojkxyuOKiUBRYhqPqKJS1lrqkjckYqYMNGBB49kdY0=
|
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 h1:5ojkxyuOKiUBRYhqPqKJS1lrqkjckYqYMNGBB49kdY0=
|
||||||
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc=
|
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc=
|
||||||
|
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY=
|
||||||
|
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -12,8 +12,8 @@ type Config struct {
|
|||||||
StatusReporter bool `toml:"status_reporter"`
|
StatusReporter bool `toml:"status_reporter"`
|
||||||
ConfigAutoReload bool `toml:"auto_reload"`
|
ConfigAutoReload bool `toml:"auto_reload"`
|
||||||
|
|
||||||
// Configuration file path
|
// Selected file path is runtime metadata, never a configurable override.
|
||||||
ConfigFile string `toml:"config_file"`
|
ConfigFile string `toml:"-"`
|
||||||
|
|
||||||
// Existing fields
|
// Existing fields
|
||||||
Logging *LogConfig `toml:"logging"`
|
Logging *LogConfig `toml:"logging"`
|
||||||
|
|||||||
+93
-91
@@ -12,84 +12,85 @@ import (
|
|||||||
lconfig "github.com/lixenwraith/config"
|
lconfig "github.com/lixenwraith/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configManager holds the global instance of the configuration manager
|
// ErrConfigNotFound identifies an explicitly requested missing configuration file.
|
||||||
var configManager *lconfig.Config
|
var ErrConfigNotFound = lconfig.ErrConfigNotFound
|
||||||
|
|
||||||
// Load is the single entry point for loading all application configuration
|
// Manager owns the configuration sources and watcher for one application instance.
|
||||||
func Load(args []string) (*Config, error) {
|
// Its snapshots are detached from each other and from the running service.
|
||||||
configPath, isExplicit := resolveConfigPath(args)
|
type Manager struct {
|
||||||
// Build configuration with all sources
|
config *lconfig.Config
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
// Create target config instance that will be populated
|
// Load reads the startup sources and validates the initial configuration.
|
||||||
finalConfig := &Config{}
|
// Watching starts only when Watch is called after successful service startup.
|
||||||
|
func Load(args []string) (*Manager, error) {
|
||||||
// Builder handles loading, populating the target struct, and validation
|
configPath, isExplicit, configArgs, err := resolveConfigPath(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
initial := defaults()
|
||||||
cfg, err := lconfig.NewBuilder().
|
cfg, err := lconfig.NewBuilder().
|
||||||
WithTarget(finalConfig). // Typed target struct
|
WithTarget(initial).
|
||||||
WithDefaults(defaults()). // Default values
|
WithEnvPrefix("LOGWISP_").
|
||||||
WithSources(
|
WithArgs(configArgs).
|
||||||
lconfig.SourceCLI,
|
WithFile(configPath).
|
||||||
lconfig.SourceEnv,
|
WithTypedValidator(ValidateConfig).
|
||||||
lconfig.SourceFile,
|
|
||||||
lconfig.SourceDefault,
|
|
||||||
).
|
|
||||||
WithEnvTransform(customEnvTransform). // Convert '.' to '_' in env separation
|
|
||||||
WithEnvPrefix("LOGWISP_"). // Environment variable prefix
|
|
||||||
WithArgs(args). // Command-line arguments
|
|
||||||
WithFile(configPath). // TOML config file
|
|
||||||
WithFileFormat("toml"). // Explicit format
|
|
||||||
WithTypedValidator(ValidateConfig). // Centralized validation
|
|
||||||
WithSecurityOptions(lconfig.SecurityOptions{
|
WithSecurityOptions(lconfig.SecurityOptions{
|
||||||
PreventPathTraversal: true,
|
PreventPathTraversal: true,
|
||||||
MaxFileSize: 10 * 1024 * 1024, // 10MB max config
|
MaxFileSize: 10 * 1024 * 1024,
|
||||||
}).
|
}).
|
||||||
Build()
|
Build()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle file not found errors - maintain existing behavior
|
if !errors.Is(err, lconfig.ErrConfigNotFound) {
|
||||||
if errors.Is(err, lconfig.ErrConfigNotFound) {
|
|
||||||
if isExplicit {
|
|
||||||
// Return empty config with file path
|
|
||||||
finalConfig.ConfigFile = configPath
|
|
||||||
return finalConfig, fmt.Errorf("config file not found: %s", configPath)
|
|
||||||
}
|
|
||||||
// If the default config file is not found, it's not an error, default/cli/env will be used
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("failed to load or validate config: %w", err)
|
return nil, fmt.Errorf("failed to load or validate config: %w", err)
|
||||||
}
|
}
|
||||||
|
if isExplicit {
|
||||||
|
return nil, fmt.Errorf("config file %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
// A missing discovered default still permits valid CLI/env/default values.
|
||||||
}
|
}
|
||||||
|
if unknown := cfg.UnknownCLIKeys(); len(unknown) > 0 && !initial.Quiet {
|
||||||
// Store the config file path for hot reload
|
|
||||||
finalConfig.ConfigFile = configPath
|
|
||||||
|
|
||||||
// Store the manager for hot reload
|
|
||||||
configManager = cfg
|
|
||||||
|
|
||||||
// Surface typo'd flags (e.g. --status-reporter vs --status_reporter);
|
|
||||||
// pre-logger phase, stderr only, suppressed in quiet mode
|
|
||||||
if unknown := cfg.UnknownCLIKeys(); len(unknown) > 0 && !finalConfig.Quiet {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: unrecognized flags ignored: %v\n", unknown)
|
fmt.Fprintf(os.Stderr, "Warning: unrecognized flags ignored: %v\n", unknown)
|
||||||
}
|
}
|
||||||
|
return &Manager{config: cfg, path: configPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Start watcher if auto-reload is enabled
|
// Snapshot validates a detached candidate before it is used to build a service.
|
||||||
if finalConfig.ConfigAutoReload {
|
// Builder validators only run during Load, so every reload needs this check too.
|
||||||
watchOpts := lconfig.WatchOptions{
|
func (m *Manager) Snapshot() (*Config, error) {
|
||||||
PollInterval: core.ReloadWatchPollInterval,
|
value, err := m.config.AsStruct()
|
||||||
Debounce: core.ReloadWatchDebounce,
|
if err != nil {
|
||||||
ReloadTimeout: core.ReloadWatchTimeout,
|
return nil, fmt.Errorf("decode configuration: %w", err)
|
||||||
VerifyPermissions: true,
|
|
||||||
}
|
|
||||||
cfg.AutoUpdateWithOptions(watchOpts)
|
|
||||||
}
|
}
|
||||||
|
cfg := value.(*Config)
|
||||||
return finalConfig, nil
|
cfg.ConfigFile = m.path
|
||||||
|
if err := ValidateConfig(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate configuration: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfigManager returns the global configuration manager instance for hot-reloading
|
// Reload rereads the selected file, retaining the startup CLI/environment sources.
|
||||||
func GetConfigManager() *lconfig.Config {
|
// Signals must call this even when automatic watching is disabled.
|
||||||
return configManager
|
func (m *Manager) Reload() (*Config, error) {
|
||||||
|
if err := m.config.LoadFile(m.path); err != nil {
|
||||||
|
return nil, fmt.Errorf("reload %q: %w", m.path, err)
|
||||||
|
}
|
||||||
|
return m.Snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch subscribes with logwisp's polling and debounce settings.
|
||||||
|
func (m *Manager) Watch() <-chan string {
|
||||||
|
opts := lconfig.DefaultWatchOptions()
|
||||||
|
opts.PollInterval = core.ReloadWatchPollInterval
|
||||||
|
opts.Debounce = core.ReloadWatchDebounce
|
||||||
|
opts.ReloadTimeout = core.ReloadWatchTimeout
|
||||||
|
return m.config.WatchWithOptions(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() { m.config.StopAutoUpdate() }
|
||||||
|
|
||||||
// defaults provides the default configuration values for the application
|
// defaults provides the default configuration values for the application
|
||||||
func defaults() *Config {
|
func defaults() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
@@ -139,10 +140,6 @@ func defaults() *Config {
|
|||||||
Config: map[string]any{
|
Config: map[string]any{
|
||||||
"special": true,
|
"special": true,
|
||||||
},
|
},
|
||||||
// Config: &FileSourceOptions{
|
|
||||||
// Directory: "./",
|
|
||||||
// Pattern: "*.log",
|
|
||||||
// CheckIntervalMS: int64(100),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PluginSinks: []PluginSinkConfig{
|
PluginSinks: []PluginSinkConfig{
|
||||||
@@ -160,46 +157,51 @@ func defaults() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveConfigPath determines the configuration file path based on CLI args, env vars, and default locations
|
// resolveConfigPath consumes file-selection flags before schema CLI parsing.
|
||||||
func resolveConfigPath(args []string) (path string, isExplicit bool) {
|
// The last selection wins, and -- terminates option handling.
|
||||||
// 1. Check for --config flag in command-line arguments (highest precedence)
|
func resolveConfigPath(args []string) (path string, isExplicit bool, remaining []string, err error) {
|
||||||
for i, arg := range args {
|
remaining = make([]string, 0, len(args))
|
||||||
if arg == "-c" {
|
for i := 0; i < len(args); i++ {
|
||||||
return args[i+1], true
|
arg := args[i]
|
||||||
|
if arg == "--" {
|
||||||
|
remaining = append(remaining, args[i:]...)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(arg, "--config=") {
|
switch {
|
||||||
return strings.TrimPrefix(arg, "--config="), true
|
case arg == "-c" || arg == "--config":
|
||||||
|
if i+1 == len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") {
|
||||||
|
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
path, isExplicit = args[i], true
|
||||||
|
case strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "-c="):
|
||||||
|
_, path, _ = strings.Cut(arg, "=")
|
||||||
|
if path == "" {
|
||||||
|
return "", false, nil, fmt.Errorf("%s requires a configuration file path", arg)
|
||||||
|
}
|
||||||
|
isExplicit = true
|
||||||
|
default:
|
||||||
|
remaining = append(remaining, arg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isExplicit {
|
||||||
// 2. Check environment variables
|
return path, true, remaining, nil
|
||||||
|
}
|
||||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||||
path = configFile
|
path = configFile
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
path = filepath.Join(configDir, configFile)
|
path = filepath.Join(configDir, configFile)
|
||||||
}
|
}
|
||||||
return path, true
|
return path, true, remaining, nil
|
||||||
}
|
}
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
return filepath.Join(configDir, "logwisp.toml"), true
|
return filepath.Join(configDir, "logwisp.toml"), true, remaining, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check default user config location
|
|
||||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
configPath := filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml")
|
path = filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml")
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return configPath, false // Found a default, but not explicitly set by user
|
return path, false, remaining, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "logwisp.toml", false, remaining, nil
|
||||||
// 4. Fallback to default in current directory
|
|
||||||
return "logwisp.toml", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// customEnvTransform converts TOML-style config paths (e.g., logging.level) to environment variable format (LOGGING_LEVEL)
|
|
||||||
func customEnvTransform(path string) string {
|
|
||||||
env := strings.ReplaceAll(path, ".", "_")
|
|
||||||
env = strings.ToUpper(env)
|
|
||||||
// env = "LOGWISP_" + env // already added by WithEnvPrefix
|
|
||||||
return env
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lconfig "github.com/lixenwraith/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isolateConfig(t *testing.T) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", dir)
|
||||||
|
t.Chdir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, path, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveConfigArguments(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
for _, args := range [][]string{{"-c", "chosen.toml"}, {"--config", "chosen.toml"}, {"--config=chosen.toml"}, {"-c=chosen.toml"}, {"-c", "old.toml", "--config=chosen.toml"}} {
|
||||||
|
path, explicit, remaining, err := resolveConfigPath(append(args, "--quiet"))
|
||||||
|
if err != nil || path != "chosen.toml" || !explicit || !reflect.DeepEqual(remaining, []string{"--quiet"}) {
|
||||||
|
t.Fatalf("%v: %q %v %v %v", args, path, explicit, remaining, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"-c"}, {"--config"}, {"--config="}, {"-c="}, {"-c", "--quiet"}} {
|
||||||
|
if _, _, _, err := resolveConfigPath(args); err == nil {
|
||||||
|
t.Fatalf("missing path accepted: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []string{"--", "-c", "ignored.toml"}
|
||||||
|
path, explicit, remaining, err := resolveConfigPath(args)
|
||||||
|
if err != nil || explicit || path != "logwisp.toml" || !reflect.DeepEqual(remaining, args) {
|
||||||
|
t.Fatalf("terminator ignored: %q %v %v %v", path, explicit, remaining, err)
|
||||||
|
}
|
||||||
|
t.Setenv("LOGWISP_CONFIG_DIR", "configs")
|
||||||
|
t.Setenv("LOGWISP_CONFIG_FILE", "env.toml")
|
||||||
|
path, explicit, _, err = resolveConfigPath(nil)
|
||||||
|
if err != nil || !explicit || path != filepath.Join("configs", "env.toml") {
|
||||||
|
t.Fatalf("environment path: %q %v %v", path, explicit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
writeConfig(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n")
|
||||||
|
t.Setenv("LOGGING_LEVEL", "invalid-bare-variable")
|
||||||
|
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
|
||||||
|
m, err := Load([]string{"--config", "selected.toml", "--logging.level=warn", "--quiet"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
if unknown := m.config.UnknownCLIKeys(); len(unknown) != 0 {
|
||||||
|
t.Fatalf("path flags reached schema parser: %v", unknown)
|
||||||
|
}
|
||||||
|
first, err := m.Snapshot()
|
||||||
|
if err != nil || first.Logging.Level != "warn" || first.ConfigFile != "selected.toml" {
|
||||||
|
t.Fatalf("snapshot: %+v %v", first, err)
|
||||||
|
}
|
||||||
|
first.Logging.Level = "mutated"
|
||||||
|
first.Pipelines[0].PluginSources[0].Config["special"] = false
|
||||||
|
second, err := m.Snapshot()
|
||||||
|
if err != nil || second.Logging.Level != "warn" || second.Pipelines[0].PluginSources[0].Config["special"] != true {
|
||||||
|
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
|
||||||
|
}
|
||||||
|
env, err := Load([]string{"-c=selected.toml"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer env.Close()
|
||||||
|
fromEnv, err := env.Snapshot()
|
||||||
|
if err != nil || fromEnv.Logging.Level != "debug" {
|
||||||
|
t.Fatalf("prefixed environment ignored: %+v %v", fromEnv, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
m, err := Load(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
if _, err := Load([]string{"-c", "missing.toml"}); !errors.Is(err, ErrConfigNotFound) {
|
||||||
|
t.Fatalf("missing explicit path: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Load([]string{"--logging.file.max_size_mb=1.5"}); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||||
|
t.Fatalf("invalid CLI hidden by missing default: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("LOGWISP_STATUS_REPORTER", "invalid")
|
||||||
|
if _, err := Load(nil); err == nil || errors.Is(err, ErrConfigNotFound) {
|
||||||
|
t.Fatalf("invalid environment hidden by missing default: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
writeConfig(t, "reload.toml", "status_reporter = true\n")
|
||||||
|
m, err := Load([]string{"-c", "reload.toml"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
old, err := m.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeConfig(t, "reload.toml", "status_reporter = false\n")
|
||||||
|
next, err := m.Reload()
|
||||||
|
if err != nil || next.StatusReporter || !old.StatusReporter || next.ConfigFile != old.ConfigFile {
|
||||||
|
t.Fatalf("disk reload/snapshot ownership: %+v %v", next, err)
|
||||||
|
}
|
||||||
|
// Builder validators do not run on LoadFile; Snapshot must run them again.
|
||||||
|
writeConfig(t, "reload.toml", "pipelines = []\n")
|
||||||
|
if _, err := m.Reload(); err == nil || !strings.Contains(err.Error(), "no pipelines") {
|
||||||
|
t.Fatalf("semantically invalid reload accepted: %v", err)
|
||||||
|
}
|
||||||
|
if len(next.Pipelines) == 0 || !old.StatusReporter {
|
||||||
|
t.Fatal("invalid edit changed a published snapshot")
|
||||||
|
}
|
||||||
|
writeConfig(t, "reload.toml", "[logging.file]\nmax_size_mb = 1.5\n")
|
||||||
|
if _, err := m.Reload(); err == nil {
|
||||||
|
t.Fatal("fractional integer accepted")
|
||||||
|
}
|
||||||
|
writeConfig(t, "reload.toml", "# remove the status override\n")
|
||||||
|
recovered, err := m.Reload()
|
||||||
|
if err != nil || !recovered.StatusReporter {
|
||||||
|
t.Fatalf("override removal/recovery: %+v %v", recovered, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
writeConfig(t, "watched.toml", "auto_reload = true\n")
|
||||||
|
m, err := Load([]string{"-c", "watched.toml"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
if m.config.IsWatching() {
|
||||||
|
t.Fatal("watching started before service startup")
|
||||||
|
}
|
||||||
|
changes := m.Watch()
|
||||||
|
if !m.config.IsWatching() {
|
||||||
|
t.Fatal("subscription did not start watching")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
select {
|
||||||
|
case _, ok := <-changes:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("watch channel still open")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("watcher did not close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedPluginOptionsKeepDefaultsAndNumericTypes(t *testing.T) {
|
||||||
|
isolateConfig(t)
|
||||||
|
writeConfig(t, "plugins.toml", `
|
||||||
|
[[pipelines]]
|
||||||
|
name = "nested"
|
||||||
|
[[pipelines.plugin_sources]]
|
||||||
|
id = "in"
|
||||||
|
type = "null"
|
||||||
|
[[pipelines.plugin_sinks]]
|
||||||
|
id = "out"
|
||||||
|
type = "tcp"
|
||||||
|
[pipelines.plugin_sinks.config]
|
||||||
|
port = 9000
|
||||||
|
write_timeout_ms = 9007199254740993
|
||||||
|
[pipelines.plugin_sinks.config.tls]
|
||||||
|
enabled = false
|
||||||
|
server_name = "relay.example"
|
||||||
|
[pipelines.plugin_sinks.config.auth]
|
||||||
|
type = "none"
|
||||||
|
allow = ["one", "two"]
|
||||||
|
`)
|
||||||
|
m, err := Load([]string{"-c", "plugins.toml"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
cfg, err := m.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts := TCPSinkOptions{Host: "127.0.0.1", BufferSize: 1000}
|
||||||
|
if err := lconfig.ScanMap(cfg.Pipelines[0].PluginSinks[0].Config, &opts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if opts.Host != "127.0.0.1" || opts.BufferSize != 1000 || opts.Port != 9000 || opts.WriteTimeoutMS != 9007199254740993 ||
|
||||||
|
opts.TLS == nil || opts.TLS.ServerName != "relay.example" || opts.Auth == nil || !reflect.DeepEqual(opts.Auth.Allow, []string{"one", "two"}) {
|
||||||
|
t.Fatalf("plugin defaults, nested tables or integer precision lost: %+v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ func NewPipeline(
|
|||||||
flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
|
flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If flow fails, stop session manager
|
// If flow fails, stop session manager
|
||||||
|
pipelineCancel()
|
||||||
sessionManager.Stop()
|
sessionManager.Stop()
|
||||||
return nil, fmt.Errorf("failed to create flow processor: %w", err)
|
return nil, fmt.Errorf("failed to create flow processor: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,7 @@ func NewPipeline(
|
|||||||
// Initialize sources and sinks
|
// Initialize sources and sinks
|
||||||
if err := pipeline.initializeComponents(); err != nil {
|
if err := pipeline.initializeComponents(); err != nil {
|
||||||
pipelineCancel()
|
pipelineCancel()
|
||||||
|
sessionManager.Stop()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import (
|
|||||||
|
|
||||||
// Service manages a collection of log processing pipelines
|
// Service manages a collection of log processing pipelines
|
||||||
type Service struct {
|
type Service struct {
|
||||||
pipelines map[string]*pipeline.Pipeline
|
pipelines map[string]*pipeline.Pipeline
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
shutdownOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new, empty service
|
// NewService creates a new, empty service
|
||||||
@@ -49,9 +50,14 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errs != nil {
|
||||||
|
// A rejected reload must not retain prepared pipelines or their workers.
|
||||||
|
svc.Shutdown()
|
||||||
|
return nil, errs
|
||||||
|
}
|
||||||
logger.Info("msg", "Service initialization completed", "pipelines", len(svc.pipelines))
|
logger.Info("msg", "Service initialization completed", "pipelines", len(svc.pipelines))
|
||||||
|
|
||||||
return svc, errs
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts all or specific pipelines
|
// Start starts all or specific pipelines
|
||||||
@@ -168,6 +174,10 @@ func (svc *Service) RemovePipeline(name string) error {
|
|||||||
|
|
||||||
// Shutdown gracefully stops all pipelines managed by the service
|
// Shutdown gracefully stops all pipelines managed by the service
|
||||||
func (svc *Service) Shutdown() {
|
func (svc *Service) Shutdown() {
|
||||||
|
svc.shutdownOnce.Do(svc.shutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) shutdown() {
|
||||||
svc.logger.Info("msg", "Service shutdown initiated")
|
svc.logger.Info("msg", "Service shutdown initiated")
|
||||||
|
|
||||||
svc.mu.Lock()
|
svc.mu.Lock()
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ func (p *Proxy) CreateSession(remoteAddr string, metadata map[string]any) *Sessi
|
|||||||
metadata["instance_id"] = p.instanceID
|
metadata["instance_id"] = p.instanceID
|
||||||
|
|
||||||
// Create session with instance-scoped source
|
// Create session with instance-scoped source
|
||||||
session := p.manager.CreateSession(remoteAddr, p.instanceID, metadata)
|
// Set identity before publishing the session to concurrent snapshot readers.
|
||||||
session.InstanceID = p.instanceID
|
return p.manager.createSession(remoteAddr, p.instanceID, p.instanceID, metadata)
|
||||||
|
|
||||||
return session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession retrieves a session if it belongs to this instance
|
// GetSession retrieves a session if it belongs to this instance
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,7 +60,12 @@ func NewManager(maxIdleTime time.Duration) *Manager {
|
|||||||
|
|
||||||
// CreateSession creates and stores a new session for a connection
|
// CreateSession creates and stores a new session for a connection
|
||||||
func (m *Manager) CreateSession(remoteAddr string, source string, metadata map[string]any) *Session {
|
func (m *Manager) CreateSession(remoteAddr string, source string, metadata map[string]any) *Session {
|
||||||
|
return m.createSession(remoteAddr, source, "", metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) createSession(remoteAddr, source, instanceID string, metadata map[string]any) *Session {
|
||||||
session := &Session{
|
session := &Session{
|
||||||
|
InstanceID: instanceID,
|
||||||
ID: generateSessionID(),
|
ID: generateSessionID(),
|
||||||
RemoteAddr: remoteAddr,
|
RemoteAddr: remoteAddr,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -127,7 +133,7 @@ func (m *Manager) GetActiveSessions() []*Session {
|
|||||||
|
|
||||||
sessions := make([]*Session, 0, len(m.sessions))
|
sessions := make([]*Session, 0, len(m.sessions))
|
||||||
for _, session := range m.sessions {
|
for _, session := range m.sessions {
|
||||||
sessions = append(sessions, session)
|
sessions = append(sessions, snapshotSession(session))
|
||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
@@ -139,7 +145,7 @@ func (m *Manager) GetSessionCount() int {
|
|||||||
return len(m.sessions)
|
return len(m.sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSessionsBySource returns all sessions matching a specific source type
|
// GetSessionsBySource returns snapshots of sessions matching a specific source type.
|
||||||
func (m *Manager) GetSessionsBySource(source string) []*Session {
|
func (m *Manager) GetSessionsBySource(source string) []*Session {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -147,13 +153,13 @@ func (m *Manager) GetSessionsBySource(source string) []*Session {
|
|||||||
var sessions []*Session
|
var sessions []*Session
|
||||||
for _, session := range m.sessions {
|
for _, session := range m.sessions {
|
||||||
if session.Source == source {
|
if session.Source == source {
|
||||||
sessions = append(sessions, session)
|
sessions = append(sessions, snapshotSession(session))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveSessionsBySource returns all active sessions for a given source
|
// GetActiveSessionsBySource returns snapshots of active sessions for a given source.
|
||||||
func (m *Manager) GetActiveSessionsBySource(source string) []*Session {
|
func (m *Manager) GetActiveSessionsBySource(source string) []*Session {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -163,12 +169,20 @@ func (m *Manager) GetActiveSessionsBySource(source string) []*Session {
|
|||||||
|
|
||||||
for _, session := range m.sessions {
|
for _, session := range m.sessions {
|
||||||
if session.Source == source && now.Sub(session.LastActivity) < m.maxIdleTime {
|
if session.Source == source && now.Sub(session.LastActivity) < m.maxIdleTime {
|
||||||
sessions = append(sessions, session)
|
sessions = append(sessions, snapshotSession(session))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call under m.mu. Metadata values are immutable after session creation; copy
|
||||||
|
// the top-level map so readers cannot add or remove entries in stored metadata.
|
||||||
|
func snapshotSession(session *Session) *Session {
|
||||||
|
snapshot := *session
|
||||||
|
snapshot.Metadata = maps.Clone(session.Metadata)
|
||||||
|
return &snapshot
|
||||||
|
}
|
||||||
|
|
||||||
// GetStats returns statistics about the session manager
|
// GetStats returns statistics about the session manager
|
||||||
func (m *Manager) GetStats() map[string]any {
|
func (m *Manager) GetStats() map[string]any {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionSnapshotsDoNotRaceActivityUpdates(t *testing.T) {
|
||||||
|
m := NewManager(time.Minute)
|
||||||
|
defer m.Stop()
|
||||||
|
proxy := NewProxy(m, "source")
|
||||||
|
stored := proxy.CreateSession("remote", map[string]any{"label": "original"})
|
||||||
|
readers := []func() []*Session{
|
||||||
|
m.GetActiveSessions,
|
||||||
|
func() []*Session { return m.GetSessionsBySource("source") },
|
||||||
|
func() []*Session { return m.GetActiveSessionsBySource("source") },
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 1000 {
|
||||||
|
proxy.UpdateActivity(stored.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for _, read := range readers {
|
||||||
|
for range 100 {
|
||||||
|
snapshots := read()
|
||||||
|
if len(snapshots) != 1 || snapshots[0] == stored || snapshots[0].InstanceID != "source" {
|
||||||
|
t.Errorf("invalid snapshot: %+v", snapshots)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
snapshot := snapshots[0]
|
||||||
|
before := snapshot.LastActivity
|
||||||
|
proxy.UpdateActivity(stored.ID)
|
||||||
|
if !snapshot.LastActivity.Equal(before) {
|
||||||
|
t.Error("activity update changed an earlier snapshot")
|
||||||
|
}
|
||||||
|
snapshot.Metadata["label"] = "changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if stored.Metadata["label"] != "original" {
|
||||||
|
t.Fatal("snapshot metadata aliases the stored map")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user