From 82c30ee1c390a6fbd9f16de531543208dae2f20f Mon Sep 17 00:00:00 2001 From: Lukas Schreiner Date: Sat, 13 Apr 2024 22:44:36 +0200 Subject: [PATCH] Feature enhancements New: - Dockerfile - Including memory, cpu and further utilities (limited to specific service types) - Including network link states and statistics - Including network port statistics --- .github/workflows/docker-publish.yml | 75 +++++++ .github/workflows/pull-request.yml | 17 ++ .gitignore | 4 + Dockerfile | 19 ++ README.md | 1 + VERSION | 2 +- monit_exporter.go | 321 +++++++++++++++++++++++---- monit_exporter_test.go | 17 +- 8 files changed, 410 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/pull-request.yml create mode 100644 Dockerfile diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c6e6733 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,75 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '15 3 * * *' + push: + branches: [ "master" ] + # Publish tags as per semver release schema + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..40e17c0 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,17 @@ +name: Pull-request +on: + pull_request: + branches: + - master +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: v1.56.2 diff --git a/.gitignore b/.gitignore index 4e68cf0..b5efb41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ +.idea/ +dist/ +bin/ +.env config.toml monit_exporter diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52ed5d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:alpine + +WORKDIR /build + +COPY go.mod . +COPY go.sum . +RUN go mod download + +COPY . . + +RUN go build -o monit_exporter . + +WORKDIR /dist + +RUN cp /build/monit_exporter . + +EXPOSE 9388 + +ENTRYPOINT ["/dist/monit_exporter"] \ No newline at end of file diff --git a/README.md b/README.md index 9bc4a31..fbf046a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Project: [monit_exporter](https://github.com/liv-io/monit_exporter) Acknowledgements: * [commercetools](https://github.com/commercetools/monit_exporter) * [delucks](https://github.com/delucks/monit_exporter) +* [chaordic](https://github.com/chaordic/monit_exporter) [contributors-shield]: https://img.shields.io/github/contributors/liv-io/monit_exporter.svg?style=flat diff --git a/VERSION b/VERSION index ee1372d..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.3.0 diff --git a/monit_exporter.go b/monit_exporter.go index 4626a44..d1a1ef8 100644 --- a/monit_exporter.go +++ b/monit_exporter.go @@ -7,7 +7,7 @@ import ( "errors" "flag" "fmt" - "io/ioutil" + "io" "net/http" "sync" @@ -19,21 +19,30 @@ import ( ) const ( - namespace = "monit" // Prefix for Prometheus metrics. + namespace = "monit" // Prefix for Prometheus metrics. + SERVICE_TYPE_FILESYSTEM = 0 + SERVICE_TYPE_DIRECTORY = 1 + SERVICE_TYPE_FILE = 2 + SERVICE_TYPE_PROCESS = 3 + SERVICE_TYPE_HOST = 4 + SERVICE_TYPE_SYSTEM = 5 + SERVICE_TYPE_FIFO = 6 + SERVICE_TYPE_PROGRAM = 7 + SERVICE_TYPE_NET = 8 ) var configFile = flag.String("conf", "./config.toml", "Configuration file for exporter") var serviceTypes = map[int]string{ - 0: "filesystem", - 1: "directory", - 2: "file", - 3: "programPid", - 4: "remoteHost", - 5: "system", - 6: "fifo", - 7: "programPath", - 8: "network", + SERVICE_TYPE_FILESYSTEM: "filesystem", + SERVICE_TYPE_DIRECTORY: "directory", + SERVICE_TYPE_FILE: "file", + SERVICE_TYPE_PROCESS: "process", + SERVICE_TYPE_HOST: "host", + SERVICE_TYPE_SYSTEM: "system", + SERVICE_TYPE_FIFO: "fifo", + SERVICE_TYPE_PROGRAM: "program", + SERVICE_TYPE_NET: "network", } type monitXML struct { @@ -42,10 +51,71 @@ type monitXML struct { // Simplified structure of monit check. type monitService struct { - Type int `xml:"type,attr"` - Name string `xml:"name"` - Status int `xml:"status"` - Monitored string `xml:"monitor"` + Type int `xml:"type,attr"` + Name string `xml:"name"` + Status int `xml:"status"` + Monitored string `xml:"monitor"` + Memory monitServiceMem `xml:"memory"` + CPU monitServiceCPU `xml:"cpu"` + DiskWrite monitServiceDisk `xml:"write"` + DiskRead monitServiceDisk `xml:"read"` + ServiceTimes monitServiceTime `xml:"servicetime"` + Ports []monitServicePort `xml:"port"` + Link monitServiceLink `xml:"link"` +} + +type monitServiceMem struct { + Percent float64 `xml:"percent,attr"` + PercentTotal float64 `xml:"percenttotal"` + Kilobyte int `xml:"kilobyte"` + KilobyteTotal int `xml:"kilobytetotal"` +} + +type monitServiceCPU struct { + Percent float64 `xml:"percent,attr"` + PercentTotal float64 `xml:"percenttotal"` +} + +type monitServiceDisk struct { + Bytes monitBytes `xml:"bytes"` +} + +type monitServiceTime struct { + Read float64 `xml:"read"` + Write float64 `xml:"write"` + Wait float64 `xml:"wait"` + Run float64 `xml:"run"` +} + +type monitServicePort struct { + Hostname string `xml:"hostname"` + Portnumber string `xml:"portnumber"` + Protocol string `xml:"protocol"` + Type string `xml:"type"` + Responsetime float64 `xml:"responsetime"` +} + +type monitServiceLink struct { + State int `xml:"state"` + Speed int `xml:"speed"` + Duplex int `xml:"duplex"` + Download monitServiceLinkDirection `xml:"download"` + Upload monitServiceLinkDirection `xml:"upload"` +} + +type monitServiceLinkDirection struct { + Packets monitNetworkCount `xml:"packets"` + Bytes monitNetworkCount `xml:"bytes"` + Errors monitNetworkCount `xml:"errors"` +} + +type monitBytes struct { + Count int `xml:"count"` + Total int `xml:"total"` +} +type monitNetworkCount struct { + Now int `xml:"now"` + Total int `xml:"total"` } // Exporter collects monit stats from the given URI and exports them using @@ -55,8 +125,15 @@ type Exporter struct { mutex sync.RWMutex client *http.Client - up prometheus.Gauge - checkStatus *prometheus.GaugeVec + up prometheus.Gauge + checkStatus *prometheus.GaugeVec + checkMem *prometheus.GaugeVec + checkCPU *prometheus.GaugeVec + checkDiskWrite *prometheus.GaugeVec + checkDiskRead *prometheus.GaugeVec + checkPortRespTimes *prometheus.GaugeVec + checkLinkState *prometheus.GaugeVec + checkLinkStats *prometheus.GaugeVec } type Config struct { @@ -68,20 +145,15 @@ type Config struct { monit_password string } -func FetchMonitStatus(c *Config) ([]byte, error) { - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: c.ignore_ssl}, - }, - } - - req, err := http.NewRequest("GET", c.monit_scrape_uri, nil) +// FetchMonitStatus gather metrics from Monit API +func FetchMonitStatus(e *Exporter) ([]byte, error) { + req, err := http.NewRequest("GET", e.config.monit_scrape_uri, nil) if err != nil { log.Errorf("Unable to create request: %v", err) } - req.SetBasicAuth(c.monit_user, c.monit_password) - resp, err := client.Do(req) + req.SetBasicAuth(e.config.monit_user, e.config.monit_password) + resp, err := e.client.Do(req) if err != nil { log.Error("Unable to fetch monit status") return nil, err @@ -89,11 +161,11 @@ func FetchMonitStatus(c *Config) ([]byte, error) { switch resp.StatusCode { case 200: case 401: - return nil, errors.New("Authentication with monit failed") + return nil, errors.New("authentication with monit failed") default: - return nil, fmt.Errorf("Monit returned %s", resp.Status) + return nil, fmt.Errorf("monit returned %s", resp.Status) } - data, err := ioutil.ReadAll(resp.Body) + data, err := io.ReadAll(resp.Body) defer resp.Body.Close() if err != nil { log.Fatal("Unable to read monit status") @@ -102,6 +174,7 @@ func FetchMonitStatus(c *Config) ([]byte, error) { return data, nil } +// ParseMonitStatus parse XML data and return it to struct func ParseMonitStatus(data []byte) (monitXML, error) { var statusChunk monitXML reader := bytes.NewReader(data) @@ -113,12 +186,13 @@ func ParseMonitStatus(data []byte) (monitXML, error) { return statusChunk, err } +// ParseConfig parse exporter binary options from command line func ParseConfig() *Config { flag.Parse() v := viper.New() - v.SetDefault("listen_address", "localhost:9388") + v.SetDefault("listen_address", "0.0.0.0:9388") v.SetDefault("metrics_path", "/metrics") v.SetDefault("ignore_ssl", false) v.SetDefault("monit_scrape_uri", "http://localhost:2812/_status?format=xml&level=full") @@ -146,18 +220,72 @@ func NewExporter(c *Config) (*Exporter, error) { return &Exporter{ config: c, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: c.ignore_ssl}, + }, + }, up: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: namespace, - Name: "exporter_up", + Name: "up", Help: "Monit status availability", }), checkStatus: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: namespace, - Name: "exporter_service_check", + Name: "service_check", Help: "Monit service check info", }, []string{"check_name", "type", "monitored"}, ), + checkMem: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_mem_bytes", + Help: "Monit service mem info", + }, + []string{"check_name", "type"}, + ), + checkCPU: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_cpu_perc", + Help: "Monit service CPU info", + }, + []string{"check_name", "type"}, + ), + checkDiskWrite: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_write_bytes", + Help: "Monit service Disk Writes Bytes", + }, + []string{"check_name", "type"}, + ), + checkDiskRead: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_read_bytes", + Help: "Monit service Disk Read Bytes", + }, + []string{"check_name", "type"}, + ), + checkPortRespTimes: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_port_response_times", + Help: "Monit service port checks response times", + }, + []string{"check_name", "hostname", "port", "protocol", "type"}, + ), + checkLinkState: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_network_link_state", + Help: "Monit service link states", + }, + []string{"check_name"}, + ), + checkLinkStats: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_network_link_statistics", + Help: "Monit service link statistics", + }, + []string{"check_name", "direction", "unit", "type"}, + ), }, nil } @@ -166,10 +294,17 @@ func NewExporter(c *Config) (*Exporter, error) { func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { e.up.Describe(ch) e.checkStatus.Describe(ch) + e.checkCPU.Describe(ch) + e.checkMem.Describe(ch) + e.checkDiskWrite.Describe(ch) + e.checkDiskRead.Describe(ch) + e.checkPortRespTimes.Describe(ch) + e.checkLinkState.Describe(ch) + e.checkLinkStats.Describe(ch) } func (e *Exporter) scrape() error { - data, err := FetchMonitStatus(e.config) + data, err := FetchMonitStatus(e) if err != nil { // set "monit_exporter_up" gauge to 0, remove previous metrics from e.checkStatus vector e.up.Set(0) @@ -187,22 +322,126 @@ func (e *Exporter) scrape() error { // Constructing metrics for _, service := range parsedData.MonitServices { e.checkStatus.With(prometheus.Labels{"check_name": service.Name, "type": serviceTypes[service.Type], "monitored": service.Monitored}).Set(float64(service.Status)) + e.checkStatus.With( + prometheus.Labels{ + "check_name": service.Name, + "type": serviceTypes[service.Type], + "monitored": service.Monitored, + }).Set(float64(service.Status)) + + // Memory + CPU only for specifiy status types (cf. monit/xml.c) + if service.Type == SERVICE_TYPE_PROCESS || service.Type == SERVICE_TYPE_SYSTEM { + e.checkMem.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "kilobyte", + }).Set(float64(service.Memory.Kilobyte * 1024)) + e.checkMem.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "kilobyteTotal", + }).Set(float64(service.Memory.KilobyteTotal * 1024)) + e.checkCPU.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "percentage", + }).Set(float64(service.CPU.Percent)) + e.checkCPU.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "percentage_total", + }).Set(float64(service.CPU.PercentTotal)) + } + if service.Type == SERVICE_TYPE_PROCESS || service.Type == SERVICE_TYPE_FILESYSTEM { + e.checkDiskWrite.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "write_count", + }).Set(float64(service.DiskWrite.Bytes.Count)) + e.checkDiskWrite.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "write_count_total", + }).Set(float64(service.DiskWrite.Bytes.Total)) + e.checkDiskRead.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "read_count", + }).Set(float64(service.DiskRead.Bytes.Count)) + e.checkDiskRead.With( + prometheus.Labels{ + "check_name": service.Name, + "type": "read_count_total", + }).Set(float64(service.DiskRead.Bytes.Total)) + } + + // Link (only relevant for network checks) + if service.Type == SERVICE_TYPE_NET { + e.checkLinkState.With( + prometheus.Labels{ + "check_name": service.Name, + }).Set(float64(service.Link.State)) + e.addNetLinkElement(&service, "download", &service.Link.Download) + e.addNetLinkElement(&service, "upload", &service.Link.Upload) + } + + // Port checks + for _, port := range service.Ports { + e.checkPortRespTimes.With( + prometheus.Labels{ + "check_name": service.Name, + "type": port.Type, + "hostname": port.Hostname, + "port": port.Portnumber, + "protocol": port.Protocol, + }).Set(float64(port.Responsetime)) + } } } return err } } +func (e *Exporter) addNetLinkElement(service *monitService, direction string, lnk *monitServiceLinkDirection) { + e.addNetLinkUnitElement(service, direction, "packets", &lnk.Packets) + e.addNetLinkUnitElement(service, direction, "bytes", &lnk.Bytes) + e.addNetLinkUnitElement(service, direction, "errors", &lnk.Errors) +} + +func (e *Exporter) addNetLinkUnitElement(service *monitService, direction string, unit string, lnk *monitNetworkCount) { + e.checkLinkStats.With( + prometheus.Labels{ + "check_name": service.Name, + "direction": direction, + "unit": unit, + "type": "now", + }).Set(float64(lnk.Now)) + e.checkLinkStats.With( + prometheus.Labels{ + "check_name": service.Name, + "direction": direction, + "unit": unit, + "type": "total", + }).Set(float64(lnk.Total)) +} + // Collect fetches the stats from configured monit location and delivers them // as Prometheus metrics. It implements prometheus.Collector. func (e *Exporter) Collect(ch chan<- prometheus.Metric) { e.mutex.Lock() // Protect metrics from concurrent collects. defer e.mutex.Unlock() e.checkStatus.Reset() - e.scrape() - e.up.Collect(ch) - e.checkStatus.Collect(ch) - return + if err := e.scrape(); err == nil { + e.up.Collect(ch) + e.checkStatus.Collect(ch) + e.checkMem.Collect(ch) + e.checkCPU.Collect(ch) + e.checkDiskWrite.Collect(ch) + e.checkDiskRead.Collect(ch) + e.checkPortRespTimes.Collect(ch) + e.checkLinkState.Collect(ch) + e.checkLinkStats.Collect(ch) + } } func main() { @@ -218,13 +457,17 @@ func main() { log.Printf("Starting monit_exporter: %s", config.listen_address) http.Handle(config.metrics_path, promhttp.Handler()) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(` + _, err := w.Write([]byte(` Monit Exporter

