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 +} + +// +// 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/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) + } + }) + } +} 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 +}