Skip to content

Commit 5c40217

Browse files
authored
Merge pull request #3 from kilnfi/feat/add-pool-watcher
feat: add a watcher to monitor pools
2 parents ce1d165 + e37b79c commit 5c40217

File tree

26 files changed

+2871
-0
lines changed

26 files changed

+2871
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
config.yaml
2+
.codegpt
3+
bin/

.mockery.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
with-expecter: true
2+
dir: "{{.InterfaceDir}}/mocks/"
3+
packages:
4+
github.com/kilnfi/cardano-validator-watcher/internal/blockfrost:
5+
interfaces:
6+
Client:

Makefile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.PHONY: generate
2+
generate:
3+
@mockery
4+
5+
.PHONY: build
6+
build: generate
7+
@go build -ldflags="-s -w" -o bin/cardano-validator-watcher cmd/watcher/main.go
8+
9+
.PHONY: run
10+
run: generate
11+
@go run cmd/watcher/main.go --config config.yaml
12+
13+
.PHONY: tests
14+
tests:
15+
@go test -v ./...
16+
17+
.PHONY: coverage
18+
coverage:
19+
@go test -coverprofile=coverage.out ./...
20+
@go tool cover -html=coverage.out
21+
22+
.PHONY: lint
23+
lint:
24+
@golangci-lint run ./...
25+
26+
.PHONY: clean
27+
clean:
28+
@echo "cleaning up..."
29+
@rm -rf *.db*

cmd/watcher/app/config/config.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/kilnfi/cardano-validator-watcher/internal/pools"
8+
)
9+
10+
type Config struct {
11+
Pools pools.Pools `mapstructure:"pools"`
12+
HTTP HTTPConfig `mapstructure:"http"`
13+
Network string `mapstructure:"network"`
14+
Blockfrost BlockFrostConfig `mapstructure:"blockfrost"`
15+
PoolWatcherConfig PoolWatcherConfig `mapstructure:"pool-watcher"`
16+
}
17+
18+
type PoolWatcherConfig struct {
19+
Enabled bool `mapstructure:"enabled"`
20+
RefreshInterval int `mapstructure:"refresh-interval"`
21+
}
22+
23+
type HTTPConfig struct {
24+
Host string `mapstructure:"host"`
25+
Port int `mapstructure:"port"`
26+
}
27+
28+
type BlockFrostConfig struct {
29+
ProjectID string `mapstructure:"project-id"`
30+
Endpoint string `mapstructure:"endpoint"`
31+
MaxRoutines int `mapstructure:"max-routines"`
32+
Timeout int `mapstructure:"timeout"`
33+
}
34+
35+
func (c *Config) Validate() error {
36+
switch c.Network {
37+
case "mainnet", "preprod":
38+
default:
39+
return fmt.Errorf("invalid network: %s. Network must be either %s or %s", c.Network, "mainnet", "preprod")
40+
}
41+
42+
if len(c.Pools) == 0 {
43+
return errors.New("at least one pool must be defined")
44+
}
45+
for _, pool := range c.Pools {
46+
if pool.Instance == "" {
47+
return errors.New("instance is required for all pools")
48+
}
49+
if pool.ID == "" {
50+
return errors.New("id is required for all pools")
51+
}
52+
if pool.Name == "" {
53+
return errors.New("name is required for all pools")
54+
}
55+
if pool.Key == "" {
56+
return errors.New("key is required for all pools")
57+
}
58+
}
59+
60+
activePools := c.Pools.GetActivePools()
61+
if len(activePools) == 0 {
62+
return errors.New("at least one active pool must be defined")
63+
}
64+
65+
if c.Blockfrost.ProjectID == "" || c.Blockfrost.Endpoint == "" {
66+
return errors.New("blockfrost project-id and endpoint are required")
67+
}
68+
69+
return nil
70+
}

