Repository: prometheus/procfs
Pull request #173 (2019-06-05 20:29:01)

Parsing Mountinfo to get information not in mountstats
As @SuperQ said [here](https://github.com/prometheus/node_exporter/pull/994#issuecomment-499133986), I've added parsing for `mountinfo` to `procfs`, along with test cases for it.


Patch:
diff --git a/mountinfo.go b/mountinfo.go
new file mode 100644
index 0000000..61fa618
--- /dev/null
+++ b/mountinfo.go
@@ -0,0 +1,178 @@
+// Copyright 2019 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 procfs
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"strconv"
+	"strings"
+)
+
+var validOptionalFields = map[string]bool{
+	"shared":         true,
+	"master":         true,
+	"propagate_from": true,
+	"unbindable":     true,
+}
+
+// A MountInfo is a type that describes the details, options
+// for each mount, parsed from /proc/self/mountinfo.
+// The fields described in each entry of /proc/self/mountinfo
+// is described in the following man page.
+// http://man7.org/linux/man-pages/man5/proc.5.html
+type MountInfo struct {
+	// Unique Id for the mount
+	MountId int
+	// The Id of the parent mount
+	ParentId int
+	// The value of `st_dev` for the files on this FS
+	MajorMinorVer string
+	// The pathname of the directory in the FS that forms
+	// the root for this mount
+	Root string
+	// The pathname of the mount point relative to the root
+	MountPoint string
+	// Mount options
+	Options map[string]string
+	// Zero or more optional fields
+	OptionalFields map[string]string
+	// The Filesystem type
+	FSType string
+	// FS specific information or "none"
+	Source string
+	// Superblock options
+	SuperOptions map[string]string
+}
+
+// Returns part of the mountinfo line, if it exists, else an empty string.
+func getStringSliceElement(parts []string, idx int, defaultValue string) string {
+	if idx >= len(parts) {
+		return defaultValue
+	}
+	return parts[idx]
+}
+
+// Reads each line of the mountinfo file, and returns a list of formatted MountInfo structs.
+func parseMountInfo(r io.Reader) ([]*MountInfo, error) {
+	mounts := []*MountInfo{}
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		mountString := scanner.Text()
+		parsedMounts, err := parseMountInfoString(mountString)
+		if err != nil {
+			return nil, err
+		}
+		mounts = append(mounts, parsedMounts)
+	}
+
+	err := scanner.Err()
+	return mounts, err
+}
+
+// Parses a mountinfo file line, and converts it to a MountInfo struct.
+// An important check here is to see if the hyphen separator, as if it does not exist,
+// it means that the line is malformed.
+func parseMountInfoString(mountString string) (*MountInfo, error) {
+	var err error
+
+	// OptionalFields can be zero, hence these checks to ensure we do not populate the wrong values in the wrong spots
+	separatorIndex := strings.Index(mountString, "-")
+	if separatorIndex == -1 {
+		return nil, fmt.Errorf("no separator found in mountinfo string: %s", mountString)
+	}
+	beforeFields := strings.Fields(mountString[:separatorIndex])
+	afterFields := strings.Fields(mountString[separatorIndex+1:])
+	if (len(beforeFields) + len(afterFields)) < 7 {
+		return nil, fmt.Errorf("too few fields")
+	}
+
+	mount := &MountInfo{
+		MajorMinorVer:  getStringSliceElement(beforeFields, 2, ""),
+		Root:           getStringSliceElement(beforeFields, 3, ""),
+		MountPoint:     getStringSliceElement(beforeFields, 4, ""),
+		Options:        mountOptionsParser(getStringSliceElement(beforeFields, 5, "")),
+		OptionalFields: nil,
+		FSType:         getStringSliceElement(afterFields, 0, ""),
+		Source:         getStringSliceElement(afterFields, 1, ""),
+		SuperOptions:   mountOptionsParser(getStringSliceElement(afterFields, 2, "")),
+	}
+
+	mount.MountId, err = strconv.Atoi(getStringSliceElement(beforeFields, 0, ""))
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse mount ID")
+	}
+	mount.ParentId, err = strconv.Atoi(getStringSliceElement(beforeFields, 1, ""))
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse parent ID")
+	}
+	// Has optional fields, which is a space separated list of values.
+	// Example: shared:2 master:7
+	if len(beforeFields) > 6 {
+		mount.OptionalFields = make(map[string]string)
+		optionalFields := beforeFields[6:]
+		for _, field := range optionalFields {
+			optionSplit := strings.Split(field, ":")
+			target, value := optionSplit[0], ""
+			if len(optionSplit) == 2 {
+				value = optionSplit[1]
+			}
+			// Checks if the 'keys' in the optional fields in the mountinfo line are acceptable.
+			// Allowed 'keys' are shared, master, propagate_from, unbindable.
+			if _, ok := validOptionalFields[target]; ok {
+				mount.OptionalFields[target] = value
+			}
+		}
+	}
+	return mount, nil
+}
+
+// Parses the mount options, superblock options.
+func mountOptionsParser(mountOptions string) map[string]string {
+	opts := make(map[string]string)
+	options := strings.Split(mountOptions, ",")
+	for _, opt := range options {
+		splitOption := strings.Split(opt, "=")
+		if len(splitOption) < 2 {
+			key := splitOption[0]
+			opts[key] = ""
+		} else {
+			key, value := splitOption[0], splitOption[1]
+			opts[key] = value
+		}
+	}
+	return opts
+}
+
+// Retrieves mountinfo information from `/proc/self/mountinfo`.
+func GetMounts() ([]*MountInfo, error) {
+	f, err := os.Open("/proc/self/mountinfo")
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return parseMountInfo(f)
+}
+
+// Retrieves mountinfo information from a processes' `/proc/<pid>/mountinfo`.
+func GetProcMounts(pid int) ([]*MountInfo, error) {
+	f, err := os.Open(fmt.Sprintf("/proc/%d/mountinfo", pid))
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return parseMountInfo(f)
+}
diff --git a/proc.go b/proc.go
index 8a84301..41c148d 100644
--- a/proc.go
+++ b/proc.go
@@ -247,6 +247,20 @@ func (p Proc) MountStats() ([]*Mount, error) {
 	return parseMountStats(f)
 }
 
