main.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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. "log/slog"
  9. "net"
  10. "os"
  11. "os/signal"
  12. "syscall"
  13. "time"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  15. "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/concurrency"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/grpcserver"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  21. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  22. "git3.techno-world.net/lrosales/broad-announce/internal/pipeline"
  23. "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
  24. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  27. )
  28. // grpcDeps implements grpcserver.IngestdDeps so internal/grpcserver doesn't
  29. // import cmd/ingestd (which would create a cycle).
  30. type grpcDeps struct {
  31. pipeline pipeline.Deps
  32. logger *slog.Logger
  33. metrics *observability.IngestdMetrics
  34. }
  35. func (g *grpcDeps) GetPipeline() pipeline.Deps { return g.pipeline }
  36. func (g *grpcDeps) GetLogger() interface{ Info(string, ...any) } { return g.logger }
  37. func (g *grpcDeps) GetMetrics() *observability.IngestdMetrics { return g.metrics }
  38. func main() {
  39. cfg, err := config.LoadIngestd()
  40. if err != nil {
  41. // Logger isn't up yet; stderr is the only thing we have.
  42. os.Stderr.WriteString("config: " + err.Error() + "\n")
  43. os.Exit(1)
  44. }
  45. logger := observability.Init(cfg.Env, cfg.LogLevel, "ingestd")
  46. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  47. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  48. defer stop()
  49. // Redis
  50. r, err := store.ConnectRedis(ctx, cfg.RedisURL)
  51. if err != nil {
  52. logger.Error("redis connect", "err", err)
  53. os.Exit(1)
  54. }
  55. defer r.Close()
  56. logger.Info("redis connected")
  57. // NATS JetStream
  58. br, err := broker.Connect(ctx, cfg.NATSURL)
  59. if err != nil {
  60. logger.Error("nats connect", "err", err)
  61. os.Exit(1)
  62. }
  63. defer br.Close()
  64. logger.Info("nats connected", "url", cfg.NATSURL)
  65. // Metrics
  66. reg, m := observability.NewRegistry("ingestd")
  67. limiter := ratelimit.New(r.Client)
  68. // M6: sliding-window dedupe TTL from config. Default 300s
  69. // (5 min), overridable via BA_INGESTD_DEDUPE_TTL_SECONDS.
  70. dedTTL := time.Duration(cfg.DedupeTTLSeconds) * time.Second
  71. if dedTTL <= 0 {
  72. dedTTL = dedupe.DefaultWindow
  73. }
  74. ded := dedupe.New(r.Client, dedTTL)
  75. // M9: circuit breaker (layer 6). Wraps the NATS publish
  76. // call so a sick broker doesn't take down ingestd.
  77. cbCfg := circuitbreaker.Config{
  78. Name: "nats-publish",
  79. FailureThreshold: cfg.CircuitFailureThreshold,
  80. FailureWindow: time.Duration(cfg.CircuitFailureWindowSecs) * time.Second,
  81. OpenDuration: time.Duration(cfg.CircuitOpenDurationSecs) * time.Second,
  82. MaxHalfOpen: cfg.CircuitMaxHalfOpen,
  83. }
  84. cb := circuitbreaker.New(cbCfg)
  85. // Also report CB state changes to Prometheus.
  86. cb.Measure = func(state int, err error) {
  87. m.CBState.WithLabelValues("nats").Set(float64(state))
  88. }
  89. // Initialize gauge to CLOSED (0) so Prometheus sees the metric before first transition.
  90. m.CBState.WithLabelValues("nats").Set(0)
  91. logger.Info("circuit breaker configured",
  92. "failure_threshold", cfg.CircuitFailureThreshold,
  93. "failure_window_sec", cfg.CircuitFailureWindowSecs,
  94. "open_duration_sec", cfg.CircuitOpenDurationSecs,
  95. "max_half_open", cfg.CircuitMaxHalfOpen,
  96. )
  97. // M9: quarantine manager (layer 7). Per-source error-rate
  98. // limiter backed by Redis so the ban is shared across
  99. // multiple ingestd instances.
  100. quarantineCfg := quarantine.Config{
  101. HitsThreshold: cfg.QuarantineHitsThreshold,
  102. HitsWindow: time.Duration(cfg.QuarantineWindowSeconds) * time.Second,
  103. BanDuration: time.Duration(cfg.QuarantineDurationSecond) * time.Second,
  104. }
  105. q := quarantine.New(r.Client, quarantineCfg)
  106. logger.Info("quarantine configured",
  107. "hits_threshold", cfg.QuarantineHitsThreshold,
  108. "hits_window_sec", cfg.QuarantineWindowSeconds,
  109. "ban_duration_sec", cfg.QuarantineDurationSecond,
  110. )
  111. // M0 source registry: loaded from env. M2 replaces with DB.
  112. sources := loadSourcesFromEnv(logger)
  113. js, err := br.NC().JetStream()
  114. if err != nil {
  115. logger.Error("nats jetstream context", "err", err)
  116. os.Exit(1)
  117. }
  118. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  119. // gate is used by the HTTP server (via the WS path; HTTP's
  120. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  121. // milestone rollout) and the WebSocket ingest path.
  122. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  123. defer perIP.Close()
  124. // M5: in-process tail hub. nil-safe; both the HTTP and
  125. // MQTT paths call Tail.Publish if non-nil.
  126. hub := tailhub.NewHub()
  127. // M6: per-source monotonic max tracker for dedupe_count.
  128. // Shared by all transports so the
  129. // ba_ingestd_dedupe_count_max_observed gauge reflects the
  130. // global peak across HTTP, MQTT, and WS.
  131. maxSeen := observability.NewMaxSeen()
  132. // grpcDeps adapts ingestd's concrete types to the grpcserver.IngestdDeps
  133. // interface so internal/grpcserver doesn't need to import cmd/ingestd.
  134. grpcDep := &grpcDeps{
  135. pipeline: pipeline.Deps{
  136. Logger: logger.With("component", "grpc"),
  137. Metrics: m,
  138. Limiter: limiter,
  139. Deduper: ded,
  140. Sources: sources,
  141. JetStream: pipeline.NewNatsPublisher(js),
  142. CompanyRatePerSec: cfg.RateLimitPerCompany,
  143. Tail: hub,
  144. Transport: "grpc",
  145. MaxSeen: maxSeen,
  146. CircuitBreaker: cb,
  147. Quarantine: q,
  148. },
  149. logger: logger,
  150. metrics: m,
  151. }
  152. deps := &httpDeps{
  153. processDeps: processDeps{pipeline.Deps{
  154. Logger: logger.With("component", "http"),
  155. Metrics: m,
  156. Limiter: limiter,
  157. Deduper: ded,
  158. Sources: sources,
  159. JetStream: pipeline.NewNatsPublisher(js),
  160. CompanyRatePerSec: cfg.RateLimitPerCompany,
  161. Tail: hub,
  162. Transport: "http",
  163. MaxSeen: maxSeen,
  164. CircuitBreaker: cb,
  165. Quarantine: q,
  166. }},
  167. MaxBytes: cfg.MaxPayloadBytes,
  168. }
  169. // M5: WS ingest + tail handler. The WS path uses a copy of
  170. // deps.processDeps with Transport="ws" so the structured
  171. // log lines and the tail event label are correct.
  172. wsDeps := &wsIngestDeps{
  173. processDeps: deps.processDeps,
  174. PerIP: perIP,
  175. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  176. ReadDeadline: 30 * time.Second,
  177. WriteDeadline: 10 * time.Second,
  178. }
  179. // Override the transport on the wsDeps copy. We have to
  180. // use a fresh processDeps because the embedded struct is a
  181. // value, not a pointer, in the struct literal above.
  182. wsDeps.processDeps.Logger = logger.With("component", "ws")
  183. wsDeps.processDeps.Transport = "ws"
  184. tailDeps := &wsTailDeps{
  185. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  186. Hub: hub,
  187. Metrics: m,
  188. }
  189. // HTTP server
  190. srv := httpserver.New(httpserver.Config{
  191. Addr: cfg.HTTPAddr,
  192. ServiceName: "ingestd",
  193. ShutdownGrace: cfg.ShutdownGrace,
  194. }, logger, observability.MetricsHandler(reg))
  195. RegisterRoutes(srv.Mux(), deps)
  196. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  197. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  198. // empty. The subscriber shares the processDeps with the HTTP
  199. // handler so the dedupe window, rate limits, and metrics are
  200. // per-source exactly once across both transports.
  201. mqttCfg := loadMQTTConfig(logger)
  202. mqttDeps := deps.processDeps
  203. mqttDeps.Logger = logger.With("component", "mqtt")
  204. mqttDeps.Transport = "mqtt"
  205. mqttErrCh := make(chan error, 1)
  206. go func() {
  207. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  208. }()
  209. grpcSrv, err := grpcserver.NewForIngestd(grpcserver.Config{
  210. Addr: cfg.GRPCAddr,
  211. MaxInflight: cfg.GRPCMaxInflight,
  212. }, grpcDep)
  213. if err != nil {
  214. logger.Error("grpcserver.NewForIngestd", "err", err)
  215. os.Exit(1)
  216. }
  217. grpcLn, err := net.Listen("tcp", cfg.GRPCAddr)
  218. if err != nil {
  219. logger.Error("grpc listen", "addr", cfg.GRPCAddr, "err", err)
  220. os.Exit(1)
  221. }
  222. go func() { grpcSrv.Serve(grpcLn) }()
  223. logger.Info("grpc server started", "addr", cfg.GRPCAddr)
  224. // Run + graceful shutdown
  225. errCh := make(chan error, 1)
  226. go func() { errCh <- srv.Start() }()
  227. select {
  228. case <-ctx.Done():
  229. logger.Info("shutdown signal received")
  230. case err := <-errCh:
  231. if err != nil {
  232. logger.Error("http server", "err", err)
  233. os.Exit(1)
  234. }
  235. case err := <-mqttErrCh:
  236. if err != nil {
  237. logger.Error("mqtt subscriber", "err", err)
  238. os.Exit(1)
  239. }
  240. }
  241. if err := srv.Shutdown(ctx); err != nil {
  242. logger.Warn("graceful shutdown", "err", err)
  243. }
  244. logger.Info("bye")
  245. }