Skip to content

Commit 9756d8d

Browse files
subprocess Add support for overriding the stdin/stdout/stderr of a subprocess (#705)
<!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description <!-- Please add any detail or context that would be useful to a reviewer. --> Add support for overriding the stdin/stdout/stderr of a subprocess. Factories are used so that a context can be injected for context aware readers and writers. ### Test Coverage <!-- Please put an `x` in the correct box e.g. `[x]` to indicate the testing coverage of this change. --> - [x] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update).
1 parent 43a651d commit 9756d8d

File tree

7 files changed

+286
-22
lines changed

7 files changed

+286
-22
lines changed

changes/20250911113619.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `subprocess` Add support for overriding the stdin/stdout/stderr of a subprocess

utils/mocks/mock_subprocess.go

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

utils/subprocess/command_wrapper.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ type command struct {
127127
as *commandUtils.CommandAsDifferentUser
128128
loggers logs.Loggers
129129
cmdWrapper cmdWrapper
130+
io ICommandIO
130131
}
131132

132133
func (c *command) createCommand(cmdCtx context.Context) *exec.Cmd {
@@ -136,8 +137,7 @@ func (c *command) createCommand(cmdCtx context.Context) *exec.Cmd {
136137
if err == nil {
137138
cmd = cancellableCmd
138139
}
139-
cmd.Stdout = newOutStreamer(cmdCtx, c.loggers)
140-
cmd.Stderr = newErrLogStreamer(cmdCtx, c.loggers)
140+
cmd.Stdin, cmd.Stdout, cmd.Stderr = c.io.Register(cmdCtx)
141141
cmd.Env = cmd.Environ()
142142
cmd.Env = append(cmd.Env, c.env...)
143143
setGroupAttrToCmd(cmd)
@@ -181,6 +181,20 @@ func newCommand(loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, e
181181
as: as,
182182
loggers: loggers,
183183
cmdWrapper: cmdWrapper{},
184+
io: NewIOFromLoggers(loggers),
185+
}
186+
return
187+
}
188+
189+
func newCommandWithCustomIO(loggers logs.Loggers, io ICommandIO, as *commandUtils.CommandAsDifferentUser, env []string, cmd string, args ...string) (osCmd *command) {
190+
osCmd = &command{
191+
cmd: cmd,
192+
args: args,
193+
env: env,
194+
as: as,
195+
loggers: loggers,
196+
cmdWrapper: cmdWrapper{},
197+
io: io,
184198
}
185199
return
186200
}

utils/subprocess/executor.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func newSubProcess(ctx context.Context, loggers logs.Loggers, env []string, mess
4747

4848
func newPlainSubProcess(ctx context.Context, loggers logs.Loggers, env []string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (p *Subprocess, err error) {
4949
p = new(Subprocess)
50-
err = p.setup(ctx, loggers, env, false, "", "", "", as, cmd, args...)
50+
err = p.setup(ctx, loggers, nil, env, false, "", "", "", as, cmd, args...)
5151
return
5252
}
5353

@@ -130,7 +130,7 @@ func (s *Subprocess) Setup(ctx context.Context, loggers logs.Loggers, messageOnS
130130

131131
// SetupWithEnvironment sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure. Compared to Setup, it allows specifying additional environment variables to be used by the process.
132132
func (s *Subprocess) SetupWithEnvironment(ctx context.Context, loggers logs.Loggers, additionalEnv []string, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
133-
return s.setup(ctx, loggers, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
133+
return s.setup(ctx, loggers, nil, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
134134
}
135135

136136
// SetupAs is similar to Setup but allows the command to be run as a different user.
@@ -140,11 +140,31 @@ func (s *Subprocess) SetupAs(ctx context.Context, loggers logs.Loggers, messageO
140140

141141
// SetupAsWithEnvironment is similar to Setup but allows the command to be run as a different user. Compared to SetupAs, it allows specifying additional environment variables to be used by the process.
142142
func (s *Subprocess) SetupAsWithEnvironment(ctx context.Context, loggers logs.Loggers, additionalEnv []string, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
143-
return s.setup(ctx, loggers, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
143+
return s.setup(ctx, loggers, nil, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
144144
}
145145

146-
// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure.
147-
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, env []string, withAdditionalMessages bool, messageOnStart, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
146+
// SetupWithCustomIO sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure. It allows the stdin, stdout, and stderr to be overridden.
147+
func (s *Subprocess) SetupWithCustomIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
148+
return s.SetupWithEnvironmentWithCustomIO(ctx, loggers, io, nil, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...)
149+
}
150+
151+
// SetupWithEnvironmentWithCustomIO sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure. Compared to SetupWithCustomIO, it allows specifying additional environment variables to be used by the process. It allows the stdin, stdout, and stderr to be overridden.
152+
func (s *Subprocess) SetupWithEnvironmentWithCustomIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, additionalEnv []string, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
153+
return s.setup(ctx, loggers, io, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
154+
}
155+
156+
// SetupAsWithCustomIO is similar to SetupWithCustomIO but allows the command to be run as a different user. It allows the stdin, stdout, and stderr to be overridden.
157+
func (s *Subprocess) SetupAsWithCustomIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
158+
return s.SetupAsWithEnvironmentWithCustomIO(ctx, loggers, io, nil, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
159+
}
160+
161+
// SetupAsWithEnvironmentWithCustomIO is similar to SetupWithCustomIO but allows the command to be run as a different user. Compared to SetupAsWithCustomIO, it allows specifying additional environment variables to be used by the process. It allows the stdin, stdout, and stderr to be overridden.
162+
func (s *Subprocess) SetupAsWithEnvironmentWithCustomIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, additionalEnv []string, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
163+
return s.setup(ctx, loggers, io, additionalEnv, true, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
164+
}
165+
166+
// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure as well as the stdin, stdout, and stderr.
167+
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, io ICommandIO, env []string, withAdditionalMessages bool, messageOnStart, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
148168
if s.IsOn() {
149169
err = s.Stop()
150170
if err != nil {
@@ -155,7 +175,11 @@ func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, env []stri
155175
defer s.mu.Unlock()
156176
s.isRunning.Store(false)
157177
s.processMonitoring = newSubprocessMonitoring(ctx)
158-
s.command = newCommand(loggers, as, env, cmd, args...)
178+
if io != nil {
179+
s.command = newCommandWithCustomIO(loggers, io, as, env, cmd, args...)
180+
} else {
181+
s.command = newCommand(loggers, as, env, cmd, args...)
182+
}
159183
s.messaging = newSubprocessMessaging(loggers, withAdditionalMessages, messageOnSuccess, messageOnFailure, messageOnStart, s.command.GetPath())
160184
s.reset()
161185
return s.check()

utils/subprocess/executor_test.go

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
package subprocess
66

77
import (
8+
"bytes"
89
"context"
910
"fmt"
11+
"io"
1012
"os"
1113
"os/exec"
1214
"regexp"
@@ -27,6 +29,45 @@ import (
2729
"github.com/ARM-software/golang-utils/utils/platform"
2830
)
2931

32+
type testIO struct {
33+
in io.Reader
34+
out *bytes.Buffer
35+
err *bytes.Buffer
36+
}
37+
38+
func newTestIO() *testIO {
39+
return &testIO{
40+
in: strings.NewReader(""),
41+
out: &bytes.Buffer{},
42+
err: &bytes.Buffer{},
43+
}
44+
}
45+
46+
func (t *testIO) Register(context.Context) (io.Reader, io.Writer, io.Writer) {
47+
return t.in, t.out, t.err
48+
}
49+
50+
type execFunc func(ctx context.Context, l logs.Loggers, cmd string, args ...string) error
51+
52+
func newDefaultExecutor(t *testing.T) execFunc {
53+
t.Helper()
54+
return func(ctx context.Context, l logs.Loggers, cmd string, args ...string) error {
55+
return Execute(ctx, l, "", "", "", cmd, args...)
56+
}
57+
}
58+
59+
func newCustomIOExecutor(t *testing.T, customIO *testIO) execFunc {
60+
t.Helper()
61+
return func(ctx context.Context, l logs.Loggers, cmd string, args ...string) (err error) {
62+
p := &Subprocess{}
63+
err = p.SetupWithEnvironmentWithCustomIO(ctx, l, customIO, nil, "", "", "", cmd, args...)
64+
if err != nil {
65+
return
66+
}
67+
return p.Execute()
68+
}
69+
}
70+
3071
func TestExecuteEmptyLines(t *testing.T) {
3172
t.Skip("would need to be reinstated when fixed")
3273
defer goleak.VerifyNone(t)
@@ -265,52 +306,95 @@ func TestStartInterrupt(t *testing.T) {
265306
func TestExecute(t *testing.T) {
266307
currentDir, err := os.Getwd()
267308
require.NoError(t, err)
309+
268310
tests := []struct {
269311
name string
270312
cmdWindows string
271313
argWindows []string
272314
cmdOther string
273315
argOther []string
316+
expectIO bool
274317
}{
275318
{
276319
name: "ShortProcess",
277320
cmdWindows: "cmd",
278321
argWindows: []string{"/c", "dir", currentDir},
279322
cmdOther: "ls",
280323
argOther: []string{"-l", currentDir},
324+
expectIO: true,
281325
},
282326
{
283327
name: "LongProcess",
284328
cmdWindows: "cmd",
285329
argWindows: []string{"/c", fmt.Sprintf("ping -n 2 -w %v localhost > nul", time.Second.Milliseconds())}, // See https://stackoverflow.com/a/79268314/45375
286330
cmdOther: "sleep",
287331
argOther: []string{"1"},
332+
expectIO: false,
288333
},
289334
}
290335

291-
for i := range tests {
292-
test := tests[i]
336+
for _, test := range tests {
293337
t.Run(test.name, func(t *testing.T) {
294338
defer goleak.VerifyNone(t)
295-
var loggers logs.Loggers = &logs.GenericLoggers{}
296-
err := loggers.Check()
297-
assert.Error(t, err)
298339

299-
err = Execute(context.Background(), loggers, "", "", "", "ls")
300-
assert.Error(t, err)
340+
customIO := newTestIO()
341+
executors := []struct {
342+
name string
343+
run execFunc
344+
io *testIO
345+
}{
346+
{"normal", newDefaultExecutor(t), nil},
347+
{"with IO", newCustomIOExecutor(t, customIO), customIO},
348+
}
301349

302-
loggers, err = logs.NewLogrLogger(logstest.NewTestLogger(t), "test")
303-
require.NoError(t, err)
304-
if platform.IsWindows() {
305-
err = Execute(context.Background(), loggers, "", "", "", test.cmdWindows, test.argWindows...)
306-
} else {
307-
err = Execute(context.Background(), loggers, "", "", "", test.cmdOther, test.argOther...)
350+
for _, executor := range executors {
351+
t.Run(executor.name, func(t *testing.T) {
352+
var loggers logs.Loggers = &logs.GenericLoggers{}
353+
err := loggers.Check()
354+
assert.Error(t, err)
355+
356+
err = executor.run(context.Background(), loggers, "ls")
357+
assert.Error(t, err)
358+
359+
loggers, err = logs.NewLogrLogger(logstest.NewTestLogger(t), "test")
360+
require.NoError(t, err)
361+
362+
if platform.IsWindows() {
363+
err = executor.run(context.Background(), loggers, test.cmdWindows, test.argWindows...)
364+
} else {
365+
err = executor.run(context.Background(), loggers, test.cmdOther, test.argOther...)
366+
}
367+
require.NoError(t, err)
368+
369+
if executor.io != nil && test.expectIO {
370+
assert.NotZero(t, executor.io.out.Len()+executor.io.err.Len()) // expect some output
371+
}
372+
})
308373
}
309-
require.NoError(t, err)
310374
})
311375
}
312376
}
313377

378+
func TestExecuteWithCustomIO_Stderr(t *testing.T) {
379+
if platform.IsWindows() {
380+
t.Skip("Uses bash and redirection so can't run on Windows")
381+
}
382+
defer goleak.VerifyNone(t)
383+
384+
loggers, err := logs.NewLogrLogger(logstest.NewTestLogger(t), "test")
385+
require.NoError(t, err)
386+
387+
customIO := newTestIO()
388+
run := newCustomIOExecutor(t, customIO)
389+
390+
msg := "hello adrien"
391+
err = run(context.Background(), loggers, "bash", "-c", fmt.Sprintf("echo %s 1>&2", msg))
392+
require.NoError(t, err)
393+
394+
require.Empty(t, customIO.out.String()) // should be no stdout
395+
require.Equal(t, fmt.Sprintln(msg), customIO.err.String())
396+
}
397+
314398
func TestOutput(t *testing.T) {
315399
loggers, err := logs.NewLogrLogger(logstest.NewTestLogger(t), "testOutput")
316400
require.NoError(t, err)

utils/subprocess/io.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package subprocess
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"sync"
8+
9+
"github.com/ARM-software/golang-utils/utils/logs"
10+
)
11+
12+
//go:generate go tool mockgen -destination=../mocks/mock_$GOPACKAGE.go -package=mocks github.com/ARM-software/golang-utils/utils/$GOPACKAGE ICommandIO
13+
14+
// ICommandIO allows you to set the stdin, stdout, and stderr that will be used in a subprocess. A context can be injected for context aware readers and writers
15+
type ICommandIO interface {
16+
// Register creates new readers and writers based on the constructor methods in the ICommandIO implementation. If the constructors are not specified then it will default to os.Stdin, os.Stdout, and os.Stderr
17+
Register(context.Context) (in io.Reader, out, errs io.Writer)
18+
}
19+
20+
type commandIO struct {
21+
newInFunc func(context.Context) io.Reader
22+
newOutFunc func(context.Context) io.Writer
23+
newErrorFunc func(context.Context) io.Writer
24+
mu sync.Mutex
25+
}
26+
27+
func NewIO(
28+
newInFunc func(context.Context) io.Reader,
29+
newOutFunc func(context.Context) io.Writer,
30+
newErrorFunc func(context.Context) io.Writer,
31+
) ICommandIO {
32+
return &commandIO{
33+
mu: sync.Mutex{},
34+
newInFunc: newInFunc,
35+
newOutFunc: newOutFunc,
36+
newErrorFunc: newErrorFunc,
37+
}
38+
}
39+
40+
func NewIOFromLoggers(loggers logs.Loggers) ICommandIO {
41+
return NewIO(
42+
nil,
43+
func(ctx context.Context) io.Writer { return newOutStreamer(ctx, loggers) },
44+
func(ctx context.Context) io.Writer { return newErrLogStreamer(ctx, loggers) },
45+
)
46+
}
47+
48+
func NewDefaultIO() ICommandIO {
49+
return NewIO(nil, nil, nil)
50+
}
51+
52+
func (c *commandIO) Register(ctx context.Context) (in io.Reader, out, errs io.Writer) {
53+
c.mu.Lock()
54+
defer c.mu.Unlock()
55+
in, out, errs = os.Stdin, os.Stdout, os.Stderr
56+
if c.newInFunc != nil {
57+
in = c.newInFunc(ctx)
58+
}
59+
if c.newOutFunc != nil {
60+
out = c.newOutFunc(ctx)
61+
}
62+
if c.newErrorFunc != nil {
63+
errs = c.newErrorFunc(ctx)
64+
}
65+
return
66+
}

0 commit comments

Comments
 (0)