From 3a20cee8ff9ae8eccf413b3ceecd35d4061c0802 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Tue, 9 Dec 2025 16:59:42 +0000 Subject: [PATCH 1/2] :sparkles: `[subprocess]` add helpers for starting processes --- changes/20251209165547.feature | 1 + utils/subprocess/executor.go | 22 +++++++- utils/subprocess/executor_test.go | 92 +++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 changes/20251209165547.feature diff --git a/changes/20251209165547.feature b/changes/20251209165547.feature new file mode 100644 index 0000000000..8305132f89 --- /dev/null +++ b/changes/20251209165547.feature @@ -0,0 +1 @@ +:sparkles: [subprocess] add helpers for starting processes diff --git a/utils/subprocess/executor.go b/utils/subprocess/executor.go index 099a105d68..48dbe91056 100644 --- a/utils/subprocess/executor.go +++ b/utils/subprocess/executor.go @@ -66,11 +66,21 @@ func ExecuteWithEnvironment(ctx context.Context, loggers logs.Loggers, additiona return ExecuteWithEnvironmentWithIO(ctx, loggers, nil, additionalEnvVars, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) } +// StartWithEnvironment starts a command (i.e. spawns a subprocess). It allows to specify the environment the subprocess should use. Each entry is of the form "key=value". +func StartWithEnvironment(ctx context.Context, loggers logs.Loggers, additionalEnvVars []string, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (*Subprocess, error) { + return StartWithEnvironmentWithIO(ctx, loggers, nil, additionalEnvVars, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) +} + // Execute executes a command (i.e. spawns a subprocess). func Execute(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) error { return ExecuteWithEnvironment(ctx, loggers, nil, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) } +// Start starts a command (i.e. spawns a subprocess). +func Start(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (*Subprocess, error) { + return StartWithEnvironment(ctx, loggers, nil, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) +} + // ExecuteAs executes a command (i.e. spawns a subprocess) as a different user. func ExecuteAs(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) error { return ExecuteAsWithEnvironment(ctx, loggers, nil, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...) @@ -86,7 +96,7 @@ func ExecuteWithSudo(ctx context.Context, loggers logs.Loggers, messageOnStart s return ExecuteAs(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Sudo(), cmd, args...) } -// ExecuteWithEnvironment executes a command (i.e. spawns a subprocess) with overridden stdin/stdout/stderr. It allows to specify the environment the subprocess should use. Each entry is of the form "key=value". +// ExecuteWithEnvironmentWithIO executes a command (i.e. spawns a subprocess) with overridden stdin/stdout/stderr. It allows to specify the environment the subprocess should use. Each entry is of the form "key=value". func ExecuteWithEnvironmentWithIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, additionalEnvVars []string, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) { p, err := NewWithEnvironmentWithIO(ctx, loggers, io, additionalEnvVars, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) if err != nil { @@ -95,6 +105,16 @@ func ExecuteWithEnvironmentWithIO(ctx context.Context, loggers logs.Loggers, io return p.Execute() } +// StartWithEnvironmentWithIO starts a command (i.e. spawns a subprocess) with overridden stdin/stdout/stderr. It allows to specify the environment the subprocess should use. Each entry is of the form "key=value". +func StartWithEnvironmentWithIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, additionalEnvVars []string, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (p *Subprocess, err error) { + p, err = NewWithEnvironmentWithIO(ctx, loggers, io, additionalEnvVars, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) + if err != nil { + return + } + err = p.Start() + return +} + // ExecuteWithIO executes a command (i.e. spawns a subprocess) with overridden stdin/stdout/stderr. func ExecuteWithIO(ctx context.Context, loggers logs.Loggers, io ICommandIO, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) error { return ExecuteWithEnvironmentWithIO(ctx, loggers, io, nil, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...) diff --git a/utils/subprocess/executor_test.go b/utils/subprocess/executor_test.go index cee016c706..55e1810297 100644 --- a/utils/subprocess/executor_test.go +++ b/utils/subprocess/executor_test.go @@ -48,6 +48,7 @@ func (t *testIO) Register(context.Context) (io.Reader, io.Writer, io.Writer) { } type execFunc func(ctx context.Context, l logs.Loggers, cmd string, args ...string) error +type startFunc func(ctx context.Context, l logs.Loggers, cmd string, args ...string) (*Subprocess, error) func newDefaultExecutor(t *testing.T) execFunc { t.Helper() @@ -68,6 +69,26 @@ func newCustomIOExecutor(t *testing.T, customIO *testIO) execFunc { } } +func newDefaultStarter(t *testing.T) startFunc { + t.Helper() + return func(ctx context.Context, l logs.Loggers, cmd string, args ...string) (*Subprocess, error) { + return Start(ctx, l, "", "", "", cmd, args...) + } +} + +func newCustomIOStarter(t *testing.T, customIO *testIO) startFunc { + t.Helper() + return func(ctx context.Context, l logs.Loggers, cmd string, args ...string) (p *Subprocess, err error) { + p = &Subprocess{} + err = p.SetupWithEnvironmentWithCustomIO(ctx, l, customIO, nil, "", "", "", cmd, args...) + if err != nil { + return + } + err = p.Start() + return + } +} + func TestExecuteEmptyLines(t *testing.T) { t.Skip("would need to be reinstated when fixed") defer goleak.VerifyNone(t) @@ -375,6 +396,77 @@ func TestExecute(t *testing.T) { } } +func TestStart(t *testing.T) { + currentDir, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + cmdWindows string + argWindows []string + cmdOther string + argOther []string + expectIO bool + }{ + { + name: "ShortProcess", + cmdWindows: "cmd", + argWindows: []string{"/c", "dir", currentDir}, + cmdOther: "ls", + argOther: []string{"-l", currentDir}, + expectIO: true, + }, + { + name: "LongProcess", + cmdWindows: "cmd", + argWindows: []string{"/c", fmt.Sprintf("ping -n 2 -w %v localhost > nul", time.Second.Milliseconds())}, // See https://stackoverflow.com/a/79268314/45375 + cmdOther: "sleep", + argOther: []string{"1"}, + expectIO: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // defer goleak.VerifyNone(t) + + customIO := newTestIO() + executors := []struct { + name string + start startFunc + io *testIO + }{ + {"normal", newDefaultStarter(t), nil}, + {"with IO", newCustomIOStarter(t, customIO), customIO}, + } + + for _, executor := range executors { + t.Run(executor.name, func(t *testing.T) { + var loggers logs.Loggers = &logs.GenericLoggers{} + err := loggers.Check() + assert.Error(t, err) + + _, err = executor.start(context.Background(), loggers, "ls") + assert.Error(t, err) + + loggers, err = logs.NewLogrLogger(logstest.NewTestLogger(t), "test") + require.NoError(t, err) + + var p *Subprocess + if platform.IsWindows() { + p, err = executor.start(context.Background(), loggers, test.cmdWindows, test.argWindows...) + } else { + p, err = executor.start(context.Background(), loggers, test.cmdOther, test.argOther...) + } + require.NoError(t, err) + require.NotNil(t, p) + require.NoError(t, p.Wait(context.Background())) + }) + } + }) + } +} + func TestExecuteWithCustomIO_Stderr(t *testing.T) { if platform.IsWindows() { t.Skip("Uses bash and redirection so can't run on Windows") From 25160352798715c7869a561a6dd972607f195908 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Thu, 11 Dec 2025 10:56:14 +0000 Subject: [PATCH 2/2] address review comment --- utils/subprocess/executor_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/utils/subprocess/executor_test.go b/utils/subprocess/executor_test.go index 55e1810297..60c21035a5 100644 --- a/utils/subprocess/executor_test.go +++ b/utils/subprocess/executor_test.go @@ -397,6 +397,7 @@ func TestExecute(t *testing.T) { } func TestStart(t *testing.T) { + currentDir, err := os.Getwd() require.NoError(t, err) @@ -428,7 +429,7 @@ func TestStart(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - // defer goleak.VerifyNone(t) + defer goleak.VerifyNone(t) customIO := newTestIO() executors := []struct { @@ -460,7 +461,9 @@ func TestStart(t *testing.T) { } require.NoError(t, err) require.NotNil(t, p) + defer func() { _ = p.Stop() }() require.NoError(t, p.Wait(context.Background())) + p.Cancel() }) } })