From b6215e649cdfc0398ca98df8e63f3773f1725840 Mon Sep 17 00:00:00 2001 From: Benjamin Drung Date: Thu, 19 Aug 2021 12:26:53 +0200 Subject: [PATCH] Add os release collector Currently Node Exporter has a metric called `node_uname_info` which of course exposes uname info. While this is nice, it does not help if you are running different OSes which could have similar uname info. Therefore parse `/etc/os-release` or `/usr/lib/os-release` and expose a `node_os_info` metric which provide information regarding the OS release/version of the node. Also expose the major.minor part of the OS release version as `node_os_version`. Since the os-release files will not change often, cache the parsed content and only refresh the cache if the modification time changes. This `os` collector will read files outside of `/proc` and `/sys`, but the os-release file is widely used and the format is standardized: https://www.freedesktop.org/software/systemd/man/os-release.html Bug: https://github.com/prometheus/node_exporter/issues/1574 Signed-off-by: Benjamin Drung --- README.md | 1 + collector/fixtures/e2e-64k-page-output.txt | 7 + collector/fixtures/e2e-output.txt | 7 + collector/fixtures/usr/lib/os-release | 12 ++ collector/os_release.go | 178 +++++++++++++++++++++ collector/os_release_test.go | 105 ++++++++++++ end-to-end-test.sh | 1 + go.mod | 1 + go.sum | 2 + 9 files changed, 314 insertions(+) create mode 100644 collector/fixtures/usr/lib/os-release create mode 100644 collector/os_release.go create mode 100644 collector/os_release_test.go diff --git a/README.md b/README.md index a8698dd6..f778f0f8 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ netstat | Exposes network statistics from `/proc/net/netstat`. This is the same nfs | Exposes NFS client statistics from `/proc/net/rpc/nfs`. This is the same information as `nfsstat -c`. | Linux nfsd | Exposes NFS kernel server statistics from `/proc/net/rpc/nfsd`. This is the same information as `nfsstat -s`. | Linux nvme | Exposes NVMe info from `/sys/class/nvme/` | Linux +os | Expose OS release info from `/etc/os-release` or `/usr/lib/os-release` | _any_ powersupplyclass | Exposes Power Supply statistics from `/sys/class/power_supply` | Linux pressure | Exposes pressure stall statistics from `/proc/pressure/`. | Linux (kernel 4.20+ and/or [CONFIG\_PSI](https://www.kernel.org/doc/html/latest/accounting/psi.html)) rapl | Exposes various statistics from `/sys/class/powercap`. | Linux diff --git a/collector/fixtures/e2e-64k-page-output.txt b/collector/fixtures/e2e-64k-page-output.txt index 726c0eda..ec900bc0 100644 --- a/collector/fixtures/e2e-64k-page-output.txt +++ b/collector/fixtures/e2e-64k-page-output.txt @@ -2442,6 +2442,12 @@ node_nfsd_server_threads 8 # HELP node_nvme_info Non-numeric data from /sys/class/nvme/, value is always 1. # TYPE node_nvme_info gauge node_nvme_info{device="nvme0",firmware_revision="1B2QEXP7",model="Samsung SSD 970 PRO 512GB",serial="S680HF8N190894I",state="live"} 1 +# HELP node_os_info A metric with a constant '1' value labeled by build_id, id, id_like, image_id, image_version, name, pretty_name, variant, variant_id, version, version_codename, version_id. +# TYPE node_os_info gauge +node_os_info{build_id="",id="ubuntu",id_like="debian",image_id="",image_version="",name="Ubuntu",pretty_name="Ubuntu 20.04.2 LTS",variant="",variant_id="",version="20.04.2 LTS (Focal Fossa)",version_codename="focal",version_id="20.04"} 1 +# HELP node_os_version Metric containing the major.minor part of the OS version. +# TYPE node_os_version gauge +node_os_version{id="ubuntu",id_like="debian",name="Ubuntu"} 20.04 # HELP node_power_supply_capacity capacity value of /sys/class/power_supply/. # TYPE node_power_supply_capacity gauge node_power_supply_capacity{power_supply="BAT0"} 81 @@ -2590,6 +2596,7 @@ node_scrape_collector_success{collector="netstat"} 1 node_scrape_collector_success{collector="nfs"} 1 node_scrape_collector_success{collector="nfsd"} 1 node_scrape_collector_success{collector="nvme"} 1 +node_scrape_collector_success{collector="os"} 1 node_scrape_collector_success{collector="powersupplyclass"} 1 node_scrape_collector_success{collector="pressure"} 1 node_scrape_collector_success{collector="processes"} 1 diff --git a/collector/fixtures/e2e-output.txt b/collector/fixtures/e2e-output.txt index 435ca7f6..c24d6a70 100644 --- a/collector/fixtures/e2e-output.txt +++ b/collector/fixtures/e2e-output.txt @@ -2640,6 +2640,12 @@ node_nfsd_server_threads 8 # HELP node_nvme_info Non-numeric data from /sys/class/nvme/, value is always 1. # TYPE node_nvme_info gauge node_nvme_info{device="nvme0",firmware_revision="1B2QEXP7",model="Samsung SSD 970 PRO 512GB",serial="S680HF8N190894I",state="live"} 1 +# HELP node_os_info A metric with a constant '1' value labeled by build_id, id, id_like, image_id, image_version, name, pretty_name, variant, variant_id, version, version_codename, version_id. +# TYPE node_os_info gauge +node_os_info{build_id="",id="ubuntu",id_like="debian",image_id="",image_version="",name="Ubuntu",pretty_name="Ubuntu 20.04.2 LTS",variant="",variant_id="",version="20.04.2 LTS (Focal Fossa)",version_codename="focal",version_id="20.04"} 1 +# HELP node_os_version Metric containing the major.minor part of the OS version. +# TYPE node_os_version gauge +node_os_version{id="ubuntu",id_like="debian",name="Ubuntu"} 20.04 # HELP node_power_supply_capacity capacity value of /sys/class/power_supply/. # TYPE node_power_supply_capacity gauge node_power_supply_capacity{power_supply="BAT0"} 81 @@ -2791,6 +2797,7 @@ node_scrape_collector_success{collector="netstat"} 1 node_scrape_collector_success{collector="nfs"} 1 node_scrape_collector_success{collector="nfsd"} 1 node_scrape_collector_success{collector="nvme"} 1 +node_scrape_collector_success{collector="os"} 1 node_scrape_collector_success{collector="powersupplyclass"} 1 node_scrape_collector_success{collector="pressure"} 1 node_scrape_collector_success{collector="processes"} 1 diff --git a/collector/fixtures/usr/lib/os-release b/collector/fixtures/usr/lib/os-release new file mode 100644 index 00000000..f228f222 --- /dev/null +++ b/collector/fixtures/usr/lib/os-release @@ -0,0 +1,12 @@ +NAME="Ubuntu" +VERSION="20.04.2 LTS (Focal Fossa)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 20.04.2 LTS" +VERSION_ID="20.04" +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +VERSION_CODENAME=focal +UBUNTU_CODENAME=focal diff --git a/collector/os_release.go b/collector/os_release.go new file mode 100644 index 00000000..cda1eb1f --- /dev/null +++ b/collector/os_release.go @@ -0,0 +1,178 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "errors" + "io" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + envparse "github.com/hashicorp/go-envparse" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + etcOSRelease = "/etc/os-release" + usrLibOSRelease = "/usr/lib/os-release" +) + +var ( + versionRegex = regexp.MustCompile(`^[0-9]+\.?[0-9]*`) +) + +type osRelease struct { + Name string + ID string + IDLike string + PrettyName string + Variant string + VariantID string + Version string + VersionID string + VersionCodename string + BuildID string + ImageID string + ImageVersion string +} + +type osReleaseCollector struct { + infoDesc *prometheus.Desc + logger log.Logger + os *osRelease + osFilename string // file name of cached release information + osMtime time.Time // mtime of cached release file + osMutex sync.Mutex + osReleaseFilenames []string // all os-release file names to check + version float64 + versionDesc *prometheus.Desc +} + +func init() { + registerCollector("os", defaultEnabled, NewOSCollector) +} + +// NewOSCollector returns a new Collector exposing os-release information. +func NewOSCollector(logger log.Logger) (Collector, error) { + return &osReleaseCollector{ + logger: logger, + infoDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "os", "info"), + "A metric with a constant '1' value labeled by build_id, id, id_like, image_id, image_version, "+ + "name, pretty_name, variant, variant_id, version, version_codename, version_id.", + []string{"build_id", "id", "id_like", "image_id", "image_version", "name", "pretty_name", + "variant", "variant_id", "version", "version_codename", "version_id"}, nil, + ), + osReleaseFilenames: []string{etcOSRelease, usrLibOSRelease}, + versionDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "os", "version"), + "Metric containing the major.minor part of the OS version.", + []string{"id", "id_like", "name"}, nil, + ), + }, nil +} + +func parseOSRelease(r io.Reader) (*osRelease, error) { + env, err := envparse.Parse(r) + return &osRelease{ + Name: env["NAME"], + ID: env["ID"], + IDLike: env["ID_LIKE"], + PrettyName: env["PRETTY_NAME"], + Variant: env["VARIANT"], + VariantID: env["VARIANT_ID"], + Version: env["VERSION"], + VersionID: env["VERSION_ID"], + VersionCodename: env["VERSION_CODENAME"], + BuildID: env["BUILD_ID"], + ImageID: env["IMAGE_ID"], + ImageVersion: env["IMAGE_VERSION"], + }, err +} + +func (c *osReleaseCollector) UpdateStruct(path string) error { + releaseFile, err := os.Open(path) + if err != nil { + return err + } + defer releaseFile.Close() + + stat, err := releaseFile.Stat() + if err != nil { + return err + } + + t := stat.ModTime() + if path == c.osFilename && t == c.osMtime { + // osReleaseCollector struct is already up-to-date. + return nil + } + + // Acquire a lock to update the osReleaseCollector struct. + c.osMutex.Lock() + defer c.osMutex.Unlock() + + level.Debug(c.logger).Log("msg", "file modification time has changed", + "file", path, "old_value", c.osMtime, "new_value", t) + c.osFilename = path + c.osMtime = t + + c.os, err = parseOSRelease(releaseFile) + if err != nil { + return err + } + + majorMinor := versionRegex.FindString(c.os.VersionID) + if majorMinor != "" { + c.version, err = strconv.ParseFloat(majorMinor, 64) + if err != nil { + return err + } + } else { + c.version = 0 + } + return nil +} + +func (c *osReleaseCollector) Update(ch chan<- prometheus.Metric) error { + for i, path := range c.osReleaseFilenames { + err := c.UpdateStruct(*rootfsPath + path) + if err == nil { + break + } + if errors.Is(err, os.ErrNotExist) { + if i >= (len(c.osReleaseFilenames) - 1) { + level.Debug(c.logger).Log("msg", "no os-release file found", "files", strings.Join(c.osReleaseFilenames, ",")) + return ErrNoData + } + continue + } + return err + } + + ch <- prometheus.MustNewConstMetric(c.infoDesc, prometheus.GaugeValue, 1.0, + c.os.BuildID, c.os.ID, c.os.IDLike, c.os.ImageID, c.os.ImageVersion, c.os.Name, c.os.PrettyName, + c.os.Variant, c.os.VariantID, c.os.Version, c.os.VersionCodename, c.os.VersionID) + if c.version > 0 { + ch <- prometheus.MustNewConstMetric(c.versionDesc, prometheus.GaugeValue, c.version, + c.os.ID, c.os.IDLike, c.os.Name) + } + return nil +} diff --git a/collector/os_release_test.go b/collector/os_release_test.go new file mode 100644 index 00000000..e37cc34c --- /dev/null +++ b/collector/os_release_test.go @@ -0,0 +1,105 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/go-kit/log" +) + +const debianBullseye string = `PRETTY_NAME="Debian GNU/Linux 11 (bullseye)" +NAME="Debian GNU/Linux" +VERSION_ID="11" +VERSION="11 (bullseye)" +VERSION_CODENAME=bullseye +ID=debian +HOME_URL="https://www.debian.org/" +SUPPORT_URL="https://www.debian.org/support" +BUG_REPORT_URL="https://bugs.debian.org/" +` + +func TestParseOSRelease(t *testing.T) { + want := &osRelease{ + Name: "Ubuntu", + ID: "ubuntu", + IDLike: "debian", + PrettyName: "Ubuntu 20.04.2 LTS", + Version: "20.04.2 LTS (Focal Fossa)", + VersionID: "20.04", + VersionCodename: "focal", + } + + osReleaseFile, err := os.Open("fixtures" + usrLibOSRelease) + if err != nil { + t.Fatal(err) + } + got, err := parseOSRelease(osReleaseFile) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(want, got) { + t.Fatalf("should have %+v osRelease: got %+v", want, got) + } + + want = &osRelease{ + Name: "Debian GNU/Linux", + ID: "debian", + PrettyName: "Debian GNU/Linux 11 (bullseye)", + Version: "11 (bullseye)", + VersionID: "11", + VersionCodename: "bullseye", + } + got, err = parseOSRelease(strings.NewReader(debianBullseye)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(want, got) { + t.Fatalf("should have %+v osRelease: got %+v", want, got) + } +} + +func TestUpdateStruct(t *testing.T) { + wantedOS := &osRelease{ + Name: "Ubuntu", + ID: "ubuntu", + IDLike: "debian", + PrettyName: "Ubuntu 20.04.2 LTS", + Version: "20.04.2 LTS (Focal Fossa)", + VersionID: "20.04", + VersionCodename: "focal", + } + wantedVersion := 20.04 + + collector, err := NewOSCollector(log.NewNopLogger()) + if err != nil { + t.Fatal(err) + } + c := collector.(*osReleaseCollector) + + err = c.UpdateStruct("fixtures" + usrLibOSRelease) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(wantedOS, c.os) { + t.Fatalf("should have %+v osRelease: got %+v", wantedOS, c.os) + } + if wantedVersion != c.version { + t.Errorf("Expected '%v' but got '%v'", wantedVersion, c.version) + } +} diff --git a/end-to-end-test.sh b/end-to-end-test.sh index d87162fe..6ad41197 100755 --- a/end-to-end-test.sh +++ b/end-to-end-test.sh @@ -100,6 +100,7 @@ then fi ./node_exporter \ + --path.rootfs="collector/fixtures" \ --path.procfs="collector/fixtures/proc" \ --path.sysfs="collector/fixtures/sys" \ $(for c in ${enabled_collectors}; do echo --collector.${c} ; done) \ diff --git a/go.mod b/go.mod index d3fbbefe..815e373a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/ema/qdisc v0.0.0-20200603082823-62d0308e3e00 github.com/go-kit/log v0.1.0 github.com/godbus/dbus v0.0.0-20190402143921-271e53dc4968 + github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e github.com/hodgesds/perf-utils v0.2.5 github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 github.com/jsimonetti/rtnetlink v0.0.0-20210713125558-2bfdf1dbdbd6 diff --git a/go.sum b/go.sum index 1a3c1acf..bf36df07 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e h1:v1d9+AJMP6i4p8BSKNU0InuvmIAdZjQLNN19V86AG4Q= +github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e/go.mod h1:/NlxCzN2D4C4L2uDE6ux/h6jM+n98VFQM14nnCIfHJU= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hodgesds/perf-utils v0.2.5 h1:X992/V3OaNJRM8Ivcram8Hhxz4JhWiKI0T8iGCJwk2k=