diff --git a/cmd/exec_test.go b/cmd/exec_test.go index a8ba16f29..2399c8019 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -52,7 +52,7 @@ func TestValidateRemoteEnv_EmptyKey(t *testing.T) { func TestNewExecCmd_RequiresWorkspaceFolderOrContainerID(t *testing.T) { execCmd := NewExecCmd(&flags.GlobalFlags{}) - execCmd.SetArgs([]string{"--", "echo", "hello"}) + execCmd.SetArgs([]string{"--", testCmdEcho, testCmdHello}) err := execCmd.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "either --workspace-folder or --container-id must be provided") @@ -97,7 +97,7 @@ func TestExecCmd_NonExistentContainerID(t *testing.T) { GlobalFlags: &flags.GlobalFlags{}, ContainerID: "nonexistent-container-id-12345", } - err := cmd.runWithContainerID(t.Context(), []string{"echo", "hello"}) + err := cmd.runWithContainerID(t.Context(), []string{testCmdEcho, testCmdHello}) require.Error(t, err) assert.Contains(t, err.Error(), "nonexistent-container-id-12345") } diff --git a/cmd/runusercommands_test.go b/cmd/runusercommands_test.go index 609f60f93..cce8c4016 100644 --- a/cmd/runusercommands_test.go +++ b/cmd/runusercommands_test.go @@ -13,11 +13,6 @@ const ( flagContainerID = "--container-id" testContainerID = "abc" testContainerIDHex = "abc123" - hookOnCreate = "onCreateCommand" - hookUpdateContent = "updateContentCommand" - hookPostCreate = "postCreateCommand" - hookPostStart = "postStartCommand" - hookPostAttach = "postAttachCommand" ) func TestNewRunUserCommandsCmd_CommandName(t *testing.T) { diff --git a/cmd/setup.go b/cmd/setup.go index 8eff913da..0c0676c10 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -2,15 +2,20 @@ package cmd import ( "context" + "errors" "fmt" "os" "path/filepath" "sort" + "strings" "github.com/devsy-org/devsy/cmd/flags" - "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/copy" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/feature" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/types" "github.com/spf13/cobra" ) @@ -19,8 +24,14 @@ const ( flagSetUpContainer = "container" flagSetUpConfig = "config" flagSetUpWorkspaceFolder = "workspace-folder" - defaultWorkspaceDir = "/workspaces" + flagSetUpDockerPath = "docker-path" dockerExecSubcommand = "exec" + + hookOnCreate = "onCreateCommand" + hookUpdateContent = "updateContentCommand" + hookPostCreate = "postCreateCommand" + hookPostStart = "postStartCommand" + hookPostAttach = "postAttachCommand" ) // SetUpCmd holds the set-up command flags. @@ -30,6 +41,7 @@ type SetUpCmd struct { Container string Config string WorkspaceFolder string + DockerPath string } // NewSetUpCmd creates a new set-up command. @@ -38,7 +50,7 @@ func NewSetUpCmd(f *flags.GlobalFlags) *cobra.Command { setupCmd := &cobra.Command{ Use: "set-up", Short: "Apply devcontainer configuration to a running container", - RunE: func(cobraCmd *cobra.Command, args []string) error { + RunE: func(cobraCmd *cobra.Command, _ []string) error { return cmd.Run(cobraCmd.Context()) }, } @@ -50,91 +62,294 @@ func NewSetUpCmd(f *flags.GlobalFlags) *cobra.Command { "Path to devcontainer.json (defaults to auto-detection in current workspace)") setupCmd.Flags().StringVar(&cmd.WorkspaceFolder, flagSetUpWorkspaceFolder, "", "Workspace folder path inside the container") + setupCmd.Flags().StringVar(&cmd.DockerPath, flagSetUpDockerPath, "", + "Path to the docker/podman executable (defaults to 'docker')") return setupCmd } // Run executes the set-up command logic. func (cmd *SetUpCmd) Run(ctx context.Context) error { - devContainerConfig, err := cmd.loadConfig() + emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + + helper := &docker.DockerHelper{DockerCommand: cmd.resolveDockerPath()} + + containerDetails, err := cmd.inspectRunningContainer(ctx, helper) if err != nil { - return fmt.Errorf("load devcontainer config: %w", err) + if emitJSON { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + } + return err } - if devContainerConfig == nil { - return fmt.Errorf("no devcontainer.json found") + + result, err := cmd.loadConfig(containerDetails) + if err != nil { + if emitJSON { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + } + return err } - workspaceFolder := cmd.resolveWorkspaceFolder() - helper := &docker.DockerHelper{DockerCommand: defaultDockerCommand} - envArgs := buildContainerEnvArgs(devContainerConfig.ContainerEnv) + workdir := cmd.resolveWorkdir(containerDetails, result) + envArgs := buildLifecycleEnvArgs(result) + envArgs = append(envArgs, buildContainerEnvArgs(result.MergedConfig.ContainerEnv)...) - opts := hookExecOpts{ - ctx: ctx, - helper: helper, - envArgs: envArgs, - workspaceFolder: workspaceFolder, + if err := cmd.installFeatures(ctx, helper, result); err != nil { + if emitJSON { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + } + return fmt.Errorf("feature installation: %w", err) } - if err := cmd.execHook(opts, devContainerConfig.PostCreateCommand); err != nil { - return fmt.Errorf("lifecycle hooks: postCreateCommand: %w", err) + params := &lifecycleExecParams{ + ctx: ctx, + helper: helper, + containerID: containerDetails.ID, + envArgs: envArgs, + workdir: workdir, } - if err := cmd.execHook(opts, devContainerConfig.PostStartCommand); err != nil { - return fmt.Errorf("lifecycle hooks: postStartCommand: %w", err) + if err := cmd.runSetUpLifecycleHooks(params, result); err != nil { + if emitJSON { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + } + return err } - log.Infof("set-up completed for container %s", cmd.Container) + user := devcconfig.GetRemoteUser(result) + log.Infof("set-up completed for container %s", containerDetails.ID) + if emitJSON { + _ = devcconfig.WriteResultJSON(os.Stderr, containerDetails.ID, user, workdir, nil) + } return nil } -type hookExecOpts struct { - ctx context.Context - helper *docker.DockerHelper - envArgs []string - workspaceFolder string +func (cmd *SetUpCmd) resolveDockerPath() string { + if cmd.DockerPath != "" { + return cmd.DockerPath + } + return defaultDockerCommand +} + +func (cmd *SetUpCmd) inspectRunningContainer( + ctx context.Context, + helper *docker.DockerHelper, +) (*devcconfig.ContainerDetails, error) { + details, err := helper.InspectContainers(ctx, []string{cmd.Container}) + if err != nil { + return nil, fmt.Errorf("inspect container %s: %w", cmd.Container, err) + } + if len(details) == 0 { + return nil, fmt.Errorf("container %s not found", cmd.Container) + } + + containerDetails := &details[0] + if !strings.EqualFold(containerDetails.State.Status, containerStatusRunning) { + return nil, fmt.Errorf( + "container %s is not running (status: %s)", + cmd.Container, + containerDetails.State.Status, + ) + } + return containerDetails, nil } -func (cmd *SetUpCmd) loadConfig() (*config.DevContainerConfig, error) { +func (cmd *SetUpCmd) loadConfig( + containerDetails *devcconfig.ContainerDetails, +) (*devcconfig.Result, error) { + var devContainerConfig *devcconfig.DevContainerConfig + var err error + if cmd.Config != "" { - return config.ParseDevContainerJSONFile(cmd.Config) + devContainerConfig, err = devcconfig.ParseDevContainerJSONFile(cmd.Config) + } else { + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return nil, fmt.Errorf("get working directory: %w", cwdErr) + } + devContainerConfig, err = devcconfig.ParseDevContainerJSON(cwd, "") + } + if err != nil { + return nil, fmt.Errorf("parse devcontainer config: %w", err) + } + if devContainerConfig == nil { + return nil, errors.New("no devcontainer configuration found") } - cwd, err := os.Getwd() + mergedConfig, err := devcconfig.MergeConfiguration(devContainerConfig, nil) if err != nil { - return nil, fmt.Errorf("get working directory: %w", err) + return nil, fmt.Errorf("merge configuration: %w", err) } - return config.ParseDevContainerJSON(cwd, "") + + return &devcconfig.Result{ + MergedConfig: mergedConfig, + ContainerDetails: containerDetails, + }, nil } -func (cmd *SetUpCmd) resolveWorkspaceFolder() string { +func (cmd *SetUpCmd) resolveWorkdir( + containerDetails *devcconfig.ContainerDetails, + result *devcconfig.Result, +) string { if cmd.WorkspaceFolder != "" { return cmd.WorkspaceFolder } + if result.MergedConfig.WorkspaceFolder != "" { + return result.MergedConfig.WorkspaceFolder + } + return containerDetails.Config.WorkingDir +} + +func (cmd *SetUpCmd) installFeatures( + ctx context.Context, + helper *docker.DockerHelper, + result *devcconfig.Result, +) error { + if len(result.MergedConfig.Features) == 0 { + return nil + } - cwd, err := os.Getwd() + featureSets, err := cmd.resolveFeatureSets(result) if err != nil { - return defaultWorkspaceDir + return err } - return filepath.Join(defaultWorkspaceDir, filepath.Base(cwd)) + if len(featureSets) == 0 { + return nil + } + + tmpDir, err := os.MkdirTemp("", "devsy-features-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + featureStageDir := filepath.Join(tmpDir, "features") + // #nosec G301 -- features need to be executable inside the container + if err := os.MkdirAll(featureStageDir, 0o750); err != nil { + return fmt.Errorf("create features staging dir: %w", err) + } + + remoteUser := devcconfig.GetRemoteUser(result) + if err := cmd.stageFeatures(featureSets, featureStageDir, remoteUser); err != nil { + return err + } + + if err := cmd.copyAndExecFeatures(ctx, helper, featureSets, featureStageDir); err != nil { + return err + } + + return nil } -func (cmd *SetUpCmd) execHook(opts hookExecOpts, hook types.LifecycleHook) error { - if len(hook) == 0 { - return nil +func (cmd *SetUpCmd) resolveFeatureSets( + result *devcconfig.Result, +) ([]*devcconfig.FeatureSet, error) { + devContainerConfig := &devcconfig.DevContainerConfig{} + devContainerConfig.Features = result.MergedConfig.Features + devContainerConfig.OverrideFeatureInstallOrder = result.MergedConfig.OverrideFeatureInstallOrder + devContainerConfig.Origin = result.MergedConfig.Origin + + featureSets, err := feature.ResolveFeatureOrder(devContainerConfig) + if err != nil { + return nil, fmt.Errorf("resolve features: %w", err) } + return featureSets, nil +} + +func (cmd *SetUpCmd) copyAndExecFeatures( + ctx context.Context, + helper *docker.DockerHelper, + featureSets []*devcconfig.FeatureSet, + featureStageDir string, +) error { + containerFeaturesPath := "/tmp/build-features" - for key, command := range hook { - if len(command) == 0 { - continue + cpArgs := []string{"cp", featureStageDir + "/.", cmd.Container + ":" + containerFeaturesPath} + if err := helper.Run(ctx, cpArgs, nil, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("copy features to container: %w", err) + } + + for i, fs := range featureSets { + log.Infof("installing feature: %s", fs.ConfigID) + installCmd := fmt.Sprintf( + "cd %s/%d && chmod +x ./devcontainer-features-install.sh && ./devcontainer-features-install.sh", + containerFeaturesPath, + i, + ) + execArgs := buildDockerExecArgs(cmd.Container, nil, "", []string{installCmd}) + if err := helper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("install feature %s: %w", fs.ConfigID, err) } - 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 (cmd *SetUpCmd) stageFeatures( + featureSets []*devcconfig.FeatureSet, + stageDir string, + remoteUser string, +) error { + builtinEnvContent := fmt.Sprintf( + "_CONTAINER_USER=%s\n_REMOTE_USER=%s\n", + remoteUser, + remoteUser, + ) + builtinEnvPath := filepath.Join(stageDir, "devcontainer-features.builtin.env") + if err := os.WriteFile(builtinEnvPath, []byte(builtinEnvContent), 0o600); err != nil { + return fmt.Errorf("write builtin env: %w", err) + } + + for i, fs := range featureSets { + featureDir := filepath.Join(stageDir, fmt.Sprintf("%d", i)) + // #nosec G301 -- feature dirs need to be traversable for docker cp + if err := os.MkdirAll(featureDir, 0o750); err != nil { + return fmt.Errorf("create feature dir %d: %w", i, err) } + + if err := copy.Directory(fs.Folder, featureDir); err != nil { + return fmt.Errorf("copy feature %s: %w", fs.ConfigID, err) + } + + envVars := feature.GetFeatureEnvVariables(fs.Config, fs.Options) + envPath := filepath.Join(featureDir, "devcontainer-features.env") + if err := os.WriteFile(envPath, []byte(strings.Join(envVars, "\n")), 0o600); err != nil { + return fmt.Errorf("write env for feature %s: %w", fs.ConfigID, err) + } + + installWrapper := feature.GetFeatureInstallWrapperScript(fs.ConfigID, fs.Config, envVars) + wrapperPath := filepath.Join(featureDir, "devcontainer-features-install.sh") + // #nosec G306 -- install scripts must be executable + if err := os.WriteFile(wrapperPath, []byte(installWrapper), 0o600); err != nil { + return fmt.Errorf("write install wrapper for feature %s: %w", fs.ConfigID, err) + } + } + + return nil +} + +func (cmd *SetUpCmd) runSetUpLifecycleHooks( + params *lifecycleExecParams, + result *devcconfig.Result, +) error { + hooks := []struct { + name string + cmds []types.LifecycleHook + }{ + {hookOnCreate, result.MergedConfig.OnCreateCommands}, + {hookUpdateContent, result.MergedConfig.UpdateContentCommands}, + {hookPostCreate, result.MergedConfig.PostCreateCommands}, + {hookPostStart, result.MergedConfig.PostStartCommands}, + {hookPostAttach, result.MergedConfig.PostAttachCommands}, } + for _, hook := range hooks { + for _, h := range hook.cmds { + if err := execLifecycleHook(params, hook.name, h); err != nil { + return fmt.Errorf("lifecycle hooks: %s: %w", hook.name, err) + } + } + } return nil } diff --git a/cmd/setup_test.go b/cmd/setup_test.go index 67329c982..702468779 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -15,6 +15,8 @@ const ( testEnvBaz = "BAZ=qux" testEnvFoo = "FOO=bar" testWorkdirFlag = "--workdir" + testCmdEcho = "echo" + testCmdHello = "hello" ) func TestNewSetUpCmd_CommandName(t *testing.T) { @@ -45,6 +47,13 @@ func TestNewSetUpCmd_WorkspaceFolderFlagOptional(t *testing.T) { assert.Equal(t, "", f.DefValue) } +func TestNewSetUpCmd_DockerPathFlagOptional(t *testing.T) { + cmd := NewSetUpCmd(&flags.GlobalFlags{}) + f := cmd.Flags().Lookup(flagSetUpDockerPath) + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) +} + func TestNewSetUpCmd_FailsWithoutContainer(t *testing.T) { cmd := NewSetUpCmd(&flags.GlobalFlags{}) cmd.SetArgs([]string{}) @@ -97,3 +106,30 @@ func TestBuildDockerExecArgs_MultipleCommandParts(t *testing.T) { } assert.Equal(t, expected, args) } + +func TestBuildDockerExecArgs_NoWorkdir(t *testing.T) { + args := buildDockerExecArgs( + testContainerName, + nil, + "", + []string{testCmdEcho, testCmdHello}, + ) + expected := []string{ + dockerExecSubcommand, + testContainerName, testCmdEcho, testCmdHello, + } + assert.Equal(t, expected, args) +} + +func TestSetUpCmd_ResolveDockerPath_Default(t *testing.T) { + cmd := &SetUpCmd{GlobalFlags: &flags.GlobalFlags{}} + assert.Equal(t, defaultDockerCommand, cmd.resolveDockerPath()) +} + +func TestSetUpCmd_ResolveDockerPath_Custom(t *testing.T) { + cmd := &SetUpCmd{ + GlobalFlags: &flags.GlobalFlags{}, + DockerPath: "/usr/local/bin/podman", + } + assert.Equal(t, "/usr/local/bin/podman", cmd.resolveDockerPath()) +} diff --git a/e2e/tests/setup/setup.go b/e2e/tests/setup/setup.go index 3a867bac3..03fa96e5d 100644 --- a/e2e/tests/setup/setup.go +++ b/e2e/tests/setup/setup.go @@ -113,6 +113,202 @@ var _ = ginkgo.Describe("devsy set-up command", ginkgo.Label("setup"), ginkgo.Or out := dockerExecInContainer(ctx, containerID, "cat", "/tmp/env-marker") gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("hello_world")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should execute onCreateCommand 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, + "onCreateCommand": "touch /tmp/oncreate-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/oncreate-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should execute updateContentCommand 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, + "updateContentCommand": "touch /tmp/updatecontent-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/updatecontent-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should execute postAttachCommand 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, + "postAttachCommand": "touch /tmp/postattach-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/postattach-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should pass remoteEnv 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) + + remoteEnvValue := "remote_env_works" + devcontainerJSON := map[string]any{ + imageKey: alpineImage, + "remoteEnv": map[string]string{"REMOTE_VAR": remoteEnvValue}, + "postCreateCommand": "sh -c 'echo -n $REMOTE_VAR > /tmp/remoteenv-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/remoteenv-marker") + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal(remoteEnvValue)) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should execute all lifecycle hooks in spec order", + 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, + "onCreateCommand": "sh -c 'echo -n 1 >> /tmp/order-marker'", + "updateContentCommand": "sh -c 'echo -n 2 >> /tmp/order-marker'", + "postCreateCommand": "sh -c 'echo -n 3 >> /tmp/order-marker'", + "postStartCommand": "sh -c 'echo -n 4 >> /tmp/order-marker'", + "postAttachCommand": "sh -c 'echo -n 5 >> /tmp/order-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/order-marker") + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("12345")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("should install a feature into the 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) + + localFeatureDir := filepath.Join(tmpDir, ".devcontainer", "my-feature") + // #nosec G301 -- test helper creating feature directory structure + err = os.MkdirAll(localFeatureDir, 0o750) + framework.ExpectNoError(err) + + featureJSON := map[string]any{ + "id": "my-feature", + "version": "1.0.0", + "name": "My Test Feature", + } + featureData, err := json.Marshal(featureJSON) + framework.ExpectNoError(err) + err = os.WriteFile( + filepath.Join(localFeatureDir, "devcontainer-feature.json"), + featureData, 0o600, + ) + framework.ExpectNoError(err) + + installScript := "#!/bin/sh\ntouch /tmp/feature-installed-marker\n" + // #nosec G306 -- install script must be executable for feature installation + err = os.WriteFile( + filepath.Join(localFeatureDir, "install.sh"), + []byte(installScript), 0o600, + ) + framework.ExpectNoError(err) + + devcontainerJSON := map[string]any{ + imageKey: alpineImage, + "features": map[string]any{ + "./my-feature": map[string]any{}, + }, + } + 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/feature-installed-marker") + gomega.Expect(out).To(gomega.BeEmpty()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) }) func startAlpineContainer(ctx context.Context) string { diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 21dc605a0..e75b29890 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -192,14 +192,14 @@ func copyFeaturesToDestination(features []*config.FeatureSet, targetDir string) // copy feature folder envPath := filepath.Join(featureDir, "devcontainer-features.env") - variables := getFeatureEnvVariables(feature.Config, feature.Options) + variables := GetFeatureEnvVariables(feature.Config, feature.Options) err = os.WriteFile(envPath, []byte(strings.Join(variables, "\n")), 0o600) if err != nil { return fmt.Errorf("write variables of feature %s: %w", feature.ConfigID, err) } installWrapperPath := filepath.Join(featureDir, "devcontainer-features-install.sh") - installWrapperContent := getFeatureInstallWrapperScript( + installWrapperContent := GetFeatureInstallWrapperScript( feature.ConfigID, feature.Config, variables, diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index c95da3c40..1abb2c014 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -29,7 +29,7 @@ const DEVCONTAINER_MANIFEST_MEDIATYPE = "application/vnd.devcontainers" var directTarballRegEx = regexp.MustCompile("devcontainer-feature-([a-zA-Z0-9_-]+).tgz") -func getFeatureInstallWrapperScript( +func GetFeatureInstallWrapperScript( idWithoutVersion string, feature *config.FeatureConfig, options []string, diff --git a/pkg/devcontainer/feature/options.go b/pkg/devcontainer/feature/options.go index e20460b60..be04794c5 100644 --- a/pkg/devcontainer/feature/options.go +++ b/pkg/devcontainer/feature/options.go @@ -135,7 +135,7 @@ func parseFeatureSecretsFile(path string) (map[string]map[string]string, error) return secrets, nil } -func getFeatureEnvVariables(feature *config.FeatureConfig, featureOptions any) []string { +func GetFeatureEnvVariables(feature *config.FeatureConfig, featureOptions any) []string { options := getFeatureValueObject(feature, featureOptions) variables := []string{} for k, v := range options {