Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions e2e/tests/up/provider_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,27 @@ var _ = ginkgo.Describe(
ginkgo.SpecTimeout(framework.GetTimeout()),
)

ginkgo.It(
"initializeCommand with object syntax runs named sub-commands in parallel",
func(ctx context.Context) {
tempDir, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker-initcmd-parallel")
framework.ExpectNoError(err)

// Both initializeCommand sub-commands run on the host and write
// marker files into the workspace folder.
// #nosec G304 -- test path from setupAndUp
one, err := os.ReadFile(filepath.Join(tempDir, "init-cmd-one.out"))
framework.ExpectNoError(err)
gomega.Expect(string(one)).To(gomega.Equal("initCmdOne"))

// #nosec G304 -- test path from setupAndUp
two, err := os.ReadFile(filepath.Join(tempDir, "init-cmd-two.out"))
framework.ExpectNoError(err)
gomega.Expect(string(two)).To(gomega.Equal("initCmdTwo"))
},
ginkgo.SpecTimeout(framework.GetTimeout()),
)

ginkgo.It("IDE accessible before postAttachCommand completes", func(ctx context.Context) {
tempDir, err := setupWorkspace(
"tests/up/testdata/docker-post-attach-nonblocking",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/devsy-org/test-images/go:1",
"initializeCommand": {
"write-one": "echo -n initCmdOne > ./init-cmd-one.out",
"write-two": ["sh", "-c", "echo -n initCmdTwo > ./init-cmd-two.out"]
}
Comment on lines +3 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fixture assumes a POSIX host shell.

echo -n is not portable (cmd.exe prints the -n literally), and write-two invokes sh directly, which isn't available on default Windows hosts. If this Docker e2e suite is ever exercised on a Windows runner this spec will silently write wrong marker contents (or fail). If Docker e2e is intentionally Linux/macOS only, this is fine as-is; otherwise consider printf/platform-gated fixtures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/tests/up/testdata/docker-initcmd-parallel/.devcontainer.json` around
lines 3 - 6, The fixture assumes a POSIX shell: replace the non-portable uses
under initializeCommand (keys write-one and write-two) so they don’t rely on
`echo -n` or invoking `sh` directly; either (A) use a portable POSIX-safe
command such as `printf` for both markers and express them as plain command
strings (e.g., use `printf '%s' ...` instead of `echo -n` and remove the
`["sh","-c",...]` form for write-two), or (B) add explicit platform gating
around this fixture so it only runs on Linux/macOS hosts; update the
initializeCommand entries (write-one, write-two) accordingly and ensure the
marker files are written with the intended exact contents on supported
platforms.

}
121 changes: 91 additions & 30 deletions pkg/devcontainer/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package devcontainer

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"

"github.com/devsy-org/devsy/pkg/devcontainer/config"
Expand Down Expand Up @@ -201,6 +203,14 @@ func isDockerFileConfig(config *config.DevContainerConfig) bool {
return config.GetDockerfile() != ""
}

// initCmdContext groups the shared state for running initializeCommand
// sub-commands.
type initCmdContext struct {
shellArgs []string
workspaceFolder string
extraEnvVars []string
}

func runInitializeCommand(
workspaceFolder string,
config *config.DevContainerConfig,
Expand All @@ -210,51 +220,102 @@ func runInitializeCommand(
return nil
}

shellArgs := []string{"sh", "-c"}
ctx := initCmdContext{
shellArgs: []string{"sh", "-c"},
workspaceFolder: workspaceFolder,
extraEnvVars: extraEnvVars,
}
// According to the devcontainer spec, `initializeCommand` needs to be run on the host.
// On Windows we can't assume everyone has `sh` added to their PATH so we need to use Windows default shell (usually cmd.exe)
if runtime.GOOS == "windows" {
comSpec := os.Getenv("COMSPEC")
if comSpec != "" {
shellArgs = []string{comSpec, "/c"}
ctx.shellArgs = []string{comSpec, "/c"}
} else {
shellArgs = []string{"cmd.exe", "/c"}
ctx.shellArgs = []string{"cmd.exe", "/c"}
}
}

for _, cmd := range config.InitializeCommand {
// should run in shell?
var args []string
if len(cmd) == 1 {
args = []string{shellArgs[0], shellArgs[1], cmd[0]}
} else {
args = cmd
}
// When the hook has multiple named keys (object syntax), run
// sub-commands concurrently per the devcontainer spec, matching
// executeLifecycleHook in lifecyclehooks.go.
if len(config.InitializeCommand) > 1 {
return ctx.runParallel(config.InitializeCommand)
}

// run the command
log.Infof("Running initializeCommand from devcontainer.json: '%s'", strings.Join(args, " "))
writer := log.Writer(log.LevelInfo)
errwriter := log.Writer(log.LevelError)
defer func() { _ = writer.Close() }()
defer func() { _ = errwriter.Close() }()

cmd := exec.Command(args[0], args[1:]...)
env := cmd.Environ()
env = append(env, extraEnvVars...)

cmd.Stdout = writer
cmd.Stderr = errwriter
cmd.Dir = workspaceFolder
cmd.Env = env
err := cmd.Run()
if err != nil {
return err
}
for name, cmd := range config.InitializeCommand {
return ctx.runSingle(name, cmd)
}

return nil
}

func (c *initCmdContext) runParallel(
commands map[string][]string,
) error {
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)

wg.Add(len(commands))
for name, cmd := range commands {
go func() {
defer wg.Done()
if err := c.runSingle(name, cmd); err != nil {
mu.Lock()
errs = append(
errs,
fmt.Errorf("named command %q failed: %w", name, err),
)
mu.Unlock()
}
}()
}

wg.Wait()
return errors.Join(errs...)
}

func (c *initCmdContext) runSingle(
name string,
cmd []string,
) error {
var args []string
if len(cmd) == 1 {
args = []string{
c.shellArgs[0],
c.shellArgs[1],
cmd[0],
}
} else {
args = cmd
}

log.Infof(
"Running initializeCommand from devcontainer.json: %s '%s'",
name,
strings.Join(args, " "),
)
writer := log.Writer(log.LevelInfo)
errwriter := log.Writer(log.LevelError)
defer func() { _ = writer.Close() }()
defer func() { _ = errwriter.Close() }()

// #nosec G204 -- args come from devcontainer.json initializeCommand
execCmd := exec.Command(args[0], args[1:]...)
env := execCmd.Environ()
env = append(env, c.extraEnvVars...)

execCmd.Stdout = writer
execCmd.Stderr = errwriter
execCmd.Dir = c.workspaceFolder
execCmd.Env = env

return execCmd.Run()
}

func getWorkspace(
workspaceFolder, workspaceID string,
conf *config.DevContainerConfig,
Expand Down
97 changes: 97 additions & 0 deletions pkg/devcontainer/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package devcontainer

import (
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newInitCmdConfig(hook types.LifecycleHook) *config.DevContainerConfig {
return &config.DevContainerConfig{
DevContainerConfigBase: config.DevContainerConfigBase{
InitializeCommand: hook,
},
}
}

func TestRunInitializeCommand_ParallelTiming(t *testing.T) {
// Two "sleep 0.5" commands that, if run in parallel, complete in ~0.5s.
cfg := newInitCmdConfig(types.LifecycleHook{
"sleep-a": {"sleep", "0.5"},
"sleep-b": {"sleep", "0.5"},
})

start := time.Now()
err := runInitializeCommand(t.TempDir(), cfg, nil)
elapsed := time.Since(start)

assert.NoError(t, err)
assert.Less(t, elapsed, 900*time.Millisecond,
"two 0.5s commands should complete in ~0.5s when parallel, not ~1s")
}

func TestRunInitializeCommand_ParallelErrorCollection(t *testing.T) {
dir := t.TempDir()
markerFile := filepath.Join(dir, "ran.txt")

cfg := newInitCmdConfig(types.LifecycleHook{
"fail": {"sh", "-c", "exit 1"},
"succeed": {"sh", "-c", fmt.Sprintf("sleep 0.1 && echo done > %s", markerFile)},
})

err := runInitializeCommand(dir, cfg, nil)

// The combined error should mention which named command failed.
assert.Error(t, err)
assert.Contains(t, err.Error(), `named command "fail" failed`)

// The succeed command should have run to completion despite the failure.
_, statErr := os.Stat(markerFile)
assert.NoError(t, statErr, "succeed command should run even when fail command errors")
}
Comment on lines +24 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Skip parallel/error tests on Windows — they rely on sleep and sh.

TestRunInitializeCommand_ParallelTiming execs sleep 0.5 and TestRunInitializeCommand_ParallelErrorCollection execs sh -c "...". Neither binary exists on a default Windows runner, so if unit tests are ever run on a Windows job these will fail spuriously rather than validate the logic under review. A small guard keeps the suite cross-platform:

Proposed guard
 func TestRunInitializeCommand_ParallelTiming(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("requires POSIX sleep/sh")
+	}
 	// Two "sleep 0.5" commands that, if run in parallel, complete in ~0.5s.
...
 func TestRunInitializeCommand_ParallelErrorCollection(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("requires POSIX sh")
+	}
 	dir := t.TempDir()

And add "runtime" to the imports. The same guard is worth applying to TestRunInitializeCommand_SingleKey and TestRunInitializeCommand_StringFormat since they also rely on sh / shell redirection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/devcontainer/run_test.go` around lines 24 - 58, These tests invoke
shell-specific binaries (`sleep`, `sh`) so add a Windows guard to skip them on
Windows; in each affected test (TestRunInitializeCommand_ParallelTiming,
TestRunInitializeCommand_ParallelErrorCollection,
TestRunInitializeCommand_SingleKey, TestRunInitializeCommand_StringFormat) add
at the top: if runtime.GOOS == "windows" { t.Skip("skipping shell-dependent test
on Windows") } and ensure the runtime package is imported; leave the rest of the
test and calls to newInitCmdConfig and runInitializeCommand unchanged.


func TestRunInitializeCommand_SingleKey(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "out.txt")

cfg := newInitCmdConfig(types.LifecycleHook{
"": {"sh", "-c", fmt.Sprintf("echo hello > %s", outFile)},
})

err := runInitializeCommand(dir, cfg, nil)
require.NoError(t, err)

data, err := os.ReadFile(outFile) // #nosec G304 -- test path from t.TempDir
require.NoError(t, err)
assert.Contains(t, string(data), "hello")
}

func TestRunInitializeCommand_StringFormat(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "out.txt")

// String format results in a single key with a shell command string.
cfg := newInitCmdConfig(types.LifecycleHook{
"": {fmt.Sprintf("echo hello > %s", outFile)},
})

err := runInitializeCommand(dir, cfg, nil)
require.NoError(t, err)

data, err := os.ReadFile(outFile) // #nosec G304 -- test path from t.TempDir
require.NoError(t, err)
assert.Contains(t, string(data), "hello")
}

func TestRunInitializeCommand_Empty(t *testing.T) {
cfg := &config.DevContainerConfig{}
err := runInitializeCommand(t.TempDir(), cfg, nil)
assert.NoError(t, err)
}
10 changes: 9 additions & 1 deletion pkg/inject/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,17 @@ func pipe(
// Wait for the first copy to complete — its error is the meaningful one.
first := <-errChan

// Close both endpoints so the remaining goroutine unblocks promptly.
// Close all closable endpoints so the remaining goroutine unblocks
// promptly instead of leaking.
_ = toStdin.Close()
_ = fromStdout.Close()
if c, ok := fromStdin.(io.Closer); ok {
_ = c.Close()
}

// Drain the second goroutine so callers can safely read toStdout after
// pipe returns without a data race.
<-errChan
Comment on lines +328 to +338

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate call sites of Inject to see whether Stdin is ever set to os.Stdin
# or another externally-owned *os.File that survives past Inject.
rg -nP -C4 '\bInject\s*\(\s*InjectOptions\b|InjectOptions\s*\{'
rg -nP -C2 '\bStdin\s*:\s*os\.Stdin\b'
# And any direct callers of pipe() within the package.
rg -nP -C3 '\bpipe\s*\('

Repository: devsy-org/devsy

Length of output: 11081


🏁 Script executed:

# First, check the InjectOptions definitions and the pipe() call context
# Check around line 226 in pkg/inject/inject.go where pipe() is called
sed -n '200,250p' pkg/inject/inject.go

# Also check the pipe() function signature and the closing logic (lines 328-338)
sed -n '308,350p' pkg/inject/inject.go

# Check how stdin flows into inject.Inject
sed -n '1,50p' pkg/inject/inject.go

Repository: devsy-org/devsy

Length of output: 3262


🏁 Script executed:

# Check the InjectOptions struct definition in both packages
rg -A 15 'type InjectOptions struct' pkg/inject/ pkg/agent/

Repository: devsy-org/devsy

Length of output: 1881


🏁 Script executed:

# Check agent.InjectAgent to see what it passes for Stdin
sed -n '1,100p' pkg/agent/inject.go | head -100

Repository: devsy-org/devsy

Length of output: 2829


🏁 Script executed:

# Check where stdin gets its default value in either package
rg -n 'Stdin.*os\.Stdin|Stdin.*=.*os\.Stdin|\.Stdin\s*=' pkg/inject/ pkg/agent/

Repository: devsy-org/devsy

Length of output: 104


🏁 Script executed:

# Check where stdin value gets assigned in agent.InjectAgent and inject.Inject
sed -n '220,280p' pkg/agent/inject.go

# Also check line 442 context in agent.go
sed -n '430,450p' pkg/agent/agent.go

# Check if there's any default assignment to Stdin in either package
rg -n 'opts\.Stdin|Stdin\s*=' pkg/agent/inject.go pkg/inject/inject.go | head -20

Repository: devsy-org/devsy

Length of output: 2373


🏁 Script executed:

# Let's see the complete Inject function flow - what stdin value reaches pipe()?
sed -n '50,150p' pkg/inject/inject.go

Repository: devsy-org/devsy

Length of output: 2462


🏁 Script executed:

# Let's verify what types os.Stdin is and whether it implements io.Closer
# Also check if bytes.NewReader implements io.Closer
python3 <<'EOF'
# os.Stdin is *os.File which implements io.Closer
# bytes.NewReader does NOT implement io.Closer
print("os.Stdin type: *os.File")
print("*os.File implements io.Closer: YES")
print("bytes.NewReader implements io.Closer: NO")
print()
print("Flow analysis:")
print("1. If opts.Stdin is nil -> becomes bytes.NewReader(nil) -> SAFE (no Close)")
print("2. If opts.Stdin is os.Stdin -> remains os.Stdin -> UNSAFE (will Close FD 0)")
print("3. Current callers don't set Stdin -> always uses case 1 (safe)")
print("4. But code allows case 2 (unsafe) if caller sets it")
EOF

Repository: devsy-org/devsy

Length of output: 444


🏁 Script executed:

# Final verification: check if there are any test cases that pass os.Stdin or similar
rg -n 'Stdin.*os\.|os\.Stdin' pkg/agent/inject_test.go pkg/inject/inject_test.go

Repository: devsy-org/devsy

Length of output: 960


🏁 Script executed:

# Double-check: make sure there's no hidden default assignment of os.Stdin somewhere
rg -n 'opts\.Stdin\s*=' pkg/agent/ pkg/inject/

Repository: devsy-org/devsy

Length of output: 41


Closing a caller-provided Stdin risks closing shared file descriptors (e.g., os.Stdin).

The drain of errChan correctly eliminates the data race on toStdout — that part is sound. However, fromStdin propagates from InjectOptions.Stdin through inject.Inject(). The pipe() function at line 333 closes any fromStdin implementing io.Closer. While current callers don't set Stdin (defaulting to safe bytes.NewReader(nil)), the code structure permits passing os.Stdin or another shared *os.File, which would close FD 0 for the entire process.

Consider either:

  • Documenting that Stdin must not be a shared resource, or
  • Wrapping fromStdin in an io.NopCloser-style adapter to prevent unintended FD closure.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/inject/inject.go` around lines 328 - 338, The pipe() current behavior
closes any fromStdin that implements io.Closer, which can accidentally close
caller-provided shared FDs (e.g., os.Stdin) passed via InjectOptions.Stdin;
change the logic so we do not close caller-provided readers: either wrap the
user-supplied InjectOptions.Stdin in an io.NopCloser before passing it into
pipe(), or add an ownership flag (e.g., ownFromStdin) to pipe()/inject.Inject()
and only call Close() on fromStdin when we created it; update references in
inject.Inject(), pipe(), and any places that construct fromStdin so the code
never closes a reader that originated from InjectOptions.Stdin.


return first
}
Loading