Repository: replicatedhq/troubleshoot
Pull request #316 (2021-02-11 19:21:59)

Analyze block devices


Patch:
diff --git a/examples/preflight/host-block-devices.yaml b/examples/preflight/host-block-devices.yaml
new file mode 100644
index 00000000..a4a0395e
--- /dev/null
+++ b/examples/preflight/host-block-devices.yaml
@@ -0,0 +1,18 @@
+apiVersion: troubleshoot.sh/v1beta2
+kind: HostPreflight
+metadata:
+  name: block
+spec:
+  collectors:
+    - blockDevices: {}
+  analyzers:
+    - blockDevices:
+        outcomes:
+          - pass:
+              when: ".* == 1"
+              message: One available block device
+          - pass:
+              when: ".* > 1"
+              message: Multiple available block devices
+          - fail:
+              message: No available block devices
diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go
index ec427e72..60d907da 100644
--- a/pkg/analyze/analyzer.go
+++ b/pkg/analyze/analyzer.go
@@ -90,6 +90,13 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle
 		}
 		return []*AnalyzeResult{result}, nil
 	}
+	if hostAnalyzer.BlockDevices != nil {
+		result, err := analyzeHostBlockDevices(hostAnalyzer.BlockDevices, getFile)
+		if err != nil {
+			return nil, err
+		}
+		return []*AnalyzeResult{result}, nil
+	}
 
 	return nil, errors.New("invalid analyzer")
 }
