Skip to content
Merged
6 changes: 6 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ jobs:
install-kind: false
requires-secret: false

- label: exec
runner: ubuntu-latest
free-disk-space: false
install-kind: false
requires-secret: false

# Up tests

- label: up-workspaces
Expand Down
202 changes: 202 additions & 0 deletions cmd/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package cmd

import (
"context"
"fmt"
"os"
"strings"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/config"
devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/docker"
"github.com/devsy-org/devsy/pkg/log"
provider2 "github.com/devsy-org/devsy/pkg/provider"
workspace2 "github.com/devsy-org/devsy/pkg/workspace"
"github.com/spf13/cobra"
"golang.org/x/term"
)

const defaultDockerCommand = "docker"

// ExecCmd holds the exec cmd flags.
type ExecCmd struct {
*flags.GlobalFlags

WorkspaceFolder string
RemoteEnv []string
}

// NewExecCmd creates a new exec command.
func NewExecCmd(f *flags.GlobalFlags) *cobra.Command {
cmd := &ExecCmd{GlobalFlags: f}
execCmd := &cobra.Command{
Use: "exec --workspace-folder <path> -- <cmd> [args...]",
Short: "Executes a command in a running workspace container",
Args: cobra.MinimumNArgs(1),
RunE: func(cobraCmd *cobra.Command, args []string) error {
ctx := cobraCmd.Context()
return cmd.Run(ctx, args)
},
}

execCmd.Flags().
StringVar(
&cmd.WorkspaceFolder,
"workspace-folder",
"",
"Path to the workspace folder",
)
_ = execCmd.MarkFlagRequired("workspace-folder")
execCmd.Flags().
StringSliceVar(
&cmd.RemoteEnv,
"remote-env",
[]string{},
"Environment variables to set in the container (KEY=VALUE format)",
)

return execCmd
}

// Run executes the exec command.
func (cmd *ExecCmd) Run(ctx context.Context, args []string) error {
if err := cmd.validateRemoteEnv(); err != nil {
return err
}

devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
if err != nil {
return err
}

client, err := workspace2.Get(ctx, workspace2.GetOptions{
DevsyConfig: devsyConfig,
Args: []string{cmd.WorkspaceFolder},
Owner: cmd.Owner,
})
if err != nil {
return fmt.Errorf("resolve workspace: %w", err)
}

dockerCommand := resolveDockerCommand(client.WorkspaceConfig())

containerDetails, err := findRunningContainer(
ctx, dockerCommand, client.Workspace(),
)
if err != nil {
return err
}

return cmd.execInContainer(ctx, dockerCommand, containerDetails.ID, args)
}

func (cmd *ExecCmd) validateRemoteEnv() error {
for _, env := range cmd.RemoteEnv {
parts := strings.SplitN(env, "=", 2)
if len(parts) != 2 || parts[0] == "" {
return fmt.Errorf("invalid remote-env value %q: must be KEY=VALUE format", env)
}
}
return nil
}

func resolveDockerCommand(
workspace *provider2.Workspace,
) string {
if workspace == nil || workspace.Context == "" {
return defaultDockerCommand
}

providerConfig, err := provider2.LoadProviderConfig(
workspace.Context,
workspace.Provider.Name,
)
if err != nil {
log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err)
return defaultDockerCommand
}

if providerConfig.Agent.Docker.Path != "" {
if expanded := os.ExpandEnv(providerConfig.Agent.Docker.Path); expanded != "" {
return expanded
}
}

return defaultDockerCommand
}

func findRunningContainer(
ctx context.Context,
dockerCommand string,
workspaceID string,
) (*devcconfig.ContainerDetails, error) {
dockerHelper := &docker.DockerHelper{
DockerCommand: dockerCommand,
}

labels := devcconfig.GetDockerLabelForID(workspaceID)
container, err := dockerHelper.FindDevContainer(ctx, labels)
if err != nil {
return nil, fmt.Errorf("find container: %w", err)
}
if container == nil {
return nil, fmt.Errorf(
"no running container found for workspace %q",
workspaceID,
)
}

if strings.ToLower(container.State.Status) != "running" {
return nil, fmt.Errorf(
"container %s is not running (status: %s)",
container.ID,
container.State.Status,
)
}

return container, nil
}

