From bcedc52d7cc2939c437b0c5d5e9b14c504ded9e8 Mon Sep 17 00:00:00 2001 From: Aire-One Date: Mon, 23 Sep 2024 03:14:17 +0200 Subject: [PATCH] refactor: complete rewrite --- .cspell.json | 8 +- .golangci.yaml | 55 +++++- .vscode/launch.json | 16 ++ .vscode/settings.json | 15 +- cmd/labtime/main.go | 26 +++ .../example-config.yaml | 2 +- go.mod | 1 + go.sum | 2 + internal/apps/labtime/app.go | 107 ++++++++++ internal/apps/labtime/flag.go | 18 ++ internal/http/middlewares.go | 33 ++++ internal/monitors/httpmonitor.go | 88 +++++++++ internal/monitors/monitor.go | 6 + internal/scheduler/scheduler.go | 60 ++++++ internal/yamlconfig/yamlconfig.go | 45 +++++ main.go | 182 ------------------ 16 files changed, 465 insertions(+), 199 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 cmd/labtime/main.go rename config/config.yaml => configs/example-config.yaml (71%) create mode 100644 internal/apps/labtime/app.go create mode 100644 internal/apps/labtime/flag.go create mode 100644 internal/http/middlewares.go create mode 100644 internal/monitors/httpmonitor.go create mode 100644 internal/monitors/monitor.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/yamlconfig/yamlconfig.go delete mode 100644 main.go diff --git a/.cspell.json b/.cspell.json index 8726010..f3be4ae 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,20 +1,26 @@ { "ignorePaths": [ + ".golangci.yaml", // Configuration file with a lot of Go specific words "go.mod", // This file is managed by Go "go.sum", // This file is managed by Go "LICENSE" // This file was automatically generated by Gitea ], "words": [ + "aireone", "buildx", "distroless", "Gitea", "gocron", "golangci", "hadolint", + "healthcheckers", "labtime", "nonroot", "promhttp", + "prommonitors", + "proxied", "sigchan", - "woodpeckerci" + "woodpeckerci", + "yamlconfig" ] } diff --git a/.golangci.yaml b/.golangci.yaml index b4d5a12..5a8a76e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,7 +1,50 @@ --- -run: - timeout: 5m - # this settings is for the CI pipeline because it fails with the default value - # we should have used the --timeout flag in the CI pipeline - # but to make the linter work locally, I need to have a config file - # so I pushed this config file until we have something to do here +linters: + enable: + - asasalint + - bodyclose + - contextcheck + - copyloopvar + - depguard + - durationcheck + - errcheck + - errname + - errorlint + - gocritic + - godot + - gofumpt + - goimports + - gosec + - gosimple + - govet + - ineffassign + - misspell + - nilerr + - nilnil + - noctx + - nolintlint + - prealloc + - predeclared + - promlinter + - reassign + - revive + - staticcheck + - tenv + - testableexamples + - thelper + - tparallel + - typecheck + - unconvert + - unused + - unparam + - usestdlibvars + - wastedassign +linters-settings: + depguard: + rules: + main: + list-mode: lax + allow: [aireone.xyz/labtime] # It's mendatory to have at least one package in allow or deny list + errcheck: + check-type-assertions: true + check-blank: true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0207fed --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/labtime/main.go", + "args": ["-config", "../../configs/example-config.yaml"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d304f4..b81aff7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,8 @@ { - "[go]": { - "editor.codeActionsOnSave": { - // "source.organizeImports": "always" - }, - "editor.formatOnSave": true - }, - "go.formatTool": "goimports", - "go.lintTool": "golangci-lint" - // "go.lintFlags": ["--fast"] // We only have the default linters and it seems they are not marked as fast, but it's fast enough for me + "go.lintTool": "golangci-lint", + "go.lintFlags": ["--fast"], + "go.useLanguageServer": true, + "gopls": { + "formatting.gofumpt": true + } } diff --git a/cmd/labtime/main.go b/cmd/labtime/main.go new file mode 100644 index 0000000..30d787d --- /dev/null +++ b/cmd/labtime/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "os" + + "aireone.xyz/labtime/internal/apps/labtime" +) + +const ( + loggerPrefix = "main:" +) + +func main() { + logger := log.New(os.Stdout, loggerPrefix, log.LstdFlags|log.Lshortfile) + cfg := labtime.LoadFlag() + + app, err := labtime.NewApp(cfg, logger) + if err != nil { + logger.Fatalf("Error creating app: %v", err) + } + + if err := app.Start(); err != nil { + logger.Fatalf("Error starting app: %v", err) + } +} diff --git a/config/config.yaml b/configs/example-config.yaml similarity index 71% rename from config/config.yaml rename to configs/example-config.yaml index 5c769d4..d782a63 100644 --- a/config/config.yaml +++ b/configs/example-config.yaml @@ -4,4 +4,4 @@ targets: url: https://aireone.xyz interval: 2 - name: Gitea - url: https://gitea.aireone.xyz \ No newline at end of file + url: https://gitea.aireone.xyz diff --git a/go.mod b/go.mod index c62554a..a4c6370 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/jonboulle/clockwork v0.4.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect diff --git a/go.sum b/go.sum index bd00398..7df523f 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= diff --git a/internal/apps/labtime/app.go b/internal/apps/labtime/app.go new file mode 100644 index 0000000..2a0a8f4 --- /dev/null +++ b/internal/apps/labtime/app.go @@ -0,0 +1,107 @@ +package labtime + +import ( + "context" + "log" + "net/http" + "os" + "time" + + "aireone.xyz/labtime/internal/monitors" + "aireone.xyz/labtime/internal/scheduler" + "aireone.xyz/labtime/internal/yamlconfig" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type App struct { + scheduler *scheduler.Scheduler + prometheusHTTPServer *http.Server + + logger *log.Logger +} + +func NewApp(flag *FlagConfig, logger *log.Logger) (*App, error) { + config, err := loadYamlConfig(flag.configFile) + if err != nil { + return nil, errors.Wrap(err, "error loading yaml config") + } + + scheduler, err := createScheduler(config, logger) + if err != nil { + return nil, errors.Wrap(err, "error creating scheduler") + } + + return &App{ + logger: logger, + scheduler: scheduler, + prometheusHTTPServer: &http.Server{ + Addr: ":2112", + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + }, + }, nil +} + +func loadYamlConfig(configFile string) (*yamlconfig.YamlConfig, error) { + file, err := os.Open(configFile) + if err != nil { + return nil, errors.Wrap(err, "error opening config file") + } + + defer file.Close() + + return yamlconfig.NewYamlConfig(file) +} + +func createScheduler(config *yamlconfig.YamlConfig, logger *log.Logger) (*scheduler.Scheduler, error) { + scheduler, err := scheduler.NewScheduler(logger) + if err != nil { + return nil, errors.Wrap(err, "error creating scheduler") + } + + httpMonitor := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "labtime_response_time_duration", + Help: "The ping time.", + }, []string{"target_name"}) + prometheus.MustRegister(httpMonitor) + + for _, t := range config.Targets { + if err := scheduler.AddJob(&monitors.HTTPMonitor{ + Label: t.Name, + URL: t.URL, + Logger: logger, + ResponseTimeMonitor: httpMonitor, + }, t.Interval); err != nil { + return nil, errors.Wrap(err, "error adding job") + } + } + + return scheduler, nil +} + +func (a *App) Start() error { + a.scheduler.Start() + + // Serve Prometheus metrics + http.Handle("/metrics", promhttp.Handler()) + if err := a.prometheusHTTPServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return errors.Wrap(err, "error starting prometheus http server") + } + + return nil +} + +func (a *App) Shutdown() error { + if err := a.scheduler.Shutdown(); err != nil { + return errors.Wrap(err, "error shutting down scheduler") + } + + if err := a.prometheusHTTPServer.Shutdown(context.TODO()); err != nil { + return errors.Wrap(err, "error shutting down prometheus http server") + } + + return nil +} diff --git a/internal/apps/labtime/flag.go b/internal/apps/labtime/flag.go new file mode 100644 index 0000000..8468d2e --- /dev/null +++ b/internal/apps/labtime/flag.go @@ -0,0 +1,18 @@ +package labtime + +import "flag" + +const ( + defaultConfigFile = "config.yaml" +) + +type FlagConfig struct { + configFile string +} + +func LoadFlag() *FlagConfig { + cfg := FlagConfig{} + flag.StringVar(&cfg.configFile, "config", defaultConfigFile, "Path to the configuration file") + flag.Parse() + return &cfg +} diff --git a/internal/http/middlewares.go b/internal/http/middlewares.go new file mode 100644 index 0000000..1d23354 --- /dev/null +++ b/internal/http/middlewares.go @@ -0,0 +1,33 @@ +package http + +import ( + "log" + "net/http" +) + +type RoundTripperMiddleware struct { + Proxied http.RoundTripper + + OnBefore func(req *http.Request) + OnAfter func(res *http.Response) +} + +func (m RoundTripperMiddleware) RoundTrip(req *http.Request) (res *http.Response, err error) { + m.OnBefore(req) + res, err = m.Proxied.RoundTrip(req) + m.OnAfter(res) + + return res, err +} + +func NewLoggerMiddleware(logger *log.Logger, proxied http.RoundTripper) *RoundTripperMiddleware { + return &RoundTripperMiddleware{ + Proxied: proxied, + OnBefore: func(req *http.Request) { + logger.Printf("Request: %s %s", req.Method, req.URL.String()) + }, + OnAfter: func(res *http.Response) { + logger.Printf("Response: %d", res.StatusCode) + }, + } +} diff --git a/internal/monitors/httpmonitor.go b/internal/monitors/httpmonitor.go new file mode 100644 index 0000000..f5506ef --- /dev/null +++ b/internal/monitors/httpmonitor.go @@ -0,0 +1,88 @@ +package monitors + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + aireoneHttp "aireone.xyz/labtime/internal/http" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" +) + +var ErrInvalidStatusCode = errors.New("expected status code 200") + +type HTTPMonitor struct { + Label string + URL string + + Logger *log.Logger + + ResponseTimeMonitor *prometheus.GaugeVec +} + +func (h *HTTPMonitor) ID() string { + return h.Label +} + +func (h *HTTPMonitor) Run() error { + d, err := h.httpHealthCheck() + if err != nil { + return errors.Wrap(err, "error running http health check") + } + + h.pushToPrometheus(d) + + return nil +} + +func newHTTPDurationMiddleware(duration *time.Duration, proxied http.RoundTripper) *aireoneHttp.RoundTripperMiddleware { + var t time.Time + + return &aireoneHttp.RoundTripperMiddleware{ + Proxied: proxied, + OnBefore: func(_ *http.Request) { + t = time.Now() + }, + OnAfter: func(_ *http.Response) { + *duration = time.Since(t) + }, + } +} + +type HTTPHealthCheckerData struct { + Duration time.Duration +} + +func (h *HTTPMonitor) httpHealthCheck() (*HTTPHealthCheckerData, error) { + r, err := http.NewRequest(http.MethodHead, h.URL, http.NoBody) + if err != nil { + return nil, errors.Wrap(err, "error creating http request") + } + req := r.WithContext(context.TODO()) + + var duration time.Duration + client := &http.Client{ + Transport: aireoneHttp.NewLoggerMiddleware(h.Logger, newHTTPDurationMiddleware(&duration, http.DefaultTransport)), + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Wrap(ErrInvalidStatusCode, fmt.Sprintf("got status code %d", resp.StatusCode)) + } + + return &HTTPHealthCheckerData{ + Duration: duration, + }, nil +} + +func (h *HTTPMonitor) pushToPrometheus(d *HTTPHealthCheckerData) { + h.Logger.Printf("Push metrics to Prometheus: Response time for %s: %v", h.Label, d.Duration.Seconds()) + h.ResponseTimeMonitor.With(prometheus.Labels{"target_name": h.Label}).Set(d.Duration.Seconds()) +} diff --git a/internal/monitors/monitor.go b/internal/monitors/monitor.go new file mode 100644 index 0000000..38e4232 --- /dev/null +++ b/internal/monitors/monitor.go @@ -0,0 +1,6 @@ +package monitors + +type Monitor interface { + ID() string + Run() error +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..1cfb24e --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,60 @@ +package scheduler + +import ( + "log" + "time" + + "aireone.xyz/labtime/internal/monitors" + "github.com/go-co-op/gocron/v2" + "github.com/pkg/errors" +) + +type Scheduler struct { + scheduler gocron.Scheduler + + logger *log.Logger +} + +func NewScheduler(logger *log.Logger) (*Scheduler, error) { + s, err := gocron.NewScheduler() + if err != nil { + return nil, errors.Wrap(err, "error creating scheduler") + } + + return &Scheduler{ + scheduler: s, + logger: logger, + }, nil +} + +func (s *Scheduler) AddJob(m monitors.Monitor, interval int) error { + j, err := s.scheduler.NewJob( + gocron.DurationJob(time.Duration(interval)*time.Second), + gocron.NewTask(func() { + s.logger.Printf("Running job for HTTP monitor %s\n", m.ID()) + if err := m.Run(); err != nil { + s.logger.Printf("Error running job for HTTP monitor %s: %s\n", m.ID(), err) + } + s.logger.Printf("Job finished for HTTP monitor %s\n", m.ID()) + }), + ) + if err != nil { + return errors.Wrap(err, "error creating job") + } + + s.logger.Printf("Job started for HTTP monitor %s with ID: %s\n", m.ID(), j.ID().String()) + + return nil +} + +func (s *Scheduler) Start() { + s.scheduler.Start() +} + +func (s *Scheduler) Shutdown() error { + if err := s.scheduler.Shutdown(); err != nil { + return errors.Wrap(err, "error shutting down scheduler") + } + + return nil +} diff --git a/internal/yamlconfig/yamlconfig.go b/internal/yamlconfig/yamlconfig.go new file mode 100644 index 0000000..2be324f --- /dev/null +++ b/internal/yamlconfig/yamlconfig.go @@ -0,0 +1,45 @@ +package yamlconfig + +import ( + "io" + + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +var ErrYAMLDecode = errors.New("error decoding YAML file") + +type YamlConfig struct { + // List of targets to ping. + Targets []struct { + // Name of the target. Used to identify the target from Prometheus. + Name string `yaml:"name"` + // URL of the target. The target should be accessible from the machine running the exporter. + // The URL should contain the protocol (http:// or https://) and the port if it's not the default one. + URL string `yaml:"url"` + // Interval to ping the target. Default is 5 seconds. + Interval int `yaml:"interval,omitempty"` + } `yaml:"targets"` +} + +func NewYamlConfig(r io.Reader) (*YamlConfig, error) { + d := yaml.NewDecoder(r) + config := &YamlConfig{} + + if err := d.Decode(&config); err != nil { + return nil, errors.Wrap(ErrYAMLDecode, err.Error()) + } + + applyDefault(config) + + return config, nil +} + +func applyDefault(config *YamlConfig) { + // Set default interval to 5 seconds if not provided + for i := range config.Targets { + if config.Targets[i].Interval == 0 { + config.Targets[i].Interval = 5 + } + } +} diff --git a/main.go b/main.go deleted file mode 100644 index d844ae6..0000000 --- a/main.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "crypto/tls" - "fmt" - "log" - "net/http" - "net/http/httptrace" - "os" - "os/signal" - "time" - - "github.com/go-co-op/gocron/v2" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - "gopkg.in/yaml.v3" -) - -type Config struct { - // List of targets to ping - Targets []struct { - // Name of the target. Used to identify the target from Prometheus. - Name string `yaml:"name"` - // URL of the target. The target should be accessible from the machine running the exporter. - // The URL should contain the protocol (http:// or https://) and the port if it's not the default one. - Url string `yaml:"url"` - // Interval to ping the target. Default is 5 seconds - Interval int `yaml:"interval,omitempty"` - } `yaml:"targets"` -} - -func NewConfig(configPath string) (*Config, error) { - // Create config structure - config := &Config{} - - // Open config file - file, err := os.Open(configPath) - if err != nil { - return nil, err - } - defer file.Close() - - // Init new YAML decode - d := yaml.NewDecoder(file) - - // Start YAML decoding from file - if err := d.Decode(&config); err != nil { - return nil, err - } - - return config, nil -} - -func Ping(url string) (int, time.Duration, error) { - req, err := http.NewRequest(http.MethodHead, url, nil) - if err != nil { - return 0, 0, err - } - - var start, connect, dns, tlsHandshake time.Time - - trace := &httptrace.ClientTrace{ - DNSStart: func(dsi httptrace.DNSStartInfo) { dns = time.Now() }, - DNSDone: func(ddi httptrace.DNSDoneInfo) { - fmt.Printf("DNS Done: %v\n", time.Since(dns)) - }, - - TLSHandshakeStart: func() { tlsHandshake = time.Now() }, - TLSHandshakeDone: func(cs tls.ConnectionState, err error) { - fmt.Printf("TLS Handshake: %v\n", time.Since(tlsHandshake)) - }, - - ConnectStart: func(network, addr string) { connect = time.Now() }, - ConnectDone: func(network, addr string, err error) { - fmt.Printf("Connect time: %v\n", time.Since(connect)) - }, - - GotFirstResponseByte: func() { - fmt.Printf("Time from start to first byte: %v\n", time.Since(start)) - }, - } - - req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) - start = time.Now() - - resp, err := http.DefaultTransport.RoundTrip(req) - if err != nil { - return 0, 0, err - } - - defer resp.Body.Close() - - return resp.StatusCode, time.Since(start), nil -} - -func main() { - // Load config - // TODO: Use a flag to specify the config file - config, err := NewConfig("config/config.yaml") - if err != nil { - log.Fatalf("Error loading config: %s", err) - } - // Print config - fmt.Printf("%+v\n", config) - - // create a scheduler - s, err := gocron.NewScheduler() - if err != nil { - log.Fatalf("Error creating the scheduler: %s",err) - } - - // Prometheus metrics - responseTimeMonitor := prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Name: "labtime_response_time_duration", - Help: "The ping time.", - }, []string{"target_name"}) - prometheus.MustRegister(responseTimeMonitor) - - // Intercept the signal to stop the program - go func() { - sigchan := make(chan os.Signal, 1) - signal.Notify(sigchan, os.Interrupt) - <-sigchan - log.Println("Program killed !") - - // do last actions and wait for all write operations to end - - // when you're done, shut it down - err = s.Shutdown() - if err != nil { - log.Fatalf("Error shutting down scheduler: %s", err) - } - - os.Exit(0) - }() - - // add a job to the scheduler - for _, target := range config.Targets { - j, err := s.NewJob( - gocron.DurationJob( - func(interval int) time.Duration { - if interval == 0 { - const defaultInterval = 5 - - return time.Duration(defaultInterval) * time.Second - } - - return time.Duration(interval) * time.Second - }(target.Interval), - ), - gocron.NewTask( - func(url string) { - status, elapsedTime, err := Ping(url) - if err != nil { - fmt.Println(err) - - return - } - - fmt.Printf("%s - Status: %d in %v\n", target.Name, status, elapsedTime.Seconds()) - // push to Prometheus - responseTimeMonitor.With(prometheus.Labels{"target_name": target.Name}).Set(elapsedTime.Seconds()) - }, - target.Url, - ), - ) - if err != nil { - log.Fatalf("Error creating job: %s", err) - } - // each job has a unique id - fmt.Printf("Job %s started with ID: %s\n", target.Name, j.ID().String()) - } - - // start the scheduler - s.Start() - - // Serve Prometheus metrics - http.Handle("/metrics", promhttp.Handler()) - if err := http.ListenAndServe(":2112", nil); err != nil { - log.Fatalf("Error starting server: %s", err) - } -}