+// MountInfo retrieves mount information for mount points in a
+// process's namespace.
+// It supplies information missing in `/proc/self/mounts` and
+// fixes various other problems with that file too.
+func (p Proc) MountInfo() ([]*MountInfo, error) {
+	f, err := os.Open(p.path("mountinfo"))
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	return parseMountInfo(f)
+}
+
 func (p Proc) fileDescriptors() ([]string, error) {
 	d, err := os.Open(p.path("fd"))
 	if err != nil {


Test patch:
diff --git a/mountinfo_test.go b/mountinfo_test.go
new file mode 100644
index 0000000..3565979
--- /dev/null
+++ b/mountinfo_test.go
@@ -0,0 +1,135 @@
+// Copyright 2019 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 procfs
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestMountInfo(t *testing.T) {
+	tests := []struct {
+		name    string
+		s       string
+		mount   *MountInfo
+		invalid bool
+	}{
+		{
+			name:    "Regular sysfs mounted at /sys",
+			s:       "16 21 0:16 / /sys rw,nosuid,nodev,noexec,relatime shared:7 - sysfs sysfs rw",
+			invalid: false,
+			mount: &MountInfo{
+				MountId:        16,
+				ParentId:       21,
+				MajorMinorVer:  "0:16",
+				Root:           "/",
+				MountPoint:     "/sys",
+				Options:        map[string]string{"rw": "", "nosuid": "", "nodev": "", "noexec": "", "relatime": ""},
+				OptionalFields: map[string]string{"shared": "7"},
+				FSType:         "sysfs",
+				Source:         "sysfs",
+				SuperOptions:   map[string]string{"rw": ""},
+			},
+		},
+		{
+			name:    "Not enough information",
+			s:       "hello",
+			invalid: true,
+		},
+		{
+			name: "Tmpfs mounted at /run",
+			s:    "225 20 0:39 / /run/user/112 rw,nosuid,nodev,relatime shared:177 - tmpfs tmpfs rw,size=405096k,mode=700,uid=112,gid=116",
+			mount: &MountInfo{
+				MountId:        225,
+				ParentId:       20,
+				MajorMinorVer:  "0:39",
+				Root:           "/",
+				MountPoint:     "/run/user/112",
+				Options:        map[string]string{"rw": "", "nosuid": "", "nodev": "", "relatime": ""},
+				OptionalFields: map[string]string{"shared": "177"},
+				FSType:         "tmpfs",
+				Source:         "tmpfs",
+				SuperOptions:   map[string]string{"rw": "", "size": "405096k", "mode": "700", "uid": "112", "gid": "116"},
+			},
+			invalid: false,
+		},
+		{
+			name: "Tmpfs mounted at /run, but no optional values",
+			s:    "225 20 0:39 / /run/user/112 rw,nosuid,nodev,relatime  - tmpfs tmpfs rw,size=405096k,mode=700,uid=112,gid=116",
+			mount: &MountInfo{
+				MountId:        225,
+				ParentId:       20,
+				MajorMinorVer:  "0:39",
+				Root:           "/",
+				MountPoint:     "/run/user/112",
+				Options:        map[string]string{"rw": "", "nosuid": "", "nodev": "", "relatime": ""},
+				OptionalFields: nil,
+				FSType:         "tmpfs",
+				Source:         "tmpfs",
+				SuperOptions:   map[string]string{"rw": "", "size": "405096k", "mode": "700", "uid": "112", "gid": "116"},
+			},
+			invalid: false,
+		},
+		{
+			name: "Tmpfs mounted at /run, with multiple optional values",
+			s:    "225 20 0:39 / /run/user/112 rw,nosuid,nodev,relatime shared:177 master:8 - tmpfs tmpfs rw,size=405096k,mode=700,uid=112,gid=116",
+			mount: &MountInfo{
+				MountId:        225,
+				ParentId:       20,
+				MajorMinorVer:  "0:39",
+				Root:           "/",
+				MountPoint:     "/run/user/112",
+				Options:        map[string]string{"rw": "", "nosuid": "", "nodev": "", "relatime": ""},
+				OptionalFields: map[string]string{"shared": "177", "master": "8"},
+				FSType:         "tmpfs",
+				Source:         "tmpfs",
+				SuperOptions:   map[string]string{"rw": "", "size": "405096k", "mode": "700", "uid": "112", "gid": "116"},
+			},
+			invalid: false,
+		},
+		{
+			name: "Tmpfs mounted at /run, with a mixture of valid and invalid optional values",
+			s:    "225 20 0:39 / /run/user/112 rw,nosuid,nodev,relatime shared:177 master:8 foo:bar - tmpfs tmpfs rw,size=405096k,mode=700,uid=112,gid=116",
+			mount: &MountInfo{
+				MountId:        225,
+				ParentId:       20,
+				MajorMinorVer:  "0:39",
+				Root:           "/",
+				MountPoint:     "/run/user/112",
+				Options:        map[string]string{"rw": "", "nosuid": "", "nodev": "", "relatime": ""},
+				OptionalFields: map[string]string{"shared": "177", "master": "8"},
+				FSType:         "tmpfs",
+				Source:         "tmpfs",
+				SuperOptions:   map[string]string{"rw": "", "size": "405096k", "mode": "700", "uid": "112", "gid": "116"},
+			},
+			invalid: false,
+		},
+	}
+
+	for i, test := range tests {
+		t.Logf("[%02d] test %q", i, test.name)
+
+		mount, err := parseMountInfoString(test.s)
+
+		if test.invalid && err == nil {
+			t.Error("expected an error, but none occurred")
+		}
+		if !test.invalid && err != nil {
+			t.Errorf("unexpected error: %v", err)
+		}
+
+		if want, have := test.mount, mount; !reflect.DeepEqual(want, have) {
+			t.Errorf("mounts:\nwant:\n%+v\nhave:\n%+v", want, have)
+		}
+	}
+}