Skip to content

Commit f6373f3

Browse files
cwyl02banjoh
andauthored
feat: save host run file output (#1376)
* feat: save cmd run output * chore: schema changes * chore: example hostCollector * chore: add log messages to key actions * fix: correctly inherit all parent env by default * chore: do not save input file the user invokes the input already got the input but those content could be sensitive to another user who received this bundle * test: unit test for host run * revert: "chore: do not save input file" This reverts commit 6af77ad. that commit is wrong * chore: fix log msg and example yaml * Ensure child cmd runs in its own working dir * Check filename for slashes not content * Update logging * Add using relative path files as commands --------- Co-authored-by: Evans Mungai <[email protected]>
1 parent 7038da8 commit f6373f3

File tree

10 files changed

+503
-22
lines changed

10 files changed

+503
-22
lines changed

config/crds/troubleshoot.sh_hostcollectors.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,11 +1368,29 @@ spec:
13681368
type: string
13691369
command:
13701370
type: string
1371+
env:
1372+
items:
1373+
type: string
1374+
type: array
13711375
exclude:
13721376
type: BoolString
1377+
ignoreParentEnvs:
1378+
type: boolean
1379+
inheritEnvs:
1380+
items:
1381+
type: string
1382+
type: array
1383+
input:
1384+
additionalProperties:
1385+
type: string
1386+
type: object
1387+
outputDir:
1388+
type: string
13731389
required:
13741390
- args
13751391
- command
1392+
- ignoreParentEnvs
1393+
- inheritEnvs
13761394
type: object
13771395
subnetAvailable:
13781396
properties:

config/crds/troubleshoot.sh_hostpreflights.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,11 +1368,29 @@ spec:
13681368
type: string
13691369
command:
13701370
type: string
1371+
env:
1372+
items:
1373+
type: string
1374+
type: array
13711375
exclude:
13721376
type: BoolString
1377+
ignoreParentEnvs:
1378+
type: boolean
1379+
inheritEnvs:
1380+
items:
1381+
type: string
1382+
type: array
1383+
input:
1384+
additionalProperties:
1385+
type: string
1386+
type: object
1387+
outputDir:
1388+
type: string
13731389
required:
13741390
- args
13751391
- command
1392+
- ignoreParentEnvs
1393+
- inheritEnvs
13761394
type: object
13771395
subnetAvailable:
13781396
properties:

config/crds/troubleshoot.sh_supportbundles.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11699,11 +11699,29 @@ spec:
1169911699
type: string
1170011700
command:
1170111701
type: string
11702+
env:
11703+
items:
11704+
type: string
11705+
type: array
1170211706
exclude:
1170311707
type: BoolString
11708+
ignoreParentEnvs:
11709+
type: boolean
11710+
inheritEnvs:
11711+
items:
11712+
type: string
11713+
type: array
11714+
input:
11715+
additionalProperties:
11716+
type: string
11717+
type: object
11718+
outputDir:
11719+
type: string
1170411720
required:
1170511721
- args
1170611722
- command
11723+
- ignoreParentEnvs
11724+
- inheritEnvs
1170711725
type: object
1170811726
subnetAvailable:
1170911727
properties:
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
apiVersion: troubleshoot.sh/v1beta2
2+
kind: HostCollector
3+
metadata:
4+
name: run-host-cmd-and-save-output
5+
spec:
6+
collectors:
7+
- run:
8+
collectorName: "my-custom-run"
9+
command: "sh"
10+
# this is for demonstration purpose only -- you probably don't want to drop your input to the bundle!
11+
args:
12+
- "-c"
13+
- "cat $TS_INPUT_DIR/dummy.yaml > $TS_WORKSPACE_DIR/dummy_content.yaml"
14+
outputDir: "myCommandOutputs"
15+
env:
16+
- AWS_REGION=us-west-1
17+
# if ignoreParentEnvs is true, it will not inherit envs from parent process.
18+
# values specified in inheritEnv will not be used either
19+
# ignoreParentEnvs: true
20+
inheritEnvs:
21+
- USER
22+
input:
23+
dummy.conf: |-
24+
[hello]
25+
hello = 1
26+
27+
[bye]
28+
bye = 2
29+
dummy.yaml: |-
30+
username: postgres
31+
password: <my-pass>
32+
dbHost: <hostname>
33+
map:
34+
key: value
35+
list:
36+
- val1
37+
- val2

pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,13 @@ type HostServices struct {
180180

181181
type HostRun struct {
182182
HostCollectorMeta `json:",inline" yaml:",inline"`
183-
Command string `json:"command"`
184-
Args []string `json:"args"`
183+
Command string `json:"command"`
184+
Args []string `json:"args"`
185+
OutputDir string `json:"outputDir,omitempty" yaml:"outputDir,omitempty"`
186+
Input map[string]string `json:"input,omitempty" yaml:"input,omitempty"`
187+
Env []string `json:"env,omitempty" yaml:"env,omitempty"`
188+
InheritEnvs []string `json:"inheritEnvs" yaml:"inheritEnvs,omitempty"`
189+
IgnoreParentEnvs bool `json:"ignoreParentEnvs" yaml:"ignoreParentEnvs,omitempty"`
185190
}
186191

187192
type HostCollect struct {

pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/collect/host_run.go

Lines changed: 160 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ package collect
33
import (
44
"bytes"
55
"encoding/json"
6+
"fmt"
7+
"os"
68
"os/exec"
79
"path/filepath"
810
"strings"
911

1012
"github.com/pkg/errors"
1113
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
14+
"k8s.io/klog/v2"
1215
)
1316

1417
type HostRunInfo struct {
15-
Command string `json:"command"`
16-
ExitCode string `json:"exitCode"`
17-
Error string `json:"error"`
18+
Command string `json:"command"`
19+
ExitCode string `json:"exitCode"`
20+
Error string `json:"error"`
21+
OutputDir string `json:"outputDir"`
22+
Input string `json:"input"`
23+
Env []string `json:"env"`
1824
}
1925

2026
type CollectHostRun struct {
@@ -31,20 +37,83 @@ func (c *CollectHostRun) IsExcluded() (bool, error) {
3137
}
3238

3339
func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][]byte, error) {
34-
runHostCollector := c.hostCollector
40+
var (
41+
cmdOutputTempDir string
42+
cmdInputTempDir string
43+
bundleOutputRelativePath string
44+
)
3545

36-
cmd := exec.Command(runHostCollector.Command, runHostCollector.Args...)
46+
runHostCollector := c.hostCollector
47+
collectorName := runHostCollector.CollectorName
48+
if collectorName == "" {
49+
collectorName = "run-host"
50+
}
3751

38-
var stdout, stderr bytes.Buffer
39-
cmd.Stdout = &stdout
40-
cmd.Stderr = &stderr
52+
cmd := exec.Command(c.attemptToConvertCmdToAbsPath(), runHostCollector.Args...)
4153

42-
runInfo := HostRunInfo{
54+
klog.V(2).Infof("Run host collector command: %q", cmd.String())
55+
runInfo := &HostRunInfo{
4356
Command: cmd.String(),
4457
ExitCode: "0",
4558
}
4659

47-
err := cmd.Run()
60+
err := c.processEnvVars(cmd)
61+
if err != nil {
62+
return nil, errors.Wrap(err, "failed to parse env variable")
63+
}
64+
65+
// Create a working directory for the command
66+
wkdir, err := os.MkdirTemp("", collectorName)
67+
defer os.RemoveAll(wkdir)
68+
if err != nil {
69+
return nil, errors.Wrap(err, "failed to create temp dir for host run")
70+
}
71+
// Change the working directory for the command to ensure the command
72+
// does not polute the parent/caller working directory
73+
cmd.Dir = wkdir
74+
75+
// if we choose to save result for the command run
76+
if runHostCollector.OutputDir != "" {
77+
cmdOutputTempDir = filepath.Join(wkdir, runHostCollector.OutputDir)
78+
err = os.MkdirAll(cmdOutputTempDir, 0755)
79+
if err != nil {
80+
return nil, errors.New(fmt.Sprintf("failed to create dir for: %s", runHostCollector.OutputDir))
81+
}
82+
cmd.Env = append(cmd.Env,
83+
fmt.Sprintf("TS_WORKSPACE_DIR=%s", cmdOutputTempDir),
84+
)
85+
}
86+
87+
if runHostCollector.Input != nil {
88+
cmdInputTempDir = filepath.Join(wkdir, "input")
89+
err = os.MkdirAll(cmdInputTempDir, 0755)
90+
if err != nil {
91+
return nil, errors.New("failed to create temp dir for host run input")
92+
}
93+
for inFilename, inFileContent := range runHostCollector.Input {
94+
if strings.Contains(inFilename, "/") {
95+
return nil, errors.New("Input filename contains '/'")
96+
}
97+
cmdInputFilePath := filepath.Join(cmdInputTempDir, inFilename)
98+
err = os.WriteFile(cmdInputFilePath, []byte(inFileContent), 0644)
99+
if err != nil {
100+
return nil, errors.Wrap(err, fmt.Sprintf("failed to write input file: %s to temp directory", inFilename))
101+
}
102+
}
103+
cmd.Env = append(cmd.Env,
104+
fmt.Sprintf("TS_INPUT_DIR=%s", cmdInputTempDir),
105+
)
106+
}
107+
108+
collectorRelativePath := filepath.Join("host-collectors/run-host", collectorName)
109+
110+
runInfo.Env = cmd.Env
111+
112+
var stdout, stderr bytes.Buffer
113+
cmd.Stdout = &stdout
114+
cmd.Stderr = &stderr
115+
116+
err = cmd.Run()
48117
if err != nil {
49118
if werr, ok := err.(*exec.ExitError); ok {
50119
runInfo.ExitCode = strings.TrimPrefix(werr.Error(), "exit status ")
@@ -54,10 +123,7 @@ func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][]
54123
}
55124
}
56125

57-
collectorName := c.hostCollector.CollectorName
58-
if collectorName == "" {
59-
collectorName = "run-host"
60-
}
126+
output := NewResult()
61127
resultInfo := filepath.Join("host-collectors/run-host", collectorName+"-info.json")
62128
result := filepath.Join("host-collectors/run-host", collectorName+".txt")
63129

@@ -66,14 +132,89 @@ func (c *CollectHostRun) Collect(progressChan chan<- interface{}) (map[string][]
66132
return nil, errors.Wrap(err, "failed to marshal run host result")
67133
}
68134

69-
output := NewResult()
70135
output.SaveResult(c.BundlePath, resultInfo, bytes.NewBuffer(b))
71136
output.SaveResult(c.BundlePath, result, bytes.NewBuffer(stdout.Bytes()))
137+
// walkthrough the output directory and save result for each file
138+
if runHostCollector.OutputDir != "" {
139+
runInfo.OutputDir = runHostCollector.OutputDir
140+
bundleOutputRelativePath = filepath.Join(collectorRelativePath, runHostCollector.OutputDir)
141+
klog.V(2).Infof("Saving command output to %q in bundle", bundleOutputRelativePath)
142+
output.SaveResults(c.BundlePath, bundleOutputRelativePath, cmdOutputTempDir)
143+
}
144+
145+
return output, nil
146+
}
147+
148+
func (c *CollectHostRun) processEnvVars(cmd *exec.Cmd) error {
149+
runHostCollector := c.hostCollector
150+
151+
if runHostCollector.IgnoreParentEnvs {
152+
klog.V(2).Info("Not inheriting the environment variables!")
153+
if runHostCollector.InheritEnvs != nil {
154+
klog.V(2).Infof("The following environment variables will not be loaded to the command: [%s]",
155+
strings.Join(runHostCollector.InheritEnvs, ","))
156+
}
157+
// clears the parent env vars
158+
cmd.Env = []string{}
159+
populateGuaranteedEnvVars(cmd)
160+
} else if runHostCollector.InheritEnvs != nil {
161+
for _, key := range runHostCollector.InheritEnvs {
162+
envVal, found := os.LookupEnv(key)
163+
if !found {
164+
return errors.New(fmt.Sprintf("inherit env variable is not found: %s", key))
165+
}
166+
cmd.Env = append(cmd.Env,
167+
fmt.Sprintf("%s=%s", key, envVal))
168+
}
169+
populateGuaranteedEnvVars(cmd)
170+
} else {
171+
cmd.Env = os.Environ()
172+
}
72173

73-
runHostOutput := map[string][]byte{
74-
resultInfo: b,
75-
result: stdout.Bytes(),
174+
if runHostCollector.Env != nil {
175+
for i := range runHostCollector.Env {
176+
parts := strings.Split(runHostCollector.Env[i], "=")
177+
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
178+
cmd.Env = append(cmd.Env,
179+
fmt.Sprintf("%s", runHostCollector.Env[i]))
180+
} else {
181+
return errors.New(fmt.Sprintf("env variable entry is missing '=' : %s", runHostCollector.Env[i]))
182+
}
183+
}
184+
}
185+
186+
return nil
187+
}
188+
189+
func populateGuaranteedEnvVars(cmd *exec.Cmd) {
190+
guaranteedEnvs := []string{"PATH", "KUBECONFIG", "PWD"}
191+
for _, key := range guaranteedEnvs {
192+
guaranteedEnvVal, found := os.LookupEnv(key)
193+
if found {
194+
cmd.Env = append(cmd.Env,
195+
fmt.Sprintf("%s=%s", key, guaranteedEnvVal))
196+
}
197+
}
198+
}
199+
200+
// attemptToConvertCmdToAbsPath checks if the command is a file path or command name
201+
// If it is a file path, it will return the absolute path else
202+
// it will return the command name as is and leave the resolution to cmd.Run()
203+
// This enables passing commands using relative paths e.g. "./my-command"
204+
// which is not possible with cmd.Run() since the child process runs
205+
// in a different working directory
206+
func (c *CollectHostRun) attemptToConvertCmdToAbsPath() string {
207+
// Attempt to check if the command is file path or command name
208+
cmdAbsPath, err := filepath.Abs(c.hostCollector.Command)
209+
if err != nil {
210+
return c.hostCollector.Command
211+
}
212+
213+
// Check if the file exists
214+
_, err = os.Stat(cmdAbsPath)
215+
if err != nil {
216+
return c.hostCollector.Command
76217
}
77218

78-
return runHostOutput, nil
219+
return cmdAbsPath
79220
}

0 commit comments

Comments
 (0)