func (cmd *ExecCmd) execInContainer(
ctx context.Context,
dockerCommand string,
containerID string,
args []string,
) error {
dockerHelper := &docker.DockerHelper{
DockerCommand: dockerCommand,
}

execArgs := []string{"exec", "-i"}
if term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 -- fd is always a valid file descriptor
execArgs = append(execArgs, "-t")
}
for _, env := range cmd.RemoteEnv {
execArgs = append(execArgs, "-e", env)
}
execArgs = append(execArgs, containerID)
execArgs = append(execArgs, args...)

redacted := strings.Join(redactExecArgs(execArgs), " ")
log.Debugf("Executing in container: %s %s", dockerCommand, redacted)
return dockerHelper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func redactExecArgs(args []string) []string {
redacted := make([]string, len(args))
for i := 0; i < len(args); i++ {
if args[i] == "-e" && i+1 < len(args) {
redacted[i] = args[i]
i++
if k, _, ok := strings.Cut(args[i], "="); ok {
redacted[i] = k + "=<redacted>"
} else {
redacted[i] = args[i]
}
} else {
redacted[i] = args[i]
}
}
return redacted
}
66 changes: 66 additions & 0 deletions cmd/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"testing"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateRemoteEnv_Valid(t *testing.T) {
cmd := &ExecCmd{
GlobalFlags: &flags.GlobalFlags{},
RemoteEnv: []string{"FOO=bar", "BAZ=qux=extra"},
}
assert.NoError(t, cmd.validateRemoteEnv())
}

func TestValidateRemoteEnv_Empty(t *testing.T) {
cmd := &ExecCmd{
GlobalFlags: &flags.GlobalFlags{},
RemoteEnv: []string{},
}
assert.NoError(t, cmd.validateRemoteEnv())
}

func TestValidateRemoteEnv_MissingEquals(t *testing.T) {
cmd := &ExecCmd{
GlobalFlags: &flags.GlobalFlags{},
RemoteEnv: []string{"INVALID"},
}
err := cmd.validateRemoteEnv()
require.Error(t, err)
assert.Contains(t, err.Error(), "must be KEY=VALUE format")
}

func TestValidateRemoteEnv_EmptyKey(t *testing.T) {
cmd := &ExecCmd{
GlobalFlags: &flags.GlobalFlags{},
RemoteEnv: []string{"=value"},
}
err := cmd.validateRemoteEnv()
require.Error(t, err)
assert.Contains(t, err.Error(), "must be KEY=VALUE format")
}

func TestNewExecCmd_RequiresWorkspaceFolder(t *testing.T) {
execCmd := NewExecCmd(&flags.GlobalFlags{})
execCmd.SetArgs([]string{"--", "echo", "hello"})
err := execCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "workspace-folder")
}

func TestNewExecCmd_RequiresArgs(t *testing.T) {
execCmd := NewExecCmd(&flags.GlobalFlags{})
execCmd.SetArgs([]string{"--workspace-folder", "/tmp/test"})
err := execCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "requires at least 1 arg")
}

func TestResolveDockerCommand_NilWorkspace(t *testing.T) {
result := resolveDockerCommand(nil)
assert.Equal(t, "docker", result)
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ func BuildRoot() *cobra.Command {
rootCmd.AddCommand(NewTroubleshootCmd(globalFlags))
rootCmd.AddCommand(NewPingCmd(globalFlags))
rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags))
rootCmd.AddCommand(NewExecCmd(globalFlags))

inheritCommandFlagsFromEnvironment(rootCmd)

