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
123 changes: 123 additions & 0 deletions e2e/tests/extends/extends.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,129 @@ var _ = ginkgo.Describe("extends property", ginkgo.Label("extends"), func() {
gomega.HaveKey("ghcr.io/devcontainers/features/go:1"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It(
"resolves ${localEnv:VAR} variable substitution in extends path",
ginkgo.Label("extends"),
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/extends/testdata/varsub-localenv",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

gomega.Expect(os.Setenv("TEST_EXTENDS_DIR", tempDir)).To(gomega.Succeed())
ginkgo.DeferCleanup(func() {
gomega.Expect(os.Unsetenv("TEST_EXTENDS_DIR")).To(gomega.Succeed())
})

stdout, _, err := readConfiguration(ctx, f, tempDir)
framework.ExpectNoError(err)

config := parseConfigFromOutput(stdout)
gomega.Expect(config).To(
gomega.HaveKeyWithValue("name", "LocalEnv Extends Child"),
)
gomega.Expect(config).To(
gomega.HaveKeyWithValue(
"image",
"ghcr.io/devsy-org/test-images/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("FROM_PARENT", "parent-value"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It(
"resolves ${localWorkspaceFolder} variable substitution in extends path",
ginkgo.Label("extends"),
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/extends/testdata/varsub-workspace-folder",
)
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", "Workspace Folder Extends Child"),
)
gomega.Expect(config).To(
gomega.HaveKeyWithValue(
"image",
"ghcr.io/devsy-org/test-images/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("FROM_PARENT", "workspace-parent-value"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It(
"resolves ${localEnv:VAR:default} with fallback when env var is unset",
ginkgo.Label("extends"),
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/extends/testdata/varsub-localenv-default",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

gomega.Expect(os.Unsetenv("DEVSY_UNSET_VAR")).To(gomega.Succeed())

stdout, _, err := readConfiguration(ctx, f, tempDir)
framework.ExpectNoError(err)

config := parseConfigFromOutput(stdout)
gomega.Expect(config).To(
gomega.HaveKeyWithValue("name", "LocalEnv Default Extends Child"),
)
gomega.Expect(config).To(
gomega.HaveKeyWithValue(
"image",
"ghcr.io/devsy-org/test-images/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("FROM_FALLBACK", "fallback-value"),
)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It(
"returns error when ${localEnv:VAR} resolves to empty for missing env var",
ginkgo.Label("extends"),
func(ctx context.Context) {
f := framework.NewDefaultFramework(initialDir + "/bin")
tempDir, err := framework.CopyToTempDirWithoutChdir(
"tests/extends/testdata/varsub-localenv-missing",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) })

gomega.Expect(os.Unsetenv("DEVSY_NONEXISTENT_VAR")).To(gomega.Succeed())

_, _, err = readConfiguration(ctx, f, tempDir)
framework.ExpectError(err)
}, ginkgo.SpecTimeout(framework.TimeoutShort()))
})

func pushOCIImage(refStr, jsonContent string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "${localEnv:DEVSY_UNSET_VAR:fallback}/devcontainer.json",
"name": "LocalEnv Default Extends Child"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/devsy-org/test-images/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"FROM_FALLBACK": "fallback-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "${localEnv:DEVSY_NONEXISTENT_VAR}/devcontainer.json",
"name": "Missing Env Var Child"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "${localEnv:TEST_EXTENDS_DIR}/parent/devcontainer.json",
"name": "LocalEnv Extends Child"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/devsy-org/test-images/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"FROM_PARENT": "parent-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/devsy-org/test-images/base:ubuntu",
"remoteUser": "vscode",
"containerEnv": {
"FROM_PARENT": "workspace-parent-value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "${localWorkspaceFolder}/configs/parent/devcontainer.json",
"name": "Workspace Folder Extends Child"
}
146 changes: 146 additions & 0 deletions pkg/devcontainer/config/extends_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,152 @@ func TestExtendsRef_MarshalJSON(t *testing.T) {
}
}

func TestExtends_LocalEnvInPath(t *testing.T) {
tmpDir := t.TempDir()
writeJSON(t, tmpDir, "parent.json", `{
"name": "parent",
"image": "ubuntu:20.04"
}`)

childDir := filepath.Join(tmpDir, "child")
// #nosec G301 -- test directory
if err := os.MkdirAll(childDir, 0o750); err != nil {
t.Fatal(err)
}

t.Setenv("TEST_EXTENDS_DIR", tmpDir)
writeJSON(t, childDir, "child.json", `{
"extends": "${localEnv:TEST_EXTENDS_DIR}/parent.json",
"name": "child"
}`)

cfg, err := ParseDevContainerJSONFile(filepath.Join(childDir, "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)
}
}

func TestExtends_LocalWorkspaceFolderInPath(t *testing.T) {
tmpDir := t.TempDir()

configsDir := filepath.Join(tmpDir, "configs")
// #nosec G301 -- test directory
if err := os.MkdirAll(configsDir, 0o750); err != nil {
t.Fatal(err)
}
writeJSON(t, configsDir, "parent.json", `{
"name": "parent",
"image": "node:18"
}`)

writeJSON(t, tmpDir, "child.json", `{
"extends": "${localWorkspaceFolder}/configs/parent.json",
"name": "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 != "node:18" {
t.Errorf("expected image 'node:18', got %q", cfg.Image)
}
}

func TestExtends_MissingEnvResolvesToEmpty(t *testing.T) {
tmpDir := t.TempDir()
writeJSON(t, tmpDir, "child.json", `{
"extends": "${localEnv:NONEXISTENT_VAR_FOR_TEST}/parent.json",
"name": "child"
}`)

_, err := ParseDevContainerJSONFile(filepath.Join(tmpDir, "child.json"))
if err == nil {
t.Fatal("expected error due to invalid path from empty env var")
}
if strings.Contains(err.Error(), "${localEnv:") {
t.Errorf(
"variable should have been substituted (resolved to empty), but got literal: %v",
err,
)
}
}

func TestExtends_LocalEnvDefaultValue(t *testing.T) {
tmpDir := t.TempDir()

fallbackDir := filepath.Join(tmpDir, "fallback")
// #nosec G301 -- test directory
if err := os.MkdirAll(fallbackDir, 0o750); err != nil {
t.Fatal(err)
}
writeJSON(t, fallbackDir, "parent.json", `{
"name": "parent",
"image": "debian:12"
}`)

t.Setenv("UNSET_VAR_FOR_EXTENDS_TEST", "")
if err := os.Unsetenv("UNSET_VAR_FOR_EXTENDS_TEST"); err != nil {
t.Fatal(err)
}

writeJSON(t, tmpDir, "child.json", `{
"extends": "${localEnv:UNSET_VAR_FOR_EXTENDS_TEST:fallback}/parent.json",
"name": "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 != "debian:12" {
t.Errorf("expected image 'debian:12', got %q", cfg.Image)
}
}

func TestExtends_LocalWorkspaceFolderBasenameInPath(t *testing.T) {
tmpDir := t.TempDir()
basename := filepath.Base(tmpDir)

parentDir := filepath.Join(tmpDir, basename)
// #nosec G301 -- test directory
if err := os.MkdirAll(parentDir, 0o750); err != nil {
t.Fatal(err)
}
writeJSON(t, parentDir, "parent.json", `{
"name": "parent",
"image": "alpine:3"
}`)

writeJSON(t, tmpDir, "child.json", `{
"extends": "${localWorkspaceFolder}/${localWorkspaceFolderBasename}/parent.json",
"name": "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 != "alpine:3" {
t.Errorf("expected image 'alpine:3', got %q", cfg.Image)
}
}

func strPtr(s string) *string {
return &s
}
33 changes: 33 additions & 0 deletions pkg/devcontainer/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error)
if !devContainer.Extends.IsEmpty() {
visited := map[string]bool{path: true}
declaringDir := filepath.Dir(path)

replacer := extendsVarReplacer(declaringDir)
for i, ref := range devContainer.Extends {
devContainer.Extends[i] = ResolveString(ref, replacer)
}

parent, err := resolveExtendsArray(
context.TODO(),
devContainer.Extends,
Expand Down Expand Up @@ -284,6 +290,33 @@ func Convert(from any, to any) error {
return json.Unmarshal(out, to)
}

// extendsVarReplacer returns a ReplaceFunction that resolves only local-scope
// variables suitable for use before the container exists.
func extendsVarReplacer(localWorkspaceFolder string) ReplaceFunction {
return func(match, variable string, args []string) string {
switch variable {
case varLocalEnv:
if len(args) > 0 {
val, ok := os.LookupEnv(args[0])
if ok {
return val
}
if len(args) > 1 {
return strings.Join(args[1:], ":")
}
return ""
}
return match
case varLocalWorkspaceFolder:
return localWorkspaceFolder
case "localWorkspaceFolderBasename":
return filepath.Base(localWorkspaceFolder)
default:
return match
}
}
}

func ParseKeyValueFile(filename string) ([]string, error) {
f, err := os.Open(filename)
if err != nil {
Expand Down
7 changes: 5 additions & 2 deletions pkg/devcontainer/config/substitute.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const (

LabelLocalFolder = "devcontainer.local_folder"
LabelConfigFile = "devcontainer.config_file"

varLocalEnv = "localEnv"
varLocalWorkspaceFolder = "localWorkspaceFolder"
)

type ReplaceFunction func(match, variable string, args []string) string
Expand Down Expand Up @@ -147,9 +150,9 @@ func replaceWithContext(
return substitutionCtx.DevContainerID
}
return match
case "localEnv":
case varLocalEnv:
return lookupValue(isWindows, substitutionCtx.Env, args, match)
case "localWorkspaceFolder":
case varLocalWorkspaceFolder:
if substitutionCtx.LocalWorkspaceFolder != "" {
return substitutionCtx.LocalWorkspaceFolder
}
Expand Down
Loading