diff --git a/go.mod b/go.mod index 61a964426..4efbfd194 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( github.com/ahmetalpbalkan/go-cursor v0.0.0-20131010032410-8136607ea412 + github.com/arduino/go-apt-client v0.0.0-20190812130613-5613f843fdc8 github.com/blang/semver v3.5.1+incompatible github.com/containers/image/v5 v5.23.1 github.com/docker/distribution v2.8.1+incompatible diff --git a/go.sum b/go.sum index 9ca9ffef2..a55f03c00 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,8 @@ github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:C github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/arduino/go-apt-client v0.0.0-20190812130613-5613f843fdc8 h1:HpmeqWCUoU+dPrz8V4KGDMDxvR+WyeJ0g6DSSqnptuY= +github.com/arduino/go-apt-client v0.0.0-20190812130613-5613f843fdc8/go.mod h1:U1gYCDLM1Kg0dG0PxUjlT09+l6/TdUZKx0FQ2CocJUU= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go index b0bcf1144..54ec59771 100644 --- a/pkg/analyze/analyzer.go +++ b/pkg/analyze/analyzer.go @@ -56,6 +56,12 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle return NewAnalyzeResultError(analyzer, errors.New("invalid host analyzer")) } + if tplanalyzer, ok := analyzer.(Templated); ok { + if err := tplanalyzer.ProcessTemplate(getFile); err != nil { + return NewAnalyzeResultError(analyzer, err) + } + } + isExcluded, _ := analyzer.IsExcluded() if isExcluded { return nil diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go index 6b04b1534..32c062bf5 100644 --- a/pkg/analyze/host_analyzer.go +++ b/pkg/analyze/host_analyzer.go @@ -2,6 +2,10 @@ package analyzer import troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" +type Templated interface { + ProcessTemplate(getFile func(string) ([]byte, error)) error +} + type HostAnalyzer interface { Title() string IsExcluded() (bool, error) @@ -44,6 +48,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b return &AnalyzeHostServices{analyzer.HostServices}, true case analyzer.HostOS != nil: return &AnalyzeHostOS{analyzer.HostOS}, true + case analyzer.InstalledPackage != nil: + return &AnalyzeInstalledPackage{analyzer.InstalledPackage}, true default: return nil, false } diff --git a/pkg/analyze/host_installed_packages.go b/pkg/analyze/host_installed_packages.go new file mode 100644 index 000000000..0207a8417 --- /dev/null +++ b/pkg/analyze/host_installed_packages.go @@ -0,0 +1,167 @@ +package analyzer + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "text/template" + + "github.com/hashicorp/go-multierror" + + "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" +) + +type AnalyzeInstalledPackage struct { + hanalyzer *v1beta2.InstalledPackageAnalyze +} + +func (a *AnalyzeInstalledPackage) Title() string { + return hostAnalyzerTitleOrDefault(a.hanalyzer.AnalyzeMeta, "Host Package") +} + +// ProcessTemplate parses the content collect by the collector an then applies the result in the 'exclude' +// property from the analyzer. +func (a *AnalyzeInstalledPackage) ProcessTemplate(getFileContents func(string) ([]byte, error)) error { + fullPath := path.Join("host-collectors", "hostPackages", "hostPackages.json") + if a.hanalyzer.CollectorName != "" { + fname := fmt.Sprintf("%s.json", a.hanalyzer.CollectorName) + fullPath = path.Join("host-collectors", "hostPackages", fname) + } + + data, err := getFileContents(fullPath) + if err != nil { + return fmt.Errorf("failed to read collector content: %w", err) + } + + var info collect.HostInfo + if err := json.Unmarshal(data, &info); err != nil { + return fmt.Errorf("failed to unmarshal collector content: %w", err) + } + + tpl := template.New("template") + tpl, err = tpl.Parse(a.hanalyzer.AnalyzeMeta.Exclude.StrVal) + if err != nil { + return fmt.Errorf("failed to parse exclude template: %w", err) + } + + buf := bytes.NewBuffer(nil) + if err := tpl.Execute(buf, info.OSInfo); err != nil { + return fmt.Errorf("failed to execute exclude template: %w", err) + } + + a.hanalyzer.AnalyzeMeta.Exclude.StrVal = buf.String() + return nil +} + +func (a *AnalyzeInstalledPackage) IsExcluded() (bool, error) { + return isExcluded(a.hanalyzer.Exclude) +} + +func (a *AnalyzeInstalledPackage) Analyze(getFileContents func(string) ([]byte, error)) ([]*AnalyzeResult, error) { + fullPath := path.Join("host-collectors", "hostPackages", "hostPackages.json") + if a.hanalyzer.CollectorName != "" { + fname := fmt.Sprintf("%s.json", a.hanalyzer.CollectorName) + fullPath = path.Join("host-collectors", "hostPackages", fname) + } + + data, err := getFileContents(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read collected file name %s: %w", fullPath, err) + } + + var ospkgs collect.HostInfo + if err := json.Unmarshal(data, &ospkgs); err != nil { + return nil, fmt.Errorf("failed to unmarshal collected data: %w", err) + } + + pkg := ospkgs.PackageByName(a.hanalyzer.PackageName) + if pkg == nil { + res := &AnalyzeResult{ + IsFail: true, + Title: fmt.Sprintf("Package %s not installed", a.hanalyzer.PackageName), + Message: fmt.Sprintf("Package %s was not found in the system", a.hanalyzer.PackageName), + } + return []*AnalyzeResult{res}, nil + } + + // if no outcome has been provided then we are only checking if the package has been + // installed. on this case just return an ok. + if len(a.hanalyzer.Outcomes) == 0 { + res := &AnalyzeResult{ + IsPass: true, + Title: fmt.Sprintf("Package %s installed", pkg.Name), + Message: fmt.Sprintf("Package %s is installed (version %s)", pkg.Name, pkg.Version), + } + return []*AnalyzeResult{res}, nil + } + + res, err := a.validateOutcomes(pkg) + if err != nil { + return nil, fmt.Errorf("failed to analyze outcomes: %w", err) + } + return []*AnalyzeResult{res}, nil +} + +func (a *AnalyzeInstalledPackage) prepareResult(outcome *v1beta2.Outcome) (*AnalyzeResult, string) { + title := a.hanalyzer.CheckName + if title == "" { + title = fmt.Sprintf("Package %s required version", a.hanalyzer.PackageName) + } + result := &AnalyzeResult{Title: title} + + if outcome.Fail != nil { + result.IsFail = true + result.Message = outcome.Fail.Message + result.URI = outcome.Fail.URI + return result, outcome.Fail.When + } + + if outcome.Warn != nil { + result.IsWarn = true + result.Message = outcome.Warn.Message + result.URI = outcome.Warn.URI + return result, outcome.Warn.When + } + + if outcome.Pass != nil { + result.IsPass = true + result.Message = outcome.Pass.Message + result.URI = outcome.Pass.URI + return result, outcome.Pass.When + } + + return nil, "" +} + +func (a *AnalyzeInstalledPackage) validateOutcomes(pkg *collect.HostInstalledPackage) (*AnalyzeResult, error) { + for _, outcome := range a.hanalyzer.Outcomes { + result, when := a.prepareResult(outcome) + if result == nil { + return nil, fmt.Errorf("empty outcome") + } else if when == "" { + return result, nil + } + + // if something went wrong when evaluating if the package is within the semantic + // version range then or the range is not valid or the package does not use semantic + // versions at all, on this case we move on and try to validate using regex. + var errs *multierror.Error + if matches, err := pkg.InRange(when); err != nil { + errs = multierror.Append(errs, err) + } else if matches { + return result, nil + } else { + continue + } + + if matches, err := pkg.MatchesRegex(when); err != nil { + errs = multierror.Append(errs, err) + return nil, errs + } else if matches { + return result, nil + } + } + return &AnalyzeResult{}, nil +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index 87bd1464c..67cc6e276 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -30,6 +30,13 @@ type TCPPortStatusAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type InstalledPackageAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` + PackageName string `json:"packageName"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type DiskUsageAnalyze struct { AnalyzeMeta `json:",inline" yaml:",inline"` CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` @@ -136,4 +143,6 @@ type HostAnalyze struct { HostServices *HostServicesAnalyze `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` HostOS *HostOSAnalyze `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` + + InstalledPackage *InstalledPackageAnalyze `json:"installedPackage,omitempty" yaml:"installedPackage,omitempty"` } diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index fb40dd30b..38cb33dda 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -33,6 +33,10 @@ type HTTPLoadBalancer struct { Timeout string `json:"timeout,omitempty"` } +type InstalledPackages struct { + HostCollectorMeta `json:",inline" yaml:",inline"` +} + type TCPPortStatus struct { HostCollectorMeta `json:",inline" yaml:",inline"` Interface string `json:"interface,omitempty"` @@ -176,6 +180,7 @@ type HostCollect struct { HostServices *HostServices `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` HostOS *HostOS `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` HostRun *HostRun `json:"run,omitempty" yaml:"run,omitempty"` + InstalledPackages *InstalledPackages `json:"installedPackages,omitempty" yaml:"installedPackages,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 7fe8fdd29..47fdbe07c 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -1527,6 +1527,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(HostOSAnalyze) (*in).DeepCopyInto(*out) } + if in.InstalledPackage != nil { + in, out := &in.InstalledPackage, &out.InstalledPackage + *out = new(InstalledPackageAnalyze) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze. @@ -1653,6 +1658,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(HostRun) (*in).DeepCopyInto(*out) } + if in.InstalledPackages != nil { + in, out := &in.InstalledPackages, &out.InstalledPackages + *out = new(InstalledPackages) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. @@ -2298,6 +2308,38 @@ func (in *Ingress) DeepCopy() *Ingress { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstalledPackageAnalyze) DeepCopyInto(out *InstalledPackageAnalyze) { + *out = *in + in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstalledPackageAnalyze. +func (in *InstalledPackageAnalyze) DeepCopy() *InstalledPackageAnalyze { + if in == nil { + return nil + } + out := new(InstalledPackageAnalyze) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstalledPackages) DeepCopyInto(out *InstalledPackages) { + *out = *in + in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstalledPackages. +func (in *InstalledPackages) DeepCopy() *InstalledPackages { + if in == nil { + return nil + } + out := new(InstalledPackages) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JobStatus) DeepCopyInto(out *JobStatus) { *out = *in diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index d7ba5749a..0d51c8732 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -53,6 +53,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str return &CollectHostOS{collector.HostOS, bundlePath}, true case collector.HostRun != nil: return &CollectHostRun{collector.HostRun, bundlePath}, true + case collector.InstalledPackages != nil: + return &CollectInstalledPackages{collector.InstalledPackages, bundlePath}, true default: return nil, false } diff --git a/pkg/collect/host_installed_packages.go b/pkg/collect/host_installed_packages.go new file mode 100644 index 000000000..7ce16bffe --- /dev/null +++ b/pkg/collect/host_installed_packages.go @@ -0,0 +1,172 @@ +package collect + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os/exec" + "path/filepath" + "regexp" + + "github.com/blang/semver" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + + apt "github.com/arduino/go-apt-client" + osutils "github.com/shirou/gopsutil/host" +) + +// HostInstalledPackage represents a given package (rpm, deb, etc) installed in the host. We only +// care about its name and version. notice that Version is only a string and it can or not comply +// with the semantic version schema. +type HostInstalledPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// InRange returns true if the package is within the provided semantic version range. +func (v *HostInstalledPackage) InRange(semverRange string) (bool, error) { + version, err := semver.Make(v.Version) + if err != nil { + return false, fmt.Errorf("package %s does not use semver: %w", v.Name, err) + } + + vrange, err := semver.ParseRange(semverRange) + if err != nil { + return false, fmt.Errorf("invalid semver range %s: %w", semverRange, err) + } + + return vrange(version), nil +} + +// MatchesRegex returns true if the installed package version matches the provided regex. +func (v *HostInstalledPackage) MatchesRegex(expression string) (bool, error) { + re, err := regexp.Compile(expression) + if err != nil { + return false, fmt.Errorf("invalid regex %s: %w", expression, err) + } + return re.Match([]byte(v.Version)), nil +} + +// HostInfo holds a list of all installed packages and the operating system +// information. +type HostInfo struct { + OSInfo *osutils.InfoStat `json:"os_info"` + InstalledPackages []HostInstalledPackage `json:"installed_packages"` +} + +// PackageByName return a package by its name. returns nil if not found. +func (h *HostInfo) PackageByName(pkgname string) *HostInstalledPackage { + for _, pkg := range h.InstalledPackages { + if pkg.Name == pkgname { + return &pkg + } + } + return nil +} + +type CollectInstalledPackages struct { + collector *troubleshootv1beta2.InstalledPackages + BundlePath string +} + +func (c *CollectInstalledPackages) Title() string { + return hostCollectorTitleOrDefault(c.collector.HostCollectorMeta, "Host Packages") +} + +func (c *CollectInstalledPackages) IsExcluded() (bool, error) { + return isExcluded(c.collector.Exclude) +} + +func (c *CollectInstalledPackages) Collect(progress chan<- interface{}) (map[string][]byte, error) { + info, err := osutils.Info() + if err != nil { + return nil, fmt.Errorf("failed to get os info: %w", err) + } else if info.OS != "linux" { + return nil, fmt.Errorf("failed to get host packages: unknown os %s", info.OS) + } + + var packages []HostInstalledPackage + switch info.PlatformFamily { + case "debian": + packages, err = c.collectDebianPackages() + case "redhat", "fedora": + packages, err = c.collectRedHatPackages() + default: + return nil, fmt.Errorf("unknown platform family: %s", info.PlatformFamily) + } + + if err != nil { + return nil, fmt.Errorf("failed to collect packages: %w", err) + } + + result := HostInfo{OSInfo: info, InstalledPackages: packages} + data, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal installed packages: %w", err) + } + + fname := "hostPackages.json" + if c.collector.CollectorName != "" { + fname = fmt.Sprintf("%s.json", c.collector.CollectorName) + } + + name := filepath.Join("host-collectors", "hostPackages", fname) + output := NewResult() + if err := output.SaveResult(c.BundlePath, name, bytes.NewBuffer(data)); err != nil { + return nil, fmt.Errorf("failed to save result: %w", err) + } + + return map[string][]byte{name: data}, nil +} + +func (c *CollectInstalledPackages) collectRedHatPackages() ([]HostInstalledPackage, error) { + cmdout := bytes.NewBuffer(nil) + cmd := exec.Command("rpm", "-qa", "--queryformat", `%{NAME},%{VERSION}\n`) + cmd.Stdout = cmdout + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to run rpm command: %w", err) + } + + var installed []HostInstalledPackage + reader := csv.NewReader(cmdout) + for { + record, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("failed to read line from rpm output: %w", err) + } + + if len(record) != 2 { + return nil, fmt.Errorf("failed to process rpm output: %+v", record) + } + + installed = append(installed, HostInstalledPackage{ + Name: record[0], + Version: record[1], + }) + } + return installed, nil +} + +func (c *CollectInstalledPackages) collectDebianPackages() ([]HostInstalledPackage, error) { + pkgs, err := apt.List() + if err != nil { + return nil, fmt.Errorf("failed to list installed packages: %w", err) + } + + var installed []HostInstalledPackage + for _, pkg := range pkgs { + if pkg.Status != "installed" { + continue + } + + installed = append(installed, HostInstalledPackage{ + Name: pkg.Name, + Version: pkg.Version, + }) + } + return installed, nil +}