iperf3exporter/main.go
Marvin Preuss 1492c8ca20
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
feat: adds wait parameter for a sleep time between download and upload job
2021-11-22 16:14:32 +01:00

427 lines
12 KiB
Go

//nolint:gochecknoglobals
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/go-playground/validator/v10"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.xsfx.dev/logginghandler"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
// rootCmd is the command line interface.
var rootCmd = &cobra.Command{
Use: "iperf3exporter",
Run: func(cmd *cobra.Command, args []string) {
if versionFlag {
fmt.Printf("iperf3exporter %s, commit %s, %s", version, commit, date) //nolint:forbidigo
return
}
http.Handle("/probe", logginghandler.Handler(http.HandlerFunc(probeHandler)))
log.Info().Str("listen", c.Exporter.Listen).Msg("starting...")
log.Fatal().Err(http.ListenAndServe(c.Exporter.Listen, nil)).Msg("goodbye")
},
}
// config is a struct that a config file can get unmarshaled to.
type config struct {
Exporter struct {
Listen string `validate:"required,hostname_port"`
Timeout time.Duration `validate:"required,gt=0"`
ProcessMetrics bool `mapstructure:"process_metrics" validate:"required"`
}
Log struct {
JSON bool
Colors bool `validate:"required"`
}
Iperf3 struct {
Time int `validate:"required"`
Wait time.Duration `validation:"required,min=1ms"`
}
}
// c is a global config struct instance.
var c config
var (
cfgFile string
versionFlag bool
)
var (
downloadSentBitsPerSecond = metrics.NewFloatCounter("iperf3_download_sent_bits_per_second")
downloadSentSeconds = metrics.NewFloatCounter("iperf3_download_sent_seconds")
downloadSentBytes = metrics.NewFloatCounter("iperf3_download_sent_bytes")
downloadSentRetransmits = metrics.NewFloatCounter("iperf3_download_sent_retransmits")
downloadReceivedBitsPerSecond = metrics.NewFloatCounter("iperf3_download_received_bits_per_second")
downloadReceivedSeconds = metrics.NewFloatCounter("iperf3_download_received_seconds")
downloadReceivedBytes = metrics.NewFloatCounter("iperf3_download_received_bytes")
)
var (
uploadSentBitsPerSecond = metrics.NewFloatCounter("iperf3_upload_sent_bits_per_second")
uploadSentSeconds = metrics.NewFloatCounter("iperf3_upload_sent_seconds")
uploadSentBytes = metrics.NewFloatCounter("iperf3_upload_sent_bytes")
uploadSentRetransmits = metrics.NewFloatCounter("iperf3_upload_sent_retransmits")
uploadReceivedBitsPerSecond = metrics.NewFloatCounter("iperf3_upload_received_bits_per_second")
uploadReceivedSeconds = metrics.NewFloatCounter("iperf3_upload_received_seconds")
uploadReceivedBytes = metrics.NewFloatCounter("iperf3_upload_received_bytes")
)
var scrapeErrors = metrics.NewCounter("iperf3_errors")
//nolint:tagliatelle
type iperfResult struct {
End struct {
SumSent struct {
Seconds float64 `json:"seconds"`
Bytes float64 `json:"bytes"`
BitsPerSecond float64 `json:"bits_per_second"`
Retransmits int `json:"retransmits"`
} `json:"sum_sent"`
SumReceived struct {
Seconds float64 `json:"seconds"`
Bytes float64 `json:"bytes"`
BitsPerSecond float64 `json:"bits_per_second"`
} `json:"sum_received"`
} `json:"end"`
}
type Target struct {
Host string
Port int
}
var (
ErrEmptyTarget = errors.New("empty target")
ErrCouldNotDetermineTarget = errors.New("could not determine target")
)
func NewTarget(t string) (Target, error) {
targetParts := strings.Split(t, ":")
var trg Target
//nolint:gomnd
switch s := len(targetParts); {
case s > 2:
// http.Error(w, "could not determine host and port", http.StatusUnprocessableEntity)
return trg, ErrCouldNotDetermineTarget
case s == 2:
p, err := strconv.Atoi(targetParts[1])
if err != nil {
return trg, fmt.Errorf("could not convert port string to int: %w", err)
}
trg = Target{
Host: targetParts[0],
Port: p,
}
case s == 1:
// Using default port.
trg = Target{
Host: targetParts[0],
Port: 5201,
}
}
// Check for empty struct.
if trg.Host == "" || trg.Port == 0 {
return Target{}, ErrEmptyTarget
}
return trg, nil
}
func runIperf(ctx context.Context, t Target, cmdArgs []string, logger zerolog.Logger) (iperfResult, error) {
args := []string{
"-J",
"-c",
t.Host,
"-p",
fmt.Sprintf("%d", t.Port),
"-t", strconv.Itoa(c.Iperf3.Time),
}
args = append(args, cmdArgs...)
cmd := exec.CommandContext(ctx, "iperf3", args...)
logger.Debug().Str("cmd", cmd.String()).Msg("created command")
// Buffers to store stdout and stderr.
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
logger.Debug().
Str("stdout", outb.String()).
Str("stderr", errb.String()).
Msg("output from failed run")
return iperfResult{}, fmt.Errorf("could not run command: %w", err)
}
// Unmarshal the output to the iperf struct.
var p iperfResult
if err := json.Unmarshal(outb.Bytes(), &p); err != nil {
return iperfResult{}, fmt.Errorf("could not unmarshal result: %w", err)
}
return p, nil
}
func download(ctx context.Context, t Target, logger zerolog.Logger) error {
r, err := runIperf(
ctx,
t,
[]string{"-R"},
logger,
)
if err != nil {
return fmt.Errorf("could not get download metrics: %w", err)
}
downloadSentBitsPerSecond.Set(r.End.SumSent.BitsPerSecond)
downloadSentBytes.Set(r.End.SumSent.Bytes)
downloadSentSeconds.Set(r.End.SumSent.Seconds)
downloadSentRetransmits.Set(float64(r.End.SumSent.Retransmits))
downloadReceivedBitsPerSecond.Set(r.End.SumReceived.BitsPerSecond)
downloadReceivedBytes.Set(r.End.SumReceived.Bytes)
downloadReceivedSeconds.Set(r.End.SumReceived.Seconds)
return nil
}
func upload(ctx context.Context, t Target, logger zerolog.Logger) error {
r, err := runIperf(
ctx,
t,
[]string{},
logger,
)
if err != nil {
return fmt.Errorf("could not get upload metrics: %w", err)
}
uploadSentBitsPerSecond.Set(r.End.SumSent.BitsPerSecond)
uploadSentBytes.Set(r.End.SumSent.Bytes)
uploadSentSeconds.Set(r.End.SumSent.Seconds)
uploadSentRetransmits.Set(float64(r.End.SumSent.Retransmits))
uploadReceivedBitsPerSecond.Set(r.End.SumReceived.BitsPerSecond)
uploadReceivedBytes.Set(r.End.SumReceived.Bytes)
uploadReceivedSeconds.Set(r.End.SumReceived.Seconds)
return nil
}
func probeHandler(w http.ResponseWriter, r *http.Request) {
logger := logginghandler.Logger(r)
// Extract target.
trgt := r.URL.Query().Get("target")
if trgt == "" {
logger.Error().Msg("could not find target in url params")
http.Error(w, "could not find target in url params", http.StatusUnprocessableEntity)
return
}
logger.Debug().Str("trgt", trgt).Msg("extracted target from url params")
// Extract port and host for target.
t, err := NewTarget(trgt)
if err != nil {
scrapeErrors.Inc()
logger.Error().Err(err).Msg("could not determine target")
http.Error(w, "could not determine target", http.StatusUnprocessableEntity)
}
ctx, cancel := context.WithTimeout(context.Background(), c.Exporter.Timeout)
defer cancel()
logger.Info().Msg("getting download metrics")
if err := download(ctx, t, logger); err != nil {
scrapeErrors.Inc()
logger.Error().Err(err).Msg("could not create download metrics")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Debug().Dur("wait", c.Iperf3.Wait).Msg("waiting")
time.Sleep(c.Iperf3.Wait)
logger.Info().Msg("getting upload metrics")
if err := upload(ctx, t, logger); err != nil {
scrapeErrors.Inc()
logger.Error().Err(err).Msg("could not create upload metrics")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Info().Msg("done scraping")
metrics.WritePrometheus(w, c.Exporter.ProcessMetrics)
}
func init() { //nolint:gochecknoinits,funlen
cobra.OnInitialize(initConfig)
// Version.
rootCmd.PersistentFlags().BoolVarP(&versionFlag, "version", "v", false, "print version")
// Config.
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file")
// Exporter.Listen.
rootCmd.PersistentFlags().String("listen", "127.0.0.1:9119", "listen string")
if err := viper.BindPFlag("exporter.listen", rootCmd.PersistentFlags().Lookup("listen")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("exporter.listen", "127.0.0.1:9119")
// Exporter.Timeout.
rootCmd.PersistentFlags().Duration("timeout", time.Minute, "scraping timeout")
if err := viper.BindPFlag("exporter.timeout", rootCmd.PersistentFlags().Lookup("timeout")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("exporter.timeout", time.Minute)
// Exporter.ProcessMetrics.
rootCmd.PersistentFlags().Bool("process-metrics", true, "exporter process metrics")
if err := viper.BindPFlag("exporter.processmetrics", rootCmd.PersistentFlags().Lookup("process-metrics")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("exporter.process_metrics", true)
// Log.JSON.
rootCmd.PersistentFlags().Bool("log-json", false, "JSON log output")
if err := viper.BindPFlag("log.json", rootCmd.PersistentFlags().Lookup("log-json")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("log.json", false)
// Log.Colors.
rootCmd.PersistentFlags().Bool("log-colors", true, "colorful log output")
if err := viper.BindPFlag("log.colors", rootCmd.PersistentFlags().Lookup("log-colors")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("log.colors", true)
// Iperf3.Time.
rootCmd.PersistentFlags().Int("time", 5, "time in seconds to transmit for") //nolint:gomnd
if err := viper.BindPFlag("iperf3.time", rootCmd.PersistentFlags().Lookup("time")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
viper.SetDefault("iperf3.time", 5) //nolint:gomnd
// IPerf3.Wait.
rootCmd.PersistentFlags().Duration("wait", time.Second, "time to wait between download and upload runs")
if err := viper.BindPFlag("iperf3.wait", rootCmd.PersistentFlags().Lookup("wait")); err != nil {
log.Fatal().Err(err).Msg("could not bind flag")
}
}
func initConfig() {
// Load config file.
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
if err := viper.ReadInConfig(); err != nil {
log.Fatal().Err(err).Msg("error on reading config")
}
}
// Environment variable handling.
viper.SetEnvPrefix("IPERF3EXPORTER")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Unmarshal config.
if err := viper.Unmarshal(&c); err != nil {
log.Fatal().Err(err).Msg("could not unmarshal config")
}
// Setting up logging.
switch c.Log.JSON {
case false:
if c.Log.Colors {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
} else {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
}
case true:
log.Logger = zerolog.New(os.Stderr).With().Timestamp().Logger()
}
log.Logger = log.With().Caller().Logger()
// Valite config.
validate := validator.New()
if err := validate.Struct(c); err != nil {
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
for _, e := range validationErrors {
log.Error().
Str("tag", e.Tag()).
Str("name", e.StructNamespace()).
Interface("value", e.Value()).
Str("kind", e.Kind().String()).
Msg("config error")
}
}
log.Fatal().Msg("config validation error")
}
}
func main() {
if err := rootCmd.Execute(); err != nil {
log.Fatal().Err(err).Msg("goodbye")
}
}