diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b351e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.yaml +.codegpt +bin/ \ No newline at end of file diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..a3d101e --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,6 @@ +with-expecter: true +dir: "{{.InterfaceDir}}/mocks/" +packages: + github.com/kilnfi/cardano-validator-watcher/internal/blockfrost: + interfaces: + Client: \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2da365a --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: generate +generate: + @mockery + +.PHONY: build +build: generate + @go build -ldflags="-s -w" -o bin/cardano-validator-watcher cmd/watcher/main.go + +.PHONY: run +run: generate + @go run cmd/watcher/main.go --config config.yaml + +.PHONY: tests +tests: + @go test -v ./... + +.PHONY: coverage +coverage: + @go test -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out + +.PHONY: lint +lint: + @golangci-lint run ./... + +.PHONY: clean +clean: + @echo "cleaning up..." + @rm -rf *.db* \ No newline at end of file diff --git a/cmd/watcher/app/config/config.go b/cmd/watcher/app/config/config.go new file mode 100644 index 0000000..fd83fa8 --- /dev/null +++ b/cmd/watcher/app/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/kilnfi/cardano-validator-watcher/internal/pools" +) + +type Config struct { + Pools pools.Pools `mapstructure:"pools"` + HTTP HTTPConfig `mapstructure:"http"` + Network string `mapstructure:"network"` + Blockfrost BlockFrostConfig `mapstructure:"blockfrost"` + PoolWatcherConfig PoolWatcherConfig `mapstructure:"pool-watcher"` +} + +type PoolWatcherConfig struct { + Enabled bool `mapstructure:"enabled"` + RefreshInterval int `mapstructure:"refresh-interval"` +} + +type HTTPConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +type BlockFrostConfig struct { + ProjectID string `mapstructure:"project-id"` + Endpoint string `mapstructure:"endpoint"` + MaxRoutines int `mapstructure:"max-routines"` + Timeout int `mapstructure:"timeout"` +} + +func (c *Config) Validate() error { + switch c.Network { + case "mainnet", "preprod": + default: + return fmt.Errorf("invalid network: %s. Network must be either %s or %s", c.Network, "mainnet", "preprod") + } + + if len(c.Pools) == 0 { + return errors.New("at least one pool must be defined") + } + for _, pool := range c.Pools { + if pool.Instance == "" { + return errors.New("instance is required for all pools") + } + if pool.ID == "" { + return errors.New("id is required for all pools") + } + if pool.Name == "" { + return errors.New("name is required for all pools") + } + if pool.Key == "" { + return errors.New("key is required for all pools") + } + } + + activePools := c.Pools.GetActivePools() + if len(activePools) == 0 { + return errors.New("at least one active pool must be defined") + } + + if c.Blockfrost.ProjectID == "" || c.Blockfrost.Endpoint == "" { + return errors.New("blockfrost project-id and endpoint are required") + } + + return nil +} diff --git a/cmd/watcher/app/watcher.go b/cmd/watcher/app/watcher.go new file mode 100644 index 0000000..eb17e0d --- /dev/null +++ b/cmd/watcher/app/watcher.go @@ -0,0 +1,256 @@ +package app + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/kilnfi/cardano-validator-watcher/cmd/watcher/app/config" + "github.com/kilnfi/cardano-validator-watcher/internal/blockfrost" + "github.com/kilnfi/cardano-validator-watcher/internal/blockfrost/blockfrostapi" + "github.com/kilnfi/cardano-validator-watcher/internal/metrics" + "github.com/kilnfi/cardano-validator-watcher/internal/pools" + "github.com/kilnfi/cardano-validator-watcher/internal/server/http" + "github.com/kilnfi/cardano-validator-watcher/internal/watcher" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/sync/errgroup" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + configFile string + server *http.Server + cfg *config.Config + logger *slog.Logger +) + +func init() { + cobra.OnInitialize(initLogger) + cobra.OnInitialize(loadConfig) +} + +func initLogger() { + var logLevel slog.Level + switch viper.GetString("log-level") { + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + case "debug": + logLevel = slog.LevelDebug + default: + logLevel = slog.LevelInfo + } + + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + })) + slog.SetDefault(logger) +} + +func NewWatcherCommand() *cobra.Command { + cmd := &cobra.Command{ + TraverseChildren: true, + Use: "cardano-validator-watcher", + Short: "cardano validator watcher is used to monitor our cardano pools", + Long: `cardano validator watcher is a long-running program designed + to collect metrics for monitoring our Cardano validation nodes. + This tool helps us ensure the health and performance of our nodes in the Cardano network.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: run, + } + + cmd.Flags().StringVarP(&configFile, "config", "", "", "config file (default is config.yml)") + cmd.Flags().StringP("log-level", "", "info", "config file (default is config.yml)") + cmd.Flags().StringP("http-server-host", "", http.ServerDefaultHost, "host on which HTTP server should listen") + cmd.Flags().IntP("http-server-port", "", http.ServerDefaultPort, "port on which HTTP server should listen") + cmd.Flags().StringP("network", "", "preprod", "cardano network ID") + cmd.Flags().StringP("blockfrost-project-id", "", "", "blockfrost project id") + cmd.Flags().StringP("blockfrost-endpoint", "", "", "blockfrost API endpoint") + cmd.Flags().IntP("blockfrost-max-routines", "", 10, "number of routines used by blockfrost to perform concurrent actions") + cmd.Flags().IntP("blockfrost-timeout", "", 60, "Timeout for requests to the Blockfrost API (in seconds)") + cmd.Flags().BoolP("pool-watcher-enabled", "", true, "Enable pool watcher") + cmd.Flags().IntP("pool-watcher-refresh-interval", "", 60, "Interval at which the pool watcher collects data about the monitored pools (in seconds)") + + // bind flag to viper + checkError(viper.BindPFlag("log-level", cmd.Flag("log-level")), "unable to bind log-level flag") + checkError(viper.BindPFlag("http.host", cmd.Flag("http-server-host")), "unable to bind http-server-host flag") + checkError(viper.BindPFlag("http.port", cmd.Flag("http-server-port")), "unable to bind http-server-port flag") + checkError(viper.BindPFlag("network", cmd.Flag("network")), "unable to bind network flag") + checkError(viper.BindPFlag("blockfrost.project-id", cmd.Flag("blockfrost-project-id")), "unable to bind blockfrost-project-id flag") + checkError(viper.BindPFlag("blockfrost.endpoint", cmd.Flag("blockfrost-endpoint")), "unable to bind blockfrost-endpoint flag") + checkError(viper.BindPFlag("blockfrost.max-routines", cmd.Flag("blockfrost-max-routines")), "unable to bind blockfrost-max-routines flag") + checkError(viper.BindPFlag("blockfrost.timeout", cmd.Flag("blockfrost-timeout")), "unable to bind blockfrost-timeout flag") + checkError(viper.BindPFlag("pool-watcher.enabled", cmd.Flag("pool-watcher-enabled")), "unable to bind pool-watcher-enabled flag") + checkError(viper.BindPFlag("pool-watcher.refresh-interval", cmd.Flag("pool-watcher-refresh-interval")), "unable to bind pool-watcher-refresh-interval flag") + + return cmd +} + +// loadConfig read the configuration and load it. +func loadConfig() { + if configFile != "" { + viper.SetConfigFile(configFile) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + } + + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.AutomaticEnv() + + // read the config file + if err := viper.ReadInConfig(); err != nil { + logger.Error("unable to read config file", slog.String("error", err.Error())) + os.Exit(1) + } + + // unmarshal the config + cfg = &config.Config{} + if err := viper.Unmarshal(cfg); err != nil { + logger.Error("unable to unmarshal config", slog.String("error", err.Error())) + os.Exit(1) + } + + // validate the config + if err := cfg.Validate(); err != nil { + logger.Error("invalid configuration", slog.String("error", err.Error())) + os.Exit(1) + } +} + +func run(_ *cobra.Command, _ []string) error { + // Initialize context and cancel function + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize signal channel for handling interrupts + ctx, cancel = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer cancel() + + eg, ctx := errgroup.WithContext(ctx) + + // Initialize blockfrost and cardano clients with options + blockfrost := createBlockfrostClient() + + // Initialize prometheus metrics + registry := prometheus.NewRegistry() + metrics := metrics.NewCollection() + metrics.MustRegister(registry) + + // Start HTTP server + if err := startHTTPServer(eg, registry); err != nil { + return fmt.Errorf("unable to start http server: %w", err) + } + + // Start Pool Watcher + if cfg.PoolWatcherConfig.Enabled { + startPoolWatcher(ctx, eg, blockfrost, metrics, cfg.Pools) + } + + <-ctx.Done() + logger.Info("shutting down") + + // shutting down HTTP server + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + logger.Info("stopping http server") + if err := server.Stop(ctx); err != nil { + logger.Error("unable to stop http service", slog.String("error", err.Error())) + } + + if err := eg.Wait(); err != nil { + if errors.Is(err, context.Canceled) { + logger.Info("Program interrupted by user") + return nil + } + return fmt.Errorf("error during execution: %w", err) + } + return nil +} + +func createBlockfrostClient() blockfrost.Client { + opts := blockfrostapi.ClientOptions{ + ProjectID: cfg.Blockfrost.ProjectID, + Server: cfg.Blockfrost.Endpoint, + MaxRoutines: cfg.Blockfrost.MaxRoutines, + Timeout: time.Second * time.Duration(cfg.Blockfrost.Timeout), + } + return blockfrostapi.NewClient(opts) +} + +func startHTTPServer(eg *errgroup.Group, registry *prometheus.Registry) error { + var err error + + server, err = http.New( + registry, + http.WithHost(cfg.HTTP.Host), + http.WithPort(cfg.HTTP.Port), + ) + if err != nil { + return fmt.Errorf("unable to create http server: %w", err) + } + + eg.Go(func() error { + logger.Info( + "starting http server", + slog.String("component", "http-server"), + slog.String("addr", fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)), + ) + if err := server.Start(); err != nil { + return fmt.Errorf("unable to start http server: %w", err) + } + return nil + }) + + return nil +} + +// startPoolWatcher starts the pool watcher service +func startPoolWatcher( + ctx context.Context, + eg *errgroup.Group, + blockfrost blockfrost.Client, + metrics *metrics.Collection, + pools pools.Pools, +) { + eg.Go(func() error { + options := watcher.PoolWatcherOptions{ + RefreshInterval: time.Second * time.Duration(cfg.PoolWatcherConfig.RefreshInterval), + Network: cfg.Network, + } + logger.Info( + "starting watcher", + slog.String("component", "pool-watcher"), + ) + poolWatcher, err := watcher.NewPoolWatcher(blockfrost, metrics, pools, options) + if err != nil { + return fmt.Errorf("unable to create pool watcher: %w", err) + } + if err := poolWatcher.Start(ctx); err != nil { + return fmt.Errorf("unable to start pool watcher: %w", err) + } + return nil + }) +} + +// checkError is a helper function to log an error and exit the program +// used for the flag parsing +func checkError(err error, msg string) { + if err != nil { + logger.Error(msg, slog.String("error", err.Error())) + os.Exit(1) + } +} diff --git a/cmd/watcher/main.go b/cmd/watcher/main.go new file mode 100644 index 0000000..f0a01b3 --- /dev/null +++ b/cmd/watcher/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/kilnfi/cardano-validator-watcher/cmd/watcher/app" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + command := app.NewWatcherCommand() + if err := command.Execute(); err != nil { + logger.Error("command execution failed", slog.String("error", err.Error())) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a502a52 --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module github.com/kilnfi/cardano-validator-watcher + +go 1.22.5 + +replace github.com/mitchellh/mapstructure => github.com/go-viper/mapstructure v1.6.0 + +require ( + github.com/blockfrost/blockfrost-go v0.2.2 + github.com/dgraph-io/ristretto v0.2.0 + github.com/prometheus/client_golang v1.19.1 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 + golang.org/x/sync v0.7.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a5fae5 --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blockfrost/blockfrost-go v0.2.2 h1:Odzw4BC46M5Fsxoj1fM48YbQkrGjQZqex692k54AadU= +github.com/blockfrost/blockfrost-go v0.2.2/go.mod h1:XdD+mryM/Rd/MqW1MfSQ0+Xfu2YnOGuwqMpLQa0jHvM= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-viper/mapstructure v1.6.0 h1:0WdPOF2rmmQDN1xo8qIgxyugvLp71HrZSWyGLxofobw= +github.com/go-viper/mapstructure v1.6.0/go.mod h1:FcbLReH7/cjaC0RVQR+LHFIrBhHF3s1e/ud1KMDoBVw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/blockfrost/blockfrost.go b/internal/blockfrost/blockfrost.go new file mode 100644 index 0000000..c61cc4c --- /dev/null +++ b/internal/blockfrost/blockfrost.go @@ -0,0 +1,26 @@ +package blockfrost + +import ( + "context" + + "github.com/blockfrost/blockfrost-go" +) + +type Client interface { + GetLatestEpoch(ctx context.Context) (blockfrost.Epoch, error) + GetLatestBlock(ctx context.Context) (blockfrost.Block, error) + GetPoolInfo(ctx context.Context, PoolID string) (blockfrost.Pool, error) + GetPoolMetadata(ctx context.Context, PoolID string) (blockfrost.PoolMetadata, error) + GetPoolRelays(ctx context.Context, PoolID string) ([]blockfrost.PoolRelay, error) + GetBlockDistributionByPool(ctx context.Context, epoch int, PoolID string) ([]string, error) + GetLastBlockFromPreviousEpoch(ctx context.Context, prevEpoch int) (blockfrost.Block, error) + GetEpochParameters(ctx context.Context, epoch int) (blockfrost.EpochParameters, error) + GetBlockBySlotAndEpoch(ctx context.Context, epoch int, slot int) (blockfrost.Block, error) + GetBlockBySlot(ctx context.Context, slot int) (blockfrost.Block, error) + Health(ctx context.Context) (blockfrost.Health, error) + GetFirstSlotInEpoch(ctx context.Context, epoch int) (int, error) + GetFirstBlockInEpoch(ctx context.Context, epoch int) (blockfrost.Block, error) + GetGenesisInfo(ctx context.Context) (blockfrost.GenesisBlock, error) + GetAllPools(ctx context.Context) ([]string, error) + GetNetworkInfo(ctx context.Context) (blockfrost.NetworkInfo, error) +} diff --git a/internal/blockfrost/blockfrostapi/blockfrost.go b/internal/blockfrost/blockfrostapi/blockfrost.go new file mode 100644 index 0000000..88ca646 --- /dev/null +++ b/internal/blockfrost/blockfrostapi/blockfrost.go @@ -0,0 +1,175 @@ +package blockfrostapi + +import ( + "context" + "net/http" + "time" + + "github.com/blockfrost/blockfrost-go" + bf "github.com/kilnfi/cardano-validator-watcher/internal/blockfrost" +) + +type Client struct { + blockfrost blockfrost.APIClient +} + +var _ bf.Client = (*Client)(nil) + +type ClientOptions struct { + ProjectID string + Server string + MaxRoutines int + Timeout time.Duration +} + +func NewClient(opts ClientOptions) *Client { + return &Client{ + blockfrost: blockfrost.NewAPIClient( + blockfrost.APIClientOptions{ + ProjectID: opts.ProjectID, + Server: opts.Server, + MaxRoutines: opts.MaxRoutines, + Client: &http.Client{ + Timeout: opts.Timeout, + }, + }, + ), + } +} + +//nolint:wrapcheck +func (c *Client) GetLatestEpoch(ctx context.Context) (blockfrost.Epoch, error) { + return c.blockfrost.EpochLatest(ctx) +} + +//nolint:wrapcheck +func (c *Client) GetLatestBlock(ctx context.Context) (blockfrost.Block, error) { + return c.blockfrost.BlockLatest(ctx) +} + +//nolint:wrapcheck +func (c *Client) GetPoolInfo(ctx context.Context, PoolID string) (blockfrost.Pool, error) { + return c.blockfrost.Pool(ctx, PoolID) +} + +//nolint:wrapcheck +func (c *Client) GetPoolMetadata(ctx context.Context, PoolID string) (blockfrost.PoolMetadata, error) { + return c.blockfrost.PoolMetadata(ctx, PoolID) +} + +//nolint:wrapcheck +func (c *Client) GetPoolRelays(ctx context.Context, PoolID string) ([]blockfrost.PoolRelay, error) { + return c.blockfrost.PoolRelays(ctx, PoolID) +} + +func (c *Client) GetBlockDistributionByPool(ctx context.Context, epoch int, PoolID string) ([]string, error) { + resultChan := c.blockfrost.EpochBlockDistributionByPoolAll(ctx, epoch, PoolID) + results := []string{} + for result := range resultChan { + if result.Err != nil { + return nil, result.Err + } + + results = append(results, result.Res...) + } + + return results, nil +} + +//nolint:wrapcheck +func (c *Client) GetEpochParameters(ctx context.Context, epoch int) (blockfrost.EpochParameters, error) { + return c.blockfrost.EpochParameters(ctx, epoch) +} + +//nolint:wrapcheck +func (c *Client) Health(ctx context.Context) (blockfrost.Health, error) { + return c.blockfrost.Health(ctx) +} + +//nolint:wrapcheck +func (c *Client) GetBlockBySlotAndEpoch(ctx context.Context, slot int, epoch int) (blockfrost.Block, error) { + return c.blockfrost.BlocksBySlotAndEpoch(ctx, slot, epoch) +} + +//nolint:wrapcheck +func (c *Client) GetBlockBySlot(ctx context.Context, slot int) (blockfrost.Block, error) { + return c.blockfrost.BlockBySlot(ctx, slot) +} + +//nolint:wrapcheck +func (c *Client) GetLastBlockFromPreviousEpoch(ctx context.Context, prevEpoch int) (blockfrost.Block, error) { + response := c.blockfrost.EpochBlockDistributionAll(ctx, prevEpoch) + results := []string{} + + for result := range response { + if result.Err != nil { + return blockfrost.Block{}, result.Err + } + + results = append(results, result.Res...) + } + + lastBlock := results[len(results)-1] + return c.blockfrost.Block(ctx, lastBlock) +} + +//nolint:wrapcheck +func (c *Client) GetFirstBlockInEpoch(ctx context.Context, epoch int) (blockfrost.Block, error) { + response := c.blockfrost.EpochBlockDistributionAll(ctx, epoch) + results := []string{} + + for result := range response { + if result.Err != nil { + return blockfrost.Block{}, result.Err + } + + results = append(results, result.Res...) + } + + return c.blockfrost.Block(ctx, results[0]) +} + +//nolint:wrapcheck +func (c *Client) GetFirstSlotInEpoch(ctx context.Context, epoch int) (int, error) { + resultChan := c.blockfrost.EpochBlockDistributionAll(ctx, epoch) + results := []string{} + for result := range resultChan { + if result.Err != nil { + return 0, result.Err + } + + results = append(results, result.Res...) + } + + firstBlock := results[0] + block, err := c.blockfrost.Block(ctx, firstBlock) + if err != nil { + return 0, err + } + + return block.Slot, nil +} + +//nolint:wrapcheck +func (c *Client) GetGenesisInfo(ctx context.Context) (blockfrost.GenesisBlock, error) { + return c.blockfrost.Genesis(ctx) +} + +func (c *Client) GetAllPools(ctx context.Context) ([]string, error) { + resultChan := c.blockfrost.PoolsAll(ctx) + results := []string{} + for result := range resultChan { + if result.Err != nil { + return nil, result.Err + } + + results = append(results, result.Res...) + } + + return results, nil +} + +//nolint:wrapcheck +func (c *Client) GetNetworkInfo(ctx context.Context) (blockfrost.NetworkInfo, error) { + return c.blockfrost.Network(ctx) +} diff --git a/internal/blockfrost/blockfrostapi/blockfrost_test.go b/internal/blockfrost/blockfrostapi/blockfrost_test.go new file mode 100644 index 0000000..1d977ae --- /dev/null +++ b/internal/blockfrost/blockfrostapi/blockfrost_test.go @@ -0,0 +1,208 @@ +package blockfrostapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blockfrost/blockfrost-go" +) + +var ( + server *httptest.Server +) + +func TestGetLatestEpoch(t *testing.T) { + ctx := context.Background() + mux := http.NewServeMux() + want := blockfrost.Epoch{ + ActiveStake: &[]string{"784953934049314"}[0], + BlockCount: 21298, + EndTime: 1603835086, + Epoch: 100, + Fees: "4203312194", + FirstBlockTime: 1603403092, + LastBlockTime: 1603835084, + Output: "7849943934049314", + StartTime: 1603403091, + TxCount: 17856, + } + mux.HandleFunc("/api/v0/epochs/latest", func(res http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(want) + if err != nil { + t.Fatalf("could not marshal response: %v", err) + } + res.WriteHeader(http.StatusOK) + if _, err := res.Write(payload); err != nil { + t.Fatalf("could not write response: %v", err) + } + }) + server = httptest.NewServer(mux) + + serverURL, _ := url.JoinPath(server.URL, "/api/v0") + blockfrostClientOpts := ClientOptions{ + ProjectID: "projectID", + Server: serverURL, + MaxRoutines: 0, + Timeout: 0, + } + client := NewClient(blockfrostClientOpts) + epoch, err := client.GetLatestEpoch(ctx) + require.NoError(t, err) + assert.Equal(t, want, epoch) +} + +func TestGetLatestBlock(t *testing.T) { + ctx := context.Background() + mux := http.NewServeMux() + + want := blockfrost.Block{ + Time: 1641338934, + Height: 15243593, + Hash: "4ea1ba291e8eef538635a53e59fddba7810d1679631cc3aed7c8e6c4091a516a", + Slot: 412162133, + Epoch: 425, + EpochSlot: 12, + SlotLeader: "kiln", + } + mux.HandleFunc("/api/v0/blocks/latest", func(res http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(want) + if err != nil { + t.Fatalf("could not marshal response: %v", err) + } + res.WriteHeader(http.StatusOK) + if _, err := res.Write(payload); err != nil { + t.Fatalf("could not write response: %v", err) + } + }) + server = httptest.NewServer(mux) + + serverURL, _ := url.JoinPath(server.URL, "/api/v0") + blockfrostClientOpts := ClientOptions{ + ProjectID: "projectID", + Server: serverURL, + MaxRoutines: 0, + Timeout: 0, + } + client := NewClient(blockfrostClientOpts) + block, err := client.GetLatestBlock(ctx) + require.NoError(t, err) + assert.Equal(t, want, block) +} + +func TestGetPoolInfo(t *testing.T) { + ctx := context.Background() + mux := http.NewServeMux() + + want := blockfrost.Pool{ + PoolID: "pool-0", + Hex: "pool-0-hex", + VrfKey: "pool-0-vrf", + BlocksMinted: 70, + LiveStake: "10000", + LiveSaturation: 0.5, + ActiveStake: "10000", + DeclaredPledge: "100", + LivePledge: "200", + } + mux.HandleFunc("/api/v0/pools/pool-0", func(res http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(want) + if err != nil { + t.Fatalf("could not marshal response: %v", err) + } + res.WriteHeader(http.StatusOK) + if _, err := res.Write(payload); err != nil { + t.Fatalf("could not write response: %v", err) + } + }) + server = httptest.NewServer(mux) + + serverURL, _ := url.JoinPath(server.URL, "/api/v0") + blockfrostClientOpts := ClientOptions{ + ProjectID: "projectID", + Server: serverURL, + MaxRoutines: 0, + Timeout: 0, + } + client := NewClient(blockfrostClientOpts) + pool, err := client.GetPoolInfo(ctx, "pool-0") + require.NoError(t, err) + assert.Equal(t, want, pool) +} + +func TestGetPoolMetadata(t *testing.T) { + ctx := context.Background() + mux := http.NewServeMux() + + want := blockfrost.PoolMetadata{ + PoolID: "pool-0", + Hex: "pool-0-hex", + Ticker: &[]string{"Pool-0"}[0], + Name: &[]string{"Pool-0"}[0], + } + mux.HandleFunc("/api/v0/pools/pool-0/metadata", func(res http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(want) + if err != nil { + t.Fatalf("could not marshal response: %v", err) + } + res.WriteHeader(http.StatusOK) + if _, err := res.Write(payload); err != nil { + t.Fatalf("could not write response: %v", err) + } + }) + server = httptest.NewServer(mux) + + serverURL, _ := url.JoinPath(server.URL, "/api/v0") + blockfrostClientOpts := ClientOptions{ + ProjectID: "projectID", + Server: serverURL, + MaxRoutines: 0, + Timeout: 0, + } + client := NewClient(blockfrostClientOpts) + poolm, err := client.GetPoolMetadata(ctx, "pool-0") + require.NoError(t, err) + assert.Equal(t, want, poolm) +} + +func TestGetPoolRelays(t *testing.T) { + ctx := context.Background() + mux := http.NewServeMux() + + want := []blockfrost.PoolRelay{ + { + Ipv4: &[]string{"10.0.0.1"}[0], + DNS: &[]string{"relay-0.example.com"}[0], + Port: 3001, + }, + } + mux.HandleFunc("/api/v0/pools/pool-0/relays", func(res http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(want) + if err != nil { + t.Fatalf("could not marshal response: %v", err) + } + res.WriteHeader(http.StatusOK) + if _, err := res.Write(payload); err != nil { + t.Fatalf("could not write response: %v", err) + } + }) + server = httptest.NewServer(mux) + + serverURL, _ := url.JoinPath(server.URL, "/api/v0") + blockfrostClientOpts := ClientOptions{ + ProjectID: "projectID", + Server: serverURL, + MaxRoutines: 0, + Timeout: 0, + } + client := NewClient(blockfrostClientOpts) + poolRelays, err := client.GetPoolRelays(ctx, "pool-0") + require.NoError(t, err) + assert.Equal(t, want, poolRelays) +} diff --git a/internal/blockfrost/mocks/mock_Client.go b/internal/blockfrost/mocks/mock_Client.go new file mode 100644 index 0000000..005dbda --- /dev/null +++ b/internal/blockfrost/mocks/mock_Client.go @@ -0,0 +1,952 @@ +// Code generated by mockery v2.43.0. DO NOT EDIT. + +package blockfrost + +import ( + context "context" + + blockfrost_go "github.com/blockfrost/blockfrost-go" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetAllPools provides a mock function with given fields: ctx +func (_m *MockClient) GetAllPools(ctx context.Context) ([]string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetAllPools") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []string); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetAllPools_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllPools' +type MockClient_GetAllPools_Call struct { + *mock.Call +} + +// GetAllPools is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetAllPools(ctx interface{}) *MockClient_GetAllPools_Call { + return &MockClient_GetAllPools_Call{Call: _e.mock.On("GetAllPools", ctx)} +} + +func (_c *MockClient_GetAllPools_Call) Run(run func(ctx context.Context)) *MockClient_GetAllPools_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetAllPools_Call) Return(_a0 []string, _a1 error) *MockClient_GetAllPools_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetAllPools_Call) RunAndReturn(run func(context.Context) ([]string, error)) *MockClient_GetAllPools_Call { + _c.Call.Return(run) + return _c +} + +// GetBlockBySlot provides a mock function with given fields: ctx, slot +func (_m *MockClient) GetBlockBySlot(ctx context.Context, slot int) (blockfrost_go.Block, error) { + ret := _m.Called(ctx, slot) + + if len(ret) == 0 { + panic("no return value specified for GetBlockBySlot") + } + + var r0 blockfrost_go.Block + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (blockfrost_go.Block, error)); ok { + return rf(ctx, slot) + } + if rf, ok := ret.Get(0).(func(context.Context, int) blockfrost_go.Block); ok { + r0 = rf(ctx, slot) + } else { + r0 = ret.Get(0).(blockfrost_go.Block) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, slot) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetBlockBySlot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlockBySlot' +type MockClient_GetBlockBySlot_Call struct { + *mock.Call +} + +// GetBlockBySlot is a helper method to define mock.On call +// - ctx context.Context +// - slot int +func (_e *MockClient_Expecter) GetBlockBySlot(ctx interface{}, slot interface{}) *MockClient_GetBlockBySlot_Call { + return &MockClient_GetBlockBySlot_Call{Call: _e.mock.On("GetBlockBySlot", ctx, slot)} +} + +func (_c *MockClient_GetBlockBySlot_Call) Run(run func(ctx context.Context, slot int)) *MockClient_GetBlockBySlot_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_GetBlockBySlot_Call) Return(_a0 blockfrost_go.Block, _a1 error) *MockClient_GetBlockBySlot_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetBlockBySlot_Call) RunAndReturn(run func(context.Context, int) (blockfrost_go.Block, error)) *MockClient_GetBlockBySlot_Call { + _c.Call.Return(run) + return _c +} + +// GetBlockBySlotAndEpoch provides a mock function with given fields: ctx, epoch, slot +func (_m *MockClient) GetBlockBySlotAndEpoch(ctx context.Context, epoch int, slot int) (blockfrost_go.Block, error) { + ret := _m.Called(ctx, epoch, slot) + + if len(ret) == 0 { + panic("no return value specified for GetBlockBySlotAndEpoch") + } + + var r0 blockfrost_go.Block + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, int) (blockfrost_go.Block, error)); ok { + return rf(ctx, epoch, slot) + } + if rf, ok := ret.Get(0).(func(context.Context, int, int) blockfrost_go.Block); ok { + r0 = rf(ctx, epoch, slot) + } else { + r0 = ret.Get(0).(blockfrost_go.Block) + } + + if rf, ok := ret.Get(1).(func(context.Context, int, int) error); ok { + r1 = rf(ctx, epoch, slot) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetBlockBySlotAndEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlockBySlotAndEpoch' +type MockClient_GetBlockBySlotAndEpoch_Call struct { + *mock.Call +} + +// GetBlockBySlotAndEpoch is a helper method to define mock.On call +// - ctx context.Context +// - epoch int +// - slot int +func (_e *MockClient_Expecter) GetBlockBySlotAndEpoch(ctx interface{}, epoch interface{}, slot interface{}) *MockClient_GetBlockBySlotAndEpoch_Call { + return &MockClient_GetBlockBySlotAndEpoch_Call{Call: _e.mock.On("GetBlockBySlotAndEpoch", ctx, epoch, slot)} +} + +func (_c *MockClient_GetBlockBySlotAndEpoch_Call) Run(run func(ctx context.Context, epoch int, slot int)) *MockClient_GetBlockBySlotAndEpoch_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(int)) + }) + return _c +} + +func (_c *MockClient_GetBlockBySlotAndEpoch_Call) Return(_a0 blockfrost_go.Block, _a1 error) *MockClient_GetBlockBySlotAndEpoch_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetBlockBySlotAndEpoch_Call) RunAndReturn(run func(context.Context, int, int) (blockfrost_go.Block, error)) *MockClient_GetBlockBySlotAndEpoch_Call { + _c.Call.Return(run) + return _c +} + +// GetBlockDistributionByPool provides a mock function with given fields: ctx, epoch, PoolID +func (_m *MockClient) GetBlockDistributionByPool(ctx context.Context, epoch int, PoolID string) ([]string, error) { + ret := _m.Called(ctx, epoch, PoolID) + + if len(ret) == 0 { + panic("no return value specified for GetBlockDistributionByPool") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) ([]string, error)); ok { + return rf(ctx, epoch, PoolID) + } + if rf, ok := ret.Get(0).(func(context.Context, int, string) []string); ok { + r0 = rf(ctx, epoch, PoolID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, string) error); ok { + r1 = rf(ctx, epoch, PoolID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetBlockDistributionByPool_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlockDistributionByPool' +type MockClient_GetBlockDistributionByPool_Call struct { + *mock.Call +} + +// GetBlockDistributionByPool is a helper method to define mock.On call +// - ctx context.Context +// - epoch int +// - PoolID string +func (_e *MockClient_Expecter) GetBlockDistributionByPool(ctx interface{}, epoch interface{}, PoolID interface{}) *MockClient_GetBlockDistributionByPool_Call { + return &MockClient_GetBlockDistributionByPool_Call{Call: _e.mock.On("GetBlockDistributionByPool", ctx, epoch, PoolID)} +} + +func (_c *MockClient_GetBlockDistributionByPool_Call) Run(run func(ctx context.Context, epoch int, PoolID string)) *MockClient_GetBlockDistributionByPool_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string)) + }) + return _c +} + +func (_c *MockClient_GetBlockDistributionByPool_Call) Return(_a0 []string, _a1 error) *MockClient_GetBlockDistributionByPool_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetBlockDistributionByPool_Call) RunAndReturn(run func(context.Context, int, string) ([]string, error)) *MockClient_GetBlockDistributionByPool_Call { + _c.Call.Return(run) + return _c +} + +// GetEpochParameters provides a mock function with given fields: ctx, epoch +func (_m *MockClient) GetEpochParameters(ctx context.Context, epoch int) (blockfrost_go.EpochParameters, error) { + ret := _m.Called(ctx, epoch) + + if len(ret) == 0 { + panic("no return value specified for GetEpochParameters") + } + + var r0 blockfrost_go.EpochParameters + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (blockfrost_go.EpochParameters, error)); ok { + return rf(ctx, epoch) + } + if rf, ok := ret.Get(0).(func(context.Context, int) blockfrost_go.EpochParameters); ok { + r0 = rf(ctx, epoch) + } else { + r0 = ret.Get(0).(blockfrost_go.EpochParameters) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, epoch) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetEpochParameters_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEpochParameters' +type MockClient_GetEpochParameters_Call struct { + *mock.Call +} + +// GetEpochParameters is a helper method to define mock.On call +// - ctx context.Context +// - epoch int +func (_e *MockClient_Expecter) GetEpochParameters(ctx interface{}, epoch interface{}) *MockClient_GetEpochParameters_Call { + return &MockClient_GetEpochParameters_Call{Call: _e.mock.On("GetEpochParameters", ctx, epoch)} +} + +func (_c *MockClient_GetEpochParameters_Call) Run(run func(ctx context.Context, epoch int)) *MockClient_GetEpochParameters_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_GetEpochParameters_Call) Return(_a0 blockfrost_go.EpochParameters, _a1 error) *MockClient_GetEpochParameters_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetEpochParameters_Call) RunAndReturn(run func(context.Context, int) (blockfrost_go.EpochParameters, error)) *MockClient_GetEpochParameters_Call { + _c.Call.Return(run) + return _c +} + +// GetFirstBlockInEpoch provides a mock function with given fields: ctx, epoch +func (_m *MockClient) GetFirstBlockInEpoch(ctx context.Context, epoch int) (blockfrost_go.Block, error) { + ret := _m.Called(ctx, epoch) + + if len(ret) == 0 { + panic("no return value specified for GetFirstBlockInEpoch") + } + + var r0 blockfrost_go.Block + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (blockfrost_go.Block, error)); ok { + return rf(ctx, epoch) + } + if rf, ok := ret.Get(0).(func(context.Context, int) blockfrost_go.Block); ok { + r0 = rf(ctx, epoch) + } else { + r0 = ret.Get(0).(blockfrost_go.Block) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, epoch) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetFirstBlockInEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFirstBlockInEpoch' +type MockClient_GetFirstBlockInEpoch_Call struct { + *mock.Call +} + +// GetFirstBlockInEpoch is a helper method to define mock.On call +// - ctx context.Context +// - epoch int +func (_e *MockClient_Expecter) GetFirstBlockInEpoch(ctx interface{}, epoch interface{}) *MockClient_GetFirstBlockInEpoch_Call { + return &MockClient_GetFirstBlockInEpoch_Call{Call: _e.mock.On("GetFirstBlockInEpoch", ctx, epoch)} +} + +func (_c *MockClient_GetFirstBlockInEpoch_Call) Run(run func(ctx context.Context, epoch int)) *MockClient_GetFirstBlockInEpoch_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_GetFirstBlockInEpoch_Call) Return(_a0 blockfrost_go.Block, _a1 error) *MockClient_GetFirstBlockInEpoch_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetFirstBlockInEpoch_Call) RunAndReturn(run func(context.Context, int) (blockfrost_go.Block, error)) *MockClient_GetFirstBlockInEpoch_Call { + _c.Call.Return(run) + return _c +} + +// GetFirstSlotInEpoch provides a mock function with given fields: ctx, epoch +func (_m *MockClient) GetFirstSlotInEpoch(ctx context.Context, epoch int) (int, error) { + ret := _m.Called(ctx, epoch) + + if len(ret) == 0 { + panic("no return value specified for GetFirstSlotInEpoch") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (int, error)); ok { + return rf(ctx, epoch) + } + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, epoch) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, epoch) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetFirstSlotInEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFirstSlotInEpoch' +type MockClient_GetFirstSlotInEpoch_Call struct { + *mock.Call +} + +// GetFirstSlotInEpoch is a helper method to define mock.On call +// - ctx context.Context +// - epoch int +func (_e *MockClient_Expecter) GetFirstSlotInEpoch(ctx interface{}, epoch interface{}) *MockClient_GetFirstSlotInEpoch_Call { + return &MockClient_GetFirstSlotInEpoch_Call{Call: _e.mock.On("GetFirstSlotInEpoch", ctx, epoch)} +} + +func (_c *MockClient_GetFirstSlotInEpoch_Call) Run(run func(ctx context.Context, epoch int)) *MockClient_GetFirstSlotInEpoch_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_GetFirstSlotInEpoch_Call) Return(_a0 int, _a1 error) *MockClient_GetFirstSlotInEpoch_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetFirstSlotInEpoch_Call) RunAndReturn(run func(context.Context, int) (int, error)) *MockClient_GetFirstSlotInEpoch_Call { + _c.Call.Return(run) + return _c +} + +// GetGenesisInfo provides a mock function with given fields: ctx +func (_m *MockClient) GetGenesisInfo(ctx context.Context) (blockfrost_go.GenesisBlock, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetGenesisInfo") + } + + var r0 blockfrost_go.GenesisBlock + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (blockfrost_go.GenesisBlock, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) blockfrost_go.GenesisBlock); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(blockfrost_go.GenesisBlock) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetGenesisInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetGenesisInfo' +type MockClient_GetGenesisInfo_Call struct { + *mock.Call +} + +// GetGenesisInfo is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetGenesisInfo(ctx interface{}) *MockClient_GetGenesisInfo_Call { + return &MockClient_GetGenesisInfo_Call{Call: _e.mock.On("GetGenesisInfo", ctx)} +} + +func (_c *MockClient_GetGenesisInfo_Call) Run(run func(ctx context.Context)) *MockClient_GetGenesisInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetGenesisInfo_Call) Return(_a0 blockfrost_go.GenesisBlock, _a1 error) *MockClient_GetGenesisInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetGenesisInfo_Call) RunAndReturn(run func(context.Context) (blockfrost_go.GenesisBlock, error)) *MockClient_GetGenesisInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetLastBlockFromPreviousEpoch provides a mock function with given fields: ctx, prevEpoch +func (_m *MockClient) GetLastBlockFromPreviousEpoch(ctx context.Context, prevEpoch int) (blockfrost_go.Block, error) { + ret := _m.Called(ctx, prevEpoch) + + if len(ret) == 0 { + panic("no return value specified for GetLastBlockFromPreviousEpoch") + } + + var r0 blockfrost_go.Block + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (blockfrost_go.Block, error)); ok { + return rf(ctx, prevEpoch) + } + if rf, ok := ret.Get(0).(func(context.Context, int) blockfrost_go.Block); ok { + r0 = rf(ctx, prevEpoch) + } else { + r0 = ret.Get(0).(blockfrost_go.Block) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, prevEpoch) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetLastBlockFromPreviousEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLastBlockFromPreviousEpoch' +type MockClient_GetLastBlockFromPreviousEpoch_Call struct { + *mock.Call +} + +// GetLastBlockFromPreviousEpoch is a helper method to define mock.On call +// - ctx context.Context +// - prevEpoch int +func (_e *MockClient_Expecter) GetLastBlockFromPreviousEpoch(ctx interface{}, prevEpoch interface{}) *MockClient_GetLastBlockFromPreviousEpoch_Call { + return &MockClient_GetLastBlockFromPreviousEpoch_Call{Call: _e.mock.On("GetLastBlockFromPreviousEpoch", ctx, prevEpoch)} +} + +func (_c *MockClient_GetLastBlockFromPreviousEpoch_Call) Run(run func(ctx context.Context, prevEpoch int)) *MockClient_GetLastBlockFromPreviousEpoch_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_GetLastBlockFromPreviousEpoch_Call) Return(_a0 blockfrost_go.Block, _a1 error) *MockClient_GetLastBlockFromPreviousEpoch_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetLastBlockFromPreviousEpoch_Call) RunAndReturn(run func(context.Context, int) (blockfrost_go.Block, error)) *MockClient_GetLastBlockFromPreviousEpoch_Call { + _c.Call.Return(run) + return _c +} + +// GetLatestBlock provides a mock function with given fields: ctx +func (_m *MockClient) GetLatestBlock(ctx context.Context) (blockfrost_go.Block, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLatestBlock") + } + + var r0 blockfrost_go.Block + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (blockfrost_go.Block, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) blockfrost_go.Block); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(blockfrost_go.Block) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetLatestBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestBlock' +type MockClient_GetLatestBlock_Call struct { + *mock.Call +} + +// GetLatestBlock is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetLatestBlock(ctx interface{}) *MockClient_GetLatestBlock_Call { + return &MockClient_GetLatestBlock_Call{Call: _e.mock.On("GetLatestBlock", ctx)} +} + +func (_c *MockClient_GetLatestBlock_Call) Run(run func(ctx context.Context)) *MockClient_GetLatestBlock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetLatestBlock_Call) Return(_a0 blockfrost_go.Block, _a1 error) *MockClient_GetLatestBlock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetLatestBlock_Call) RunAndReturn(run func(context.Context) (blockfrost_go.Block, error)) *MockClient_GetLatestBlock_Call { + _c.Call.Return(run) + return _c +} + +// GetLatestEpoch provides a mock function with given fields: ctx +func (_m *MockClient) GetLatestEpoch(ctx context.Context) (blockfrost_go.Epoch, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLatestEpoch") + } + + var r0 blockfrost_go.Epoch + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (blockfrost_go.Epoch, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) blockfrost_go.Epoch); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(blockfrost_go.Epoch) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetLatestEpoch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestEpoch' +type MockClient_GetLatestEpoch_Call struct { + *mock.Call +} + +// GetLatestEpoch is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetLatestEpoch(ctx interface{}) *MockClient_GetLatestEpoch_Call { + return &MockClient_GetLatestEpoch_Call{Call: _e.mock.On("GetLatestEpoch", ctx)} +} + +func (_c *MockClient_GetLatestEpoch_Call) Run(run func(ctx context.Context)) *MockClient_GetLatestEpoch_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetLatestEpoch_Call) Return(_a0 blockfrost_go.Epoch, _a1 error) *MockClient_GetLatestEpoch_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetLatestEpoch_Call) RunAndReturn(run func(context.Context) (blockfrost_go.Epoch, error)) *MockClient_GetLatestEpoch_Call { + _c.Call.Return(run) + return _c +} + +// GetNetworkInfo provides a mock function with given fields: ctx +func (_m *MockClient) GetNetworkInfo(ctx context.Context) (blockfrost_go.NetworkInfo, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetNetworkInfo") + } + + var r0 blockfrost_go.NetworkInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (blockfrost_go.NetworkInfo, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) blockfrost_go.NetworkInfo); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(blockfrost_go.NetworkInfo) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetNetworkInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetworkInfo' +type MockClient_GetNetworkInfo_Call struct { + *mock.Call +} + +// GetNetworkInfo is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetNetworkInfo(ctx interface{}) *MockClient_GetNetworkInfo_Call { + return &MockClient_GetNetworkInfo_Call{Call: _e.mock.On("GetNetworkInfo", ctx)} +} + +func (_c *MockClient_GetNetworkInfo_Call) Run(run func(ctx context.Context)) *MockClient_GetNetworkInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetNetworkInfo_Call) Return(_a0 blockfrost_go.NetworkInfo, _a1 error) *MockClient_GetNetworkInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetNetworkInfo_Call) RunAndReturn(run func(context.Context) (blockfrost_go.NetworkInfo, error)) *MockClient_GetNetworkInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetPoolInfo provides a mock function with given fields: ctx, PoolID +func (_m *MockClient) GetPoolInfo(ctx context.Context, PoolID string) (blockfrost_go.Pool, error) { + ret := _m.Called(ctx, PoolID) + + if len(ret) == 0 { + panic("no return value specified for GetPoolInfo") + } + + var r0 blockfrost_go.Pool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (blockfrost_go.Pool, error)); ok { + return rf(ctx, PoolID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) blockfrost_go.Pool); ok { + r0 = rf(ctx, PoolID) + } else { + r0 = ret.Get(0).(blockfrost_go.Pool) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, PoolID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetPoolInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPoolInfo' +type MockClient_GetPoolInfo_Call struct { + *mock.Call +} + +// GetPoolInfo is a helper method to define mock.On call +// - ctx context.Context +// - PoolID string +func (_e *MockClient_Expecter) GetPoolInfo(ctx interface{}, PoolID interface{}) *MockClient_GetPoolInfo_Call { + return &MockClient_GetPoolInfo_Call{Call: _e.mock.On("GetPoolInfo", ctx, PoolID)} +} + +func (_c *MockClient_GetPoolInfo_Call) Run(run func(ctx context.Context, PoolID string)) *MockClient_GetPoolInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetPoolInfo_Call) Return(_a0 blockfrost_go.Pool, _a1 error) *MockClient_GetPoolInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetPoolInfo_Call) RunAndReturn(run func(context.Context, string) (blockfrost_go.Pool, error)) *MockClient_GetPoolInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetPoolMetadata provides a mock function with given fields: ctx, PoolID +func (_m *MockClient) GetPoolMetadata(ctx context.Context, PoolID string) (blockfrost_go.PoolMetadata, error) { + ret := _m.Called(ctx, PoolID) + + if len(ret) == 0 { + panic("no return value specified for GetPoolMetadata") + } + + var r0 blockfrost_go.PoolMetadata + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (blockfrost_go.PoolMetadata, error)); ok { + return rf(ctx, PoolID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) blockfrost_go.PoolMetadata); ok { + r0 = rf(ctx, PoolID) + } else { + r0 = ret.Get(0).(blockfrost_go.PoolMetadata) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, PoolID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetPoolMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPoolMetadata' +type MockClient_GetPoolMetadata_Call struct { + *mock.Call +} + +// GetPoolMetadata is a helper method to define mock.On call +// - ctx context.Context +// - PoolID string +func (_e *MockClient_Expecter) GetPoolMetadata(ctx interface{}, PoolID interface{}) *MockClient_GetPoolMetadata_Call { + return &MockClient_GetPoolMetadata_Call{Call: _e.mock.On("GetPoolMetadata", ctx, PoolID)} +} + +func (_c *MockClient_GetPoolMetadata_Call) Run(run func(ctx context.Context, PoolID string)) *MockClient_GetPoolMetadata_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetPoolMetadata_Call) Return(_a0 blockfrost_go.PoolMetadata, _a1 error) *MockClient_GetPoolMetadata_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetPoolMetadata_Call) RunAndReturn(run func(context.Context, string) (blockfrost_go.PoolMetadata, error)) *MockClient_GetPoolMetadata_Call { + _c.Call.Return(run) + return _c +} + +// GetPoolRelays provides a mock function with given fields: ctx, PoolID +func (_m *MockClient) GetPoolRelays(ctx context.Context, PoolID string) ([]blockfrost_go.PoolRelay, error) { + ret := _m.Called(ctx, PoolID) + + if len(ret) == 0 { + panic("no return value specified for GetPoolRelays") + } + + var r0 []blockfrost_go.PoolRelay + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]blockfrost_go.PoolRelay, error)); ok { + return rf(ctx, PoolID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []blockfrost_go.PoolRelay); ok { + r0 = rf(ctx, PoolID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]blockfrost_go.PoolRelay) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, PoolID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetPoolRelays_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPoolRelays' +type MockClient_GetPoolRelays_Call struct { + *mock.Call +} + +// GetPoolRelays is a helper method to define mock.On call +// - ctx context.Context +// - PoolID string +func (_e *MockClient_Expecter) GetPoolRelays(ctx interface{}, PoolID interface{}) *MockClient_GetPoolRelays_Call { + return &MockClient_GetPoolRelays_Call{Call: _e.mock.On("GetPoolRelays", ctx, PoolID)} +} + +func (_c *MockClient_GetPoolRelays_Call) Run(run func(ctx context.Context, PoolID string)) *MockClient_GetPoolRelays_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetPoolRelays_Call) Return(_a0 []blockfrost_go.PoolRelay, _a1 error) *MockClient_GetPoolRelays_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetPoolRelays_Call) RunAndReturn(run func(context.Context, string) ([]blockfrost_go.PoolRelay, error)) *MockClient_GetPoolRelays_Call { + _c.Call.Return(run) + return _c +} + +// Health provides a mock function with given fields: ctx +func (_m *MockClient) Health(ctx context.Context) (blockfrost_go.Health, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Health") + } + + var r0 blockfrost_go.Health + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (blockfrost_go.Health, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) blockfrost_go.Health); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(blockfrost_go.Health) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_Health_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Health' +type MockClient_Health_Call struct { + *mock.Call +} + +// Health is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) Health(ctx interface{}) *MockClient_Health_Call { + return &MockClient_Health_Call{Call: _e.mock.On("Health", ctx)} +} + +func (_c *MockClient_Health_Call) Run(run func(ctx context.Context)) *MockClient_Health_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_Health_Call) Return(_a0 blockfrost_go.Health, _a1 error) *MockClient_Health_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_Health_Call) RunAndReturn(run func(context.Context) (blockfrost_go.Health, error)) *MockClient_Health_Call { + _c.Call.Return(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..90d7535 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,59 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" +) + +type Collection struct { + RelaysPerPool *prometheus.GaugeVec + PoolsPledgeMet *prometheus.GaugeVec + PoolsSaturationLevel *prometheus.GaugeVec + MonitoredValidatorsCount *prometheus.GaugeVec +} + +func NewCollection() *Collection { + return &Collection{ + RelaysPerPool: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "cardano_validator_watcher", + Name: "pool_relays", + Help: "Count of relays associated with each pool", + }, + []string{"pool_name", "pool_id", "pool_instance"}, + ), + PoolsPledgeMet: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "cardano_validator_watcher", + Name: "pool_pledge_met", + Help: "Whether the pool has met its pledge requirements or not (0 or 1)", + }, + []string{"pool_name", "pool_id", "pool_instance"}, + ), + PoolsSaturationLevel: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "cardano_validator_watcher", + Name: "pool_saturation_level", + Help: "The current saturation level of the pool in percent", + }, + []string{"pool_name", "pool_id", "pool_instance"}, + ), + MonitoredValidatorsCount: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "cardano_validator_watcher", + Name: "monitored_validators", + Help: "number of validators monitored by the watcher", + }, + []string{"status"}, + ), + } +} + +func (m *Collection) MustRegister(reg prometheus.Registerer) { + reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + reg.MustRegister(collectors.NewGoCollector()) + reg.MustRegister(m.RelaysPerPool) + reg.MustRegister(m.PoolsPledgeMet) + reg.MustRegister(m.PoolsSaturationLevel) + reg.MustRegister(m.MonitoredValidatorsCount) +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..0782dc0 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,41 @@ +package metrics + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func TestNewCollection(t *testing.T) { + metrics := NewCollection() + require.NotNil(t, metrics) +} + +func TestMustRegister(t *testing.T) { + metrics := NewCollection() + + // register metrics that need labels + metrics.RelaysPerPool.WithLabelValues("pool_name", "pool_id", "pool_instance").Set(5) + metrics.PoolsPledgeMet.WithLabelValues("pool_name", "pool_id", "pool_instance").Set(1) + metrics.PoolsSaturationLevel.WithLabelValues("pool_name", "pool_id", "pool_instance").Set(85) + metrics.MonitoredValidatorsCount.WithLabelValues("active").Set(10) + + registry := prometheus.NewRegistry() + metrics.MustRegister(registry) + + // The expected number of metrics to be registered, based on the definitions provided in the Collection struct. + expectedMetricsCount := 4 + + var totalRegisteredMetrics int + size, _ := registry.Gather() + for _, item := range size { + if strings.HasPrefix(*item.Name, "cardano_validator_watcher") { + totalRegisteredMetrics++ + } + } + + require.NotNil(t, metrics) + require.Equal(t, expectedMetricsCount, totalRegisteredMetrics) +} diff --git a/internal/pools/pools.go b/internal/pools/pools.go new file mode 100644 index 0000000..ceb19b7 --- /dev/null +++ b/internal/pools/pools.go @@ -0,0 +1,46 @@ +package pools + +type Pools []Pool + +type Pool struct { + ID string `mapstructure:"id"` + Instance string `mapstructure:"instance"` + Name string `mapstructure:"name"` + Key string `mapstructure:"key"` + Exclude bool `mapstructure:"exclude"` + AllowEmptySlots bool `mapstructure:"allow-empty-slots"` +} + +type PoolStats struct { + Active int + Excluded int + Total int +} + +func (p *Pools) GetExcludedPools() []Pool { + var excludedPools []Pool + for _, pool := range *p { + if pool.Exclude { + excludedPools = append(excludedPools, pool) + } + } + return excludedPools +} + +func (p *Pools) GetActivePools() []Pool { + var activePools []Pool + for _, pool := range *p { + if !pool.Exclude { + activePools = append(activePools, pool) + } + } + return activePools +} + +func (p *Pools) GetPoolStats() PoolStats { + return PoolStats{ + Active: len(p.GetActivePools()), + Excluded: len(p.GetExcludedPools()), + Total: len(*p), + } +} diff --git a/internal/pools/pools_test.go b/internal/pools/pools_test.go new file mode 100644 index 0000000..11d2cf7 --- /dev/null +++ b/internal/pools/pools_test.go @@ -0,0 +1,65 @@ +package pools + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + defaultPools = Pools{ + { + Instance: "pool1", + ID: "pool1", + Name: "pool1", + Key: "pool1", + Exclude: false, + AllowEmptySlots: false, + }, + { + Instance: "pool2", + ID: "pool2", + Name: "pool2", + Key: "pool2", + Exclude: false, + AllowEmptySlots: true, + }, + { + Instance: "pool3", + ID: "pool3", + Name: "pool3", + Key: "pool3", + Exclude: true, + AllowEmptySlots: false, + }, + } +) + +func TestGetActivePools(t *testing.T) { + t.Parallel() + + pools := defaultPools + + activePools := pools.GetActivePools() + require.Len(t, activePools, 2) +} + +func TestGetExcludedPools(t *testing.T) { + t.Parallel() + + pools := defaultPools + + excludedPools := pools.GetExcludedPools() + require.Len(t, excludedPools, 1) +} + +func TestGetPoolStats(t *testing.T) { + t.Parallel() + + pools := defaultPools + + stats := pools.GetPoolStats() + require.Equal(t, 2, stats.Active) + require.Equal(t, 1, stats.Excluded) + require.Equal(t, 3, stats.Total) +} diff --git a/internal/server/http/handlers.go b/internal/server/http/handlers.go new file mode 100644 index 0000000..669619f --- /dev/null +++ b/internal/server/http/handlers.go @@ -0,0 +1,43 @@ +package http + +import ( + "log/slog" + "net/http" +) + +// Handler represents the HTTP handlers for the server +type Handler struct { + logger *slog.Logger +} + +// NewHandler returns a new Handler +func NewHandler(logger *slog.Logger) *Handler { + return &Handler{ + logger: logger, + } +} + +// Default redirects to the metrics endpoint +func (h *Handler) Default(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.Redirect(w, r, "/metrics", http.StatusMovedPermanently) + return + } + http.NotFound(w, r) +} + +// Live checks the liveness of the service +// If the service is alive, it returns a 200 OK status +// If the service is not alive, it returns a 500 Internal Server Error status +func (h *Handler) LiveProbe(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Health OK")) +} + +// Ready checks the readiness of the service by checking the health of our services +// If the service is ready, it returns a 200 OK status +// If the service is not ready, it returns a 500 Internal Server Error status +func (h *Handler) ReadyProbe(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Health OK")) +} diff --git a/internal/server/http/handlers_test.go b/internal/server/http/handlers_test.go new file mode 100644 index 0000000..2ae572f --- /dev/null +++ b/internal/server/http/handlers_test.go @@ -0,0 +1,107 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/kilnfi/cardano-validator-watcher/internal/metrics" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultHandler(t *testing.T) { + t.Parallel() + + t.Run("GoodPath_DefaultHandlerIsOk", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + server, err := New( + nil, + ) + + require.NoError(t, err) + server.router.ServeHTTP(w, r) + assert.Equal(t, http.StatusMovedPermanently, w.Code) + }) + + t.Run("GoodPath_DefaultHandlerShouldReturn404ForUnknownPath", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "/fake", nil) + w := httptest.NewRecorder() + + server, err := New( + nil, + ) + + require.NoError(t, err) + server.router.ServeHTTP(w, r) + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestLiveProbe(t *testing.T) { + t.Parallel() + + t.Run("GoodPath_LiveProbeIsReady", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "/livez", nil) + w := httptest.NewRecorder() + + server, err := New( + nil, + ) + require.NoError(t, err) + server.router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestReadyProbe(t *testing.T) { + t.Parallel() + + t.Run("GoodPath_ReadyProbeIsReady", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "/readyz", nil) + w := httptest.NewRecorder() + + server, err := New( + nil, + ) + require.NoError(t, err) + server.router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestMetricsHandler(t *testing.T) { + t.Parallel() + + t.Run("GoodPath_MetricsHandlerIsOk", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "/metrics", nil) + w := httptest.NewRecorder() + + registry := prometheus.NewRegistry() + metrics := metrics.NewCollection() + metrics.MustRegister(registry) + + server, err := New( + registry, + ) + + require.NoError(t, err) + server.router.ServeHTTP(w, r) + assert.Equal(t, http.StatusOK, w.Code) + }) +} diff --git a/internal/server/http/options.go b/internal/server/http/options.go new file mode 100644 index 0000000..cd21178 --- /dev/null +++ b/internal/server/http/options.go @@ -0,0 +1,40 @@ +package http + +import "time" + +type options struct { + host string + port int + readTimeout time.Duration + writeTimeout time.Duration +} + +type ServerOptionsFunc func(*options) error + +func WithHost(host string) ServerOptionsFunc { + return func(o *options) error { + o.host = host + return nil + } +} + +func WithPort(port int) ServerOptionsFunc { + return func(o *options) error { + o.port = port + return nil + } +} + +func WithReadTimeout(timeout time.Duration) ServerOptionsFunc { + return func(o *options) error { + o.readTimeout = timeout + return nil + } +} + +func WithWriteTimeout(timeout time.Duration) ServerOptionsFunc { + return func(o *options) error { + o.writeTimeout = timeout + return nil + } +} diff --git a/internal/server/http/options_test.go b/internal/server/http/options_test.go new file mode 100644 index 0000000..79fefaa --- /dev/null +++ b/internal/server/http/options_test.go @@ -0,0 +1,43 @@ +package http + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithHost(t *testing.T) { + t.Parallel() + + opts := &options{} + err := WithHost("0.0.0.0")(opts) + + require.NoError(t, err) + assert.Equal(t, "0.0.0.0", opts.host) +} + +func TestWithPort(t *testing.T) { + t.Parallel() + + opts := &options{} + err := WithPort(9090)(opts) + + require.NoError(t, err) + assert.Equal(t, 9090, opts.port) +} + +func TestWithTimeouts(t *testing.T) { + t.Parallel() + + opts := &options{} + err := WithReadTimeout(15 * time.Second)(opts) + require.NoError(t, err) + + err = WithWriteTimeout(30 * time.Second)(opts) + require.NoError(t, err) + + assert.Equal(t, 15*time.Second, opts.readTimeout) + assert.Equal(t, 30*time.Second, opts.writeTimeout) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go new file mode 100644 index 0000000..0cc240a --- /dev/null +++ b/internal/server/http/server.go @@ -0,0 +1,96 @@ +package http + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + ServerDefaultReadTimeout time.Duration = 15 * time.Second + ServerDefaultWriteTimeout time.Duration = 30 * time.Second + ServerDefaultHost string = "127.0.0.0" + ServerDefaultPort int = 8080 +) + +type Server struct { + logger *slog.Logger + router *http.ServeMux + server *http.Server + + registry *prometheus.Registry + + options *options +} + +func New( + registry *prometheus.Registry, + opts ...ServerOptionsFunc, +) (*Server, error) { + logger := slog.With( + slog.String("component", "http-server"), + ) + + options := &options{ + host: ServerDefaultHost, + port: ServerDefaultPort, + readTimeout: ServerDefaultReadTimeout, + writeTimeout: ServerDefaultWriteTimeout, + } + + for _, opt := range opts { + if err := opt(options); err != nil { + return nil, err + } + } + + router := http.NewServeMux() + addr := fmt.Sprintf("%s:%d", options.host, options.port) + + server := &Server{ + logger: logger, + router: router, + server: &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: options.readTimeout, + WriteTimeout: options.writeTimeout, + }, + registry: registry, + options: options, + } + + server.registerRoutes() + + return server, nil +} + +// Start starts the HTTP server +func (s *Server) Start() error { + if err := s.server.ListenAndServe(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + return nil +} + +// Stop stops the HTTP server +func (s *Server) Stop(ctx context.Context) error { + if err := s.server.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown server: %w", err) + } + return nil +} + +func (s *Server) registerRoutes() { + handler := NewHandler(s.logger) + + s.router.HandleFunc("GET /", handler.Default) + s.router.HandleFunc("GET /livez", handler.LiveProbe) + s.router.HandleFunc("GET /readyz", handler.ReadyProbe) + s.router.Handle("GET /metrics", promhttp.HandlerFor(s.registry, promhttp.HandlerOpts{})) +} diff --git a/internal/server/http/server_test.go b/internal/server/http/server_test.go new file mode 100644 index 0000000..34ad821 --- /dev/null +++ b/internal/server/http/server_test.go @@ -0,0 +1,79 @@ +package http + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + DefaultReadTimeout = 15 * time.Second + DefaultWriteTimeout = 30 * time.Second + DefaultHost = "0.0.0.0" + DefaultPort = 8000 +) + +func TestNewServer(t *testing.T) { + t.Parallel() + + t.Run("WithOptions", func(t *testing.T) { + t.Parallel() + + server, err := New( + nil, + WithHost(DefaultHost), + WithPort(DefaultPort), + WithReadTimeout(DefaultReadTimeout), + WithWriteTimeout(DefaultWriteTimeout), + ) + + require.NoError(t, err) + assert.Equal(t, DefaultHost, server.options.host) + assert.Equal(t, DefaultPort, server.options.port) + assert.Equal(t, DefaultReadTimeout, server.options.readTimeout) + assert.Equal(t, DefaultWriteTimeout, server.options.writeTimeout) + }) + t.Run("WithDefaultOptions", func(t *testing.T) { + t.Parallel() + + server, err := New( + nil, + WithHost(ServerDefaultHost), + WithPort(ServerDefaultPort), + WithReadTimeout(ServerDefaultReadTimeout), + WithWriteTimeout(ServerDefaultWriteTimeout), + ) + require.NoError(t, err) + assert.Equal(t, ServerDefaultHost, server.options.host) + assert.Equal(t, ServerDefaultPort, server.options.port) + assert.Equal(t, ServerDefaultReadTimeout, server.options.readTimeout) + assert.Equal(t, ServerDefaultWriteTimeout, server.options.writeTimeout) + }) +} + +func TestStartServer(t *testing.T) { + t.Parallel() + server, err := New( + nil, + WithHost(DefaultHost), + WithPort(DefaultPort), + WithReadTimeout(DefaultReadTimeout), + WithWriteTimeout(DefaultWriteTimeout), + ) + require.NoError(t, err) + + go func() { + ticker := time.NewTimer(time.Second * 30) + <-ticker.C + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Stop(ctx) + }() + + err = server.Start() + require.ErrorIs(t, err, http.ErrServerClosed) +} diff --git a/internal/watcher/.DS_Store b/internal/watcher/.DS_Store new file mode 100644 index 0000000..50be675 Binary files /dev/null and b/internal/watcher/.DS_Store differ diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..ded0e3a --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,9 @@ +package watcher + +import ( + "context" +) + +type Watcher interface { + Start(ctx context.Context) error +} diff --git a/internal/watcher/watcher_pool.go b/internal/watcher/watcher_pool.go new file mode 100644 index 0000000..5d75ff5 --- /dev/null +++ b/internal/watcher/watcher_pool.go @@ -0,0 +1,195 @@ +package watcher + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "time" + + bfAPI "github.com/blockfrost/blockfrost-go" + "github.com/dgraph-io/ristretto" + "github.com/kilnfi/cardano-validator-watcher/internal/blockfrost" + "github.com/kilnfi/cardano-validator-watcher/internal/metrics" + "github.com/kilnfi/cardano-validator-watcher/internal/pools" +) + +// PoolWatcheropts represents the options for the pool watcher. +type PoolWatcherOptions struct { + Network string + RefreshInterval time.Duration +} + +// PoolWatcher represents a watcher for a set of Cardano pools. +type PoolWatcher struct { + logger *slog.Logger + blockfrost blockfrost.Client + metrics *metrics.Collection + pools pools.Pools + poolstats pools.PoolStats + cache *ristretto.Cache + opts PoolWatcherOptions +} + +var _ Watcher = (*PoolWatcher)(nil) + +// NewPoolWatcher creates a new instance of PoolWatcher. +// It takes a context, a blockfrost client, and a metrics collection as parameters. +// It returns a pointer to the created PoolWatcher. +func NewPoolWatcher( + blockfrost blockfrost.Client, + metrics *metrics.Collection, + pools pools.Pools, + opts PoolWatcherOptions, +) (*PoolWatcher, error) { + logger := slog.With( + slog.String("component", "pool-watcher"), + ) + + cache, err := ristretto.NewCache(&ristretto.Config{ + NumCounters: 1e7, + MaxCost: 1 << 30, + BufferItems: 64, + }) + if err != nil { + return nil, fmt.Errorf("unable to create cache: %w", err) + } + + return &PoolWatcher{ + logger: logger, + blockfrost: blockfrost, + metrics: metrics, + pools: pools, + poolstats: pools.GetPoolStats(), + cache: cache, + opts: opts, + }, nil +} + +// Start starts the PoolWatcher and periodically calls a function. +// It uses a ticker to trigger the function call every 30 seconds. +// The function call can be canceled by canceling the provided context. +func (w *PoolWatcher) Start(ctx context.Context) error { + ticker := time.NewTicker(w.opts.RefreshInterval) + defer ticker.Stop() + + for { + if err := w.fetch(ctx); err != nil { + w.logger.Error("unable to fetch pool data", slog.String("error", err.Error())) + } + + select { + case <-ctx.Done(): + w.logger.Info("stopping watcher") + return fmt.Errorf("context done in watcher: %w", ctx.Err()) + case <-ticker.C: + continue + } + } +} + +// run executes the main logic of the PoolWatcher. +// It collects data about a pool and periodically creates Prometheus metrics. +func (w *PoolWatcher) fetch(ctx context.Context) error { + // Get the number of watched pools + w.metrics.MonitoredValidatorsCount.WithLabelValues("total").Set(float64(w.poolstats.Total)) + w.metrics.MonitoredValidatorsCount.WithLabelValues("active").Set(float64(w.poolstats.Active)) + w.metrics.MonitoredValidatorsCount.WithLabelValues("excluded").Set(float64(w.poolstats.Excluded)) + + // Loop through the pools + for _, pool := range w.pools.GetActivePools() { + // Get pool metadata + poolMetadata, err := w.getPoolMetadata(ctx, pool.ID) + if err != nil { + return fmt.Errorf("unable to retrieve metadata for pool '%s': %w", pool.ID, err) + } + + // Get pool details + poolInfo, err := w.getPoolInfo(ctx, pool.ID) + if err != nil { + return fmt.Errorf("unable to retrieve details for pool '%s': %w", pool.ID, err) + } + + // Set pool saturation level + w.metrics.PoolsSaturationLevel.WithLabelValues(pool.Name, pool.ID, pool.Instance).Set(poolInfo.LiveSaturation) + + // check if the pool has met its pledge requirements and set the metric accordingly + livePledge, err := strconv.Atoi(poolInfo.LivePledge) + if err != nil { + return fmt.Errorf("unable to convert live pledge to integer: %w", err) + } + + declaredPledge, err := strconv.Atoi(poolInfo.DeclaredPledge) + if err != nil { + return fmt.Errorf("unable to convert declared pledge to integer: %w", err) + } + if livePledge >= declaredPledge { + w.metrics.PoolsPledgeMet.WithLabelValues(pool.Name, pool.ID, pool.Instance).Set(1) + } else { + w.metrics.PoolsPledgeMet.WithLabelValues(pool.Name, pool.ID, pool.Instance).Set(0) + } + + // Get number of relay servers associated with the pool + poolRelays, err := w.getPoolRelays(ctx, pool.ID) + if err != nil { + return fmt.Errorf("unable to retrieve relays for pool '%s': %w", pool.ID, err) + } + w.metrics.RelaysPerPool.WithLabelValues(*poolMetadata.Ticker, pool.ID, pool.Instance).Set(float64(len(poolRelays))) + } + + return nil +} + +func (w *PoolWatcher) getPoolMetadata(ctx context.Context, PoolID string) (bfAPI.PoolMetadata, error) { + var err error + var metadata bfAPI.PoolMetadata + + poolMetadata, ok := w.cache.Get(PoolID + "_metadata") + if !ok { + poolMetadata, err = w.blockfrost.GetPoolMetadata(ctx, PoolID) + if err != nil { + return metadata, fmt.Errorf("unable to retrieve metadata for pool '%s': %w", PoolID, err) + } + w.cache.SetWithTTL(PoolID+"_metadata", poolMetadata, 1, 2*w.opts.RefreshInterval) + w.cache.Wait() + } + metadata = poolMetadata.(bfAPI.PoolMetadata) + + return metadata, nil +} + +func (w *PoolWatcher) getPoolRelays(ctx context.Context, PoolID string) ([]bfAPI.PoolRelay, error) { + var err error + var relays []bfAPI.PoolRelay + + poolRelays, ok := w.cache.Get(PoolID + "_relays") + if !ok { + poolRelays, err = w.blockfrost.GetPoolRelays(ctx, PoolID) + if err != nil { + return relays, fmt.Errorf("unable to retrieve relays for pool '%s': %w", PoolID, err) + } + w.cache.SetWithTTL(PoolID+"_relays", poolRelays, 1, 2*w.opts.RefreshInterval) + w.cache.Wait() + } + relays = poolRelays.([]bfAPI.PoolRelay) + + return relays, nil +} + +func (w *PoolWatcher) getPoolInfo(ctx context.Context, PoolID string) (bfAPI.Pool, error) { + var err error + var pool bfAPI.Pool + + poolInfo, ok := w.cache.Get(PoolID + "_info") + if !ok { + poolInfo, err = w.blockfrost.GetPoolInfo(ctx, PoolID) + if err != nil { + return pool, fmt.Errorf("unable to retrieve info for pool '%s': %w", PoolID, err) + } + w.cache.SetWithTTL(PoolID+"_info", poolInfo, 1, 2*w.opts.RefreshInterval) + w.cache.Wait() + } + pool = poolInfo.(bfAPI.Pool) + + return pool, nil +} diff --git a/internal/watcher/watcher_pool_test.go b/internal/watcher/watcher_pool_test.go new file mode 100644 index 0000000..fba891b --- /dev/null +++ b/internal/watcher/watcher_pool_test.go @@ -0,0 +1,140 @@ +package watcher + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/blockfrost/blockfrost-go" + bfMocks "github.com/kilnfi/cardano-validator-watcher/internal/blockfrost/mocks" + "github.com/kilnfi/cardano-validator-watcher/internal/metrics" + "github.com/kilnfi/cardano-validator-watcher/internal/pools" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + metricsOutputTemplate = ` +# HELP cardano_validator_watcher_monitored_validators number of validators monitored by the watcher +# TYPE cardano_validator_watcher_monitored_validators gauge +cardano_validator_watcher_monitored_validators{status="active"} %d +cardano_validator_watcher_monitored_validators{status="excluded"} %d +cardano_validator_watcher_monitored_validators{status="total"} %d +# HELP cardano_validator_watcher_pool_pledge_met Whether the pool has met its pledge requirements or not (0 or 1) +# TYPE cardano_validator_watcher_pool_pledge_met gauge +cardano_validator_watcher_pool_pledge_met{pool_id="pool-0",pool_instance="pool-0",pool_name="pool-0"} %d +# HELP cardano_validator_watcher_pool_relays Count of relays associated with each pool +# TYPE cardano_validator_watcher_pool_relays gauge +cardano_validator_watcher_pool_relays{pool_id="pool-0",pool_instance="pool-0",pool_name="pool-0"} %d +# HELP cardano_validator_watcher_pool_saturation_level The current saturation level of the pool in percent +# TYPE cardano_validator_watcher_pool_saturation_level gauge +cardano_validator_watcher_pool_saturation_level{pool_id="pool-0",pool_instance="pool-0",pool_name="pool-0"} %f +` +) + +func setupPools(t *testing.T) pools.Pools { + t.Helper() + return pools.Pools{ + { + ID: "pool-0", + Instance: "pool-0", + Key: "key", + Name: "pool-0", + Exclude: false, + }, + { + ID: "pool-1", + Instance: "pool-1", + Key: "key", + Name: "pool-1", + Exclude: true, + }, + } +} + +func TestPoolWatcher_Start(t *testing.T) { + t.Parallel() + t.Run("GoodPath_CollectAllMetrics", func(t *testing.T) { + t.Parallel() + + pools := setupPools(t) + poolStats := pools.GetPoolStats() + saturation := 0.5 + declaredPledge := "100" + livePledge := "200" + relays := []blockfrost.PoolRelay{ + { + Ipv4: &[]string{"10.10.10.10"}[0], + DNS: &[]string{"relays.example.com"}[0], + Port: 3001, + }, + } + + metricsExpectedOutput := fmt.Sprintf(metricsOutputTemplate, + poolStats.Active, + poolStats.Excluded, + poolStats.Total, + 1, + len(relays), + saturation, + ) + + metricsUnderTest := []string{ + "cardano_validator_watcher_monitored_validators", + "cardano_validator_watcher_pool_pledge_met", + "cardano_validator_watcher_pool_relays", + "cardano_validator_watcher_pool_saturation_level", + } + + registry := prometheus.NewRegistry() + metrics := metrics.NewCollection() + metrics.MustRegister(registry) + + bf := bfMocks.NewMockClient(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + <-ticker.C + cancel() + }() + + // Mocks + bf.EXPECT().GetPoolMetadata(mock.Anything, pools[0].ID).Return( + blockfrost.PoolMetadata{Ticker: &pools[0].Name}, nil, + ) + bf.EXPECT().GetPoolInfo(mock.Anything, pools[0].ID).Return( + blockfrost.Pool{ + LiveSaturation: saturation, + LivePledge: livePledge, + DeclaredPledge: declaredPledge, + }, nil, + ) + bf.EXPECT().GetPoolRelays( + mock.Anything, + pools[0].ID, + ).Return( + relays, nil, + ) + + options := PoolWatcherOptions{RefreshInterval: 10 * time.Second, Network: "preprod"} + watcher, err := NewPoolWatcher( + bf, + metrics, + pools, + options, + ) + require.NoError(t, err) + err = watcher.Start(ctx) + require.ErrorIs(t, err, context.Canceled) + b := bytes.NewBufferString(metricsExpectedOutput) + err = testutil.CollectAndCompare(registry, b, metricsUnderTest...) + require.NoError(t, err) + }) +}