diff --git a/.github/workflows/container-ci.yaml b/.github/workflows/container-ci.yaml deleted file mode 100644 index ec79846..0000000 --- a/.github/workflows/container-ci.yaml +++ /dev/null @@ -1,60 +0,0 @@ -name: Container Integrations - -on: - push: - branches: - - "**" - tags: - - "v*.*.*" - workflow_dispatch: - -jobs: - # Push image to GitHub Packages. - # See also https://docs.docker.com/docker-hub/builds/ - publish: - # Ensure test job passes before pushing image. - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout Project - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - # list of Docker images to use as base name for tags - images: ghcr.io/kashalls/kromgo - # generate Docker tags based on the following events/attributes - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and Push Docker image - uses: docker/build-push-action@v6.6.1 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/does-the-container-build.yaml b/.github/workflows/does-the-container-build.yaml deleted file mode 100644 index eb32fb2..0000000 --- a/.github/workflows/does-the-container-build.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Does The Container Build? - -on: - pull_request: - -jobs: - # Run tests. - # See also https://docs.docker.com/docker-hub/builds/automated-testing/ - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - - - name: Run tests - run: | - docker build . --file Dockerfile \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..ee3ab5f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,61 @@ +--- +name: Release + +on: + pull_request: + push: + branches: ["main"] + release: + types: ["published"] + +jobs: + build-image: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},prefix=v + type=semver,pattern={{major}}.{{minor}},prefix=v + type=semver,pattern={{major}},prefix=v + type=ref,event=branch + type=ref,event=pr + flavor: | + latest=auto + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} + REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} diff --git a/Dockerfile b/Dockerfile index bf890df..8ecafe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,16 @@ -# Build Project -FROM golang:1.22.5-alpine as build -WORKDIR /go/src/github.com/kashalls/kromgo +FROM golang:1.22-alpine AS build +ARG PKG=github.com/kashalls/kromgo +ARG VERSION=dev +ARG REVISION=dev +WORKDIR /build +COPY . . +RUN go build -ldflags "-s -w -X main.Version=${VERSION} -X main.Gitsha=${REVISION}" ./cmd/kromgo -ARG TARGETOS -ARG TARGETARCH -ARG TARGETVARIANT="" - -ENV GO111MODULE=on \ - CGO_ENABLED=0 \ - GOOS=${TARGETOS} \ - GOARCH=${TARGETARCH} \ - GOARM=${TARGETVARIANT} - -COPY go.mod go.sum ./ -RUN go mod download -COPY *.go ./ -RUN go build -ldflags="-s -w" -o /kromgo - -# Final Image -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static-debian12:nonroot USER nonroot:nonroot -COPY --from=build --chown=nonroot:nonroot /kromgo /kromgo/ -EXPOSE 8080 - -CMD ["/kromgo/kromgo"] +COPY --from=build --chmod=555 /build/kromgo /kromgo/kromgo +EXPOSE 8080/tcp 8888/tcp LABEL \ org.opencontainers.image.title="kromgo" \ - org.opencontainers.image.source="https://github.com/kashalls/kromgo" \ No newline at end of file + org.opencontainers.image.source="https://github.com/kashalls/kromgo" +ENTRYPOINT ["/kromgo/kromgo"] \ No newline at end of file diff --git a/cmd/kromgo/init/configuration/configuration.go b/cmd/kromgo/init/configuration/configuration.go new file mode 100644 index 0000000..4e28300 --- /dev/null +++ b/cmd/kromgo/init/configuration/configuration.go @@ -0,0 +1,94 @@ +package configuration + +import ( + "os" + "time" + + "github.com/caarlos0/env/v11" + "github.com/kashalls/kromgo/cmd/kromgo/init/log" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type ServerConfig struct { + ServerHost string `env:"SERVER_HOST" envDefault:"localhost"` + ServerPort int `env:"SERVER_PORT" envDefault:"8080"` + + HealthHost string `env:"HEALTH_HOST" envDefault:"localhost"` + HealthPort int `env:"HEALTH_PORT" envDefault:"8888"` + + ServerReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT"` + ServerWriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT"` + ServerLogging bool `env:"SERVER_LOGGING"` + + RatelimitEnable bool `env:"RATELIMIT_ENABLE"` + RatelimitAll bool `env:"RATELIMIT_ALL"` + RatelimitByRealIP bool `env:"RATELIMIT_BY_REAL_IP"` + RatelimitRequestLimit int `env:"RATELIMIT_REQUEST_LIMIT" envDefault:"100"` + RatelimitWindowLength time.Duration `env:"RATELIMIT_WINDOW_LENGTH" envDefault:"1m"` +} + +// KromgoConfig struct for configuration environmental variables +type KromgoConfig struct { + Prometheus string `yaml:"prometheus,omitempty" json:"prometheus,omitempty"` + Metrics []Metric `yaml:"metrics" json:"metrics"` +} + +type Metric struct { + Name string `yaml:"name" json:"name"` + Query string `yaml:"query" json:"query"` + Label string `yaml:"label,omitempty" json:"label,omitempty"` + Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` + Suffix string `yaml:"suffix,omitempty" json:"suffix,omitempty"` + Colors []MetricColor `yaml:"colors,omitempty" json:"colors,omitempty"` +} + +type MetricColor struct { + Min float64 `yaml:"min" json:"min"` + Max float64 `yaml:"max" json:"max"` + Color string `yaml:"color,omitempty" json:"color,omitempty"` + ValueOverride string `yaml:"valueOverride,omitempty" json:"valueOverride,omitempty"` +} + +var ConfigPath = "/kromgo/config.yaml" // Default config file path +var ProcessedMetrics map[string]Metric + +// Init sets up configuration by reading set environmental variables +func Init(configPath string) KromgoConfig { + + if configPath == "" { + configPath = ConfigPath + } + + // Read file from path. + data, err := os.ReadFile(configPath) + if err != nil { + log.Error("error reading config file", zap.Error(err)) + os.Exit(1) + } + + var config KromgoConfig + if err := yaml.Unmarshal(data, &config); err != nil { + log.Error("error unmarshalling config yaml", zap.Error(err)) + os.Exit(1) + } + + ProcessedMetrics = preprocess(config.Metrics) + return config +} + +func InitServer() ServerConfig { + cfg := ServerConfig{} + if err := env.Parse(&cfg); err != nil { + log.Error("error reading configuration from environment", zap.Error(err)) + } + return cfg +} + +func preprocess(metrics []Metric) map[string]Metric { + reverseMap := make(map[string]Metric) + for _, obj := range metrics { + reverseMap[obj.Name] = obj + } + return reverseMap +} diff --git a/cmd/kromgo/init/log/log.go b/cmd/kromgo/init/log/log.go new file mode 100644 index 0000000..586a973 --- /dev/null +++ b/cmd/kromgo/init/log/log.go @@ -0,0 +1,66 @@ +package log + +import ( + "os" + + "go.uber.org/zap" +) + +var logger *zap.Logger + +func Init() { + config := zap.NewProductionConfig() + + // Set the log format + format := os.Getenv("LOG_FORMAT") + if format == "test" { + config.Encoding = "console" + } else { + config.Encoding = "json" + } + + // Set the log level + level := os.Getenv("LOG_LEVEL") + switch level { + case "debug": + config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + case "info": + config.Level = zap.NewAtomicLevelAt(zap.InfoLevel) + case "warn": + config.Level = zap.NewAtomicLevelAt(zap.WarnLevel) + case "error": + config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel) + default: + config.Level = zap.NewAtomicLevelAt(zap.InfoLevel) + } + + // Build the logger + var err error + logger, err = config.Build() + if err != nil { + panic(err) + } + + // Ensure we flush any buffered log entries + defer logger.Sync() +} + +func Info(message string, fields ...zap.Field) { + logger.Info(message, fields...) +} + +func Debug(message string, fields ...zap.Field) { + logger.Debug(message, fields...) +} + +func Error(message string, fields ...zap.Field) { + logger.Error(message, fields...) +} + +func Fatal(message string, fields ...zap.Field) { + logger.Fatal(message, fields...) +} + +func With(fields ...zap.Field) *zap.Logger { + return logger.With(fields...) +} \ No newline at end of file diff --git a/cmd/kromgo/init/prometheus/prometheus.go b/cmd/kromgo/init/prometheus/prometheus.go new file mode 100644 index 0000000..6e72141 --- /dev/null +++ b/cmd/kromgo/init/prometheus/prometheus.go @@ -0,0 +1,33 @@ +package prometheus + +import ( + "fmt" + "os" + + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" +) + +var Papi v1.API + +func Init(config configuration.KromgoConfig) (v1.API, error) { + prometheusURL := os.Getenv("PROMETHEUS_URL") + if prometheusURL != "" { + config.Prometheus = prometheusURL + } + + if len(config.Prometheus) == 0 { + return nil, fmt.Errorf("no url pointing to a prometheus instance was provided") + } + + client, err := api.NewClient(api.Config{ + Address: config.Prometheus, + }) + if err != nil { + return nil, fmt.Errorf("error creating prometheus client: %s", err) + } + + Papi = v1.NewAPI(client) + return Papi, nil +} diff --git a/cmd/kromgo/init/server/server.go b/cmd/kromgo/init/server/server.go new file mode 100644 index 0000000..8cb7e6e --- /dev/null +++ b/cmd/kromgo/init/server/server.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/kashalls/kromgo/cmd/kromgo/init/log" + "github.com/kashalls/kromgo/pkg/kromgo" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.uber.org/zap" +) + +// HealthCheckHandler returns the status of the service +func HealthCheckHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// ReadinessHandler returns whether the service is ready to accept requests +func ReadinessHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// Init initializes the http server +func Init(config configuration.KromgoConfig, serverConfig configuration.ServerConfig) (*http.Server, *http.Server) { + + mainRouter := chi.NewRouter() + if serverConfig.ServerLogging { + mainRouter.Use(middleware.Logger) + } + if serverConfig.RatelimitEnable { + if serverConfig.RatelimitAll { + mainRouter.Use(httprate.LimitAll(serverConfig.RatelimitRequestLimit, serverConfig.RatelimitWindowLength)) + } else if serverConfig.RatelimitByRealIP { + mainRouter.Use(httprate.LimitByRealIP(serverConfig.RatelimitRequestLimit, serverConfig.RatelimitWindowLength)) + } else { + mainRouter.Use(httprate.LimitByIP(serverConfig.RatelimitRequestLimit, serverConfig.RatelimitWindowLength)) + } + } + + mainRouter.Get("/{metric}", func(w http.ResponseWriter, r *http.Request) { + kromgo.KromgoRequestHandler(w, r, config) + }) + + mainServer := createHTTPServer(fmt.Sprintf("%s:%d", serverConfig.ServerHost, serverConfig.ServerPort), mainRouter, serverConfig.ServerReadTimeout, serverConfig.ServerWriteTimeout) + go func() { + log.Info("starting kromgo server", zap.String("address", mainServer.Addr)) + if err := mainServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("unable to start kromgo server", zap.String("address", mainServer.Addr), zap.Error(err)) + } + }() + + healthRouter := chi.NewRouter() + healthRouter.Get("/metrics", promhttp.Handler().ServeHTTP) + healthRouter.Get("/healthz", HealthCheckHandler) + healthRouter.Get("/-/health", HealthCheckHandler) + healthRouter.Get("/readyz", ReadinessHandler) + healthRouter.Get("/-/ready", ReadinessHandler) + + healthServer := createHTTPServer(fmt.Sprintf("%s:%d", serverConfig.HealthHost, serverConfig.HealthPort), healthRouter, serverConfig.ServerReadTimeout, serverConfig.ServerWriteTimeout) + go func() { + log.Info("starting health server", zap.String("address", healthServer.Addr)) + if err := healthServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("unable to start health server", zap.String("address", healthServer.Addr), zap.Error(err)) + } + }() + + return mainServer, healthServer +} + +func createHTTPServer(addr string, hand http.Handler, readTimeout, writeTimeout time.Duration) *http.Server { + return &http.Server{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + Addr: addr, + Handler: hand, + } +} + +// ShutdownGracefully gracefully shutdown the http server +func ShutdownGracefully(mainServer *http.Server, healthServer *http.Server) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + sig := <-sigCh + + log.Info("shutting down servers due to received signal", zap.Any("signal", sig)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := mainServer.Shutdown(ctx); err != nil { + log.Error("error shutting down main server", zap.Error(err)) + } + + if err := healthServer.Shutdown(ctx); err != nil { + log.Error("error shutting down health server", zap.Error(err)) + } +} diff --git a/cmd/kromgo/main.go b/cmd/kromgo/main.go new file mode 100644 index 0000000..54c1c98 --- /dev/null +++ b/cmd/kromgo/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/invopop/jsonschema" + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/kashalls/kromgo/cmd/kromgo/init/log" + "github.com/kashalls/kromgo/cmd/kromgo/init/prometheus" + "github.com/kashalls/kromgo/cmd/kromgo/init/server" + + "go.uber.org/zap" +) + +const banner = ` +kromgo +version: %s (%s) + +` + +var ( + Version = "local" + Gitsha = "?" +) + +func main() { + fmt.Printf(banner, Version, Gitsha) + + configPathFlag := flag.String("config", "", "Path to the YAML config file") + jsonSchemaFlag := flag.Bool("jsonschema", false, "Dump JSON Schema for config file") + flag.Parse() + + if *jsonSchemaFlag { + jsonString, _ := json.MarshalIndent(jsonschema.Reflect(&configuration.KromgoConfig{}), "", " ") + fmt.Println(string(jsonString)) + return + } + + log.Init() + + config := configuration.Init(*configPathFlag) + serverConfig := configuration.InitServer() + _, err := prometheus.Init(config) + if err != nil { + log.Error("failed to initialize prometheus", zap.Error(err)) + } + + main, health := server.Init(config, serverConfig) + server.ShutdownGracefully(main, health) +} diff --git a/config.yaml.example b/config.yaml.example index 08dc4da..1354c60 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,7 +1,5 @@ --- # yaml-language-server: $schema=https://raw.githubusercontent.com/kashalls/kromgo/main/config.schema.json -debug: false -port: 8080 metrics: - name: node_cpu_usage query: "round(cluster:node_cpu:ratio_rate5m * 100, 0.1)" diff --git a/go.mod b/go.mod index fb8a90d..6b900d2 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,36 @@ -module kashall.dev/kubernetes-json-shields +module github.com/kashalls/kromgo go 1.22.1 require ( + github.com/caarlos0/env/v11 v11.2.0 + github.com/go-chi/chi v1.5.5 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/httprate v0.12.0 github.com/invopop/jsonschema v0.12.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/common v0.55.0 - gopkg.in/yaml.v2 v2.4.0 + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.21.0 // indirect google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index 02c5c58..9d9badc 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,19 @@ 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/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs= +github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.12.0 h1:08D/te3pOTJe5+VAZTQrHxwdsH2NyliiUoRD1naKaMg= +github.com/go-chi/httprate v0.12.0/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= 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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -33,52 +38,44 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 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.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= -github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw= -github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= -github.com/prometheus/common v0.52.2 h1:LW8Vk7BccEdONfrJBDffQGRtpSzi5CQaRZGtboOO2ck= -github.com/prometheus/common v0.52.2/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= -github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= -github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= -github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= -github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.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/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= -google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go deleted file mode 100644 index 31d08d1..0000000 --- a/main.go +++ /dev/null @@ -1,305 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "log/slog" - "net/http" - "os" - "strconv" - "time" - - "github.com/invopop/jsonschema" - "github.com/prometheus/client_golang/api" - v1 "github.com/prometheus/client_golang/api/prometheus/v1" - "github.com/prometheus/common/model" - "gopkg.in/yaml.v2" -) - -type MetricColor struct { - Min float64 `yaml:"min" json:"min"` - Max float64 `yaml:"max" json:"max"` - Color string `yaml:"color,omitempty" json:"color,omitempty"` - ValueOverride string `yaml:"valueOverride,omitempty" json:"valueOverride,omitempty"` -} - -type Metric struct { - Name string `yaml:"name" json:"name"` - Query string `yaml:"query" json:"query"` - Label string `yaml:"label,omitempty" json:"label,omitempty"` - Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` - Suffix string `yaml:"suffix,omitempty" json:"suffix,omitempty"` - Colors []MetricColor `yaml:"colors,omitempty" json:"colors,omitempty"` -} - -type Config struct { - Debug bool `yaml:"debug,omitempty" json:"debug,omitempty"` - Port string `yaml:"port,omitempty" json:"port,omitempty"` - Prometheus string `yaml:"prometheus,omitempty" json:"prometheus,omitempty"` - Metrics []Metric `yaml:"metrics" json:"metrics"` -} - -type MetricResult struct { - Metric map[string]interface{} `json:"metric"` - Value []interface{} `json:"value"` -} - -var configPath = "/kromgo/config.yaml" // Default config file path - -func main() { - logLevel := &slog.LevelVar{} - logLevel.Set(slog.LevelInfo) - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: logLevel, - })) - slog.SetDefault(logger) - - // Check if a custom config file path is provided via command line argument - configPathFlag := flag.String("config", "", "Path to the YAML config file") - jsonSchemaFlag := flag.Bool("jsonschema", false, "Dump JSON Schema for config file") - flag.Parse() - - if *jsonSchemaFlag { - jsonString, _ := json.MarshalIndent(jsonschema.Reflect(&Config{}), "", " ") - fmt.Println(string(jsonString)) - return - } - - if *configPathFlag != "" { - configPath = *configPathFlag - } - - // Load the YAML config file - config, err := loadConfig(configPath) - if err != nil { - fmt.Printf("Error loading config: %s\n", err) - os.Exit(1) - } - - if config.Debug { - logLevel.Set(slog.LevelDebug) - } - - prometheusURL := os.Getenv("PROMETHEUS_URL") - if prometheusURL != "" { - config.Prometheus = prometheusURL - } - - if len(config.Prometheus) == 0 { - panic("No valid prometheus endpoint was set in config or environment.") - } - - // Create a Prometheus API client - client, err := api.NewClient(api.Config{ - Address: config.Prometheus, - }) - if err != nil { - fmt.Printf("Error creating Prometheus client: %s\n", err) - os.Exit(1) - } - - // Create a Prometheus v1 API client - v1api := v1.NewAPI(client) - - http.HandleFunc("/-/ready", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Kromgo is Ready")) - return - }) - - // Set up HTTP server - http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) { - - slog.Info("incoming request", - slog.String("method", r.Method), - slog.String("ip", r.RemoteAddr), - slog.String("url", r.URL.String()), - ) - - // Get the metric name from the query parameter - metricName := r.URL.Query().Get("metric") - responseFormat := r.URL.Query().Get("format") - - // Find the corresponding metric configuration - var metric Metric - for _, configMetric := range config.Metrics { - if configMetric.Name == metricName { - metric = configMetric - break - } - } - - // If metric not found, return an error - if metric.Query == "" { - slog.Error( - "metric not found", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - ) - http.Error(w, "Metric not found", http.StatusNotFound) - return - } - - // Run the Prometheus query - result, warnings, err := v1api.Query(r.Context(), metric.Query, time.Now()) - if err != nil { - slog.Error( - "error executing query", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - "error", err, - ) - http.Error(w, fmt.Sprintf("Error executing query: %s", err), http.StatusInternalServerError) - return - } - - if len(warnings) > 0 { - fmt.Println("Warnings while executing query:", warnings) - } - - // Convert the result to JSON - jsonResult, err := json.Marshal(result) - slog.Debug( - "query result", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - slog.String("query", metric.Query), - slog.String("result", string(jsonResult)), - ) - if err != nil { - slog.Error( - "could not convert to json", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - "error", err, - ) - http.Error(w, fmt.Sprintf("Error converting result to JSON: %s", err), http.StatusInternalServerError) - return - } - - if len(jsonResult) <= 0 { - slog.Error( - "query returned no results", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - slog.String("query", metric.Query), - ) - http.Error(w, "Query returned no results", http.StatusNotFound) - return - } - - if (responseFormat == "raw") { - w.Header().Set("Content-Type", "application/json") - w.Write(jsonResult) - return - } else { - - responseResult := result.(model.Vector) - resultValue := float64(responseResult[0].Value) - colorConfig := getColorConfig(metric.Colors, resultValue) - - var whatAmIShowing string = strconv.FormatFloat(resultValue, 'f', -1, 64) - - if len(metric.Label) > 0 { - value, err := ExtractLabelValue(responseResult, metric.Label) - if err != nil { - http.Error(w, "Label was not present in query.", http.StatusBadGateway) - slog.Error( - "label was not found in query result", - slog.String("ip", r.RemoteAddr), - slog.String("metric", metric.Name), - "label", metric.Label, - ) - return - } - whatAmIShowing = value - } - - if len(colorConfig.ValueOverride) > 0 { - whatAmIShowing = colorConfig.ValueOverride - } - - message := metric.Prefix + whatAmIShowing + metric.Suffix - - data := map[string]interface{}{ - "schemaVersion": 1, - "label": metricName, - "message": message, - } - - if colorConfig.Color != "" { - data["color"] = colorConfig.Color - } - - // Convert the data to JSON - jsonData, err := json.Marshal(data) - if err != nil { - http.Error(w, "Error converting to JSON", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) - - } - }) - - // Determine the HTTP server port - port := os.Getenv("PORT") - if port == "" { - port = config.Port - if port == "" { - port = "8080" - } - } - - // Start the HTTP server - slog.Info("server is listening", - slog.String("port", port), - ) - http.ListenAndServe(":"+port, nil) -} - -// Load the YAML config file -func loadConfig(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("error reading config file: %s", err) - } - - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("error unmarshalling YAML: %s", err) - } - - return &config, nil -} - -func getColorConfig(colors []MetricColor, value float64) MetricColor { - for _, colorConfig := range colors { - if value >= colorConfig.Min && value <= colorConfig.Max { - return colorConfig - } - } - - // MetricColors is enabled, but the value does not have a corresponding value to it. - // We return a default value here only if the result value falls outside the range. - return MetricColor{ - Min: value, - Max: value, - } -} - -func ExtractLabelValue(vector model.Vector, labelName string) (string, error) { - // Extract label value from the first sample of the result - if len(vector) > 0 { - // Check if the label exists in the first sample - if val, ok := vector[0].Metric[model.LabelName(labelName)]; ok { - return string(val), nil - } - } - - // If label not found, return an error - return "", fmt.Errorf("label '%s' not found in the query result", labelName) -} diff --git a/pkg/kromgo/errors.go b/pkg/kromgo/errors.go new file mode 100644 index 0000000..d07b39d --- /dev/null +++ b/pkg/kromgo/errors.go @@ -0,0 +1,36 @@ +package kromgo + +import ( + "encoding/json" + "net/http" + + "go.uber.org/zap" +) + +type EndpointResponse struct { + SchemaVersion int `json:"schemaVersion"` + Label string `json:"label"` + Message string `json:"message"` + Color string `json:"color,omitempty"` + Error bool `json:"isError,omitempty"` + Style string `json:"style,omitempty"` +} + +func HandleError(w http.ResponseWriter, r *http.Request, metric string, reason string, code int) { + response := EndpointResponse{ + SchemaVersion: 1, + Label: metric, + Message: reason, + Error: true, + } + + jsonResponse, err := json.Marshal(response) + if err != nil { + requestLog(r).With(zap.Error(err)).Error("error converting data to json response") + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(code) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonResponse) +} diff --git a/pkg/kromgo/kromgo.go b/pkg/kromgo/kromgo.go new file mode 100644 index 0000000..4fad94d --- /dev/null +++ b/pkg/kromgo/kromgo.go @@ -0,0 +1,109 @@ +package kromgo + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/kashalls/kromgo/cmd/kromgo/init/log" + "github.com/kashalls/kromgo/cmd/kromgo/init/prometheus" + "github.com/prometheus/common/model" + "go.uber.org/zap" +) + +func KromgoRequestHandler(w http.ResponseWriter, r *http.Request, config configuration.KromgoConfig) { + requestMetric := chi.URLParam(r, "metric") + if requestMetric == "query" { + requestMetric = r.URL.Query().Get("metric") + } + requestFormat := r.URL.Query().Get("format") + + metric, exists := configuration.ProcessedMetrics[requestMetric] + + if !exists { + requestLog(r).Error("metric not found") + HandleError(w, r, requestMetric, "Not Found", http.StatusNotFound) + return + } + + // Run the Prometheus query + promResult, warnings, err := prometheus.Papi.Query(r.Context(), metric.Query, time.Now()) + if err != nil { + requestLog(r).With(zap.Error(err)).Error("error executing metric query") + w.WriteHeader(http.StatusInternalServerError) + HandleError(w, r, requestMetric, "Query Error", http.StatusInternalServerError) + return + } + if len(warnings) > 0 { + for _, warning := range warnings { + requestLog(r).With(zap.String("warning", warning)).Warn("encountered warnings while executing metric query") + } + } + jsonResult, err := json.Marshal(promResult) + requestLog(r).With(zap.String("result", string(jsonResult))).Debug("query result") + if err != nil { + requestLog(r).With(zap.Error(err)).Error("could not convert query result to json") + HandleError(w, r, requestMetric, "Query Error", http.StatusInternalServerError) + return + } + + if len(jsonResult) <= 0 { + requestLog(r).Error("query returned no results") + HandleError(w, r, requestMetric, "No Data", http.StatusOK) + return + } + + if requestFormat == "raw" { + w.Header().Set("Content-Type", "application/json") + w.Write(jsonResult) + return + } + + prometheusData := promResult.(model.Vector) + resultValue := float64(prometheusData[0].Value) + colorConfig := GetColorConfig(metric.Colors, resultValue) + + var customResponse string = strconv.FormatFloat(resultValue, 'f', -1, 64) + if len(metric.Label) > 0 { + labelValue, err := ExtractLabelValue(prometheusData, metric.Label) + if err != nil { + requestLog(r).With(zap.String("label", metric.Label), zap.Error(err)).Error("label was not found in query result") + HandleError(w, r, requestMetric, "No Data", http.StatusOK) + return + } + customResponse = labelValue + } + if len(colorConfig.ValueOverride) > 0 { + customResponse = colorConfig.ValueOverride + } + + data := map[string]interface{}{ + "schemaVersion": 1, + "label": metric.Name, + "message": metric.Prefix + customResponse + metric.Suffix, + } + + if colorConfig.Color != "" { + data["color"] = colorConfig.Color + } + + jsonResponse, err := json.Marshal(data) + if err != nil { + requestLog(r).With(zap.Error(err)).Error("error converting data to json response") + HandleError(w, r, requestMetric, "Error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonResponse) +} + +func requestLog(r *http.Request) *zap.Logger { + requestMetric := chi.URLParam(r, "metric") + requestFormat := r.URL.Query().Get("format") + + return log.With(zap.String("req_method", r.Method), zap.String("req_path", r.URL.Path), zap.String("metric", requestMetric), zap.String("format", requestFormat)) +} \ No newline at end of file diff --git a/pkg/kromgo/utils.go b/pkg/kromgo/utils.go new file mode 100644 index 0000000..a07d34a --- /dev/null +++ b/pkg/kromgo/utils.go @@ -0,0 +1,36 @@ +package kromgo + +import ( + "fmt" + + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/prometheus/common/model" +) + +func GetColorConfig(colors []configuration.MetricColor, value float64) configuration.MetricColor { + for _, colorConfig := range colors { + if value >= colorConfig.Min && value <= colorConfig.Max { + return colorConfig + } + } + + // MetricColors is enabled, but the value does not have a corresponding value to it. + // We return a default value here only if the result value falls outside the range. + return configuration.MetricColor{ + Min: value, + Max: value, + } +} + +func ExtractLabelValue(vector model.Vector, labelName string) (string, error) { + // Extract label value from the first sample of the result + if len(vector) > 0 { + // Check if the label exists in the first sample + if val, ok := vector[0].Metric[model.LabelName(labelName)]; ok { + return string(val), nil + } + } + + // If label not found, return an error + return "", fmt.Errorf("label '%s' not found in the query result", labelName) +} diff --git a/pkg/kromgo/utils_test.go b/pkg/kromgo/utils_test.go new file mode 100644 index 0000000..c3b1e34 --- /dev/null +++ b/pkg/kromgo/utils_test.go @@ -0,0 +1,157 @@ +package kromgo + +import ( + "testing" + + "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" +) + +func TestGetColorConfig_MatchingRange(t *testing.T) { + colors := []configuration.MetricColor{ + {Min: 0, Max: 10, Color: "blue", ValueOverride: "low"}, + {Min: 11, Max: 20, Color: "green", ValueOverride: "medium"}, + {Min: 21, Max: 30, Color: "red", ValueOverride: "high"}, + } + + value := 15.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 11, Max: 20, Color: "green", ValueOverride: "medium"} + assert.Equal(t, expected, result) +} + +func TestGetColorConfig_ExactMatch(t *testing.T) { + colors := []configuration.MetricColor{ + {Min: 0, Max: 10, Color: "blue"}, + {Min: 10, Max: 20, Color: "green"}, + } + + value := 10.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 10, Max: 20, Color: "green"} + assert.Equal(t, expected, result) +} + +func TestGetColorConfig_NoMatch(t *testing.T) { + colors := []configuration.MetricColor{ + {Min: 0, Max: 10, Color: "blue"}, + {Min: 11, Max: 20, Color: "green"}, + } + + value := 25.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 25, Max: 25} + assert.Equal(t, expected, result) +} + +func TestGetColorConfig_EmptyColors(t *testing.T) { + colors := []configuration.MetricColor{} + + value := 10.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 10, Max: 10} + assert.Equal(t, expected, result) +} + +func TestGetColorConfig_ValueBelowMin(t *testing.T) { + colors := []configuration.MetricColor{ + {Min: 10, Max: 20, Color: "green"}, + {Min: 21, Max: 30, Color: "red"}, + } + + value := 5.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 5, Max: 5} + assert.Equal(t, expected, result) +} + +func TestGetColorConfig_ValueAboveMax(t *testing.T) { + colors := []configuration.MetricColor{ + {Min: 0, Max: 10, Color: "blue"}, + {Min: 11, Max: 20, Color: "green"}, + } + + value := 25.0 + + result := GetColorConfig(colors, value) + + expected := configuration.MetricColor{Min: 25, Max: 25} + assert.Equal(t, expected, result) +} + +func TestExtractLabelValue_LabelExists(t *testing.T) { + vector := model.Vector{ + &model.Sample{ + Metric: model.Metric{ + "label1": "value1", + "label2": "value2", + }, + }, + } + + labelName := "label1" + expectedValue := "value1" + + value, err := ExtractLabelValue(vector, labelName) + + assert.NoError(t, err) + assert.Equal(t, expectedValue, value) +} + +func TestExtractLabelValue_LabelDoesNotExist(t *testing.T) { + vector := model.Vector{ + &model.Sample{ + Metric: model.Metric{ + "label1": "value1", + }, + }, + } + + labelName := "label2" + + value, err := ExtractLabelValue(vector, labelName) + + assert.Error(t, err) + assert.Equal(t, "", value) + assert.Equal(t, "label 'label2' not found in the query result", err.Error()) +} + +func TestExtractLabelValue_EmptyVector(t *testing.T) { + vector := model.Vector{} + labelName := "label1" + + value, err := ExtractLabelValue(vector, labelName) + + assert.Error(t, err) + assert.Equal(t, "", value) + assert.Equal(t, "label 'label1' not found in the query result", err.Error()) +} + +func TestExtractLabelValue_LabelEmptyValue(t *testing.T) { + vector := model.Vector{ + &model.Sample{ + Metric: model.Metric{ + "label1": "", // Empty string value for the label + }, + }, + } + + labelName := "label1" + expectedValue := "" + + value, err := ExtractLabelValue(vector, labelName) + + assert.NoError(t, err) + assert.Equal(t, expectedValue, value) +}