diff --git a/config/config.go b/config/config.go index ef97c73..bace862 100644 --- a/config/config.go +++ b/config/config.go @@ -54,7 +54,10 @@ type RouteConfig struct { Sink string `yaml:"sink"` // sink name // 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"` } diff --git a/config/load.go b/config/load.go index 28eabb1..8f8a96a 100644 --- a/config/load.go +++ b/config/load.go @@ -133,16 +133,12 @@ func (c *Config) Validate() error { m.Add(fieldErr(path+".sink", fmt.Sprintf("references unknown sink %q (define it under sinks:)", r.Sink))) } - if len(r.Kinds) == 0 { - // You could relax this later (e.g. empty == "all kinds"), but for now - // 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 { - kpath := fmt.Sprintf("%s.kinds[%d]", path, j) - if strings.TrimSpace(k) == "" { - m.Add(fieldErr(kpath, "kind cannot be empty")) - } + // Kinds is optional. If omitted or empty, the route matches ALL kinds. + // If provided, each entry must be non-empty. + for j, k := range r.Kinds { + kpath := fmt.Sprintf("%s.kinds[%d]", path, j) + if strings.TrimSpace(k) == "" { + m.Add(fieldErr(kpath, "kind cannot be empty")) } } } diff --git a/config/validate_test.go b/config/validate_test.go new file mode 100644 index 0000000..a7d14da --- /dev/null +++ b/config/validate_test.go @@ -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) + } +} diff --git a/dispatch/routes.go b/dispatch/routes.go index 98accc7..f9c8dd8 100644 --- a/dispatch/routes.go +++ b/dispatch/routes.go @@ -13,11 +13,13 @@ import ( // Behavior: // - If cfg.Routes is empty, we default to "all sinks receive all kinds". // (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). // -// Note: config.Validate() already ensures route.sink references a known sink and -// route.kinds are non-empty strings. We re-check a few invariants here anyway so -// CompileRoutes is safe to call even if a daemon chooses not to call Validate(). +// Note: config.Validate() ensures route.sink references a known sink and rejects +// blank kind entries. We re-check a few invariants here anyway so CompileRoutes +// is safe to call even if a daemon chooses not to call Validate(). func CompileRoutes(cfg *config.Config) ([]Route, error) { if cfg == 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") } - // 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)) for i, s := range cfg.Sinks { - name := strings.TrimSpace(s.Name) - if name == "" { + if strings.TrimSpace(s.Name) == "" { 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. @@ -52,16 +53,21 @@ func CompileRoutes(cfg *config.Config) ([]Route, error) { out := make([]Route, 0, len(cfg.Routes)) for i, r := range cfg.Routes { - sink := strings.TrimSpace(r.Sink) - if sink == "" { + sink := r.Sink + if strings.TrimSpace(sink) == "" { return nil, fmt.Errorf("dispatch.CompileRoutes: routes[%d].sink is required", i) } if !sinkNames[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 { - 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)) diff --git a/dispatch/routes_test.go b/dispatch/routes_test.go new file mode 100644 index 0000000..a61f2dd --- /dev/null +++ b/dispatch/routes_test.go @@ -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) + } + } +} diff --git a/doc.go b/doc.go index e34fb70..a0b1937 100644 --- a/doc.go +++ b/doc.go @@ -234,7 +234,8 @@ // - dispatch.Fanout: one buffered queue + worker goroutine per sink // // - 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 // Shared logger type used across feedkit packages.