From f8a564d982c1c05aee3e6b7ab8b4b1443e99a49c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 00:27:51 -0500 Subject: [PATCH] feat(cmd): add `set-up` command for BYOC container configuration Introduces a new `devsy set-up` command that applies devcontainer lifecycle hooks (postCreateCommand, postStartCommand) and containerEnv variables to an already-running container via `docker exec`, without building an image. --- cmd/root.go | 1 + cmd/setup.go | 177 +++++++++++++++++++++++++++++++++++++++ cmd/setup_test.go | 99 ++++++++++++++++++++++ e2e/tests/setup/setup.go | 158 ++++++++++++++++++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 cmd/setup.go create mode 100644 cmd/setup_test.go create mode 100644 e2e/tests/setup/setup.go diff --git a/cmd/root.go b/cmd/root.go index d97545ddf..1ce7e2730 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -131,6 +131,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags)) rootCmd.AddCommand(NewExecCmd(globalFlags)) rootCmd.AddCommand(NewOutdatedCmd(globalFlags)) + rootCmd.AddCommand(NewSetUpCmd(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/cmd/setup.go b/cmd/setup.go new file mode 100644 index 000000000..8eff913da --- /dev/null +++ b/cmd/setup.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/types" + "github.com/spf13/cobra" +) + +const ( + flagSetUpContainer = "container" + flagSetUpConfig = "config" + flagSetUpWorkspaceFolder = "workspace-folder" + defaultWorkspaceDir = "/workspaces" + dockerExecSubcommand = "exec" +) + +// SetUpCmd holds the set-up command flags. +type SetUpCmd struct { + *flags.GlobalFlags + + Container string + Config string + WorkspaceFolder string +} + +// NewSetUpCmd creates a new set-up command. +func NewSetUpCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &SetUpCmd{GlobalFlags: f} + setupCmd := &cobra.Command{ + Use: "set-up", + Short: "Apply devcontainer configuration to a running container", + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + + setupCmd.Flags().StringVar(&cmd.Container, flagSetUpContainer, "", + "The container ID or name to apply configuration to (required)") + _ = setupCmd.MarkFlagRequired(flagSetUpContainer) + setupCmd.Flags().StringVar(&cmd.Config, flagSetUpConfig, "", + "Path to devcontainer.json (defaults to auto-detection in current workspace)") + setupCmd.Flags().StringVar(&cmd.WorkspaceFolder, flagSetUpWorkspaceFolder, "", + "Workspace folder path inside the container") + + return setupCmd +} + +// Run executes the set-up command logic. +func (cmd *SetUpCmd) Run(ctx context.Context) error { + devContainerConfig, err := cmd.loadConfig() + if err != nil { + return fmt.Errorf("load devcontainer config: %w", err) + } + if devContainerConfig == nil { + return fmt.Errorf("no devcontainer.json found") + } + + workspaceFolder := cmd.resolveWorkspaceFolder() + helper := &docker.DockerHelper{DockerCommand: defaultDockerCommand} + envArgs := buildContainerEnvArgs(devContainerConfig.ContainerEnv) + + opts := hookExecOpts{ + ctx: ctx, + helper: helper, + envArgs: envArgs, + workspaceFolder: workspaceFolder, + } + + if err := cmd.execHook(opts, devContainerConfig.PostCreateCommand); err != nil { + return fmt.Errorf("lifecycle hooks: postCreateCommand: %w", err) + } + + if err := cmd.execHook(opts, devContainerConfig.PostStartCommand); err != nil { + return fmt.Errorf("lifecycle hooks: postStartCommand: %w", err) + } + + log.Infof("set-up completed for container %s", cmd.Container) + return nil +} + +type hookExecOpts struct { + ctx context.Context + helper *docker.DockerHelper + envArgs []string + workspaceFolder string +} + +func (cmd *SetUpCmd) loadConfig() (*config.DevContainerConfig, error) { + if cmd.Config != "" { + return config.ParseDevContainerJSONFile(cmd.Config) + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + return config.ParseDevContainerJSON(cwd, "") +} + +func (cmd *SetUpCmd) resolveWorkspaceFolder() string { + if cmd.WorkspaceFolder != "" { + return cmd.WorkspaceFolder + } + + cwd, err := os.Getwd() + if err != nil { + return defaultWorkspaceDir + } + return filepath.Join(defaultWorkspaceDir, filepath.Base(cwd)) +} + +func (cmd *SetUpCmd) execHook(opts hookExecOpts, hook types.LifecycleHook) error { + if len(hook) == 0 { + return nil + } + + for key, command := range hook { + if len(command) == 0 { + continue + } + log.Infof("executing lifecycle hook: %s %v", key, command) + + args := buildDockerExecArgs(cmd.Container, opts.envArgs, opts.workspaceFolder, command) + if err := opts.helper.Run(opts.ctx, args, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("command %q failed: %w", key, err) + } + } + + return nil +} + +func buildDockerExecArgs( + container string, + envArgs []string, + workspaceFolder string, + command []string, +) []string { + args := []string{dockerExecSubcommand} + args = append(args, envArgs...) + if workspaceFolder != "" { + args = append(args, "--workdir", workspaceFolder) + } + args = append(args, container) + if len(command) == 1 { + args = append(args, "sh", "-c", command[0]) + } else { + args = append(args, command...) + } + return args +} + +func buildContainerEnvArgs(containerEnv map[string]string) []string { + if len(containerEnv) == 0 { + return nil + } + + keys := make([]string, 0, len(containerEnv)) + for k := range containerEnv { + keys = append(keys, k) + } + sort.Strings(keys) + + args := make([]string, 0, len(containerEnv)*2) + for _, k := range keys { + args = append(args, "-e", k+"="+containerEnv[k]) + } + return args +} diff --git a/cmd/setup_test.go b/cmd/setup_test.go new file mode 100644 index 000000000..67329c982 --- /dev/null +++ b/cmd/setup_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testContainerName = "my-container" + testWorkspacePath = "/workspaces/project" + testEnvFlag = "-e" + testEnvBaz = "BAZ=qux" + testEnvFoo = "FOO=bar" + testWorkdirFlag = "--workdir" +) + +func TestNewSetUpCmd_CommandName(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + assert.Equal(t, "set-up", cmd.Use) +} + +func TestNewSetUpCmd_ContainerFlagRequired(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup(flagSetUpContainer) + require.NotNil(t, f) + + annotations := cmd.MarkFlagRequired(flagSetUpContainer) + assert.NoError(t, annotations) +} + +func TestNewSetUpCmd_ConfigFlagOptional(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup(flagSetUpConfig) + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) +} + +func TestNewSetUpCmd_WorkspaceFolderFlagOptional(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup(flagSetUpWorkspaceFolder) + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) +} + +func TestNewSetUpCmd_FailsWithoutContainer(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} + +func TestBuildContainerEnvArgs(t *testing.T) { + env := map[string]string{ + "FOO": "bar", + "BAZ": "qux", + } + args := buildContainerEnvArgs(env) + expected := []string{testEnvFlag, testEnvBaz, testEnvFlag, testEnvFoo} + assert.Equal(t, expected, args) +} + +func TestBuildContainerEnvArgs_Empty(t *testing.T) { + args := buildContainerEnvArgs(nil) + assert.Nil(t, args) +} + +func TestBuildDockerExecArgs_SingleStringCommand(t *testing.T) { + args := buildDockerExecArgs( + testContainerName, + []string{testEnvFlag, testEnvFoo}, + testWorkspacePath, + []string{"touch /tmp/test"}, + ) + expected := []string{ + dockerExecSubcommand, testEnvFlag, testEnvFoo, + testWorkdirFlag, testWorkspacePath, + testContainerName, "sh", "-c", "touch /tmp/test", + } + assert.Equal(t, expected, args) +} + +func TestBuildDockerExecArgs_MultipleCommandParts(t *testing.T) { + args := buildDockerExecArgs( + testContainerName, + nil, + testWorkspacePath, + []string{"ls", "-la", "/tmp"}, + ) + expected := []string{ + dockerExecSubcommand, + testWorkdirFlag, testWorkspacePath, + testContainerName, "ls", "-la", "/tmp", + } + assert.Equal(t, expected, args) +} diff --git a/e2e/tests/setup/setup.go b/e2e/tests/setup/setup.go new file mode 100644 index 000000000..3a867bac3 --- /dev/null +++ b/e2e/tests/setup/setup.go @@ -0,0 +1,158 @@ +package setup + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + setUpCommand = "set-up" + containerFlag = "--container" + configFlag = "--config" + alpineImage = "alpine" + dockerBin = "docker" + imageKey = "image" + devcontainerFn = "devcontainer.json" +) + +var _ = ginkgo.Describe("devsy set-up command", ginkgo.Label("setup"), ginkgo.Ordered, func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("should execute postCreateCommand in a running container", + func(ctx context.Context) { + containerID := startAlpineContainer(ctx) + ginkgo.DeferCleanup(removeContainer, containerID) + + tmpDir, err := framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tmpDir) + + devcontainerJSON := map[string]any{ + imageKey: alpineImage, + "postCreateCommand": "touch /tmp/setup-test-marker", + } + writeDevcontainerJSON(tmpDir, devcontainerJSON) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _, _, err = f.ExecCommandCapture(ctx, []string{ + setUpCommand, + containerFlag, containerID, + configFlag, filepath.Join(tmpDir, ".devcontainer", devcontainerFn), + }) + framework.ExpectNoError(err) + + out := dockerExecInContainer(ctx, containerID, "cat", "/tmp/setup-test-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should execute postStartCommand in a running container", + func(ctx context.Context) { + containerID := startAlpineContainer(ctx) + ginkgo.DeferCleanup(removeContainer, containerID) + + tmpDir, err := framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tmpDir) + + devcontainerJSON := map[string]any{ + imageKey: alpineImage, + "postStartCommand": "touch /tmp/poststart-marker", + } + writeDevcontainerJSON(tmpDir, devcontainerJSON) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _, _, err = f.ExecCommandCapture(ctx, []string{ + setUpCommand, + containerFlag, containerID, + configFlag, filepath.Join(tmpDir, ".devcontainer", devcontainerFn), + }) + framework.ExpectNoError(err) + + out := dockerExecInContainer(ctx, containerID, "cat", "/tmp/poststart-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass containerEnv to lifecycle commands", + func(ctx context.Context) { + containerID := startAlpineContainer(ctx) + ginkgo.DeferCleanup(removeContainer, containerID) + + tmpDir, err := framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tmpDir) + + devcontainerJSON := map[string]any{ + imageKey: alpineImage, + "containerEnv": map[string]string{"MY_VAR": "hello_world"}, + "postCreateCommand": "sh -c 'echo -n $MY_VAR > /tmp/env-marker'", + } + writeDevcontainerJSON(tmpDir, devcontainerJSON) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _, _, err = f.ExecCommandCapture(ctx, []string{ + setUpCommand, + containerFlag, containerID, + configFlag, filepath.Join(tmpDir, ".devcontainer", devcontainerFn), + }) + framework.ExpectNoError(err) + + out := dockerExecInContainer(ctx, containerID, "cat", "/tmp/env-marker") + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("hello_world")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) + +func startAlpineContainer(ctx context.Context) string { + cmd := exec.CommandContext( + ctx, dockerBin, "run", "-d", alpineImage, "sleep", "3600", + ) + out, err := cmd.Output() + framework.ExpectNoError(err) + return strings.TrimSpace(string(out)) +} + +func removeContainer(containerID string) { + // #nosec G204 -- test helper with controlled input + cmd := exec.Command(dockerBin, "rm", "-f", containerID) + _ = cmd.Run() +} + +func dockerExecInContainer( + ctx context.Context, + containerID string, + args ...string, +) string { + execArgs := append([]string{"exec", containerID}, args...) + // #nosec G204 -- test helper with controlled input + cmd := exec.CommandContext(ctx, dockerBin, execArgs...) + out, err := cmd.CombinedOutput() + framework.ExpectNoError(err) + return string(out) +} + +func writeDevcontainerJSON(dir string, cfg map[string]any) { + devcontainerDir := filepath.Join(dir, ".devcontainer") + err := os.MkdirAll(devcontainerDir, 0o700) + framework.ExpectNoError(err) + + data, err := json.Marshal(cfg) + framework.ExpectNoError(err) + + err = os.WriteFile( + filepath.Join(devcontainerDir, devcontainerFn), data, 0o600, + ) + framework.ExpectNoError(err) +}