forked from argoproj/argo-cd
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathexec_test.go
More file actions
267 lines (226 loc) · 8.52 KB
/
exec_test.go
File metadata and controls
267 lines (226 loc) · 8.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
package exec
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"syscall"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_timeout(t *testing.T) {
t.Run("Default", func(t *testing.T) {
initTimeout()
assert.Equal(t, 90*time.Second, timeout)
assert.Equal(t, 10*time.Second, fatalTimeout)
})
t.Run("Default", func(t *testing.T) {
t.Setenv("ARGOCD_EXEC_TIMEOUT", "1s")
t.Setenv("ARGOCD_EXEC_FATAL_TIMEOUT", "2s")
initTimeout()
assert.Equal(t, 1*time.Second, timeout)
assert.Equal(t, 2*time.Second, fatalTimeout)
})
}
func TestRun(t *testing.T) {
out, err := Run(exec.Command("ls"))
require.NoError(t, err)
assert.NotEmpty(t, out)
}
func TestHideUsernamePassword(t *testing.T) {
_, err := RunWithRedactor(exec.Command("helm registry login https://charts.bitnami.com/bitnami", "--username", "foo", "--password", "bar"), nil)
require.Error(t, err)
redactor := func(text string) string {
return regexp.MustCompile("(--username|--password) [^ ]*").ReplaceAllString(text, "$1 ******")
}
_, err = RunWithRedactor(exec.Command("helm registry login https://charts.bitnami.com/bitnami", "--username", "foo", "--password", "bar"), redactor)
require.Error(t, err)
}
// This tests a cmd that properly handles a SIGTERM signal
func TestRunWithExecRunOpts(t *testing.T) {
t.Setenv("ARGOCD_EXEC_TIMEOUT", "200ms")
initTimeout()
opts := ExecRunOpts{
TimeoutBehavior: TimeoutBehavior{
Signal: syscall.SIGTERM,
ShouldWait: true,
},
}
_, err := RunWithExecRunOpts(exec.Command("sh", "-c", "trap 'trap - 15 && echo captured && exit' 15 && sleep 2"), opts)
assert.ErrorContains(t, err, "failed timeout after 200ms")
}
// This tests a mis-behaved cmd that stalls on SIGTERM and requires a SIGKILL
func TestRunWithExecRunOptsFatal(t *testing.T) {
t.Setenv("ARGOCD_EXEC_TIMEOUT", "200ms")
t.Setenv("ARGOCD_EXEC_FATAL_TIMEOUT", "100ms")
initTimeout()
opts := ExecRunOpts{
TimeoutBehavior: TimeoutBehavior{
Signal: syscall.SIGTERM,
ShouldWait: true,
},
}
// The returned error string in this case should contain a "fatal" in this case
_, err := RunWithExecRunOpts(exec.Command("sh", "-c", "trap 'trap - 15 && echo captured && sleep 10000' 15 && sleep 2"), opts)
// The expected timeout is ARGOCD_EXEC_TIMEOUT + ARGOCD_EXEC_FATAL_TIMEOUT = 200ms + 100ms = 300ms
assert.ErrorContains(t, err, "failed fatal timeout after 300ms")
}
func Test_getCommandArgsToLog(t *testing.T) {
testCases := []struct {
name string
args []string
expected string
}{
{
name: "no spaces",
args: []string{"sh", "-c", "cat"},
expected: "sh -c cat",
},
{
name: "spaces",
args: []string{"sh", "-c", `echo "hello world"`},
expected: `sh -c "echo \"hello world\""`,
},
{
name: "empty string arg",
args: []string{"sh", "-c", ""},
expected: `sh -c ""`,
},
}
for _, tc := range testCases {
tcc := tc
t.Run(tcc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tcc.expected, GetCommandArgsToLog(exec.Command(tcc.args[0], tcc.args[1:]...)))
})
}
}
func TestRunCommand(t *testing.T) {
hook := test.NewGlobal()
log.SetLevel(log.DebugLevel)
defer log.SetLevel(log.InfoLevel)
message, err := RunCommand("echo", CmdOpts{Redactor: Redact([]string{"world"})}, "hello world")
require.NoError(t, err)
assert.Equal(t, "hello world", message)
assert.Len(t, hook.Entries, 2)
entry := hook.Entries[0]
assert.Equal(t, log.InfoLevel, entry.Level)
assert.Equal(t, "echo hello ******", entry.Message)
assert.Contains(t, entry.Data, "dir")
assert.Contains(t, entry.Data, "execID")
entry = hook.Entries[1]
assert.Equal(t, log.DebugLevel, entry.Level)
assert.Equal(t, "hello ******\n", entry.Message)
assert.Contains(t, entry.Data, "duration")
assert.Contains(t, entry.Data, "execID")
}
func TestRunCommandSignal(t *testing.T) {
hook := test.NewGlobal()
log.SetLevel(log.DebugLevel)
defer log.SetLevel(log.InfoLevel)
timeoutBehavior := TimeoutBehavior{Signal: syscall.SIGTERM, ShouldWait: true}
output, err := RunCommand("sh", CmdOpts{Timeout: 200 * time.Millisecond, TimeoutBehavior: timeoutBehavior}, "-c", "trap 'trap - 15 && echo captured && exit' 15 && sleep 2")
assert.Equal(t, "captured", output)
require.EqualError(t, err, "`sh -c trap 'trap - 15 && echo captured && exit' 15 && sleep 2` failed timeout after 200ms")
assert.Len(t, hook.Entries, 3)
}
func TestTrimmedOutput(t *testing.T) {
message, err := RunCommand("printf", CmdOpts{}, "hello world")
require.NoError(t, err)
assert.Equal(t, "hello world", message)
}
func TestRunCommandExitErr(t *testing.T) {
hook := test.NewGlobal()
log.SetLevel(log.DebugLevel)
defer log.SetLevel(log.InfoLevel)
output, err := RunCommand("sh", CmdOpts{Redactor: Redact([]string{"world"})}, "-c", "echo hello world && echo my-error >&2 && exit 1")
assert.Equal(t, "hello world", output)
require.EqualError(t, err, "`sh -c echo hello ****** && echo my-error >&2 && exit 1` failed exit status 1: my-error")
assert.Len(t, hook.Entries, 3)
entry := hook.Entries[0]
assert.Equal(t, log.InfoLevel, entry.Level)
assert.Equal(t, "sh -c echo hello ****** && echo my-error >&2 && exit 1", entry.Message)
assert.Contains(t, entry.Data, "dir")
assert.Contains(t, entry.Data, "execID")
entry = hook.Entries[1]
assert.Equal(t, log.DebugLevel, entry.Level)
assert.Equal(t, "hello ******\n", entry.Message)
assert.Contains(t, entry.Data, "duration")
assert.Contains(t, entry.Data, "execID")
entry = hook.Entries[2]
assert.Equal(t, log.ErrorLevel, entry.Level)
assert.Equal(t, "`sh -c echo hello ****** && echo my-error >&2 && exit 1` failed exit status 1: my-error", entry.Message)
assert.Contains(t, entry.Data, "execID")
}
func TestRunCommandErr(t *testing.T) {
log.SetLevel(log.DebugLevel)
defer log.SetLevel(log.InfoLevel)
output, err := RunCommand("sh", CmdOpts{Redactor: Redact([]string{"world"})}, "-c", ">&2 echo 'failure'; false")
assert.Empty(t, output)
assert.EqualError(t, err, "`sh -c >&2 echo 'failure'; false` failed exit status 1: failure")
}
func TestRunInDir(t *testing.T) {
cmd := exec.Command("pwd")
cmd.Dir = "/"
message, err := RunCommandExt(cmd, CmdOpts{})
require.NoError(t, err)
assert.Equal(t, "/", message)
}
func TestRedact(t *testing.T) {
assert.Equal(t, "", Redact(nil)(""))
assert.Equal(t, "", Redact([]string{})(""))
assert.Equal(t, "", Redact([]string{"foo"})(""))
assert.Equal(t, "foo", Redact([]string{})("foo"))
assert.Equal(t, "******", Redact([]string{"foo"})("foo"))
assert.Equal(t, "****** ******", Redact([]string{"foo", "bar"})("foo bar"))
assert.Equal(t, "****** ******", Redact([]string{"foo"})("foo foo"))
}
func TestRunCaptureStderr(t *testing.T) {
output, err := RunCommand("sh", CmdOpts{CaptureStderr: true}, "-c", "echo hello world && echo my-error >&2 && exit 0")
assert.Equal(t, "hello world\nmy-error", output)
assert.NoError(t, err)
}
// This test demonstrates that when a process group is signaled, all child processes are also terminated and file locks released.
func TestProcessGroupSignalRemovesChildLock(t *testing.T) {
hook := test.NewGlobal()
log.SetLevel(log.DebugLevel)
defer log.SetLevel(log.InfoLevel)
dir := t.TempDir()
lockFile := filepath.Join(dir, "lockfile")
childScript := filepath.Join(dir, "child.sh")
parentScript := filepath.Join(dir, "parent.sh")
// Child: create lock file; on SIGTERM remove it and exit
child := "#!/bin/sh\n" +
"trap 'rm -f lockfile; exit 0' TERM\n" +
"touch lockfile\n" +
"sleep 100\n"
require.NoError(t, os.WriteFile(childScript, []byte(child), 0o755))
// Parent: start child in background and sleep
parent := "#!/bin/sh\n" +
"./child.sh &\n" +
"sleep 100\n"
require.NoError(t, os.WriteFile(parentScript, []byte(parent), 0o755))
// Run parent with a short timeout; our implementation signals the process group
opts := CmdOpts{
Timeout: 500 * time.Millisecond,
FatalTimeout: 500 * time.Millisecond,
TimeoutBehavior: TimeoutBehavior{
Signal: syscall.SIGTERM,
ShouldWait: true,
},
}
_, err := RunCommand("sh", opts, "-c", "cd "+dir+" && ./parent.sh")
require.Error(t, err)
// Give a bit of time for traps to run and for the process tree to settle
time.Sleep(200 * time.Millisecond)
// Because the process group was signaled, the child should have removed the lock
_, statErr := os.Stat(lockFile)
require.Error(t, statErr, "expected lock file to be removed when process group is signaled")
assert.True(t, os.IsNotExist(statErr))
// basic sanity: logs produced
require.GreaterOrEqual(t, len(hook.Entries), 1)
}