Files
Noah Campbell b4e3ed278a Removes silent failing (#1877)
* preserves stdout and stderr from collectors

* Delete eliminate-silent-failures.md

* Update host_kernel_modules_test.go

* added error logs when a collector fails to start

* Update host_filesystem_performance_linux.go

* fixed error saving logic inconsistency

* Update collect.go
2025-10-04 15:14:10 -07:00

260 lines
7.8 KiB
Go

package collect
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"k8s.io/klog/v2"
)
type HostRunInfo struct {
Command string `json:"command"`
ExitCode string `json:"exitCode"`
Error string `json:"error"`
OutputDir string `json:"outputDir"`
Input string `json:"input"`
Env []string `json:"env"`
}
type CollectHostRun struct {
hostCollector *troubleshootv1beta2.HostRun
BundlePath string
}
func (c *CollectHostRun) Title() string {
return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Run Host")
}
func (c *CollectHostRun) IsExcluded() (bool, error) {
return isExcluded(c.hostCollector.Exclude)
}
func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][]byte, error) {
var (
cmdOutputTempDir string
cmdInputTempDir string
bundleOutputRelativePath string
)
runHostCollector := c.hostCollector
collectorName := runHostCollector.CollectorName
if collectorName == "" {
collectorName = "run-host"
}
var (
timeout time.Duration
cmd *exec.Cmd
errInvalidDuration error
)
ctx := context.Background()
cmdPath := c.attemptToConvertCmdToAbsPath()
if runHostCollector.Timeout != "" {
timeout, errInvalidDuration = time.ParseDuration(runHostCollector.Timeout)
if errInvalidDuration != nil {
return nil, errors.Wrapf(errInvalidDuration, "failed to parse timeout %q", runHostCollector.Timeout)
}
}
if timeout <= time.Duration(0) {
cmd = exec.Command(cmdPath, runHostCollector.Args...)
} else {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd = exec.CommandContext(timeoutCtx, cmdPath, runHostCollector.Args...)
}
klog.V(2).Infof("Run host collector command: %q", cmd.String())
runInfo := &HostRunInfo{
Command: cmd.String(),
ExitCode: "0",
}
err := c.processEnvVars(cmd)
if err != nil {
return nil, errors.Wrap(err, "failed to parse env variable")
}
// Create a working directory for the command
wkdir, err := os.MkdirTemp("", collectorName)
defer os.RemoveAll(wkdir)
if err != nil {
return nil, errors.Wrap(err, "failed to create temp dir for host run")
}
// Change the working directory for the command to ensure the command
// does not polute the parent/caller working directory
cmd.Dir = wkdir
// if we choose to save result for the command run
if runHostCollector.OutputDir != "" {
cmdOutputTempDir = filepath.Join(wkdir, runHostCollector.OutputDir)
err = os.MkdirAll(cmdOutputTempDir, 0755)
if err != nil {
return nil, errors.New(fmt.Sprintf("failed to create dir for: %s", runHostCollector.OutputDir))
}
cmd.Env = append(cmd.Env,
fmt.Sprintf("TS_OUTPUT_DIR=%s", cmdOutputTempDir),
)
}
if runHostCollector.Input != nil {
cmdInputTempDir = filepath.Join(wkdir, "input")
err = os.MkdirAll(cmdInputTempDir, 0755)
if err != nil {
return nil, errors.New("failed to create temp dir for host run input")
}
for inFilename, inFileContent := range runHostCollector.Input {
if strings.Contains(inFilename, "/") {
return nil, errors.New("Input filename contains '/'")
}
cmdInputFilePath := filepath.Join(cmdInputTempDir, inFilename)
err = os.WriteFile(cmdInputFilePath, []byte(inFileContent), 0644)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to write input file: %s to temp directory", inFilename))
}
}
cmd.Env = append(cmd.Env,
fmt.Sprintf("TS_INPUT_DIR=%s", cmdInputTempDir),
)
}
collectorRelativePath := filepath.Join("host-collectors/run-host", collectorName)
runInfo.Env = cmd.Env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
if werr, ok := err.(*exec.ExitError); ok {
runInfo.ExitCode = strings.TrimPrefix(werr.Error(), "exit status ")
runInfo.Error = stderr.String()
} else if err == context.DeadlineExceeded {
runInfo.ExitCode = "-1"
runInfo.Error = fmt.Sprintf("command timed out after %s", timeout.String())
} else {
return nil, errors.Wrap(err, "failed to run")
}
}
output := NewResult()
resultInfo := filepath.Join("host-collectors/run-host", collectorName+"-info.json")
result := filepath.Join("host-collectors/run-host", collectorName+".txt")
b, err := json.Marshal(runInfo)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal run host result")
}
output.SaveResult(c.BundlePath, resultInfo, bytes.NewBuffer(b))
output.SaveResult(c.BundlePath, result, bytes.NewBuffer(stdout.Bytes()))
// Save stderr if present (even on success)
if stderr.Len() > 0 {
stderrResult := filepath.Join("host-collectors/run-host", collectorName+"-stderr.txt")
klog.V(2).Infof("Saving command stderr to %q in bundle", stderrResult)
output.SaveResult(c.BundlePath, stderrResult, bytes.NewBuffer(stderr.Bytes()))
}
// walkthrough the output directory and save result for each file
if runHostCollector.OutputDir != "" {
runInfo.OutputDir = runHostCollector.OutputDir
bundleOutputRelativePath = filepath.Join(collectorRelativePath, runHostCollector.OutputDir)
klog.V(2).Infof("Saving command output to %q in bundle", bundleOutputRelativePath)
output.SaveResults(c.BundlePath, bundleOutputRelativePath, cmdOutputTempDir)
}
return output, nil
}
func (c *CollectHostRun) processEnvVars(cmd *exec.Cmd) error {
runHostCollector := c.hostCollector
if runHostCollector.IgnoreParentEnvs {
klog.V(2).Info("Not inheriting the environment variables!")
if runHostCollector.InheritEnvs != nil {
klog.V(2).Infof("The following environment variables will not be loaded to the command: [%s]",
strings.Join(runHostCollector.InheritEnvs, ","))
}
// clears the parent env vars
cmd.Env = []string{}
populateGuaranteedEnvVars(cmd)
} else if runHostCollector.InheritEnvs != nil {
for _, key := range runHostCollector.InheritEnvs {
envVal, found := os.LookupEnv(key)
if !found {
return errors.New(fmt.Sprintf("inherit env variable is not found: %s", key))
}
cmd.Env = append(cmd.Env,
fmt.Sprintf("%s=%s", key, envVal))
}
populateGuaranteedEnvVars(cmd)
} else {
cmd.Env = os.Environ()
}
if runHostCollector.Env != nil {
for i := range runHostCollector.Env {
parts := strings.Split(runHostCollector.Env[i], "=")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
cmd.Env = append(cmd.Env,
fmt.Sprintf("%s", runHostCollector.Env[i]))
} else {
return errors.New(fmt.Sprintf("env variable entry is missing '=' : %s", runHostCollector.Env[i]))
}
}
}
return nil
}
func populateGuaranteedEnvVars(cmd *exec.Cmd) {
guaranteedEnvs := []string{"PATH", "KUBECONFIG", "PWD"}
for _, key := range guaranteedEnvs {
guaranteedEnvVal, found := os.LookupEnv(key)
if found {
cmd.Env = append(cmd.Env,
fmt.Sprintf("%s=%s", key, guaranteedEnvVal))
}
}
}
// attemptToConvertCmdToAbsPath checks if the command is a file path or command name
// If it is a file path, it will return the absolute path else
// it will return the command name as is and leave the resolution to cmd.Run()
// This enables passing commands using relative paths e.g. "./my-command"
// which is not possible with cmd.Run() since the child process runs
// in a different working directory
func (c *CollectHostRun) attemptToConvertCmdToAbsPath() string {
// Attempt to check if the command is file path or command name
cmdAbsPath, err := filepath.Abs(c.hostCollector.Command)
if err != nil {
return c.hostCollector.Command
}
// Check if the file exists
_, err = os.Stat(cmdAbsPath)
if err != nil {
return c.hostCollector.Command
}
return cmdAbsPath
}
func (c *CollectHostRun) RemoteCollect(progressChan chan<- interface{}) (map[string][]byte, error) {
return nil, ErrRemoteCollectorNotImplemented
}