Files
Benjamin YangandNoah Campbell 17518349ff Fix/exec textanalyze path clean (#1865)
* created roadmap and yaml claude agent

* Update roadmap.md

* Fix textAnalyze analyzer to auto-match exec collector nested paths

- Auto-detect exec output files (*-stdout.txt, *-stderr.txt, *-errors.json)
- Convert simple filenames to wildcard patterns automatically
- Preserve existing wildcard patterns
- Fixes 'No matching file' errors for exec + textAnalyze workflows

---------

Co-authored-by: Noah Campbell <noah.edward.campbell@gmail.com>
2025-09-29 17:49:54 -07:00

354 lines
9.7 KiB
Go

package analyzer
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
)
type AnalyzeTextAnalyze struct {
analyzer *troubleshootv1beta2.TextAnalyze
}
func (a *AnalyzeTextAnalyze) Title() string {
checkName := a.analyzer.CheckName
if checkName == "" {
checkName = a.analyzer.CollectorName
}
return checkName
}
func (a *AnalyzeTextAnalyze) IsExcluded() (bool, error) {
return isExcluded(a.analyzer.Exclude)
}
func (a *AnalyzeTextAnalyze) Analyze(getFile getCollectedFileContents, findFiles getChildCollectedFileContents) ([]*AnalyzeResult, error) {
return analyzeTextAnalyze(a.analyzer, findFiles, a.Title())
}
func analyzeTextAnalyze(
analyzer *troubleshootv1beta2.TextAnalyze, getCollectedFileContents getChildCollectedFileContents, title string,
) ([]*AnalyzeResult, error) {
fullPath := filepath.Join(analyzer.CollectorName, analyzer.FileName)
// Auto-handle exec collector output files which are nested deeper than expected
// Exec collectors store files in: {collectorName}/{namespace}/{podName}/{fileName}
// But textAnalyze expects: {collectorName}/{fileName}
// If the fileName looks like exec output and doesn't already have wildcards, make it work automatically
if isLikelyExecOutput(analyzer.FileName) && !containsWildcards(analyzer.FileName) && !containsWildcards(fullPath) {
fullPath = filepath.Join(analyzer.CollectorName, "*", "*", analyzer.FileName)
}
excludeFiles := []string{}
for _, excludeFile := range analyzer.ExcludeFiles {
excludePath := filepath.Join(analyzer.CollectorName, excludeFile)
// Apply same logic to exclude files
if isLikelyExecOutput(excludeFile) && !containsWildcards(excludeFile) && !containsWildcards(excludePath) {
excludePath = filepath.Join(analyzer.CollectorName, "*", "*", excludeFile)
}
excludeFiles = append(excludeFiles, excludePath)
}
collected, err := getCollectedFileContents(fullPath, excludeFiles)
if err != nil {
return nil, errors.Wrapf(err, "failed to read collected file name: %s", fullPath)
}
if len(collected) == 0 {
if analyzer.IgnoreIfNoFiles {
return nil, nil
}
return []*AnalyzeResult{
{
Title: title,
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
IsWarn: true,
Message: "No matching files",
},
}, nil
}
results := []*AnalyzeResult{}
if analyzer.RegexPattern != "" {
for _, fileContents := range collected {
result, err := analyzeRegexPattern(analyzer.RegexPattern, fileContents, analyzer.Outcomes, title)
if err != nil {
return nil, err
}
if result != nil {
results = append(results, result)
}
}
}
if analyzer.RegexGroups != "" {
for _, fileContents := range collected {
result, err := analyzeRegexGroups(analyzer.RegexGroups, fileContents, analyzer.Outcomes, title)
if err != nil {
return nil, err
}
if result != nil {
results = append(results, result)
}
}
}
for i := range results {
results[i].Strict = analyzer.Strict.BoolOrDefaultFalse()
}
if len(results) > 0 {
return results, nil
}
return []*AnalyzeResult{
{
Title: title,
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
IsFail: true,
Message: "Invalid analyzer",
},
}, nil
}
// isLikelyExecOutput checks if a filename looks like exec collector output
func isLikelyExecOutput(fileName string) bool {
return strings.HasSuffix(fileName, "-stdout.txt") ||
strings.HasSuffix(fileName, "-stderr.txt") ||
strings.HasSuffix(fileName, "-errors.json")
}
// containsWildcards checks if a path contains glob wildcards
func containsWildcards(path string) bool {
return strings.Contains(path, "*") ||
strings.Contains(path, "?") ||
strings.Contains(path, "[")
}
func analyzeRegexPattern(pattern string, collected []byte, outcomes []*troubleshootv1beta2.Outcome, checkName string) (*AnalyzeResult, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, errors.Wrapf(err, "failed to compile regex: %s", pattern)
}
result := AnalyzeResult{
Title: checkName,
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
}
isMatch := re.MatchString(string(collected))
for _, outcome := range outcomes {
if outcome.Fail != nil {
// if the outcome.Fail.When is not set, default to false
if outcome.Fail.When == "" {
outcome.Fail.When = "false"
}
failWhen, err := strconv.ParseBool(outcome.Fail.When)
if err != nil {
return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Fail.When)
}
if isMatch == failWhen {
result.IsFail = true
result.IsWarn = false
result.Message = outcome.Fail.Message
result.URI = outcome.Fail.URI
}
} else if outcome.Warn != nil {
// if the outcome.Warn.When is not set, default to false
if outcome.Warn.When == "" {
outcome.Warn.When = "false"
}
warnWhen, err := strconv.ParseBool(outcome.Warn.When)
if err != nil {
return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Warn.When)
}
if isMatch == warnWhen {
result.IsWarn = true
result.Message = outcome.Warn.Message
result.URI = outcome.Warn.URI
}
} else if outcome.Pass != nil {
// if the outcome.Pass.When is not set, default to true
if outcome.Pass.When == "" {
outcome.Pass.When = "true"
}
passWhen, err := strconv.ParseBool(outcome.Pass.When)
if err != nil {
return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Pass.When)
}
if isMatch == passWhen {
result.IsPass = true
result.Message = outcome.Pass.Message
result.URI = outcome.Pass.URI
}
}
}
return &result, nil
}
func analyzeRegexGroups(pattern string, collected []byte, outcomes []*troubleshootv1beta2.Outcome, checkName string) (*AnalyzeResult, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, errors.Wrapf(err, "failed to compile regex: %s", pattern)
}
match := re.FindStringSubmatch(string(collected))
result := &AnalyzeResult{
Title: checkName,
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg?w=13&h=16",
}
foundMatches := map[string]string{}
for i, name := range re.SubexpNames() {
if i != 0 && name != "" && len(match) > i {
foundMatches[name] = match[i]
}
}
// allow fallthrough
for _, outcome := range outcomes {
if outcome.Fail != nil {
isMatch, err := compareRegex(outcome.Fail.When, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to compare regex fail conditional")
}
if isMatch {
result.IsFail = true
tplMessage, err := util.RenderTemplate(outcome.Fail.Message, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to template message in outcome.Fail block")
}
result.Message = tplMessage
result.URI = outcome.Fail.URI
return result, nil
}
} else if outcome.Warn != nil {
isMatch, err := compareRegex(outcome.Warn.When, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to compare regex warn conditional")
}
if isMatch {
result.IsWarn = true
tplMessage, err := util.RenderTemplate(outcome.Warn.Message, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to template message in outcome.Warn block")
}
result.Message = tplMessage
result.URI = outcome.Warn.URI
return result, nil
}
} else if outcome.Pass != nil {
isMatch, err := compareRegex(outcome.Pass.When, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to compare regex pass conditional")
}
if isMatch {
result.IsPass = true
tplMessage, err := util.RenderTemplate(outcome.Pass.Message, foundMatches)
if err != nil {
return result, errors.Wrap(err, "failed to template message in outcome.Pass block")
}
result.Message = tplMessage
result.URI = outcome.Pass.URI
return result, nil
}
}
}
return result, nil
}
func compareRegex(conditional string, foundMatches map[string]string) (bool, error) {
if conditional == "" {
return true, nil
}
parts := strings.Split(strings.TrimSpace(conditional), " ")
if len(parts) != 3 {
return false, errors.New("unable to parse regex conditional")
}
lookForMatchName := parts[0]
operator := parts[1]
lookForValue := parts[2]
// handle empty strings
if lookForValue == "''" || lookForValue == `""` {
lookForValue = ""
}
foundValue, ok := foundMatches[lookForMatchName]
if !ok {
// not an error, just wasn't matched
return false, nil
}
// if the value side of the conditional is an int, we assume it's an int
lookForValueInt, err := strconv.Atoi(lookForValue)
if err == nil {
foundValueInt, err := strconv.Atoi(foundValue)
if err != nil {
// not an error but maybe it should be...
return false, nil
}
switch operator {
case "=":
fallthrough
case "==":
fallthrough
case "===":
return foundValueInt == lookForValueInt, nil
case "<":
return foundValueInt < lookForValueInt, nil
case ">":
return foundValueInt > lookForValueInt, nil
case "<=":
return foundValueInt <= lookForValueInt, nil
case ">=":
return foundValueInt >= lookForValueInt, nil
}
} else {
// all we can support is "=" and "==" and "===" for now
if operator != "=" && operator != "==" && operator != "===" {
return false, fmt.Errorf("unexpected operator %q in regex comparator, cannot compare %q and %q", operator, foundValue, lookForValue)
}
return foundValue == lookForValue, nil
}
return false, nil
}