Expand Down
1 change: 1 addition & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
_ "github.com/devsy-org/devsy/e2e/tests/context"
_ "github.com/devsy-org/devsy/e2e/tests/dockerinstall"
_ "github.com/devsy-org/devsy/e2e/tests/down"
_ "github.com/devsy-org/devsy/e2e/tests/exec"
_ "github.com/devsy-org/devsy/e2e/tests/ide"
_ "github.com/devsy-org/devsy/e2e/tests/integration"
_ "github.com/devsy-org/devsy/e2e/tests/logs"
Expand Down
83 changes: 83 additions & 0 deletions e2e/tests/exec/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package exec

import (
"context"
"os"
"strings"

"github.com/devsy-org/devsy/e2e/framework"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

var _ = ginkgo.Describe("devsy exec test suite", ginkgo.Label("exec"), ginkgo.Ordered, func() {
var initialDir string

ginkgo.BeforeEach(func() {
var err error
initialDir, err = os.Getwd()
framework.ExpectNoError(err)
})

ginkgo.It("should exec a command in a running workspace container",
func(ctx context.Context) {
tempDir, err := framework.CopyToTempDir("tests/exec/testdata")
framework.ExpectNoError(err)

f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir)
framework.CleanupTempDir(initialDir, tempDir)
})

err = f.DevsyUp(ctx, tempDir)
framework.ExpectNoError(err)

stdout, _, err := f.ExecCommandCapture(ctx, []string{
"exec",
"--workspace-folder", tempDir,
"--", "echo", "-n", "hello",
})
framework.ExpectNoError(err)
gomega.Expect(stdout).To(gomega.Equal("hello"))
Comment on lines +38 to +44

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

Inconsistent stdout assertion strategy between tests 1 and 2.

Test 1 asserts gomega.Equal("hello") on raw stdout while Test 2 (line 70) wraps with strings.TrimSpace before equality. Looking at ExecCommandCapture, it captures only the child process stdout (not stderr) into execOut, so for echo -n hello you should get exactly "hello". However, docker exec (or any wrapper layer that prints status/log lines to stdout) could add noise on certain runners and make this assertion flaky. Consider either trimming consistently or using gomega.ContainSubstring("hello") to make Test 1 as resilient as Test 2 — -n already removes the trailing newline, so a stray newline from a wrapper would break this exact match.

🔧 Optional fix
-			framework.ExpectNoError(err)
-			gomega.Expect(stdout).To(gomega.Equal("hello"))
+			framework.ExpectNoError(err)
+			gomega.Expect(strings.TrimSpace(stdout)).To(gomega.Equal("hello"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/tests/exec/exec.go` around lines 38 - 44, The stdout assertion in the
first test is brittle — it compares raw stdout to "hello" while other tests trim
whitespace; update the assertion that checks the stdout variable (from
ExecCommandCapture) to be resilient to wrapper noise by either comparing
strings.TrimSpace(stdout) to "hello" or using gomega.ContainSubstring("hello")
so it matches the approach used in the other test; locate the assertion that
reads gomega.Expect(stdout).To(gomega.Equal("hello")) and replace it with the
chosen tolerant assertion.

}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("should pass remote-env to the container",
func(ctx context.Context) {
tempDir, err := framework.CopyToTempDir("tests/exec/testdata")
framework.ExpectNoError(err)

f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir)
framework.CleanupTempDir(initialDir, tempDir)
})

err = f.DevsyUp(ctx, tempDir)
framework.ExpectNoError(err)

stdout, _, err := f.ExecCommandCapture(ctx, []string{
"exec",
"--workspace-folder", tempDir,
"--remote-env", "MY_TEST_VAR=test_value",
"--", "sh", "-c", "echo -n $MY_TEST_VAR",
})
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(stdout)).To(gomega.Equal("test_value"))
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It("should fail without --workspace-folder flag",
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")

_, _, err := f.ExecCommandCapture(ctx, []string{
"exec",
"--", "echo", "hello",
})
framework.ExpectError(err)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))
})
4 changes: 4 additions & 0 deletions e2e/tests/exec/testdata/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Exec Test",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu"
}
Loading