dispatch: allow empty route kinds (match all) + add routing tests
- config: permit routes[].kinds to be omitted/empty; treat as "all kinds"
- dispatch: compile empty kinds to Route{Kinds:nil} (match all kinds)
- tests: add coverage for route compilation + config validation edge cases
Files:
- config/load.go
- config/config.go
- dispatch/routes.go
- config/validate_test.go
- dispatch/routes_test.go
This commit is contained in:
@@ -54,7 +54,10 @@ type RouteConfig struct {
|
|||||||
Sink string `yaml:"sink"` // sink name
|
Sink string `yaml:"sink"` // sink name
|
||||||
|
|
||||||
// Kinds is domain-defined. feedkit only enforces that each entry is non-empty.
|
// Kinds is domain-defined. feedkit only enforces that each entry is non-empty.
|
||||||
// Whether a given daemon "recognizes" a kind is domain-specific validation.
|
//
|
||||||
|
// If Kinds is omitted or empty, the route matches ALL kinds.
|
||||||
|
// This is useful when you want explicit per-sink routing rules even when a
|
||||||
|
// particular sink should receive everything.
|
||||||
Kinds []string `yaml:"kinds"`
|
Kinds []string `yaml:"kinds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,11 +133,8 @@ func (c *Config) Validate() error {
|
|||||||
m.Add(fieldErr(path+".sink", fmt.Sprintf("references unknown sink %q (define it under sinks:)", r.Sink)))
|
m.Add(fieldErr(path+".sink", fmt.Sprintf("references unknown sink %q (define it under sinks:)", r.Sink)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.Kinds) == 0 {
|
// Kinds is optional. If omitted or empty, the route matches ALL kinds.
|
||||||
// You could relax this later (e.g. empty == "all kinds"), but for now
|
// If provided, each entry must be non-empty.
|
||||||
// keeping it strict prevents accidental "route does nothing".
|
|
||||||
m.Add(fieldErr(path+".kinds", "must contain at least one kind"))
|
|
||||||
} else {
|
|
||||||
for j, k := range r.Kinds {
|
for j, k := range r.Kinds {
|
||||||
kpath := fmt.Sprintf("%s.kinds[%d]", path, j)
|
kpath := fmt.Sprintf("%s.kinds[%d]", path, j)
|
||||||
if strings.TrimSpace(k) == "" {
|
if strings.TrimSpace(k) == "" {
|
||||||
@@ -145,7 +142,6 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return m.Err()
|
return m.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
48
config/validate_test.go
Normal file
48
config/validate_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate_RouteKindsEmptyIsAllowed(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Sources: []SourceConfig{
|
||||||
|
{Name: "src1", Driver: "driver1", Every: Duration{Duration: time.Minute}},
|
||||||
|
},
|
||||||
|
Sinks: []SinkConfig{
|
||||||
|
{Name: "sink1", Driver: "stdout"},
|
||||||
|
},
|
||||||
|
Routes: []RouteConfig{
|
||||||
|
{Sink: "sink1", Kinds: nil}, // omitted
|
||||||
|
{Sink: "sink1", Kinds: []string{}}, // explicit empty
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_RouteKindsRejectsBlankEntries(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Sources: []SourceConfig{
|
||||||
|
{Name: "src1", Driver: "driver1", Every: Duration{Duration: time.Minute}},
|
||||||
|
},
|
||||||
|
Sinks: []SinkConfig{
|
||||||
|
{Name: "sink1", Driver: "stdout"},
|
||||||
|
},
|
||||||
|
Routes: []RouteConfig{
|
||||||
|
{Sink: "sink1", Kinds: []string{"observation", " ", "alert"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "routes[0].kinds[1]") {
|
||||||
|
t.Fatalf("expected error to mention blank kind entry, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
// Behavior:
|
// Behavior:
|
||||||
// - If cfg.Routes is empty, we default to "all sinks receive all kinds".
|
// - If cfg.Routes is empty, we default to "all sinks receive all kinds".
|
||||||
// (Implemented as one Route per sink with Kinds == nil.)
|
// (Implemented as one Route per sink with Kinds == nil.)
|
||||||
|
// - If a specific route's kinds: is omitted or empty, that route matches ALL kinds.
|
||||||
|
// (Also compiled as Kinds == nil.)
|
||||||
// - Kind strings are normalized via event.ParseKind (lowercase + trim).
|
// - Kind strings are normalized via event.ParseKind (lowercase + trim).
|
||||||
//
|
//
|
||||||
// Note: config.Validate() already ensures route.sink references a known sink and
|
// Note: config.Validate() ensures route.sink references a known sink and rejects
|
||||||
// route.kinds are non-empty strings. We re-check a few invariants here anyway so
|
// blank kind entries. We re-check a few invariants here anyway so CompileRoutes
|
||||||
// CompileRoutes is safe to call even if a daemon chooses not to call Validate().
|
// is safe to call even if a daemon chooses not to call Validate().
|
||||||
func CompileRoutes(cfg *config.Config) ([]Route, error) {
|
func CompileRoutes(cfg *config.Config) ([]Route, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: cfg is nil")
|
return nil, fmt.Errorf("dispatch.CompileRoutes: cfg is nil")
|
||||||
@@ -27,14 +29,13 @@ func CompileRoutes(cfg *config.Config) ([]Route, error) {
|
|||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: cfg has no sinks")
|
return nil, fmt.Errorf("dispatch.CompileRoutes: cfg has no sinks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a quick lookup of sink names.
|
// Build a quick lookup of sink names (exact match; no normalization).
|
||||||
sinkNames := make(map[string]bool, len(cfg.Sinks))
|
sinkNames := make(map[string]bool, len(cfg.Sinks))
|
||||||
for i, s := range cfg.Sinks {
|
for i, s := range cfg.Sinks {
|
||||||
name := strings.TrimSpace(s.Name)
|
if strings.TrimSpace(s.Name) == "" {
|
||||||
if name == "" {
|
|
||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: sinks[%d].name is empty", i)
|
return nil, fmt.Errorf("dispatch.CompileRoutes: sinks[%d].name is empty", i)
|
||||||
}
|
}
|
||||||
sinkNames[name] = true
|
sinkNames[s.Name] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default routing: everything to every sink.
|
// Default routing: everything to every sink.
|
||||||
@@ -52,16 +53,21 @@ func CompileRoutes(cfg *config.Config) ([]Route, error) {
|
|||||||
out := make([]Route, 0, len(cfg.Routes))
|
out := make([]Route, 0, len(cfg.Routes))
|
||||||
|
|
||||||
for i, r := range cfg.Routes {
|
for i, r := range cfg.Routes {
|
||||||
sink := strings.TrimSpace(r.Sink)
|
sink := r.Sink
|
||||||
if sink == "" {
|
if strings.TrimSpace(sink) == "" {
|
||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].sink is required", i)
|
return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].sink is required", i)
|
||||||
}
|
}
|
||||||
if !sinkNames[sink] {
|
if !sinkNames[sink] {
|
||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].sink references unknown sink %q", i, sink)
|
return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].sink references unknown sink %q", i, sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If kinds is omitted/empty, this route matches all kinds.
|
||||||
if len(r.Kinds) == 0 {
|
if len(r.Kinds) == 0 {
|
||||||
return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].kinds must contain at least one kind", i)
|
out = append(out, Route{
|
||||||
|
SinkName: sink,
|
||||||
|
Kinds: nil,
|
||||||
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
kinds := make(map[event.Kind]bool, len(r.Kinds))
|
kinds := make(map[event.Kind]bool, len(r.Kinds))
|
||||||
|
|||||||
67
dispatch/routes_test.go
Normal file
67
dispatch/routes_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package dispatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompileRoutes_DefaultIsAllSinksAllKinds(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sinks: []config.SinkConfig{
|
||||||
|
{Name: "a", Driver: "stdout"},
|
||||||
|
{Name: "b", Driver: "stdout"},
|
||||||
|
},
|
||||||
|
// Routes omitted => default
|
||||||
|
}
|
||||||
|
|
||||||
|
routes, err := CompileRoutes(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompileRoutes error: %v", err)
|
||||||
|
}
|
||||||
|
if len(routes) != 2 {
|
||||||
|
t.Fatalf("expected 2 routes, got %d", len(routes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order should match cfg.Sinks order (deterministic).
|
||||||
|
if routes[0].SinkName != "a" || routes[1].SinkName != "b" {
|
||||||
|
t.Fatalf("unexpected route order: %+v", routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range routes {
|
||||||
|
if len(r.Kinds) != 0 {
|
||||||
|
t.Fatalf("expected nil/empty kinds for default routes, got: %+v", r.Kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileRoutes_EmptyKindsMeansAllKinds(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sinks: []config.SinkConfig{
|
||||||
|
{Name: "sink1", Driver: "stdout"},
|
||||||
|
},
|
||||||
|
Routes: []config.RouteConfig{
|
||||||
|
{Sink: "sink1"}, // omitted kinds
|
||||||
|
{Sink: "sink1", Kinds: nil}, // explicit nil
|
||||||
|
{Sink: "sink1", Kinds: []string{}}, // explicit empty
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
routes, err := CompileRoutes(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompileRoutes error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(routes) != 3 {
|
||||||
|
t.Fatalf("expected 3 routes, got %d", len(routes))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, r := range routes {
|
||||||
|
if r.SinkName != "sink1" {
|
||||||
|
t.Fatalf("route[%d] unexpected sink: %q", i, r.SinkName)
|
||||||
|
}
|
||||||
|
if len(r.Kinds) != 0 {
|
||||||
|
t.Fatalf("route[%d] expected nil/empty kinds (match all), got: %+v", i, r.Kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
doc.go
3
doc.go
@@ -234,7 +234,8 @@
|
|||||||
// - dispatch.Fanout: one buffered queue + worker goroutine per sink
|
// - dispatch.Fanout: one buffered queue + worker goroutine per sink
|
||||||
//
|
//
|
||||||
// - dispatch.CompileRoutes(*config.Config) compiles cfg.Routes into []dispatch.Route.
|
// - dispatch.CompileRoutes(*config.Config) compiles cfg.Routes into []dispatch.Route.
|
||||||
// If routes: is omitted, it defaults to "all sinks receive all kinds".
|
// If routes: is omitted, it defaults to "all sinks receive all kinds". If a route
|
||||||
|
// omits kinds: (or sets it empty), that route matches all kinds.
|
||||||
//
|
//
|
||||||
// - logging
|
// - logging
|
||||||
// Shared logger type used across feedkit packages.
|
// Shared logger type used across feedkit packages.
|
||||||
|
|||||||
Reference in New Issue
Block a user