diff --git a/common/settings/structs.go b/common/settings/structs.go new file mode 100644 index 00000000..4ac00cb8 --- /dev/null +++ b/common/settings/structs.go @@ -0,0 +1,45 @@ +package settings + +import ( + "fmt" + + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" +) + +// MongoDB contains configuration settings for a MongoDB database. +type MongoDB struct { + MongodbURI string `split_words:"true" required:"true"` + MongodbDatabase string `split_words:"true" required:"true"` +} + +// Logger contains configuration settings for a logger. +type Logger struct { + LogLevel string `split_words:"true" default:"INFO"` +} + +// Monitoring contains configuration settings for the monitoring endpoints. +type Monitoring struct { + // MonitoringPort defines the TCP port for the monitoring endpoints. + MonitoringPort string `split_words:"true" default:"8000"` + PprofEnabled bool `split_words:"true" default:"false"` +} + +// LoadFromEnv loads the configuration settings from environment variables. +// +// If there is a .env file in the current directory, it will be used to +// populate the environment variables. +func LoadFromEnv[T any]() (*T, error) { + + // Load .env file (if it exists) + _ = godotenv.Load() + + // Load environment variables into a struct + var settings T + err := envconfig.Process("", &settings) + if err != nil { + return nil, fmt.Errorf("failed to read config from environment: %w", err) + } + + return &settings, nil +} diff --git a/event-watcher/cmd/service/main.go b/event-watcher/cmd/service/main.go new file mode 100644 index 00000000..024e764b --- /dev/null +++ b/event-watcher/cmd/service/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/wormhole-foundation/wormhole-explorer/common/health" + "github.com/wormhole-foundation/wormhole-explorer/common/logger" + "github.com/wormhole-foundation/wormhole-explorer/common/settings" + "github.com/wormhole-foundation/wormhole-explorer/event-watcher/config" + "github.com/wormhole-foundation/wormhole-explorer/event-watcher/http" + "go.uber.org/zap" +) + +func main() { + + // Load config + cfg, err := settings.LoadFromEnv[config.ServiceSettings]() + if err != nil { + log.Fatal("Error loading config: ", err) + } + + // Build rootLogger + rootLogger := logger.New("wormhole-explorer-core-contract-watcher", logger.WithLevel(cfg.LogLevel)) + + // Create top-level context + rootCtx, rootCtxCancel := context.WithCancel(context.Background()) + + //TODO: this requires merging https://github.com/wormhole-foundation/wormhole-explorer/pull/590, + // which is currently under code review. + // + //// Connect to MongoDB + //rootLogger.Info("connecting to MongoDB...") + //db, err := dbutil.Connect(rootCtx, cfg.MongodbURI, cfg.MongodbDatabase) + //if err != nil { + // rootLogger.Fatal("Error connecting to MongoDB", zap.Error(err)) + //} + + // Start serving the monitoring endpoints. + plugins := []health.Check{ /*health.Mongo(db.Database)*/ } //TODO blocked on https://github.com/wormhole-foundation/wormhole-explorer/pull/590 + server := http.NewServer( + rootLogger, + cfg.MonitoringPort, + cfg.PprofEnabled, + plugins..., + ) + server.Start() + + // Block until we get a termination signal or the context is cancelled + rootLogger.Info("waiting for termination signal or context cancellation...") + sigterm := make(chan os.Signal, 1) + signal.Notify(sigterm, syscall.SIGINT, syscall.SIGTERM) + select { + case <-rootCtx.Done(): + rootLogger.Warn("terminating (root context cancelled)") + case signal := <-sigterm: + rootLogger.Info("terminating (signal received)", zap.String("signal", signal.String())) + } + + // Shut down gracefully + rootLogger.Info("disconnecting from MongoDB...") + // db.Disconnect(rootCtx) //TODO blocked on https://github.com/wormhole-foundation/wormhole-explorer/pull/590 + rootLogger.Info("cancelling root context...") + rootCtxCancel() + rootLogger.Info("terminated") +} diff --git a/event-watcher/config/structs.go b/event-watcher/config/structs.go new file mode 100644 index 00000000..6e64562b --- /dev/null +++ b/event-watcher/config/structs.go @@ -0,0 +1,12 @@ +package config + +import ( + "github.com/wormhole-foundation/wormhole-explorer/common/settings" +) + +// ServiceSettings models the configuration settings for the event-watcher service. +type ServiceSettings struct { + settings.Logger + settings.MongoDB + settings.Monitoring +} diff --git a/event-watcher/http/server.go b/event-watcher/http/server.go new file mode 100644 index 00000000..38108591 --- /dev/null +++ b/event-watcher/http/server.go @@ -0,0 +1,120 @@ +package http + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/pprof" + "github.com/wormhole-foundation/wormhole-explorer/common/health" + "go.uber.org/zap" +) + +type Server struct { + app *fiber.App + port string + logger *zap.Logger +} + +func NewServer(logger *zap.Logger, port string, pprofEnabled bool, checks ...health.Check) *Server { + + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + + // config use of middlware. + if pprofEnabled { + app.Use(pprof.New()) + } + + ctrl := newController(checks, logger) + api := app.Group("/api") + api.Get("/health", ctrl.healthCheck) + api.Get("/ready", ctrl.readinessCheck) + + return &Server{ + app: app, + port: port, + logger: logger, + } +} + +// Start initiates the serving of HTTP requests. +func (s *Server) Start() { + + addr := ":" + s.port + s.logger.Info("Monitoring server starting", zap.String("bindAddress", addr)) + + go func() { + err := s.app.Listen(addr) + if err != nil { + s.logger.Error("Failed to start monitoring server", zap.Error(err), zap.String("bindAddress", addr)) + } + }() +} + +// Stop gracefully shuts down the server. +// +// Blocks until all active connections are closed. +func (s *Server) Stop() { + _ = s.app.Shutdown() +} + +type controller struct { + checks []health.Check + logger *zap.Logger +} + +// newController creates a Controller instance. +func newController(checks []health.Check, logger *zap.Logger) *controller { + return &controller{checks: checks, logger: logger} +} + +// healthCheck is the HTTP handler for the route `GET /health`. +func (c *controller) healthCheck(ctx *fiber.Ctx) error { + + response := ctx.JSON(struct { + Status string `json:"status"` + }{ + Status: "OK", + }) + + return response +} + +// readinessCheck is the HTTP handler for the route `GET /ready`. +func (c *controller) readinessCheck(ctx *fiber.Ctx) error { + + requestCtx := ctx.Context() + requestID := fmt.Sprintf("%v", requestCtx.Value("requestid")) + + // For every callback, check whether it is passing + for _, check := range c.checks { + if err := check(requestCtx); err != nil { + + c.logger.Error( + "Readiness check failed", + zap.Error(err), + zap.String("requestID", requestID), + ) + + // Return error information to the caller + response := ctx. + Status(fiber.StatusInternalServerError). + JSON(struct { + Ready string `json:"ready"` + Error string `json:"error"` + }{ + Ready: "NO", + Error: err.Error(), + }) + return response + } + } + + // All checks passed + response := ctx.Status(fiber.StatusOK). + JSON(struct { + Ready string `json:"ready"` + }{ + Ready: "OK", + }) + return response +}