main.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // Command ingestd receives alerts via HTTP POST / WebSocket / MQTT / gRPC,
  2. // validates, rate-limits, dedupes, and publishes to NATS JetStream.
  3. //
  4. // M0: HTTP POST endpoint only. Other transports land in M5 / M4 / M11.
  5. package main
  6. import (
  7. "context"
  8. "os"
  9. "os/signal"
  10. "syscall"
  11. "time"
  12. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  13. "git3.techno-world.net/lrosales/broad-announce/internal/concurrency"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  15. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  21. )
  22. func main() {
  23. cfg, err := config.LoadIngestd()
  24. if err != nil {
  25. // Logger isn't up yet; stderr is the only thing we have.
  26. os.Stderr.WriteString("config: " + err.Error() + "\n")
  27. os.Exit(1)
  28. }
  29. logger := observability.Init(cfg.Env, cfg.LogLevel, "ingestd")
  30. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  31. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  32. defer stop()
  33. // Redis
  34. r, err := store.ConnectRedis(ctx, cfg.RedisURL)
  35. if err != nil {
  36. logger.Error("redis connect", "err", err)
  37. os.Exit(1)
  38. }
  39. defer r.Close()
  40. logger.Info("redis connected")
  41. // NATS JetStream
  42. br, err := broker.Connect(ctx, cfg.NATSURL)
  43. if err != nil {
  44. logger.Error("nats connect", "err", err)
  45. os.Exit(1)
  46. }
  47. defer br.Close()
  48. logger.Info("nats connected", "url", cfg.NATSURL)
  49. // Metrics
  50. reg, m := observability.NewRegistry("ingestd")
  51. limiter := ratelimit.New(r.Client)
  52. ded := dedupe.New(r.Client, dedupe.DefaultWindow)
  53. // M0 source registry: loaded from env. M2 replaces with DB.
  54. sources := loadSourcesFromEnv(logger)
  55. js, err := br.NC().JetStream()
  56. if err != nil {
  57. logger.Error("nats jetstream context", "err", err)
  58. os.Exit(1)
  59. }
  60. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  61. // gate is used by the HTTP server (via the WS path; HTTP's
  62. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  63. // milestone rollout) and the WebSocket ingest path.
  64. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  65. defer perIP.Close()
  66. // M5: in-process tail hub. nil-safe; both the HTTP and
  67. // MQTT paths call Tail.Publish if non-nil.
  68. hub := tailhub.NewHub()
  69. deps := &httpDeps{
  70. processDeps: processDeps{
  71. Logger: logger.With("component", "http"),
  72. Metrics: m,
  73. Limiter: limiter,
  74. Deduper: ded,
  75. Sources: sources,
  76. JetStream: newNatsPublisher(js),
  77. CompanyRatePerSec: cfg.RateLimitPerCompany,
  78. Tail: hub,
  79. Transport: "http",
  80. },
  81. MaxBytes: cfg.MaxPayloadBytes,
  82. }
  83. // M5: WS ingest + tail handler. The WS path uses a copy of
  84. // deps.processDeps with Transport="ws" so the structured
  85. // log lines and the tail event label are correct.
  86. wsDeps := &wsIngestDeps{
  87. processDeps: deps.processDeps,
  88. PerIP: perIP,
  89. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  90. ReadDeadline: 30 * time.Second,
  91. WriteDeadline: 10 * time.Second,
  92. }
  93. // Override the transport on the wsDeps copy. We have to
  94. // use a fresh processDeps because the embedded struct is a
  95. // value, not a pointer, in the struct literal above.
  96. wsDeps.processDeps.Logger = logger.With("component", "ws")
  97. wsDeps.processDeps.Transport = "ws"
  98. tailDeps := &wsTailDeps{
  99. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  100. Hub: hub,
  101. Metrics: m,
  102. }
  103. // HTTP server
  104. srv := httpserver.New(httpserver.Config{
  105. Addr: cfg.HTTPAddr,
  106. ServiceName: "ingestd",
  107. ShutdownGrace: cfg.ShutdownGrace,
  108. }, logger, observability.MetricsHandler(reg))
  109. RegisterRoutes(srv.Mux(), deps)
  110. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  111. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  112. // empty. The subscriber shares the processDeps with the HTTP
  113. // handler so the dedupe window, rate limits, and metrics are
  114. // per-source exactly once across both transports.
  115. mqttCfg := loadMQTTConfig(logger)
  116. mqttDeps := deps.processDeps
  117. mqttDeps.Logger = logger.With("component", "mqtt")
  118. mqttDeps.Transport = "mqtt"
  119. mqttErrCh := make(chan error, 1)
  120. go func() {
  121. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  122. }()
  123. // Run + graceful shutdown
  124. errCh := make(chan error, 1)
  125. go func() { errCh <- srv.Start() }()
  126. select {
  127. case <-ctx.Done():
  128. logger.Info("shutdown signal received")
  129. case err := <-errCh:
  130. if err != nil {
  131. logger.Error("http server", "err", err)
  132. os.Exit(1)
  133. }
  134. case err := <-mqttErrCh:
  135. if err != nil {
  136. logger.Error("mqtt subscriber", "err", err)
  137. os.Exit(1)
  138. }
  139. }
  140. if err := srv.Shutdown(ctx); err != nil {
  141. logger.Warn("graceful shutdown", "err", err)
  142. }
  143. logger.Info("bye")
  144. }