diff --git a/cmd/server/main.go b/cmd/server/main.go index 6b8ff0b..63733cc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,7 +8,10 @@ import ( "git.faercol.me/monitoring/sys-exporter/registry" "github.com/prometheus/client_golang/prometheus/promhttp" + _ "git.faercol.me/monitoring/sys-exporter/collector/loadavg" + _ "git.faercol.me/monitoring/sys-exporter/collector/meminfo" _ "git.faercol.me/monitoring/sys-exporter/collector/pacman" + _ "git.faercol.me/monitoring/sys-exporter/collector/systemd" _ "git.faercol.me/monitoring/sys-exporter/collector/uptime" ) diff --git a/collector/loadavg/loadavg.go b/collector/loadavg/loadavg.go new file mode 100644 index 0000000..8cbdfc0 --- /dev/null +++ b/collector/loadavg/loadavg.go @@ -0,0 +1,86 @@ +package loadavg + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + "git.faercol.me/monitoring/sys-exporter/registry" + "github.com/prometheus/client_golang/prometheus" +) + +const loadAvgFileLocation = "/proc/loadavg" + +type LoadAvgCollector struct { + loadAvgFileLocation string + promExporter *prometheus.SummaryVec + updateFreq time.Duration +} + +func (c *LoadAvgCollector) Collect() interface{} { + return nil +} + +func (c *LoadAvgCollector) update() error { + fileContent, err := os.ReadFile(c.loadAvgFileLocation) + if err != nil { + return fmt.Errorf("failed to read loadavg file: %w", err) + } + + vals := strings.Split(string(fileContent), " ") + if len(vals) != 5 { + return fmt.Errorf("invalid format %q for loadavg file", string(fileContent)) + } + + var load1Min, load5Min, load15Min float64 + if load1Min, err = strconv.ParseFloat(vals[0], 64); err != nil { + return fmt.Errorf("invalid value %q for load 1min: %w", vals[0], err) + } + if load5Min, err = strconv.ParseFloat(vals[1], 64); err != nil { + return fmt.Errorf("invalid value %q for load 5mins: %w", vals[1], err) + } + if load15Min, err = strconv.ParseFloat(vals[2], 64); err != nil { + return fmt.Errorf("invalid value %q for load 15min: %w", vals[2], err) + } + + c.promExporter.WithLabelValues("1min").Observe(load1Min) + c.promExporter.WithLabelValues("5mins").Observe(load5Min) + c.promExporter.WithLabelValues("15mins").Observe(load15Min) + return nil +} + +func (c *LoadAvgCollector) Run(ctx context.Context) { + if err := c.update(); err != nil { + fmt.Printf("Failed to init loadavg collector: %s\n", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-time.After(c.updateFreq): + if err := c.update(); err != nil { + fmt.Printf("Failed to update loadavg collector: %s\n", err) + } + } + } +} + +func (c *LoadAvgCollector) PromCollector() prometheus.Collector { + return c.promExporter +} + +func New() *LoadAvgCollector { + return &LoadAvgCollector{ + updateFreq: 1 * time.Second, + loadAvgFileLocation: loadAvgFileLocation, + promExporter: prometheus.NewSummaryVec(prometheus.SummaryOpts{Namespace: "system", Name: "loadavg", Help: "Load average of the system"}, []string{"period"}), + } +} + +func init() { + registry.R.MustRegisterCollector("system.loadavg", New()) +} diff --git a/collector/meminfo/meminfo.go b/collector/meminfo/meminfo.go new file mode 100644 index 0000000..f0dd887 --- /dev/null +++ b/collector/meminfo/meminfo.go @@ -0,0 +1,114 @@ +package meminfo + +import ( + "context" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" + + "git.faercol.me/monitoring/sys-exporter/registry" + "git.faercol.me/monitoring/sys-exporter/utils" + "github.com/prometheus/client_golang/prometheus" +) + +const meminfoFileLocation = "/proc/meminfo" + +var meminfoRegex = regexp.MustCompile(`(?m)^(?P\S+):\s+(?P\d+(?:\s\S+$|$))`) + +func valueInBytes(rawVal string) (int, error) { + split := strings.Split(rawVal, " ") + + baseVal, err := strconv.ParseInt(split[0], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid base value %q: %w", split[0], err) + } + + if len(split) == 1 { // no unit present in the value + return int(baseVal), nil + } + + switch split[1] { + case "B": + return int(baseVal), nil + case "kB": + return 1000 * int(baseVal), nil + case "MB": + return 1_000_000 * int(baseVal), nil + case "GB": + return 1_000_000_000 * int(baseVal), nil + case "TB": + return 1_000_000_000_000 * int(baseVal), nil + default: + return 0, fmt.Errorf("invalid unit %q", split[1]) + } +} + +type MeminfoCollector struct { + meminfoFileLocation string + + promExporter *prometheus.SummaryVec + updateFreq time.Duration +} + +func (c *MeminfoCollector) Collect() interface{} { + return nil +} + +func (c *MeminfoCollector) update() error { + fileContent, err := os.ReadFile(c.meminfoFileLocation) + if err != nil { + return fmt.Errorf("failed to read meminfo file: %w", err) + } + + rawMatches, err := utils.HandleRegexp(string(fileContent), *meminfoRegex) + if err != nil { + return fmt.Errorf("failed to parse meminfo file: %w", err) + } + + mappedVals := utils.HandleKeyValRegexRes(rawMatches) + for valueType, rawVal := range mappedVals { + val, err := valueInBytes(rawVal) + if err != nil { + return fmt.Errorf("invalid value in meminfo file: %w", err) + } + c.promExporter.WithLabelValues(valueType).Observe(float64(val)) + } + + return nil +} + +func (c *MeminfoCollector) Run(ctx context.Context) { + if err := c.update(); err != nil { + fmt.Printf("Failed to init meminfo collector: %s\n", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-time.After(c.updateFreq): + if err := c.update(); err != nil { + fmt.Printf("Failed to update meminfo collector: %s\n", err) + } + } + } +} + +func (c *MeminfoCollector) PromCollector() prometheus.Collector { + return c.promExporter +} + +func New() *MeminfoCollector { + return &MeminfoCollector{ + updateFreq: 1 * time.Second, + meminfoFileLocation: meminfoFileLocation, + promExporter: prometheus.NewSummaryVec(prometheus.SummaryOpts{Namespace: "system", Name: "meminfo", Help: "System memory info"}, []string{"type"}), + } +} + +func init() { + registry.R.MustRegisterCollector("system.meminfo", New()) +} diff --git a/collector/pacman/pacman.go b/collector/pacman/pacman.go index ab2b8c6..7a70a5d 100644 --- a/collector/pacman/pacman.go +++ b/collector/pacman/pacman.go @@ -20,6 +20,9 @@ func listCommandLines(command string, args ...string) (int, error) { cmd := exec.Command(command, args...) out, err := cmd.CombinedOutput() if err != nil { + if string(out) == "" { // pacman can return a returncode 1 when the list is empty + return 0, nil + } return 0, fmt.Errorf("failed to run command: %w (%s)", err, string(out)) } diff --git a/collector/systemd/systemd.go b/collector/systemd/systemd.go new file mode 100644 index 0000000..122a3e5 --- /dev/null +++ b/collector/systemd/systemd.go @@ -0,0 +1,130 @@ +package systemd + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "git.faercol.me/monitoring/sys-exporter/registry" + "github.com/prometheus/client_golang/prometheus" +) + +type serviceStatus struct { + Unit string `json:"unit"` + Load string `json:"load"` + Active string `json:"active"` + Sub string `json:"sub"` + Description string `json:"description"` +} + +func (s serviceStatus) unitType() string { + split := strings.Split(s.Unit, ".") + if len(split) < 2 { + return "" + } + return split[len(split)-1] +} + +type SystemdCollector struct { + promExporter *prometheus.GaugeVec + updateFreq time.Duration +} + +func (c *SystemdCollector) Collect() interface{} { + return nil +} + +func (c *SystemdCollector) getServicesStatus() ([]serviceStatus, error) { + out, err := exec.Command("/sbin/systemctl", "list-units", "--output", "json").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to run systemd command: %w", err) + } + + res := []serviceStatus{} + if err := json.Unmarshal(out, &res); err != nil { + return nil, fmt.Errorf("failed to parse systemd output: %w", err) + } + + return res, nil +} + +func (c *SystemdCollector) update() error { + systemd_output, err := c.getServicesStatus() + if err != nil { + return fmt.Errorf("failed to get services status: %w", err) + } + + mappingPerType := map[string][]serviceStatus{} + for _, s := range systemd_output { + mappingPerType[s.unitType()] = append(mappingPerType[s.unitType()], s) + } + + totalUnits := 0 + runningUnits := 0 + failedUnits := 0 + + for unitType, units := range mappingPerType { + + unitsForType := 0 + healthyForType := 0 + failedForType := 0 + + for _, u := range units { + unitsForType++ + totalUnits++ + if u.Active == "active" { + runningUnits++ + healthyForType++ + } + if u.Active == "failed" { + failedUnits++ + failedForType++ + } + } + + c.promExporter.WithLabelValues(unitType, "total").Set(float64(unitsForType)) + c.promExporter.WithLabelValues(unitType, "running").Set(float64(healthyForType)) + c.promExporter.WithLabelValues(unitType, "failed").Set(float64(failedForType)) + } + + c.promExporter.WithLabelValues("total", "total").Set(float64(totalUnits)) + c.promExporter.WithLabelValues("total", "running").Set(float64(runningUnits)) + c.promExporter.WithLabelValues("total", "failed").Set(float64(failedUnits)) + + return nil +} + +func (c *SystemdCollector) Run(ctx context.Context) { + if err := c.update(); err != nil { + fmt.Printf("Failed to init systemd status: %s\n", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-time.After(c.updateFreq): + if err := c.update(); err != nil { + fmt.Printf("Failed to update systemd status: %s\n", err) + } + } + } +} + +func (c *SystemdCollector) PromCollector() prometheus.Collector { + return c.promExporter +} + +func New() *SystemdCollector { + return &SystemdCollector{ + updateFreq: 30 * time.Second, + promExporter: prometheus.NewGaugeVec(prometheus.GaugeOpts{Namespace: "services", Subsystem: "systemd", Name: "running_units", Help: "Count of running services for systemd"}, []string{"type", "status"}), + } +} + +func init() { + registry.R.MustRegisterCollector("services.systemd", New()) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..6bd4570 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,73 @@ +package utils + +import ( + "errors" + "regexp" +) + +// HandleRegexp handles a regex with named capture groups. +// If the given regexp does not have any named capture group, or if no match is found, an error is returned +// Matches are returned as a map which keys are the capture groups names +func HandleRegexp(dat string, pattern regexp.Regexp) (res []map[string]string, err error) { + namedGroups := 0 + for _, n := range pattern.SubexpNames() { + if n != "" { + namedGroups++ + } + } + if namedGroups == 0 { + return nil, errors.New("no named subgroup in pattern") + } + + matches := pattern.FindAllStringSubmatch(dat, -1) + if matches == nil { + return nil, errors.New("no match found") + } + res = make([]map[string]string, 0) + + for _, line := range matches { + subMatchMap := make(map[string]string) + for i, name := range pattern.SubexpNames() { + if i != 0 { + subMatchMap[name] = string(line[i]) + } + } + res = append(res, subMatchMap) + } + return +} + +// HandleKeyValRegexRes merges a list of maps containing result as a key/value format +// +// Input maps all have the following format: +// +// { +// "key": "some_key", +// "value": "some_val", +// } +// +// Final result will have the format +// +// { +// "some_key": "some_val", +// } +func HandleKeyValRegexRes(rawMatches []map[string]string) map[string]string { + matchedMap := make(map[string]string) + for _, m := range rawMatches { + currKey := "" + currVal := "" + for k, v := range m { + if k == "key" { + currKey = v + } else if k == "value" { + currVal = v + } + // All other keys are ignored + // Should not happen given the given regexp + } + if currKey != "" && currVal != "" { + matchedMap[currKey] = currVal + } + } + return matchedMap +}