diff --git a/pkg/analyze/host_block_devices.go b/pkg/analyze/host_block_devices.go
new file mode 100644
index 00000000..c4b5fca8
--- /dev/null
+++ b/pkg/analyze/host_block_devices.go
@@ -0,0 +1,183 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+)
+
+func analyzeHostBlockDevices(hostAnalyzer *troubleshootv1beta2.BlockDevicesAnalyze, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	contents, err := getCollectedFileContents("system/block_devices.json")
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get collected file")
+	}
+
+	var devices []collect.BlockDeviceInfo
+	if err := json.Unmarshal(contents, &devices); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal block devices info")
+	}
+
+	result := AnalyzeResult{}
+
+	title := hostAnalyzer.CheckName
+	if title == "" {
+		title = "Block Devices"
+	}
+	result.Title = title
+
+	for _, outcome := range hostAnalyzer.Outcomes {
+		if outcome.Fail != nil {
+			if outcome.Fail.When == "" {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostBlockDevicesConditionalToActual(outcome.Fail.When, devices)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Fail.When)
+			}
+
+			if isMatch {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return &result, nil
+			}
+		} else if outcome.Warn != nil {
+			if outcome.Warn.When == "" {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostBlockDevicesConditionalToActual(outcome.Warn.When, devices)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Warn.When)
+			}
+
+			if isMatch {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return &result, nil
+			}
+		} else if outcome.Pass != nil {
+			if outcome.Pass.When == "" {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostBlockDevicesConditionalToActual(outcome.Pass.When, devices)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Pass.When)
+			}
+
+			if isMatch {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return &result, nil
+			}
+		}
+	}
+
+	return &result, nil
+}
+
+// <regexp> <op> <count>
+// example: sdb > 0
+func compareHostBlockDevicesConditionalToActual(conditional string, devices []collect.BlockDeviceInfo) (res bool, err error) {
+	parts := strings.Split(conditional, " ")
+	if len(parts) != 3 {
+		return false, fmt.Errorf("Expected exactly 3 parts, got %d", len(parts))
+	}
+
+	rx, err := regexp.Compile(parts[0])
+	if err != nil {
+		return false, errors.Wrapf(err, "failed to compile regex %q", parts[0])
+	}
+	count := countEligibleBlockDevices(rx, devices)
+
+	desiredInt, err := strconv.Atoi(parts[2])
+	if err != nil {
+		return false, errors.Wrapf(err, "failed to parse desired quantity %q", parts[2])
+	}
+
+	switch parts[1] {
+	case ">":
+		return count > desiredInt, nil
+	case ">=":
+		return count >= desiredInt, nil
+	case "<":
+		return count < desiredInt, nil
+	case "<=":
+		return count <= desiredInt, nil
+	case "=", "==", "===":
+		return count == desiredInt, nil
+	}
+
+	return false, fmt.Errorf("Unexpected operator %q", parts[1])
+}
+
+func countEligibleBlockDevices(rx *regexp.Regexp, devices []collect.BlockDeviceInfo) int {
+	count := 0
+
+	for _, device := range devices {
+		if isEligibleBlockDevice(rx, device, devices) {
+			count++
+		}
+	}
+
+	return count
+}
+
+func isEligibleBlockDevice(rx *regexp.Regexp, device collect.BlockDeviceInfo, devices []collect.BlockDeviceInfo) bool {
+	if !rx.MatchString(device.Name) {
+		return false
+	}
+
+	if device.Type != "disk" {
+		return false
+	}
+
+	if device.Mountpoint != "" {
+		return false
+	}
+
+	if device.FilesystemType != "" {
+		return false
+	}
+
+	if device.ReadOnly {
+		return false
+	}
+
+	if device.Removable {
+		return false
+	}
+
+	for _, d := range devices {
+		if d.ParentKernelName == device.KernelName {
+			return false
+		}
+	}
+
+	return true
+}
diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
index 0d05b7e9..915a1d8d 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
@@ -39,6 +39,11 @@ type TimeAnalyze struct {
 	Outcomes    []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
 
+type BlockDevicesAnalyze struct {
+	AnalyzeMeta `json:",inline" yaml:",inline"`
+	Outcomes    []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
 type HostAnalyze struct {
 	CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"`
 	//
@@ -53,4 +58,6 @@ type HostAnalyze struct {
 	HTTP *HTTPAnalyze `json:"http" yaml:"http"`
 
 	Time *TimeAnalyze `json:"time" yaml:"time"`
+
+	BlockDevices *BlockDevicesAnalyze `json:"blockDevices" yaml:"blockDevices"`
 }
diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
index 4ec90e29..b912724a 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
@@ -63,6 +63,10 @@ type HostTime struct {
 	HostCollectorMeta `json:",inline" yaml:",inline"`
 }
 
+type HostBlockDevices struct {
+	HostCollectorMeta `json:",inline" yaml:",inline"`
+}
+
 type HostCollect struct {
 	CPU              *CPU              `json:"cpu,omitempty" yaml:"cpu,omitempty"`
 	Memory           *Memory           `json:"memory,omitempty" yaml:"memory,omitempty"`
@@ -74,6 +78,7 @@ type HostCollect struct {
 	DiskUsage        *DiskUsage        `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"`
 	HTTP             *HostHTTP         `json:"http,omitempty" yaml:"http,omitempty"`
 	Time             *HostTime         `json:"time,omitempty" yaml:"time,omitempty"`
+	BlockDevices     *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"`
 }
 
 func (c *HostCollect) GetName() string {
diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
index c2e532b3..50a68dc6 100644
--- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
@@ -287,6 +287,33 @@ func (in *AnalyzerStatus) DeepCopy() *AnalyzerStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *BlockDevicesAnalyze) DeepCopyInto(out *BlockDevicesAnalyze) {
+	*out = *in
+	out.AnalyzeMeta = in.AnalyzeMeta
+	if in.Outcomes != nil {
+		in, out := &in.Outcomes, &out.Outcomes
+		*out = make([]*Outcome, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(Outcome)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BlockDevicesAnalyze.
+func (in *BlockDevicesAnalyze) DeepCopy() *BlockDevicesAnalyze {
+	if in == nil {
+		return nil
+	}
+	out := new(BlockDevicesAnalyze)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *CPU) DeepCopyInto(out *CPU) {
 	*out = *in
@@ -1108,6 +1135,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
 		*out = new(TimeAnalyze)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.BlockDevices != nil {
+		in, out := &in.BlockDevices, &out.BlockDevices
+		*out = new(BlockDevicesAnalyze)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
@@ -1120,6 +1152,22 @@ func (in *HostAnalyze) DeepCopy() *HostAnalyze {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostBlockDevices) DeepCopyInto(out *HostBlockDevices) {
+	*out = *in
+	out.HostCollectorMeta = in.HostCollectorMeta
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostBlockDevices.
+func (in *HostBlockDevices) DeepCopy() *HostBlockDevices {
+	if in == nil {
+		return nil
+	}
+	out := new(HostBlockDevices)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HostCollect) DeepCopyInto(out *HostCollect) {
 	*out = *in
@@ -1173,6 +1221,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) {
 		*out = new(HostTime)
 		**out = **in
 	}
+	if in.BlockDevices != nil {
+		in, out := &in.BlockDevices, &out.BlockDevices
+		*out = new(HostBlockDevices)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect.
diff --git a/pkg/collect/host_block_device.go b/pkg/collect/host_block_device.go
new file mode 100644
index 00000000..b88f2044
--- /dev/null
+++ b/pkg/collect/host_block_device.go
@@ -0,0 +1,76 @@
+package collect
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+
+	"github.com/pkg/errors"
+)
+
+type BlockDeviceInfo struct {
+	Name             string `json:"name"`
+	KernelName       string `json:"kernel_name"`
+	ParentKernelName string `json:"parent_kernel_name"`
+	Type             string `json:"type"`
+	Major            int    `json:"major"`
+	Minor            int    `json:"minor"`
+	Size             uint64 `json:"size"`
+	FilesystemType   string `json:"filesystem_type"`
+	Mountpoint       string `json:"mountpoint"`
+	Serial           string `json:"serial"`
+	ReadOnly         bool   `json:"read_only"`
+	Removable        bool   `json:"removable"`
+}
+
+const lsblkColumns = "NAME,KNAME,PKNAME,TYPE,MAJ:MIN,SIZE,FSTYPE,MOUNTPOINT,SERIAL,RO,RM"
+const lsblkFormat = `NAME=%q KNAME=%q PKNAME=%q TYPE=%q MAJ:MIN="%d:%d" SIZE="%d" FSTYPE=%q MOUNTPOINT=%q SERIAL=%q RO="%d" RM="%d0"`
+
+func HostBlockDevices(c *HostCollector) (map[string][]byte, error) {
+	var devices []BlockDeviceInfo
+
+	cmd := exec.Command("lsblk", "--noheadings", "--bytes", "--pairs", "-o", lsblkColumns)
+	stdout, err := cmd.Output()
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to execute lsblk")
+	}
+	buf := bytes.NewBuffer(stdout)
+	scanner := bufio.NewScanner(buf)
+
+	for scanner.Scan() {
+		bdi := BlockDeviceInfo{}
+		var ro int
+		var rm int
+		fmt.Sscanf(
+			scanner.Text(),
+			lsblkFormat,
+			&bdi.Name,
+			&bdi.KernelName,
+			&bdi.ParentKernelName,
+			&bdi.Type,
+			&bdi.Major,
+			&bdi.Minor,
+			&bdi.Size,
+			&bdi.FilesystemType,
+			&bdi.Mountpoint,
+			&bdi.Serial,
+			&ro,
+			&rm,
+		)
+		bdi.ReadOnly = ro == 1
+		bdi.Removable = rm == 1
+
+		devices = append(devices, bdi)
+	}
+
+	b, err := json.Marshal(devices)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal block device info")
+	}
+
+	return map[string][]byte{
+		"system/block_devices.json": b,
+	}, nil
+}
diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go
index f5c9e3bf..37dbd25a 100644
--- a/pkg/collect/host_collector.go
+++ b/pkg/collect/host_collector.go
@@ -32,6 +32,8 @@ func (c *HostCollector) RunCollectorSync() (result map[string][]byte, err error)
 		result, err = HostHTTP(c)
 	} else if c.Collect.Time != nil {
 		result, err = HostTime(c)
+	} else if c.Collect.BlockDevices != nil {
+		result, err = HostBlockDevices(c)
 	} else {
 		err = errors.New("no spec found to run")
 		return


Test patch:
diff --git a/pkg/analyze/host_block_devices_test.go b/pkg/analyze/host_block_devices_test.go
new file mode 100644
index 00000000..09e8fd3f
--- /dev/null
+++ b/pkg/analyze/host_block_devices_test.go
@@ -0,0 +1,175 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"testing"
+
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAnalyzeBlockDevices(t *testing.T) {
+	tests := []struct {
+		name         string
+		devices      []collect.BlockDeviceInfo
+		hostAnalyzer *troubleshootv1beta2.BlockDevicesAnalyze
+		result       *AnalyzeResult
+		expectErr    bool
+	}{
+		{
+			name: "sdb == 1, pass when there is an empty /dev/sdb",
+			devices: []collect.BlockDeviceInfo{
+				{
+					Name:       "sdb",
+					KernelName: "sdb",
+					Type:       "disk",
+					Major:      8,
+					Serial:     "disk1",
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.BlockDevicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    "sdb == 1",
+							Message: "Block device available",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Block Devices",
+				IsPass:  true,
+				Message: "Block device available",
+			},
+		},
+		{
+			name: "sdb == 1, fail when partitioned",
+			devices: []collect.BlockDeviceInfo{
+				{
+					Name:       "sdb",
+					KernelName: "sdb",
+					Type:       "disk",
+					Major:      8,
+					Serial:     "disk1",
+				},
+				{
+					Name:             "sdb1",
+					KernelName:       "sdb1",
+					ParentKernelName: "sdb",
+					Type:             "part",
+					Major:            8,
+					Minor:            1,
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.BlockDevicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    "sdb == 1",
+							Message: "Block device available",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "No block device available",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Block Devices",
+				IsFail:  true,
+				Message: "No block device available",
+			},
+		},
+		{
+			name: "sdb == 1, fail when it has a filesystem",
+			devices: []collect.BlockDeviceInfo{
+				{
+					Name:           "sdb",
+					KernelName:     "sdb",
+					Type:           "disk",
+					Major:          8,
+					Serial:         "disk1",
+					FilesystemType: "ext4",
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.BlockDevicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    "sdb == 1",
+							Message: "Block device available",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "No block device available",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Block Devices",
+				IsFail:  true,
+				Message: "No block device available",
+			},
+		},
+		{
+			name: ".* > 0, fail when only loop devices are found",
+			devices: []collect.BlockDeviceInfo{
+				{
+					Name:       "loop0",
+					KernelName: "loop0",
+					Type:       "loop",
+					Major:      7,
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.BlockDevicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    ".* > 0",
+							Message: "Block device available",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "No block device available",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Block Devices",
+				IsFail:  true,
+				Message: "No block device available",
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+			b, err := json.Marshal(test.devices)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			getCollectedFileContents := func(filename string) ([]byte, error) {
+				return b, nil
+			}
+
+			result, err := analyzeHostBlockDevices(test.hostAnalyzer, getCollectedFileContents)
+			if test.expectErr {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+			}
+
+			assert.Equal(t, test.result, result)
+		})
+	}
+}