-
Notifications
You must be signed in to change notification settings - Fork 2
feat(cli): add exec command for container command execution #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4622ec2
b2ccf6e
12c7783
3f5f145
7bc0cfb
bf7814d
679f6f7
e642985
33972e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| 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) | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent stdout assertion strategy between tests 1 and 2. Test 1 asserts 🔧 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 |
||
| }, 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())) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "name": "Exec Test", | ||
| "image": "mcr.microsoft.com/devcontainers/base:ubuntu" | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.