diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 4da3b781f..4946f701f 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -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 diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 000000000..12a3d44f6 --- /dev/null +++ b/cmd/exec.go @@ -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 -- [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 + "=" + } else { + redacted[i] = args[i] + } + } else { + redacted[i] = args[i] + } + } + return redacted +} diff --git a/cmd/exec_test.go b/cmd/exec_test.go new file mode 100644 index 000000000..d2a26b8ec --- /dev/null +++ b/cmd/exec_test.go @@ -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) +} diff --git a/cmd/root.go b/cmd/root.go index f2fe2336d..1a60acf39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index db0636b6a..bcf7e5698 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -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" diff --git a/e2e/tests/exec/exec.go b/e2e/tests/exec/exec.go new file mode 100644 index 000000000..a7d969260 --- /dev/null +++ b/e2e/tests/exec/exec.go @@ -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")) + }, 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())) +}) diff --git a/e2e/tests/exec/testdata/devcontainer.json b/e2e/tests/exec/testdata/devcontainer.json new file mode 100644 index 000000000..1aabfc5f8 --- /dev/null +++ b/e2e/tests/exec/testdata/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Exec Test", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu" +}