diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml index ee7719888..60801398a 100644 --- a/config/crds/troubleshoot.sh_hostcollectors.yaml +++ b/config/crds/troubleshoot.sh_hostcollectors.yaml @@ -1368,11 +1368,29 @@ spec: type: string command: type: string + env: + items: + type: string + type: array exclude: type: BoolString + ignoreParentEnvs: + type: boolean + inheritEnvs: + items: + type: string + type: array + input: + additionalProperties: + type: string + type: object + outputDir: + type: string required: - args - command + - ignoreParentEnvs + - inheritEnvs type: object subnetAvailable: properties: diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml index e72375bf8..836910682 100644 --- a/config/crds/troubleshoot.sh_hostpreflights.yaml +++ b/config/crds/troubleshoot.sh_hostpreflights.yaml @@ -1368,11 +1368,29 @@ spec: type: string command: type: string + env: + items: + type: string + type: array exclude: type: BoolString + ignoreParentEnvs: + type: boolean + inheritEnvs: + items: + type: string + type: array + input: + additionalProperties: + type: string + type: object + outputDir: + type: string required: - args - command + - ignoreParentEnvs + - inheritEnvs type: object subnetAvailable: properties: diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index 405d62cc0..b08584b1e 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -11699,11 +11699,29 @@ spec: type: string command: type: string + env: + items: + type: string + type: array exclude: type: BoolString + ignoreParentEnvs: + type: boolean + inheritEnvs: + items: + type: string + type: array + input: + additionalProperties: + type: string + type: object + outputDir: + type: string required: - args - command + - ignoreParentEnvs + - inheritEnvs type: object subnetAvailable: properties: diff --git a/examples/collect/host/run-and-save-output.yaml b/examples/collect/host/run-and-save-output.yaml new file mode 100644 index 000000000..324741355 --- /dev/null +++ b/examples/collect/host/run-and-save-output.yaml @@ -0,0 +1,37 @@ +apiVersion: troubleshoot.sh/v1beta2 +kind: HostCollector +metadata: + name: run-host-cmd-and-save-output +spec: + collectors: + - run: + collectorName: "my-custom-run" + command: "sh" + # this is for demonstration purpose only -- you probably don't want to drop your input to the bundle! + args: + - "-c" + - "cat $TS_INPUT_DIR/dummy.yaml > $TS_WORKSPACE_DIR/dummy_content.yaml" + outputDir: "myCommandOutputs" + env: + - AWS_REGION=us-west-1 + # if ignoreParentEnvs is true, it will not inherit envs from parent process. + # values specified in inheritEnv will not be used either + # ignoreParentEnvs: true + inheritEnvs: + - USER + input: + dummy.conf: |- + [hello] + hello = 1 + + [bye] + bye = 2 + dummy.yaml: |- + username: postgres + password: + dbHost: + map: + key: value + list: + - val1 + - val2 diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 260a416ef..08eab484b 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -180,8 +180,13 @@ type HostServices struct { type HostRun struct { HostCollectorMeta `json:",inline" yaml:",inline"` - Command string `json:"command"` - Args []string `json:"args"` + Command string `json:"command"` + Args []string `json:"args"` + OutputDir string `json:"outputDir,omitempty" yaml:"outputDir,omitempty"` + Input map[string]string `json:"input,omitempty" yaml:"input,omitempty"` + Env []string `json:"env,omitempty" yaml:"env,omitempty"` + InheritEnvs []string `json:"inheritEnvs" yaml:"inheritEnvs,omitempty"` + IgnoreParentEnvs bool `json:"ignoreParentEnvs" yaml:"ignoreParentEnvs,omitempty"` } type HostCollect struct { diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 9d388f7d6..81e96f6a3 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -2307,6 +2307,23 @@ func (in *HostRun) DeepCopyInto(out *HostRun) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Input != nil { + in, out := &in.Input, &out.Input + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.InheritEnvs != nil { + in, out := &in.InheritEnvs, &out.InheritEnvs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostRun. diff --git a/pkg/collect/host_run.go b/pkg/collect/host_run.go index f3ede9907..a2b7e6fb7 100644 --- a/pkg/collect/host_run.go +++ b/pkg/collect/host_run.go @@ -3,18 +3,24 @@ package collect import ( "bytes" "encoding/json" + "fmt" + "os" "os/exec" "path/filepath" "strings" "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"` + 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 { @@ -31,20 +37,83 @@ func (c *CollectHostRun) IsExcluded() (bool, error) { } func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][]byte, error) { - runHostCollector := c.hostCollector + var ( + cmdOutputTempDir string + cmdInputTempDir string + bundleOutputRelativePath string + ) - cmd := exec.Command(runHostCollector.Command, runHostCollector.Args...) + runHostCollector := c.hostCollector + collectorName := runHostCollector.CollectorName + if collectorName == "" { + collectorName = "run-host" + } - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + cmd := exec.Command(c.attemptToConvertCmdToAbsPath(), runHostCollector.Args...) - runInfo := HostRunInfo{ + klog.V(2).Infof("Run host collector command: %q", cmd.String()) + runInfo := &HostRunInfo{ Command: cmd.String(), ExitCode: "0", } - err := cmd.Run() + 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_WORKSPACE_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 ") @@ -54,10 +123,7 @@ func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][] } } - collectorName := c.hostCollector.CollectorName - if collectorName == "" { - collectorName = "run-host" - } + output := NewResult() resultInfo := filepath.Join("host-collectors/run-host", collectorName+"-info.json") result := filepath.Join("host-collectors/run-host", collectorName+".txt") @@ -66,14 +132,89 @@ func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][] return nil, errors.Wrap(err, "failed to marshal run host result") } - output := NewResult() output.SaveResult(c.BundlePath, resultInfo, bytes.NewBuffer(b)) output.SaveResult(c.BundlePath, result, bytes.NewBuffer(stdout.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() + } - runHostOutput := map[string][]byte{ - resultInfo: b, - result: stdout.Bytes(), + 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 runHostOutput, nil + return cmdAbsPath } diff --git a/pkg/collect/host_run_test.go b/pkg/collect/host_run_test.go new file mode 100644 index 000000000..f415b4d11 --- /dev/null +++ b/pkg/collect/host_run_test.go @@ -0,0 +1,160 @@ +package collect + +import ( + "fmt" + "os" + "os/exec" + "path" + "testing" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessEnv(t *testing.T) { + pathEnv := fmt.Sprintf("%s=%s", "PATH", os.Getenv("PATH")) + pwdEnv := fmt.Sprintf("%s=%s", "PWD", os.Getenv("PWD")) + homeEnv := fmt.Sprintf("%s=%s", "HOME", os.Getenv("HOME")) + os.Setenv("KUBECONFIG", "/some/kubeconfig") + kubeconfigEnv := fmt.Sprintf("%s=%s", "KUBECONFIG", os.Getenv("KUBECONFIG")) + tests := []struct { + name string + collector *CollectHostRun + parentEnv []string + want []string + }{ + { + name: "setting Env field", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + Env: []string{ + "USER=dummy", + "AWS_REGION=us-east-1", + }, + }, + BundlePath: "", + }, + want: append([]string{ + "USER=dummy", + "AWS_REGION=us-east-1", + }, os.Environ()...), + }, + { + name: "guaranteed env", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + IgnoreParentEnvs: true, + }, + BundlePath: "", + }, + want: []string{pathEnv, pwdEnv, kubeconfigEnv}, + }, + { + name: "ignoring parent env", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + Env: []string{ + "USER=dummy", + "AWS_REGION=us-east-1", + }, + IgnoreParentEnvs: true, + // inheritEnv will be ignored if IgnoreParentEnvs is true + InheritEnvs: []string{ + "HOME", + }, + }, + BundlePath: "", + }, + want: append([]string{ + "USER=dummy", + "AWS_REGION=us-east-1", + }, pathEnv, pwdEnv, kubeconfigEnv), + }, + { + name: "inheriting a subset of parent env", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + Env: []string{ + "AWS_REGION=us-east-1", + }, + InheritEnvs: []string{ + "HOME", + }, + }, + BundlePath: "", + }, + want: append([]string{ + "AWS_REGION=us-east-1", + }, pathEnv, pwdEnv, homeEnv, kubeconfigEnv), + }, + } + + for _, test := range tests { + cmd := exec.Command(test.collector.hostCollector.Command, test.collector.hostCollector.Args...) + err := test.collector.processEnvVars(cmd) + require.NoError(t, err) + require.ElementsMatch(t, test.want, cmd.Env) + } +} + +func TestCollectHostRun_Collect(t *testing.T) { + testDir, mkdirErr := os.MkdirTemp("", "host-run-test-*") + defer os.RemoveAll(testDir) + require.NoError(t, mkdirErr) + tests := []struct { + name string + collector *CollectHostRun + parentEnv []string + want map[string][]byte + wantError bool + }{ + { + name: "saving cmd run output", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + HostCollectorMeta: troubleshootv1beta2.HostCollectorMeta{ + CollectorName: "my-cmd-with-output", + }, + Command: "sh", + Args: []string{"-c", "echo ${TS_INPUT_DIR}/dummy.conf > $TS_WORKSPACE_DIR/input-file.txt"}, + Input: map[string]string{ + "dummy.conf": "[hello]\nhello = 1", + }, + OutputDir: "magic-output", + }, + BundlePath: path.Join(testDir, "bundle-1"), + }, + want: CollectorResult{ + "host-collectors/run-host/my-cmd-with-output-info.json": nil, + "host-collectors/run-host/my-cmd-with-output.txt": nil, + "host-collectors/run-host/my-cmd-with-output/magic-output/input-file.txt": nil, + }, + }, + { + name: "invalid input filename", + collector: &CollectHostRun{ + hostCollector: &troubleshootv1beta2.HostRun{ + HostCollectorMeta: troubleshootv1beta2.HostCollectorMeta{ + CollectorName: "test", + }, + Input: map[string]string{ + "/home/ec2-user/invalid-path.conf": "[hello]\nhello = 1", + "valid-path.conf": "[env]\ndummy = dummy", + }, + }, + }, + wantError: true, + }, + } + + for _, test := range tests { + got, err := test.collector.Collect(nil) + if test.wantError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.want, got) + } + } +} diff --git a/pkg/collect/result.go b/pkg/collect/result.go index efd9b7ee1..225362539 100644 --- a/pkg/collect/result.go +++ b/pkg/collect/result.go @@ -4,9 +4,12 @@ import ( "archive/tar" "bytes" "compress/gzip" + "fmt" "io" "os" + "path" "path/filepath" + "strings" "github.com/pkg/errors" "k8s.io/klog/v2" @@ -136,6 +139,44 @@ func (r CollectorResult) SaveResult(bundlePath string, relativePath string, read return nil } +// SaveResults walk a target directory and call SaveResult on all files retrieved from the walk. +func (r CollectorResult) SaveResults(bundlePath, relativePath, targetDir string) error { + dirPath := path.Join(bundlePath, relativePath) + if err := os.MkdirAll(dirPath, 0777); err != nil { + return errors.Wrap(err, "failed to create output file directory") + } + + err := filepath.WalkDir(targetDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return errors.Wrap(err, "error from WalkDirFunc") + } + + if !d.IsDir() { + file, err := os.Open(path) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to open file: %s", path)) + } + fileBytes, err := io.ReadAll(file) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to read file: %s", path)) + } + bundleRelativePath := filepath.Join(relativePath, strings.TrimPrefix(path, targetDir+"/")) + err = r.SaveResult(bundlePath, bundleRelativePath, bytes.NewBuffer(fileBytes)) + if err != nil { + return errors.Wrap(err, "error from SaveResult call") + } + } + + return nil + }) + + if err != nil { + return errors.Wrap(err, "error from WalkDir call") + } + + return nil +} + func (r CollectorResult) ReplaceResult(bundlePath string, relativePath string, reader io.Reader) error { if bundlePath == "" { data, err := io.ReadAll(reader) diff --git a/schemas/supportbundle-troubleshoot-v1beta2.json b/schemas/supportbundle-troubleshoot-v1beta2.json index 0fc868784..320d1d198 100644 --- a/schemas/supportbundle-troubleshoot-v1beta2.json +++ b/schemas/supportbundle-troubleshoot-v1beta2.json @@ -11472,7 +11472,9 @@ "type": "object", "required": [ "args", - "command" + "command", + "ignoreParentEnvs", + "inheritEnvs" ], "properties": { "args": { @@ -11487,8 +11489,32 @@ "command": { "type": "string" }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, "exclude": { "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "ignoreParentEnvs": { + "type": "boolean" + }, + "inheritEnvs": { + "type": "array", + "items": { + "type": "string" + } + }, + "input": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "outputDir": { + "type": "string" } } },