Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions e2e/tests/extends-up/extends_up.go
Original file line number Diff line number Diff line change
@@ -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()))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"image": "ghcr.io/devsy-org/test-images/go:1",
"containerEnv": {
"FROM_BASE": "base-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "base.json",
"name": "extends-up-test",
"containerEnv": {
"FROM_CHILD": "child-value"
}
}
164 changes: 164 additions & 0 deletions e2e/tests/extends/extends.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"forwardPorts": [3000, 4000],
"capAdd": ["SYS_PTRACE"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "base.json",
"name": "Array Replace Child",
"forwardPorts": [8080]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "other.json",
"name": "Cycle A"
}
4 changes: 4 additions & 0 deletions e2e/tests/extends/testdata/cycle/.devcontainer/other.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "devcontainer.json",
"name": "Cycle B"
}
12 changes: 12 additions & 0 deletions e2e/tests/extends/testdata/deep-merge/.devcontainer/base.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "nonexistent.json",
"name": "Missing File"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "parent.json",
"name": "Multi Level Child",
"containerEnv": {
"LEVEL": "child"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"LEVEL": "grandparent",
"GP_ONLY": "gp-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "grandparent.json",
"containerEnv": {
"LEVEL": "parent",
"PARENT_ONLY": "parent-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"BASE_VAR": "from-base"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "base.json",
"name": "Single Level Child"
}
3 changes: 3 additions & 0 deletions pkg/devcontainer/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
Loading
Loading