diff --git a/cmd/weatherfeeder/config.yml b/cmd/weatherfeeder/config.yml index 2fd3073..715ef48 100644 --- a/cmd/weatherfeeder/config.yml +++ b/cmd/weatherfeeder/config.yml @@ -1,7 +1,8 @@ --- sources: - name: NWSObservationKSTL - kind: observation + mode: poll + kinds: ["observation"] driver: nws_observation every: 10m params: @@ -9,7 +10,8 @@ sources: user_agent: "HomeOps (eric@maximumdirect.net)" # - name: OpenMeteoObservation -# kind: observation +# mode: poll +# kinds: ["observation"] # driver: openmeteo_observation # every: 10m # params: @@ -17,7 +19,8 @@ sources: # user_agent: "HomeOps (eric@maximumdirect.net)" # - name: OpenWeatherObservation -# kind: observation +# mode: poll +# kinds: ["observation"] # driver: openweather_observation # every: 10m # params: @@ -25,7 +28,8 @@ sources: # user_agent: "HomeOps (eric@maximumdirect.net)" # - name: NWSObservationKSUS -# kind: observation +# mode: poll +# kinds: ["observation"] # driver: nws_observation # every: 10m # params: @@ -33,7 +37,8 @@ sources: # user_agent: "HomeOps (eric@maximumdirect.net)" # - name: NWSObservationKCPS -# kind: observation +# mode: poll +# kinds: ["observation"] # driver: nws_observation # every: 10m # params: @@ -41,7 +46,8 @@ sources: # user_agent: "HomeOps (eric@maximumdirect.net)" - name: NWSHourlyForecastSTL - kind: forecast + mode: poll + kinds: ["forecast"] driver: nws_forecast every: 45m params: @@ -49,7 +55,8 @@ sources: user_agent: "HomeOps (eric@maximumdirect.net)" - name: OpenMeteoHourlyForecastSTL - kind: forecast + mode: poll + kinds: ["forecast"] driver: openmeteo_forecast every: 60m params: @@ -57,7 +64,8 @@ sources: user_agent: "HomeOps (eric@maximumdirect.net)" - name: NWSAlertsSTL - kind: alert + mode: poll + kinds: ["alert"] driver: nws_alerts every: 1m params: diff --git a/cmd/weatherfeeder/main.go b/cmd/weatherfeeder/main.go index 626d468..a239a70 100644 --- a/cmd/weatherfeeder/main.go +++ b/cmd/weatherfeeder/main.go @@ -7,6 +7,7 @@ import ( "log" "os" "os/signal" + "sort" "strings" "syscall" "time" @@ -60,22 +61,12 @@ func main() { log.Fatalf("build source failed (sources[%d] name=%q driver=%q): %v", i, sc.Name, sc.Driver, err) } - // Optional safety: if config.kind is set, ensure it matches the source.Kind(). - if strings.TrimSpace(sc.Kind) != "" { - expectedKind, err := fkevent.ParseKind(sc.Kind) - if err != nil { - log.Fatalf("invalid kind in config (sources[%d] name=%q kind=%q): %v", i, sc.Name, sc.Kind, err) - } - if in.Kind() != expectedKind { - log.Fatalf( - "source kind mismatch (sources[%d] name=%q driver=%q): config kind=%q but driver emits kind=%q", - i, sc.Name, sc.Driver, expectedKind, in.Kind(), - ) - } + if err := validateSourceExpectedKinds(sc, in); err != nil { + log.Fatalf("source expected kinds validation failed (sources[%d] name=%q driver=%q): %v", i, sc.Name, sc.Driver, err) } // If this is a polling source, every is required. - if _, ok := in.(fksources.Source); ok && sc.Every.Duration <= 0 { + if _, ok := in.(fksources.PollSource); ok && sc.Every.Duration <= 0 { log.Fatalf( "polling source missing/invalid interval (sources[%d] name=%q driver=%q): sources[].every must be > 0", i, sc.Name, sc.Driver, @@ -203,5 +194,74 @@ func isContextShutdown(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } +func validateSourceExpectedKinds(sc config.SourceConfig, in fksources.Input) error { + expectedKinds, err := parseExpectedKinds(sc.ExpectedKinds()) + if err != nil { + return err + } + if len(expectedKinds) == 0 { + return nil + } + + advertisedKinds := advertisedSourceKinds(in) + if len(advertisedKinds) == 0 { + return nil + } + + for kind := range expectedKinds { + if !advertisedKinds[kind] { + return fmt.Errorf( + "configured expected kind %q not advertised by source (configured=%v advertised=%v)", + kind, + sortedKinds(expectedKinds), + sortedKinds(advertisedKinds), + ) + } + } + return nil +} + +func parseExpectedKinds(raw []string) (map[fkevent.Kind]bool, error) { + kinds := map[fkevent.Kind]bool{} + for i, k := range raw { + kind, err := fkevent.ParseKind(k) + if err != nil { + return nil, fmt.Errorf("invalid expected kind at index %d (%q): %w", i, k, err) + } + kinds[kind] = true + } + return kinds, nil +} + +func advertisedSourceKinds(in fksources.Input) map[fkevent.Kind]bool { + if in == nil { + return nil + } + + kinds := map[fkevent.Kind]bool{} + if ks, ok := in.(fksources.KindsSource); ok { + for _, kind := range ks.Kinds() { + kinds[kind] = true + } + return kinds + } + + if ks, ok := in.(fksources.KindSource); ok { + kinds[ks.Kind()] = true + return kinds + } + + return nil +} + +func sortedKinds(kindSet map[fkevent.Kind]bool) []string { + out := make([]string, 0, len(kindSet)) + for kind := range kindSet { + out = append(out, string(kind)) + } + sort.Strings(out) + return out +} + // keep time imported (mirrors your previous main.go defensive trick) var _ = time.Second diff --git a/cmd/weatherfeeder/main_test.go b/cmd/weatherfeeder/main_test.go new file mode 100644 index 0000000..69bd020 --- /dev/null +++ b/cmd/weatherfeeder/main_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/ejr/feedkit/config" + fkevent "gitea.maximumdirect.net/ejr/feedkit/event" +) + +type testInput struct { + name string +} + +func (s testInput) Name() string { return s.name } + +type testKindSource struct { + testInput + kind fkevent.Kind +} + +func (s testKindSource) Kind() fkevent.Kind { return s.kind } + +type testKindsSource struct { + testInput + kinds []fkevent.Kind +} + +func (s testKindsSource) Kinds() []fkevent.Kind { return s.kinds } + +func TestValidateSourceExpectedKindsLegacyKindFallback(t *testing.T) { + sc := config.SourceConfig{Kind: "observation"} + in := testKindSource{ + testInput: testInput{name: "test"}, + kind: fkevent.Kind("observation"), + } + + if err := validateSourceExpectedKinds(sc, in); err != nil { + t.Fatalf("validateSourceExpectedKinds() unexpected error: %v", err) + } +} + +func TestValidateSourceExpectedKindsSubsetAllowed(t *testing.T) { + sc := config.SourceConfig{Kinds: []string{"observation"}} + in := testKindsSource{ + testInput: testInput{name: "test"}, + kinds: []fkevent.Kind{"observation", "forecast"}, + } + + if err := validateSourceExpectedKinds(sc, in); err != nil { + t.Fatalf("validateSourceExpectedKinds() unexpected error: %v", err) + } +} + +func TestValidateSourceExpectedKindsMismatchFails(t *testing.T) { + sc := config.SourceConfig{Kinds: []string{"alert"}} + in := testKindsSource{ + testInput: testInput{name: "test"}, + kinds: []fkevent.Kind{"observation", "forecast"}, + } + + err := validateSourceExpectedKinds(sc, in) + if err == nil { + t.Fatalf("validateSourceExpectedKinds() expected mismatch error, got nil") + } + if !strings.Contains(err.Error(), "configured expected kind") { + t.Fatalf("validateSourceExpectedKinds() error %q does not include expected message", err) + } +} + +func TestValidateSourceExpectedKindsNoMetadataSkipsCheck(t *testing.T) { + sc := config.SourceConfig{Kinds: []string{"alert"}} + in := testInput{name: "test"} + + if err := validateSourceExpectedKinds(sc, in); err != nil { + t.Fatalf("validateSourceExpectedKinds() unexpected error: %v", err) + } +} + +func TestParseExpectedKindsRejectsEmptyValues(t *testing.T) { + if _, err := parseExpectedKinds([]string{""}); err == nil { + t.Fatalf("parseExpectedKinds() expected error for empty kind") + } +} + +func TestExampleConfigLoads(t *testing.T) { + if _, err := config.Load("config.yml"); err != nil { + t.Fatalf("config.Load(config.yml) unexpected error: %v", err) + } +} diff --git a/go.mod b/go.mod index 5383d7f..cee0177 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module gitea.maximumdirect.net/ejr/weatherfeeder go 1.25 -require gitea.maximumdirect.net/ejr/feedkit v0.5.0 +require gitea.maximumdirect.net/ejr/feedkit v0.6.0 require ( github.com/klauspost/compress v1.17.2 // indirect diff --git a/internal/sources/builtins.go b/internal/sources/builtins.go index 6aa6f8c..22dd475 100644 --- a/internal/sources/builtins.go +++ b/internal/sources/builtins.go @@ -13,26 +13,26 @@ import ( // Keeping this in one place makes main.go very readable. func RegisterBuiltins(r *fksource.Registry) { // NWS drivers - r.Register("nws_observation", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("nws_observation", func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewObservationSource(cfg) }) - r.Register("nws_alerts", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("nws_alerts", func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewAlertsSource(cfg) }) - r.Register("nws_forecast", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("nws_forecast", func(cfg config.SourceConfig) (fksource.PollSource, error) { return nws.NewForecastSource(cfg) }) // Open-Meteo drivers - r.Register("openmeteo_observation", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("openmeteo_observation", func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewObservationSource(cfg) }) - r.Register("openmeteo_forecast", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("openmeteo_forecast", func(cfg config.SourceConfig) (fksource.PollSource, error) { return openmeteo.NewForecastSource(cfg) }) // OpenWeatherMap drivers - r.Register("openweather_observation", func(cfg config.SourceConfig) (fksource.Source, error) { + r.RegisterPoll("openweather_observation", func(cfg config.SourceConfig) (fksource.PollSource, error) { return openweather.NewObservationSource(cfg) }) }