Repository: replicatedhq/troubleshoot
Pull request #341 (2021-03-30 19:59:25)

systemctl services collector


Patch:
diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go
index bb1d2a18..3d6cdde5 100644
--- a/pkg/analyze/analyzer.go
+++ b/pkg/analyze/analyzer.go
@@ -60,9 +60,16 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle
 }
 
 func NewAnalyzeResultError(analyzer HostAnalyzer, err error) []*AnalyzeResult {
+	if analyzer != nil {
+		return []*AnalyzeResult{{
+			IsFail:  true,
+			Title:   analyzer.Title(),
+			Message: fmt.Sprintf("Analyzer Failed: %v", err),
+		}}
+	}
 	return []*AnalyzeResult{{
 		IsFail:  true,
-		Title:   analyzer.Title(),
+		Title:   "nil analyzer",
 		Message: fmt.Sprintf("Analyzer Failed: %v", err),
 	}}
 }
diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go
index 3ca07dbf..d06e60af 100644
--- a/pkg/analyze/host_analyzer.go
+++ b/pkg/analyze/host_analyzer.go
@@ -36,6 +36,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b
 		return &AnalyzeHostFilesystemPerformance{analyzer.FilesystemPerformance}, true
 	case analyzer.Certificate != nil:
 		return &AnalyzeHostCertificate{analyzer.Certificate}, true
+	case analyzer.HostServices != nil:
+		return &AnalyzeHostServices{analyzer.HostServices}, true
 	default:
 		return nil, false
 	}