Monit Exporter

Metrics

`)) + + if err != nil { + log.Fatal(err) + } }) log.Fatal(http.ListenAndServe(config.listen_address, nil)) diff --git a/monit_exporter_test.go b/monit_exporter_test.go index 85f5605..5f52d36 100644 --- a/monit_exporter_test.go +++ b/monit_exporter_test.go @@ -1,13 +1,15 @@ package main import ( + "io" "net/http" "net/http/httptest" "testing" "fmt" - "io/ioutil" "time" + + log "github.com/sirupsen/logrus" ) const ( @@ -20,7 +22,9 @@ const ( func TestMonitStatus(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(monitStatus)) + if _, err := w.Write([]byte(monitStatus)); err != nil { + log.Error(err) + } }) server := httptest.NewServer(handler) config := ParseConfig() @@ -67,7 +71,7 @@ func TestHttpQueryExporter(t *testing.T) { if err != nil { t.Fatal(err) } - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { t.Error(err) } @@ -82,10 +86,11 @@ func TestHttpQueryExporter(t *testing.T) { func AuthHandler(w http.ResponseWriter, r *http.Request) { user, pass, _ := r.BasicAuth() if user == monitUser && pass == monitPassword { - w.Write([]byte(monitStatus)) - + if _, err := w.Write([]byte(monitStatus)); err != nil { + log.Error(err) + } } else { - http.Error(w, "Unauthorized.", 401) + http.Error(w, "Unauthorized.", http.StatusUnauthorized) } }