cmd/watcher/app/watcher.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"os/signal"
10+
"strings"
11+
"syscall"
12+
"time"
13+
14+
"github.com/kilnfi/cardano-validator-watcher/cmd/watcher/app/config"
15+
"github.com/kilnfi/cardano-validator-watcher/internal/blockfrost"
16+
"github.com/kilnfi/cardano-validator-watcher/internal/blockfrost/blockfrostapi"
17+
"github.com/kilnfi/cardano-validator-watcher/internal/metrics"
18+
"github.com/kilnfi/cardano-validator-watcher/internal/pools"
19+
"github.com/kilnfi/cardano-validator-watcher/internal/server/http"
20+
"github.com/kilnfi/cardano-validator-watcher/internal/watcher"
21+
"github.com/prometheus/client_golang/prometheus"
22+
"golang.org/x/sync/errgroup"
23+
24+
"github.com/spf13/cobra"
25+
"github.com/spf13/viper"
26+
)
27+
28+
var (
29+
configFile string
30+
server *http.Server
31+
cfg *config.Config
32+
logger *slog.Logger
33+
)
34+
35+
func init() {
36+
cobra.OnInitialize(initLogger)
37+
cobra.OnInitialize(loadConfig)
38+
}
39+
40+
func initLogger() {
41+
var logLevel slog.Level
42+
switch viper.GetString("log-level") {
43+
case "info":
44+
logLevel = slog.LevelInfo
45+
case "warn":
46+
logLevel = slog.LevelWarn
47+
case "error":
48+
logLevel = slog.LevelError
49+
case "debug":
50+
logLevel = slog.LevelDebug
51+
default:
52+
logLevel = slog.LevelInfo
53+
}
54+
55+
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
56+
Level: logLevel,
57+
}))
58+
slog.SetDefault(logger)
59+
}
60+
61+
func NewWatcherCommand() *cobra.Command {
62+
cmd := &cobra.Command{
63+
TraverseChildren: true,
64+
Use: "cardano-validator-watcher",
65+
Short: "cardano validator watcher is used to monitor our cardano pools",
66+
Long: `cardano validator watcher is a long-running program designed
67+
to collect metrics for monitoring our Cardano validation nodes.
68+
This tool helps us ensure the health and performance of our nodes in the Cardano network.`,
69+
SilenceUsage: true,
70+
SilenceErrors: true,
71+
RunE: run,
72+
}
73+
74+
cmd.Flags().StringVarP(&configFile, "config", "", "", "config file (default is config.yml)")
75+
cmd.Flags().StringP("log-level", "", "info", "config file (default is config.yml)")
76+
cmd.Flags().StringP("http-server-host", "", http.ServerDefaultHost, "host on which HTTP server should listen")
77+
cmd.Flags().IntP("http-server-port", "", http.ServerDefaultPort, "port on which HTTP server should listen")
78+
cmd.Flags().StringP("network", "", "preprod", "cardano network ID")
79+
cmd.Flags().StringP("blockfrost-project-id", "", "", "blockfrost project id")
80+
cmd.Flags().StringP("blockfrost-endpoint", "", "", "blockfrost API endpoint")
81+
cmd.Flags().IntP("blockfrost-max-routines", "", 10, "number of routines used by blockfrost to perform concurrent actions")
82+
cmd.Flags().IntP("blockfrost-timeout", "", 60, "Timeout for requests to the Blockfrost API (in seconds)")
83+
cmd.Flags().BoolP("pool-watcher-enabled", "", true, "Enable pool watcher")
84+
cmd.Flags().IntP("pool-watcher-refresh-interval", "", 60, "Interval at which the pool watcher collects data about the monitored pools (in seconds)")
85+
86+
// bind flag to viper
87+
checkError(viper.BindPFlag("log-level", cmd.Flag("log-level")), "unable to bind log-level flag")
88+
checkError(viper.BindPFlag("http.host", cmd.Flag("http-server-host")), "unable to bind http-server-host flag")
89+
checkError(viper.BindPFlag("http.port", cmd.Flag("http-server-port")), "unable to bind http-server-port flag")
90+
checkError(viper.BindPFlag("network", cmd.Flag("network")), "unable to bind network flag")
91+
checkError(viper.BindPFlag("blockfrost.project-id", cmd.Flag("blockfrost-project-id")), "unable to bind blockfrost-project-id flag")
92+
checkError(viper.BindPFlag("blockfrost.endpoint", cmd.Flag("blockfrost-endpoint")), "unable to bind blockfrost-endpoint flag")
93+
checkError(viper.BindPFlag("blockfrost.max-routines", cmd.Flag("blockfrost-max-routines")), "unable to bind blockfrost-max-routines flag")
94+
checkError(viper.BindPFlag("blockfrost.timeout", cmd.Flag("blockfrost-timeout")), "unable to bind blockfrost-timeout flag")
95+
checkError(viper.BindPFlag("pool-watcher.enabled", cmd.Flag("pool-watcher-enabled")), "unable to bind pool-watcher-enabled flag")
96+
checkError(viper.BindPFlag("pool-watcher.refresh-interval", cmd.Flag("pool-watcher-refresh-interval")), "unable to bind pool-watcher-refresh-interval flag")
97+
98+
return cmd
99+
}
100+
101+
// loadConfig read the configuration and load it.
102+
func loadConfig() {
103+
if configFile != "" {
104+
viper.SetConfigFile(configFile)
105+
} else {
106+
viper.SetConfigName("config")
107+
viper.SetConfigType("yaml")
108+
viper.AddConfigPath(".")
109+
}
110+
111+
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
112+
viper.AutomaticEnv()
113+
114+
// read the config file
115+
if err := viper.ReadInConfig(); err != nil {
116+
logger.Error("unable to read config file", slog.String("error", err.Error()))
117+
os.Exit(1)
118+
}
119+
120+
// unmarshal the config
121+
cfg = &config.Config{}
122+
if err := viper.Unmarshal(cfg); err != nil {
123+
logger.Error("unable to unmarshal config", slog.String("error", err.Error()))
124+
os.Exit(1)
125+
}
126+
127+
// validate the config
128+
if err := cfg.Validate(); err != nil {
129+
logger.Error("invalid configuration", slog.String("error", err.Error()))
130+
os.Exit(1)
131+
}
132+
}
133+
134+
func run(_ *cobra.Command, _ []string) error {
135+
// Initialize context and cancel function
136+
ctx, cancel := context.WithCancel(context.Background())
137+
defer cancel()
138+
139+
// Initialize signal channel for handling interrupts
140+
ctx, cancel = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
141+
defer cancel()
142+
143+
eg, ctx := errgroup.WithContext(ctx)
144+
145+
// Initialize blockfrost and cardano clients with options
146+
blockfrost := createBlockfrostClient()
147+
148+
// Initialize prometheus metrics
149+
registry := prometheus.NewRegistry()
150+
metrics := metrics.NewCollection()
151+
metrics.MustRegister(registry)
152+
153+
// Start HTTP server
154+
if err := startHTTPServer(eg, registry); err != nil {
155+
return fmt.Errorf("unable to start http server: %w", err)
156+
}
157+
158+
// Start Pool Watcher
159+
if cfg.PoolWatcherConfig.Enabled {
160+
startPoolWatcher(ctx, eg, blockfrost, metrics, cfg.Pools)
161+
}
162+
163+
<-ctx.Done()
164+
logger.Info("shutting down")
165+
166+
// shutting down HTTP server
167+
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
168+
defer cancel()
169+
logger.Info("stopping http server")
170+
if err := server.Stop(ctx); err != nil {
171+
logger.Error("unable to stop http service", slog.String("error", err.Error()))
172+
}
173+
174+
if err := eg.Wait(); err != nil {
175+
if errors.Is(err, context.Canceled) {
176+
logger.Info("Program interrupted by user")
177+
return nil
178+
}
179+
return fmt.Errorf("error during execution: %w", err)
180+
}
181+
return nil
182+
}
183+
184+
func createBlockfrostClient() blockfrost.Client {
185+
opts := blockfrostapi.ClientOptions{
186+
ProjectID: cfg.Blockfrost.ProjectID,
187+
Server: cfg.Blockfrost.Endpoint,
188+
MaxRoutines: cfg.Blockfrost.MaxRoutines,
189+
Timeout: time.Second * time.Duration(cfg.Blockfrost.Timeout),
190+
}
191+
return blockfrostapi.NewClient(opts)
192+
}
193+
194+
func startHTTPServer(eg *errgroup.Group, registry *prometheus.Registry) error {
195+
var err error
196+
197+
server, err = http.New(
198+
registry,
199+
http.WithHost(cfg.HTTP.Host),
200+
http.WithPort(cfg.HTTP.Port),
201+
)
202+
if err != nil {
203+
return fmt.Errorf("unable to create http server: %w", err)
204+
}
205+
206+
eg.Go(func() error {
207+
logger.Info(
208+
"starting http server",
209+
slog.String("component", "http-server"),
210+
slog.String("addr", fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)),
211+
)
212+
if err := server.Start(); err != nil {
213+
return fmt.Errorf("unable to start http server: %w", err)
214+
}
215+
return nil
216+
})
217+
218+
return nil
219+
}
220+
221+
// startPoolWatcher starts the pool watcher service
222+
func startPoolWatcher(
223+
ctx context.Context,
224+
eg *errgroup.Group,
225+
blockfrost blockfrost.Client,
226+
metrics *metrics.Collection,
227+
pools pools.Pools,
228+
) {
229+
eg.Go(func() error {
230+
options := watcher.PoolWatcherOptions{
231+
RefreshInterval: time.Second * time.Duration(cfg.PoolWatcherConfig.RefreshInterval),
232+
Network: cfg.Network,
233+
}
234+
logger.Info(
235+
"starting watcher",
236+
slog.String("component", "pool-watcher"),
237+
)
238+
poolWatcher, err := watcher.NewPoolWatcher(blockfrost, metrics, pools, options)
239+
if err != nil {
240+
return fmt.Errorf("unable to create pool watcher: %w", err)
241+
}
242+
if err := poolWatcher.Start(ctx); err != nil {
243+
return fmt.Errorf("unable to start pool watcher: %w", err)
244+
}
245+
return nil
246+
})
247+
}
248+
249+
// checkError is a helper function to log an error and exit the program
250+
// used for the flag parsing
251+
func checkError(err error, msg string) {
252+
if err != nil {
253+
logger.Error(msg, slog.String("error", err.Error()))
254+
os.Exit(1)
255+
}
256+
}

cmd/watcher/main.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package main
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
7+
"github.com/kilnfi/cardano-validator-watcher/cmd/watcher/app"
8+
)
9+
10+
func main() {
11+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
12+
Level: slog.LevelInfo,
13+
}))
14+
15+
command := app.NewWatcherCommand()
16+
if err := command.Execute(); err != nil {
17+
logger.Error("command execution failed", slog.String("error", err.Error()))
18+
os.Exit(1)
19+
}
20+
}

0 commit comments

Comments
 (0)