From 0bcd5183f5dec547512e3ef5543897354dc695bf Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Thu, 11 Feb 2021 04:15:27 +0000 Subject: [PATCH] Analyze block devices --- examples/preflight/host-block-devices.yaml | 18 ++ pkg/analyze/analyzer.go | 7 + pkg/analyze/host_block_devices.go | 183 ++++++++++++++++++ pkg/analyze/host_block_devices_test.go | 175 +++++++++++++++++ .../v1beta2/hostanalyzer_shared.go | 7 + .../v1beta2/hostcollector_shared.go | 5 + .../v1beta2/zz_generated.deepcopy.go | 53 +++++ pkg/collect/host_block_device.go | 76 ++++++++ pkg/collect/host_collector.go | 2 + 9 files changed, 526 insertions(+) create mode 100644 examples/preflight/host-block-devices.yaml create mode 100644 pkg/analyze/host_block_devices.go create mode 100644 pkg/analyze/host_block_devices_test.go create mode 100644 pkg/collect/host_block_device.go 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 +} + +// +// 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/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) + }) + } +} 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