diff --git a/pkg/analyze/host_services.go b/pkg/analyze/host_services.go
new file mode 100644
index 00000000..b989e722
--- /dev/null
+++ b/pkg/analyze/host_services.go
@@ -0,0 +1,150 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+)
+
+type AnalyzeHostServices struct {
+	hostAnalyzer *troubleshootv1beta2.HostServicesAnalyze
+}
+
+func (a *AnalyzeHostServices) Title() string {
+	return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Host Services")
+}
+
+func (a *AnalyzeHostServices) IsExcluded() (bool, error) {
+	return isExcluded(a.hostAnalyzer.Exclude)
+}
+
+func (a *AnalyzeHostServices) Analyze(getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	hostAnalyzer := a.hostAnalyzer
+
+	contents, err := getCollectedFileContents(collect.HostServicesPath)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get collected file")
+	}
+
+	var services []collect.ServiceInfo
+	if err := json.Unmarshal(contents, &services); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal systemctl service info")
+	}
+
+	result := AnalyzeResult{}
+
+	result.Title = a.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 := compareHostServicesConditionalToActual(outcome.Fail.When, services)
+			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 := compareHostServicesConditionalToActual(outcome.Warn.When, services)
+			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 := compareHostServicesConditionalToActual(outcome.Pass.When, services)
+			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
+}
+
+// <service> <op> <state>
+// example: ufw.service = active
+func compareHostServicesConditionalToActual(conditional string, services []collect.ServiceInfo) (res bool, err error) {
+	parts := strings.Split(conditional, " ")
+	if len(parts) != 3 {
+		return false, fmt.Errorf("expected exactly 3 parts, got %d", len(parts))
+	}
+
+	switch parts[1] {
+	case "=", "==":
+		for _, service := range services {
+			if isServiceMatch(service.Unit, parts[0]) {
+				return service.Active == parts[2], nil
+			}
+		}
+		return false, nil
+	case "!=", "<>":
+		for _, service := range services {
+			if isServiceMatch(service.Unit, parts[0]) {
+				return service.Active != parts[2], nil
+			}
+		}
+		return false, nil
+	}
+
+	return false, fmt.Errorf("unexpected operator %q", parts[1])
+}
+
+func isServiceMatch(serviceName string, matchName string) bool {
+	if serviceName == matchName {
+		return true
+	}
+
+	if strings.HasPrefix(serviceName, matchName) {
+		return true
+	}
+
+	return false
+}
diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
index 9b386afa..f8a2a6da 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
@@ -75,6 +75,11 @@ type CertificateAnalyze struct {
 	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
 
+type HostServicesAnalyze struct {
+	AnalyzeMeta `json:",inline" yaml:",inline"`
+	Outcomes    []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
 type HostAnalyze struct {
 	CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"`
 	//
@@ -100,4 +105,6 @@ type HostAnalyze struct {
 	FilesystemPerformance *FilesystemPerformanceAnalyze `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"`
 
 	Certificate *CertificateAnalyze `json:"certificate,omitempty" yaml:"certificate,omitempty"`
+
+	HostServices *HostServicesAnalyze `json:"hostServices,omitempty" yaml:"hostServices,omitempty"`
 }
diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
index 65dd2aea..f7f07437 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
@@ -88,6 +88,10 @@ type Certificate struct {
 	KeyPath           string `json:"keyPath" yaml:"keyPath"`
 }
 
+type HostServices 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"`
@@ -103,6 +107,7 @@ type HostCollect struct {
 	TCPConnect            *TCPConnect            `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"`
 	FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"`
 	Certificate           *Certificate           `json:"certificate,omitempty" yaml:"certificate,omitempty"`
+	HostServices          *HostServices          `json:"hostServices,omitempty" yaml:"hostServices,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 e4dbed22..6b9a336a 100644
--- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
@@ -1278,6 +1278,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
 		*out = new(CertificateAnalyze)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.HostServices != nil {
+		in, out := &in.HostServices, &out.HostServices
+		*out = new(HostServicesAnalyze)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
@@ -1379,6 +1384,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) {
 		*out = new(Certificate)
 		**out = **in
 	}
+	if in.HostServices != nil {
+		in, out := &in.HostServices, &out.HostServices
+		*out = new(HostServices)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect.
@@ -1549,6 +1559,49 @@ func (in *HostPreflightStatus) DeepCopy() *HostPreflightStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostServices) DeepCopyInto(out *HostServices) {
+	*out = *in
+	out.HostCollectorMeta = in.HostCollectorMeta
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostServices.
+func (in *HostServices) DeepCopy() *HostServices {
+	if in == nil {
+		return nil
+	}
+	out := new(HostServices)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostServicesAnalyze) DeepCopyInto(out *HostServicesAnalyze) {
+	*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 HostServicesAnalyze.
+func (in *HostServicesAnalyze) DeepCopy() *HostServicesAnalyze {
+	if in == nil {
+		return nil
+	}
+	out := new(HostServicesAnalyze)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HostTime) DeepCopyInto(out *HostTime) {
 	*out = *in
diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go
index 9a44f2d4..a864cc9b 100644
--- a/pkg/collect/host_collector.go
+++ b/pkg/collect/host_collector.go
@@ -38,6 +38,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect) (HostCollector
 		return &CollectHostFilesystemPerformance{collector.FilesystemPerformance}, true
 	case collector.Certificate != nil:
 		return &CollectHostCertificate{collector.Certificate}, true
+	case collector.HostServices != nil:
+		return &CollectHostServices{collector.HostServices}, true
 	default:
 		return nil, false
 	}
diff --git a/pkg/collect/host_services.go b/pkg/collect/host_services.go
new file mode 100644
index 00000000..00be2b0c
--- /dev/null
+++ b/pkg/collect/host_services.go
@@ -0,0 +1,69 @@
+package collect
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+)
+
+type ServiceInfo struct {
+	Unit   string `json:"Unit"`
+	Load   string `json:"Load"`
+	Active string `json:"Active"`
+	Sub    string `json:"Sub"`
+}
+
+const systemctlFormat = `%s %s %s %s` // this leaves off the description
+const HostServicesPath = `system/systemctl_services.json`
+
+type CollectHostServices struct {
+	hostCollector *troubleshootv1beta2.HostServices
+}
+
+func (c *CollectHostServices) Title() string {
+	return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Block Devices")
+}
+
+func (c *CollectHostServices) IsExcluded() (bool, error) {
+	return isExcluded(c.hostCollector.Exclude)
+}
+
+func (c *CollectHostServices) Collect(progressChan chan<- interface{}) (map[string][]byte, error) {
+	var devices []ServiceInfo
+
+	cmd := exec.Command("systemctl", "list-units", "--type=service", "--no-legend", "--all")
+	stdout, err := cmd.Output()
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to execute systemctl")
+	}
+	buf := bytes.NewBuffer(stdout)
+	scanner := bufio.NewScanner(buf)
+
+	for scanner.Scan() {
+		bdi := ServiceInfo{}
+		fmt.Sscanf(
+			scanner.Text(),
+			systemctlFormat,
+			&bdi.Unit,
+			&bdi.Load,
+			&bdi.Active,
+			&bdi.Sub,
+		)
+
+		devices = append(devices, bdi)
+	}
+
+	b, err := json.Marshal(devices)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal systemctl service info")
+	}
+
+	return map[string][]byte{
+		HostServicesPath: b,
+	}, nil
+}


Test patch:
diff --git a/pkg/analyze/host_services_test.go b/pkg/analyze/host_services_test.go
new file mode 100644
index 00000000..e20df7cf
--- /dev/null
+++ b/pkg/analyze/host_services_test.go
@@ -0,0 +1,171 @@
+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 TestAnalyzeHostServices(t *testing.T) {
+	tests := []struct {
+		name         string
+		info         []collect.ServiceInfo
+		hostAnalyzer *troubleshootv1beta2.HostServicesAnalyze
+		result       *AnalyzeResult
+		expectErr    bool
+	}{
+		{
+			name: "service 'a' is active",
+			info: []collect.ServiceInfo{
+				{
+					Unit:   "a.service",
+					Active: "active",
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.HostServicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							When:    "a.service == active",
+							Message: "the service 'a' is active",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Host Services",
+				IsFail:  true,
+				Message: "the service 'a' is active",
+			},
+		},
+		{
+			name: "connected, fail",
+			info: []collect.ServiceInfo{
+				{
+					Unit:   "a.service",
+					Active: "active",
+				},
+				{
+					Unit:   "b.service",
+					Active: "stopped",
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.HostServicesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							When:    "a.service != active",
+							Message: "service 'a' is active",
+						},
+					},
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    "b.service != active",
+							Message: "service 'b' is not active",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "Host Services",
+				IsPass:  true,
+				Message: "service 'b' is not active",
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+			b, err := json.Marshal(test.info)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			getCollectedFileContents := func(filename string) ([]byte, error) {
+				return b, nil
+			}
+
+			result, err := (&AnalyzeHostServices{test.hostAnalyzer}).Analyze(getCollectedFileContents)
+			if test.expectErr {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+			}
+
+			assert.Equal(t, test.result, result)
+		})
+	}
+}
+
+func Test_compareHostServicesConditionalToActual(t *testing.T) {
+	tests := []struct {
+		name        string
+		conditional string
+		services    []collect.ServiceInfo
+		wantRes     bool
+		wantErr     bool
+	}{
+		{
+			name:        "match second item",
+			conditional: "abc.service = active",
+			services: []collect.ServiceInfo{
+				{
+					Unit: "first",
+				},
+				{
+					Unit:   "abc.service",
+					Active: "active",
+				},
+			},
+			wantRes: true,
+		},
+		{
+			name:        "item not in list",
+			conditional: "abc = active",
+			services: []collect.ServiceInfo{
+				{
+					Unit: "first",
+				},
+			},
+			wantRes: false,
+		},
+		{
+			name:        "item does not match",
+			conditional: "abc = active",
+			services: []collect.ServiceInfo{
+				{
+					Unit:   "abc.service",
+					Active: "stopped",
+				},
+			},
+			wantRes: false,
+		},
+		{
+			name:        "other operator",
+			conditional: "abc * active",
+			services: []collect.ServiceInfo{
+				{
+					Unit:   "abc.service",
+					Active: "stopped",
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := require.New(t)
+			gotRes, err := compareHostServicesConditionalToActual(tt.conditional, tt.services)
+			if tt.wantErr {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+				req.Equal(tt.wantRes, gotRes)
+			}
+		})
+	}
+}