diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 4946f701f..182b3df7f 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -192,6 +192,18 @@ jobs: install-kind: false requires-secret: false + - label: extends + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + + - label: extends-up + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + - label: down runner: ubuntu-latest free-disk-space: false diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 1745df044..a293207ef 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -13,6 +13,8 @@ import ( _ "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/extends" + _ "github.com/devsy-org/devsy/e2e/tests/extends-up" _ "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/extends-up/extends_up.go b/e2e/tests/extends-up/extends_up.go new file mode 100644 index 000000000..6e375a995 --- /dev/null +++ b/e2e/tests/extends-up/extends_up.go @@ -0,0 +1,45 @@ +package extendsup + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("extends up", ginkgo.Label("extends-up"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("starts container with merged config from extends chain", func(ctx context.Context) { + f, err := framework.SetupDockerProvider(filepath.Join(initialDir, "bin"), "docker") + framework.ExpectNoError(err) + + tempDir, err := framework.CopyToTempDir("tests/extends-up/testdata/up-extends") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + // Verify env from parent (base.json) + out, err := f.DevsySSH(ctx, tempDir, "echo -n $FROM_BASE") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("base-value")) + + // Verify env from child (devcontainer.json) + out, err = f.DevsySSH(ctx, tempDir, "echo -n $FROM_CHILD") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("child-value")) + }, ginkgo.SpecTimeout(framework.TimeoutLong())) +}) diff --git a/e2e/tests/extends-up/testdata/up-extends/.devcontainer/base.json b/e2e/tests/extends-up/testdata/up-extends/.devcontainer/base.json new file mode 100644 index 000000000..386c94ed6 --- /dev/null +++ b/e2e/tests/extends-up/testdata/up-extends/.devcontainer/base.json @@ -0,0 +1,6 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "containerEnv": { + "FROM_BASE": "base-value" + } +} diff --git a/e2e/tests/extends-up/testdata/up-extends/.devcontainer/devcontainer.json b/e2e/tests/extends-up/testdata/up-extends/.devcontainer/devcontainer.json new file mode 100644 index 000000000..efc043cf5 --- /dev/null +++ b/e2e/tests/extends-up/testdata/up-extends/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "extends": "base.json", + "name": "extends-up-test", + "containerEnv": { + "FROM_CHILD": "child-value" + } +} diff --git a/e2e/tests/extends/extends.go b/e2e/tests/extends/extends.go new file mode 100644 index 000000000..c3145dd13 --- /dev/null +++ b/e2e/tests/extends/extends.go @@ -0,0 +1,164 @@ +package extends + +import ( + "context" + "encoding/json" + "os" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("extends property", ginkgo.Label("extends"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It("resolves single-level extends inheriting parent fields", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/single-level", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To(gomega.HaveKeyWithValue("name", "Single Level Child")) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("image", "mcr.microsoft.com/devcontainers/base:ubuntu"), + ) + gomega.Expect(config).To(gomega.HaveKeyWithValue("remoteUser", "vscode")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("deep merges map fields from parent and child", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/deep-merge", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To(gomega.HaveKeyWithValue("name", "Deep Merge Child")) + + containerEnv, ok := config["containerEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "containerEnv should be an object") + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("SHARED_KEY", "from-child")) + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("BASE_ONLY", "base-value")) + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("CHILD_ONLY", "child-value")) + + features, ok := config["features"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "features should be an object") + gomega.Expect(features).To(gomega.HaveKey("ghcr.io/devcontainers/features/node:1")) + gomega.Expect(features).To(gomega.HaveKey("ghcr.io/devcontainers/features/go:1")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("replaces array fields entirely from child", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/array-replace", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To(gomega.HaveKeyWithValue("name", "Array Replace Child")) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("image", "mcr.microsoft.com/devcontainers/base:ubuntu"), + ) + + forwardPorts, ok := config["forwardPorts"].([]any) + gomega.Expect(ok).To(gomega.BeTrue(), "forwardPorts should be an array") + gomega.Expect(forwardPorts).To(gomega.HaveLen(1)) + gomega.Expect(forwardPorts[0]).To(gomega.Equal("8080")) + + // capAdd from parent should remain since child didn't set it + capAdd, ok := config["capAdd"].([]any) + gomega.Expect(ok).To(gomega.BeTrue(), "capAdd should be inherited from parent") + gomega.Expect(capAdd).To(gomega.ContainElement("SYS_PTRACE")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("resolves multi-level extends chain", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/multi-level", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := readConfiguration(ctx, f, tempDir) + framework.ExpectNoError(err) + + config := parseConfigFromOutput(stdout) + gomega.Expect(config).To(gomega.HaveKeyWithValue("name", "Multi Level Child")) + gomega.Expect(config).To( + gomega.HaveKeyWithValue("image", "mcr.microsoft.com/devcontainers/base:ubuntu"), + ) + gomega.Expect(config).To(gomega.HaveKeyWithValue("remoteUser", "vscode")) + + containerEnv, ok := config["containerEnv"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "containerEnv should be an object") + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("LEVEL", "child")) + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("PARENT_ONLY", "parent-value")) + gomega.Expect(containerEnv).To(gomega.HaveKeyWithValue("GP_ONLY", "gp-value")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("returns error on circular extends", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/cycle", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err = readConfiguration(ctx, f, tempDir) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("returns error when extends references missing file", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDirWithoutChdir( + "tests/extends/testdata/missing-file", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err = readConfiguration(ctx, f, tempDir) + framework.ExpectError(err) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) +}) + +func readConfiguration( + ctx context.Context, + f *framework.Framework, + workspaceFolder string, +) (string, string, error) { + return f.ExecCommandCapture(ctx, []string{ + "read-configuration", + "--workspace-folder", workspaceFolder, + }) +} + +func parseConfigFromOutput(stdout string) map[string]any { + var result map[string]any + err := json.Unmarshal([]byte(stdout), &result) + framework.ExpectNoError(err, "output should be valid JSON") + + config, ok := result["configuration"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), "configuration should be an object") + return config +} diff --git a/e2e/tests/extends/testdata/array-replace/.devcontainer/base.json b/e2e/tests/extends/testdata/array-replace/.devcontainer/base.json new file mode 100644 index 000000000..cc5b7615a --- /dev/null +++ b/e2e/tests/extends/testdata/array-replace/.devcontainer/base.json @@ -0,0 +1,5 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000, 4000], + "capAdd": ["SYS_PTRACE"] +} diff --git a/e2e/tests/extends/testdata/array-replace/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/array-replace/.devcontainer/devcontainer.json new file mode 100644 index 000000000..44d1a9f88 --- /dev/null +++ b/e2e/tests/extends/testdata/array-replace/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "extends": "base.json", + "name": "Array Replace Child", + "forwardPorts": [8080] +} diff --git a/e2e/tests/extends/testdata/cycle/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/cycle/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f43c63483 --- /dev/null +++ b/e2e/tests/extends/testdata/cycle/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "other.json", + "name": "Cycle A" +} diff --git a/e2e/tests/extends/testdata/cycle/.devcontainer/other.json b/e2e/tests/extends/testdata/cycle/.devcontainer/other.json new file mode 100644 index 000000000..28c7746c6 --- /dev/null +++ b/e2e/tests/extends/testdata/cycle/.devcontainer/other.json @@ -0,0 +1,4 @@ +{ + "extends": "devcontainer.json", + "name": "Cycle B" +} diff --git a/e2e/tests/extends/testdata/deep-merge/.devcontainer/base.json b/e2e/tests/extends/testdata/deep-merge/.devcontainer/base.json new file mode 100644 index 000000000..62066edf8 --- /dev/null +++ b/e2e/tests/extends/testdata/deep-merge/.devcontainer/base.json @@ -0,0 +1,12 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "containerEnv": { + "SHARED_KEY": "from-base", + "BASE_ONLY": "base-value" + }, + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "18" + } + } +} diff --git a/e2e/tests/extends/testdata/deep-merge/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/deep-merge/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c00f9121d --- /dev/null +++ b/e2e/tests/extends/testdata/deep-merge/.devcontainer/devcontainer.json @@ -0,0 +1,13 @@ +{ + "extends": "base.json", + "name": "Deep Merge Child", + "containerEnv": { + "SHARED_KEY": "from-child", + "CHILD_ONLY": "child-value" + }, + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "1.21" + } + } +} diff --git a/e2e/tests/extends/testdata/missing-file/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/missing-file/.devcontainer/devcontainer.json new file mode 100644 index 000000000..090c3764a --- /dev/null +++ b/e2e/tests/extends/testdata/missing-file/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "nonexistent.json", + "name": "Missing File" +} diff --git a/e2e/tests/extends/testdata/multi-level/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/multi-level/.devcontainer/devcontainer.json new file mode 100644 index 000000000..fc80ab47d --- /dev/null +++ b/e2e/tests/extends/testdata/multi-level/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "extends": "parent.json", + "name": "Multi Level Child", + "containerEnv": { + "LEVEL": "child" + } +} diff --git a/e2e/tests/extends/testdata/multi-level/.devcontainer/grandparent.json b/e2e/tests/extends/testdata/multi-level/.devcontainer/grandparent.json new file mode 100644 index 000000000..286b5b5f7 --- /dev/null +++ b/e2e/tests/extends/testdata/multi-level/.devcontainer/grandparent.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "LEVEL": "grandparent", + "GP_ONLY": "gp-value" + } +} diff --git a/e2e/tests/extends/testdata/multi-level/.devcontainer/parent.json b/e2e/tests/extends/testdata/multi-level/.devcontainer/parent.json new file mode 100644 index 000000000..560fd7fbc --- /dev/null +++ b/e2e/tests/extends/testdata/multi-level/.devcontainer/parent.json @@ -0,0 +1,7 @@ +{ + "extends": "grandparent.json", + "containerEnv": { + "LEVEL": "parent", + "PARENT_ONLY": "parent-value" + } +} diff --git a/e2e/tests/extends/testdata/single-level/.devcontainer/base.json b/e2e/tests/extends/testdata/single-level/.devcontainer/base.json new file mode 100644 index 000000000..b66714e5f --- /dev/null +++ b/e2e/tests/extends/testdata/single-level/.devcontainer/base.json @@ -0,0 +1,7 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "BASE_VAR": "from-base" + } +} diff --git a/e2e/tests/extends/testdata/single-level/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/single-level/.devcontainer/devcontainer.json new file mode 100644 index 000000000..53a5e42cc --- /dev/null +++ b/e2e/tests/extends/testdata/single-level/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "base.json", + "name": "Single Level Child" +} diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 4bdd73c04..66f8fac36 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -45,6 +45,9 @@ func CloneDevContainerConfig(config *DevContainerConfig) *DevContainerConfig { } type DevContainerConfigBase struct { + // Path to another devcontainer.json to inherit from. + Extends string `json:"extends,omitempty"` + // A name for the dev container which can be displayed to the user. Name string `json:"name,omitempty"` diff --git a/pkg/devcontainer/config/extends.go b/pkg/devcontainer/config/extends.go new file mode 100644 index 000000000..063486345 --- /dev/null +++ b/pkg/devcontainer/config/extends.go @@ -0,0 +1,278 @@ +package config + +import ( + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + + "github.com/tailscale/hujson" +) + +// resolveExtends resolves the extends chain for a devcontainer.json file. +// It returns the fully resolved parent config (with its own extends already merged). +// visited tracks files already in the resolution chain for cycle detection. +func resolveExtends( + extendsRef, declaringDir string, + visited map[string]bool, +) (*DevContainerConfig, error) { + // Resolve relative path against the declaring file's directory + refPath := extendsRef + if !filepath.IsAbs(refPath) { + refPath = filepath.Join(declaringDir, refPath) + } + + absPath, err := filepath.Abs(refPath) + if err != nil { + return nil, fmt.Errorf("extends: resolve path %q: %w", extendsRef, err) + } + + // Cycle detection + if visited[absPath] { + return nil, fmt.Errorf("extends: cycle detected, %q already in chain", absPath) + } + + return parseDevContainerJSONFileWithVisited(absPath, visited) +} + +// parseDevContainerJSONFileWithVisited parses a devcontainer.json and recursively resolves extends. +func parseDevContainerJSONFileWithVisited( + path string, + visited map[string]bool, +) (*DevContainerConfig, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("make path absolute: %w", err) + } + + // Mark this file as visited + visited[absPath] = true + + // #nosec G304 -- path is derived from user-authored devcontainer.json extends field + bytes, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("extends: read %q: %w", absPath, err) + } + + devContainer := &DevContainerConfig{} + normalized, err := hujson.Standardize(bytes) + if err != nil { + return nil, fmt.Errorf("extends: parse jsonc %q: %w", absPath, err) + } + err = json.Unmarshal(normalized, devContainer) + if err != nil { + return nil, fmt.Errorf("extends: unmarshal %q: %w", absPath, err) + } + devContainer.Origin = absPath + + // Recursively resolve extends + if devContainer.Extends != "" { + declaringDir := filepath.Dir(absPath) + parent, err := resolveExtends(devContainer.Extends, declaringDir, visited) + if err != nil { + return nil, err + } + devContainer = mergeExtendsConfigs(parent, devContainer) + } + + return devContainer, nil +} + +func mergeExtendsConfigs(parent, child *DevContainerConfig) *DevContainerConfig { + result := CloneDevContainerConfig(parent) + + mergeScalars(result, child) + mergePointerScalars(result, child) + mergeMapsInto(result, child) + mergeArrays(result, child) + mergeLifecycleHooks(result, child) + mergeNestedStructs(result, child) + + // Special + result.Origin = child.Origin + result.Extends = "" + + return result +} + +func mergeScalars(result, child *DevContainerConfig) { + mergeBaseScalars(result, child) + mergeContainerScalars(result, child) +} + +func mergeBaseScalars(result, child *DevContainerConfig) { + if child.Name != "" { + result.Name = child.Name + } + if child.Image != "" { + result.Image = child.Image + } + if child.Dockerfile != "" { + result.Dockerfile = child.Dockerfile + } + if child.Context != "" { + result.Context = child.Context + } + if child.Service != "" { + result.Service = child.Service + } + if child.ContainerUser != "" { + result.ContainerUser = child.ContainerUser + } + if child.RemoteUser != "" { + result.RemoteUser = child.RemoteUser + } +} + +func mergeContainerScalars(result, child *DevContainerConfig) { + if child.WorkspaceFolder != "" { + result.WorkspaceFolder = child.WorkspaceFolder + } + if child.WorkspaceMount != "" { + result.WorkspaceMount = child.WorkspaceMount + } + if child.ShutdownAction != "" { + result.ShutdownAction = child.ShutdownAction + } + if child.WaitFor != "" { + result.WaitFor = child.WaitFor + } + if child.UserEnvProbe != "" { + result.UserEnvProbe = child.UserEnvProbe + } + if child.ContainerID != "" { + result.ContainerID = child.ContainerID + } +} + +// mergePointerScalars copies non-nil pointer fields from child into result. +func mergePointerScalars(result, child *DevContainerConfig) { + if child.UpdateRemoteUserUID != nil { + result.UpdateRemoteUserUID = child.UpdateRemoteUserUID + } + if child.OverrideCommand != nil { + result.OverrideCommand = child.OverrideCommand + } + if child.Init != nil { + result.Init = child.Init + } + if child.Privileged != nil { + result.Privileged = child.Privileged + } + if child.OtherPortsAttributes != nil { + result.OtherPortsAttributes = child.OtherPortsAttributes + } +} + +// mergeMapsInto deep-merges map fields (parent as base, child keys override). +func mergeMapsInto(result, child *DevContainerConfig) { + result.Features = deepMergeMapAny(result.Features, child.Features) + result.PortsAttributes = deepMergeMap(result.PortsAttributes, child.PortsAttributes) + result.RemoteEnv = deepMergeMap(result.RemoteEnv, child.RemoteEnv) + result.ContainerEnv = deepMergeMap(result.ContainerEnv, child.ContainerEnv) + result.Customizations = deepMergeMapAny(result.Customizations, child.Customizations) + result.Secrets = deepMergeMap(result.Secrets, child.Secrets) +} + +func mergeArrays(result, child *DevContainerConfig) { + mergeNonComposeArrays(result, child) + mergeComposeArrays(result, child) +} + +func mergeNonComposeArrays(result, child *DevContainerConfig) { + if child.ForwardPorts != nil { + result.ForwardPorts = child.ForwardPorts + } + if child.Mounts != nil { + result.Mounts = child.Mounts + } + if child.RunArgs != nil { + result.RunArgs = child.RunArgs + } + if child.CapAdd != nil { + result.CapAdd = child.CapAdd + } + if child.SecurityOpt != nil { + result.SecurityOpt = child.SecurityOpt + } + if child.AppPort != nil { + result.AppPort = child.AppPort + } +} + +func mergeComposeArrays(result, child *DevContainerConfig) { + if child.RunServices != nil { + result.RunServices = child.RunServices + } + if child.OverrideFeatureInstallOrder != nil { + result.OverrideFeatureInstallOrder = child.OverrideFeatureInstallOrder + } + if child.DockerComposeFile != nil { + result.DockerComposeFile = child.DockerComposeFile + } +} + +// mergeLifecycleHooks replaces lifecycle hooks when child has non-empty values. +func mergeLifecycleHooks(result, child *DevContainerConfig) { + if len(child.InitializeCommand) > 0 { + result.InitializeCommand = child.InitializeCommand + } + if len(child.OnCreateCommand) > 0 { + result.OnCreateCommand = child.OnCreateCommand + } + if len(child.UpdateContentCommand) > 0 { + result.UpdateContentCommand = child.UpdateContentCommand + } + if len(child.PostCreateCommand) > 0 { + result.PostCreateCommand = child.PostCreateCommand + } + if len(child.PostStartCommand) > 0 { + result.PostStartCommand = child.PostStartCommand + } + if len(child.PostAttachCommand) > 0 { + result.PostAttachCommand = child.PostAttachCommand + } +} + +// mergeNestedStructs handles Build and HostRequirements merging. +func mergeNestedStructs(result, child *DevContainerConfig) { + if child.Build != nil { + if result.Build != nil { + // Deep merge Build.Args + mergedArgs := deepMergeMap(result.Build.Args, child.Build.Args) + result.Build = child.Build + if mergedArgs != nil { + result.Build.Args = mergedArgs + } + } else { + result.Build = child.Build + } + } + + if child.HostRequirements != nil { + result.HostRequirements = child.HostRequirements + } +} + +// deepMergeMap merges two maps where child keys override parent keys. +func deepMergeMap[V any](parent, child map[string]V) map[string]V { + if parent == nil && child == nil { + return nil + } + merged := make(map[string]V) + maps.Copy(merged, parent) + maps.Copy(merged, child) + return merged +} + +// deepMergeMapAny merges two map[string]any maps where child keys override parent keys. +func deepMergeMapAny(parent, child map[string]any) map[string]any { + if parent == nil && child == nil { + return nil + } + merged := make(map[string]any) + maps.Copy(merged, parent) + maps.Copy(merged, child) + return merged +} diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go new file mode 100644 index 000000000..24e17b422 --- /dev/null +++ b/pkg/devcontainer/config/extends_test.go @@ -0,0 +1,504 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devsy-org/devsy/pkg/types" +) + +const ( + testNameChild = "child" + testImageUbuntu = "ubuntu:20.04" + testUserRoot = "root" + testOriginParent = "/tmp/parent.json" + testOriginChild = "/tmp/child.json" +) + +func writeJSON(t *testing.T, dir, filename, content string) string { + t.Helper() + path := filepath.Join(dir, filename) + // #nosec G306 -- test file + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtends_BasicScalarOverride(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "image": "ubuntu:20.04", + "remoteUser": "root" + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "name": "child", + "remoteUser": "vscode" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != testImageUbuntu { + t.Errorf("expected image 'ubuntu:20.04', got %q", cfg.Image) + } + if cfg.RemoteUser != "vscode" { + t.Errorf("expected remoteUser 'vscode', got %q", cfg.RemoteUser) + } + if cfg.Extends != "" { + t.Errorf("expected extends to be cleared, got %q", cfg.Extends) + } +} + +func TestExtends_MapDeepMerge_ContainerEnv(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "containerEnv": { + "FOO": "from_parent", + "BAR": "from_parent" + } + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "containerEnv": { + "FOO": "from_child", + "BAZ": "from_child" + } + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + if cfg.ContainerEnv["FOO"] != "from_child" { + t.Errorf("expected FOO='from_child', got %q", cfg.ContainerEnv["FOO"]) + } + if cfg.ContainerEnv["BAR"] != "from_parent" { + t.Errorf("expected BAR='from_parent', got %q", cfg.ContainerEnv["BAR"]) + } + if cfg.ContainerEnv["BAZ"] != "from_child" { + t.Errorf("expected BAZ='from_child', got %q", cfg.ContainerEnv["BAZ"]) + } +} + +func TestExtends_MapDeepMerge_Features(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/node:1": {"version": "16"} + } + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "features": { + "ghcr.io/devcontainers/features/node:1": {"version": "20"}, + "ghcr.io/devcontainers/features/python:1": {} + } + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + if _, ok := cfg.Features["ghcr.io/devcontainers/features/git:1"]; !ok { + t.Error("expected git feature to be preserved from parent") + } + if _, ok := cfg.Features["ghcr.io/devcontainers/features/python:1"]; !ok { + t.Error("expected python feature to be added from child") + } + nodeFeature, ok := cfg.Features["ghcr.io/devcontainers/features/node:1"] + if !ok { + t.Fatal("expected node feature") + } + nodeMap, ok := nodeFeature.(map[string]any) + if !ok { + t.Fatalf("expected node feature to be map, got %T", nodeFeature) + } + if nodeMap["version"] != "20" { + t.Errorf("expected node version '20', got %v", nodeMap["version"]) + } +} + +func TestExtends_ArrayReplacement(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "forwardPorts": [3000, 5000], + "runArgs": ["--network=host"], + "capAdd": ["SYS_PTRACE"] + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "forwardPorts": [8080], + "capAdd": ["NET_ADMIN", "SYS_ADMIN"] + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + // forwardPorts: replaced entirely + if len(cfg.ForwardPorts) != 1 || cfg.ForwardPorts[0] != "8080" { + t.Errorf("expected forwardPorts [8080], got %v", cfg.ForwardPorts) + } + // runArgs: preserved from parent (child didn't set it) + if len(cfg.RunArgs) != 1 || cfg.RunArgs[0] != "--network=host" { + t.Errorf("expected runArgs [--network=host], got %v", cfg.RunArgs) + } + // capAdd: replaced entirely + if len(cfg.CapAdd) != 2 { + t.Errorf("expected 2 capAdd entries, got %d", len(cfg.CapAdd)) + } +} + +func TestExtends_LifecycleHookReplacement(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "postCreateCommand": "echo parent", + "onCreateCommand": "echo oncreate-parent" + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "postCreateCommand": "echo child" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + // postCreateCommand: child replaces + if len(cfg.PostCreateCommand) == 0 { + t.Fatal("expected postCreateCommand to be set") + } + cmds := cfg.PostCreateCommand[""] + if len(cmds) != 1 || cmds[0] != "echo child" { + t.Errorf("expected postCreateCommand 'echo child', got %v", cfg.PostCreateCommand) + } + + // onCreateCommand: preserved from parent + if len(cfg.OnCreateCommand) == 0 { + t.Fatal("expected onCreateCommand to be preserved from parent") + } + oncreate := cfg.OnCreateCommand[""] + if len(oncreate) != 1 || oncreate[0] != "echo oncreate-parent" { + t.Errorf("expected onCreateCommand 'echo oncreate-parent', got %v", cfg.OnCreateCommand) + } +} + +func TestExtends_CycleDetection(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "a.json", `{ + "extends": "b.json", + "name": "a" + }`) + writeJSON(t, tmpDir, "b.json", `{ + "extends": "a.json", + "name": "b" + }`) + + _, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "a.json")) + if err == nil { + t.Fatal("expected cycle detection error") + } + if !strings.Contains(err.Error(), "cycle detected") { + t.Errorf("expected 'cycle detected' in error, got: %v", err) + } +} + +func TestExtends_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "child.json", `{ + "extends": "nonexistent.json", + "name": "child" + }`) + + _, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err == nil { + t.Fatal("expected error for missing extends file") + } + if !strings.Contains(err.Error(), "nonexistent.json") { + t.Errorf("expected error to mention missing file, got: %v", err) + } +} + +func TestExtends_MultiLevel(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "grandparent.json", `{ + "name": "grandparent", + "image": "ubuntu:18.04", + "remoteUser": "root", + "containerEnv": {"LEVEL": "grandparent", "GP_ONLY": "gp"} + }`) + writeJSON(t, tmpDir, "parent.json", `{ + "extends": "grandparent.json", + "name": "parent", + "image": "ubuntu:20.04", + "containerEnv": {"LEVEL": "parent", "P_ONLY": "p"} + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "name": "child", + "containerEnv": {"LEVEL": "child"} + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + if cfg.Name != testNameChild { + t.Errorf("expected name 'child', got %q", cfg.Name) + } + if cfg.Image != testImageUbuntu { + t.Errorf("expected image 'ubuntu:20.04', got %q", cfg.Image) + } + if cfg.RemoteUser != testUserRoot { + t.Errorf("expected remoteUser 'root', got %q", cfg.RemoteUser) + } + if cfg.ContainerEnv["LEVEL"] != testNameChild { + t.Errorf("expected LEVEL='child', got %q", cfg.ContainerEnv["LEVEL"]) + } + if cfg.ContainerEnv["GP_ONLY"] != "gp" { + t.Errorf("expected GP_ONLY='gp', got %q", cfg.ContainerEnv["GP_ONLY"]) + } + if cfg.ContainerEnv["P_ONLY"] != "p" { + t.Errorf("expected P_ONLY='p', got %q", cfg.ContainerEnv["P_ONLY"]) + } +} + +func TestExtends_NoExtends(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "standalone.json", `{ + "name": "standalone", + "image": "node:18" + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "standalone.json")) + if err != nil { + t.Fatal(err) + } + if cfg.Name != "standalone" { + t.Errorf("expected name 'standalone', got %q", cfg.Name) + } + if cfg.Image != "node:18" { + t.Errorf("expected image 'node:18', got %q", cfg.Image) + } +} + +func TestExtends_OriginPreserved(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{"name": "parent", "image": "ubuntu:20.04"}`) + childPath := writeJSON(t, tmpDir, "child.json", `{"extends": "parent.json", "name": "child"}`) + + cfg, err := ParseDevContainerJSONFile(childPath) + if err != nil { + t.Fatal(err) + } + + absChild, _ := filepath.Abs(childPath) + if cfg.Origin != absChild { + t.Errorf("expected Origin=%q, got %q", absChild, cfg.Origin) + } +} + +func TestExtends_NestedStructBuildMerge(t *testing.T) { + tmpDir := t.TempDir() + writeJSON(t, tmpDir, "parent.json", `{ + "name": "parent", + "build": { + "dockerfile": "Dockerfile.parent", + "args": {"BASE_IMAGE": "ubuntu:20.04", "VARIANT": "default"} + } + }`) + writeJSON(t, tmpDir, "child.json", `{ + "extends": "parent.json", + "build": { + "dockerfile": "Dockerfile.child", + "args": {"VARIANT": "custom", "EXTRA": "added"} + } + }`) + + cfg, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json")) + if err != nil { + t.Fatal(err) + } + + if cfg.Build == nil { + t.Fatal("expected Build to be set") + } + if cfg.Build.Dockerfile != "Dockerfile.child" { + t.Errorf("expected dockerfile 'Dockerfile.child', got %q", cfg.Build.Dockerfile) + } + if cfg.Build.Args["BASE_IMAGE"] != testImageUbuntu { + t.Errorf("expected BASE_IMAGE from parent, got %q", cfg.Build.Args["BASE_IMAGE"]) + } + if cfg.Build.Args["VARIANT"] != "custom" { + t.Errorf("expected VARIANT='custom' from child, got %q", cfg.Build.Args["VARIANT"]) + } + if cfg.Build.Args["EXTRA"] != "added" { + t.Errorf("expected EXTRA='added' from child, got %q", cfg.Build.Args["EXTRA"]) + } +} + +func TestMergeExtendsConfigs_Scalars(t *testing.T) { + parent := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + Name: "parent", + RemoteUser: testUserRoot, + }, + ImageContainer: ImageContainer{Image: testImageUbuntu}, + } + parent.Origin = testOriginParent + + child := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + Name: testNameChild, + }, + } + child.Origin = testOriginChild + + result := mergeExtendsConfigs(parent, child) + + if result.Name != testNameChild { + t.Errorf("Name: got %q, want 'child'", result.Name) + } + if result.RemoteUser != testUserRoot { + t.Errorf("RemoteUser: got %q, want 'root'", result.RemoteUser) + } + if result.Image != testImageUbuntu { + t.Errorf("Image: got %q, want 'ubuntu:20.04'", result.Image) + } + if result.Origin != testOriginChild { + t.Errorf("Origin: got %q, want %q", result.Origin, testOriginChild) + } + if result.Extends != "" { + t.Errorf("Extends: should be cleared, got %q", result.Extends) + } +} + +func TestMergeExtendsConfigs_PointerScalars(t *testing.T) { + boolTrue := true + boolFalse := false + + parent := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + UpdateRemoteUserUID: &boolTrue, + }, + NonComposeBase: NonComposeBase{ + Init: &boolTrue, + }, + } + parent.Origin = testOriginParent + + child := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + UpdateRemoteUserUID: &boolFalse, + }, + } + child.Origin = testOriginChild + + result := mergeExtendsConfigs(parent, child) + + if result.UpdateRemoteUserUID == nil || *result.UpdateRemoteUserUID != false { + t.Error("UpdateRemoteUserUID: expected false from child") + } + if result.Init == nil || *result.Init != true { + t.Error("Init: expected true from parent") + } +} + +func TestMergeExtendsConfigs_Maps(t *testing.T) { + parent := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + Features: map[string]any{"feat-a": map[string]any{}}, + RemoteEnv: map[string]*string{"A": strPtr("1")}, + }, + NonComposeBase: NonComposeBase{ + ContainerEnv: map[string]string{"X": "parent"}, + }, + } + parent.Origin = testOriginParent + + child := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + Features: map[string]any{"feat-b": map[string]any{}}, + RemoteEnv: map[string]*string{"B": strPtr("2")}, + }, + NonComposeBase: NonComposeBase{ + ContainerEnv: map[string]string{"X": testNameChild, "Y": testNameChild}, + }, + } + child.Origin = testOriginChild + + result := mergeExtendsConfigs(parent, child) + + if result.Features["feat-a"] == nil { + t.Error("Features: expected feat-a from parent") + } + if result.Features["feat-b"] == nil { + t.Error("Features: expected feat-b from child") + } + if result.ContainerEnv["X"] != testNameChild { + t.Errorf("ContainerEnv X: got %q, want 'child'", result.ContainerEnv["X"]) + } + if result.ContainerEnv["Y"] != testNameChild { + t.Errorf("ContainerEnv Y: got %q, want 'child'", result.ContainerEnv["Y"]) + } + if *result.RemoteEnv["A"] != "1" { + t.Error("RemoteEnv: expected A=1 from parent") + } + if *result.RemoteEnv["B"] != "2" { + t.Error("RemoteEnv: expected B=2 from child") + } +} + +func TestMergeExtendsConfigs_ArraysAndHooks(t *testing.T) { + parent := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + ForwardPorts: types.StrIntArray{"3000"}, + InitializeCommand: types.LifecycleHook{"": {"echo parent-init"}}, + }, + DevContainerActions: DevContainerActions{ + OnCreateCommand: types.LifecycleHook{"": {"echo parent-oncreate"}}, + }, + } + parent.Origin = testOriginParent + + child := &DevContainerConfig{} + child.Origin = testOriginChild + + result := mergeExtendsConfigs(parent, child) + + // Arrays: parent preserved when child is nil + if len(result.ForwardPorts) != 1 || result.ForwardPorts[0] != "3000" { + t.Errorf("ForwardPorts: expected [3000] from parent, got %v", result.ForwardPorts) + } + // Lifecycle: parent preserved when child is empty + if len(result.InitializeCommand) == 0 { + t.Error("InitializeCommand: expected parent value") + } + if len(result.OnCreateCommand) == 0 { + t.Error("OnCreateCommand: expected parent value") + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index 012e6acdb..d2f70108e 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -95,6 +95,18 @@ func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error) return nil, err } devContainer.Origin = path + + // Resolve extends before applying legacy transforms + if devContainer.Extends != "" { + visited := map[string]bool{path: true} + declaringDir := filepath.Dir(path) + parent, err := resolveExtends(devContainer.Extends, declaringDir, visited) + if err != nil { + return nil, err + } + devContainer = mergeExtendsConfigs(parent, devContainer) + } + return replaceLegacy(devContainer) }