From 93d97db795bcbe01446e99bdb3b57d3896ef59b5 Mon Sep 17 00:00:00 2001 From: manjari Date: Thu, 1 Oct 2026 23:18:22 +0000 Subject: [PATCH] feat(platform): Add local subprocess implementation of gitworkspace.Workspace --- .../extension/gitworkspace/local/BUILD.bazel | 20 +++ .../extension/gitworkspace/local/local.go | 110 +++++++++++++ .../gitworkspace/local/local_test.go | 148 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 platform/extension/gitworkspace/local/BUILD.bazel create mode 100644 platform/extension/gitworkspace/local/local.go create mode 100644 platform/extension/gitworkspace/local/local_test.go diff --git a/platform/extension/gitworkspace/local/BUILD.bazel b/platform/extension/gitworkspace/local/BUILD.bazel new file mode 100644 index 00000000..13b923d5 --- /dev/null +++ b/platform/extension/gitworkspace/local/BUILD.bazel @@ -0,0 +1,20 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["local.go"], + importpath = "github.com/uber/submitqueue/platform/extension/gitworkspace/local", + visibility = ["//visibility:public"], + deps = ["//platform/extension/gitworkspace:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["local_test.go"], + embed = [":go_default_library"], + deps = [ + "//platform/extension/gitworkspace:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/platform/extension/gitworkspace/local/local.go b/platform/extension/gitworkspace/local/local.go new file mode 100644 index 00000000..78697c96 --- /dev/null +++ b/platform/extension/gitworkspace/local/local.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package local implements gitworkspace.Workspace by executing commands as +// local subprocesses against a git clone on disk. +package local + +import ( + "bytes" + "context" + "errors" + "os/exec" + "strings" + + "github.com/uber/submitqueue/platform/extension/gitworkspace" +) + +// skippedExitCode marks a command that was not executed because a prior +// command in the same batch failed. +const skippedExitCode = -1 + +// Params configures a local workspace factory. +type Params struct { + // Dir is the path to the local git clone. + Dir string +} + +type factory struct { + dir string +} + +// NewFactory creates a gitworkspace.Factory that runs commands as local +// subprocesses in the given directory. +func NewFactory(p Params) gitworkspace.Factory { + return &factory{dir: p.Dir} +} + +func (f *factory) For(_ context.Context, _ string) (gitworkspace.Workspace, error) { + return &workspace{dir: f.dir}, nil +} + +type workspace struct { + dir string +} + +func (w *workspace) Exec(commands []gitworkspace.Command) ([]gitworkspace.Output, error) { + outputs := make([]gitworkspace.Output, 0, len(commands)) + for i, cmd := range commands { + out := w.run(cmd) + outputs = append(outputs, out) + if out.ExitCode != 0 { + for _, remaining := range commands[i+1:] { + outputs = append(outputs, gitworkspace.Output{ + Alias: remaining.Alias, + ExitCode: skippedExitCode, + }) + } + break + } + } + return outputs, nil +} + +func (w *workspace) run(cmd gitworkspace.Command) gitworkspace.Output { + c := exec.Command(cmd.Bin, cmd.Args...) + c.Dir = w.dir + if cmd.Stdin != "" { + c.Stdin = strings.NewReader(cmd.Stdin) + } + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + + err := c.Run() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return gitworkspace.Output{ + Alias: cmd.Alias, + ExitCode: int32(exitErr.ExitCode()), + Stdout: stdout.String(), + Stderr: stderr.String(), + } + } + return gitworkspace.Output{ + Alias: cmd.Alias, + ExitCode: skippedExitCode, + Stderr: err.Error(), + } + } + return gitworkspace.Output{ + Alias: cmd.Alias, + ExitCode: 0, + Stdout: stdout.String(), + Stderr: stderr.String(), + } +} + +func (w *workspace) Close() error { return nil } diff --git a/platform/extension/gitworkspace/local/local_test.go b/platform/extension/gitworkspace/local/local_test.go new file mode 100644 index 00000000..c291ef7f --- /dev/null +++ b/platform/extension/gitworkspace/local/local_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package local + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/platform/extension/gitworkspace" +) + +func TestExec_Success(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "echo", Bin: "echo", Args: []string{"hello"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 1) + assert.Equal(t, "echo", outputs[0].Alias) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.Equal(t, "hello\n", outputs[0].Stdout) +} + +func TestExec_MultipleCommands(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "first", Bin: "echo", Args: []string{"one"}}, + {Alias: "second", Bin: "echo", Args: []string{"two"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 2) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.Equal(t, int32(0), outputs[1].ExitCode) + assert.Equal(t, "one\n", outputs[0].Stdout) + assert.Equal(t, "two\n", outputs[1].Stdout) +} + +func TestExec_SkipsAfterFailure(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "ok", Bin: "echo", Args: []string{"fine"}}, + {Alias: "fail", Bin: "false"}, + {Alias: "skipped", Bin: "echo", Args: []string{"never"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 3) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.NotEqual(t, int32(0), outputs[1].ExitCode) + assert.Equal(t, int32(skippedExitCode), outputs[2].ExitCode) + assert.Equal(t, "skipped", outputs[2].Alias) +} + +func TestExec_StatePersistsAcrossCalls(t *testing.T) { + dir := t.TempDir() + ws, err := NewFactory(Params{Dir: dir}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + _, err = ws.Exec([]gitworkspace.Command{ + {Alias: "write", Bin: "sh", Args: []string{"-c", "echo content > testfile"}}, + }) + require.NoError(t, err) + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "read", Bin: "cat", Args: []string{"testfile"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 1) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.Equal(t, "content\n", outputs[0].Stdout) +} + +func TestExec_Stdin(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "cat", Bin: "cat", Stdin: "from stdin"}, + }) + require.NoError(t, err) + require.Len(t, outputs, 1) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.Equal(t, "from stdin", outputs[0].Stdout) +} + +func TestExec_Stderr(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "err", Bin: "sh", Args: []string{"-c", "echo oops >&2"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 1) + assert.Equal(t, int32(0), outputs[0].ExitCode) + assert.Equal(t, "oops\n", outputs[0].Stderr) +} + +func TestExec_InvalidBinary(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec([]gitworkspace.Command{ + {Alias: "bad", Bin: "nonexistent-binary-xyz"}, + {Alias: "skipped", Bin: "echo", Args: []string{"never"}}, + }) + require.NoError(t, err) + require.Len(t, outputs, 2) + assert.Equal(t, int32(skippedExitCode), outputs[0].ExitCode) + assert.NotEmpty(t, outputs[0].Stderr) + assert.Equal(t, int32(skippedExitCode), outputs[1].ExitCode) +} + +func TestExec_EmptyBatch(t *testing.T) { + ws, err := NewFactory(Params{Dir: t.TempDir()}).For(context.Background(), "") + require.NoError(t, err) + defer ws.Close() + + outputs, err := ws.Exec(nil) + require.NoError(t, err) + assert.Empty(t, outputs) +}