diff --git a/e2e/tests/extends/extends.go b/e2e/tests/extends/extends.go index 2e37e973b..6706f2d97 100644 --- a/e2e/tests/extends/extends.go +++ b/e2e/tests/extends/extends.go @@ -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) { diff --git a/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json new file mode 100644 index 000000000..f4eb55897 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:DEVSY_UNSET_VAR:fallback}/devcontainer.json", + "name": "LocalEnv Default Extends Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json new file mode 100644 index 000000000..7c1f881c7 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-default/.devcontainer/fallback/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_FALLBACK": "fallback-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7ebe2d02f --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv-missing/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:DEVSY_NONEXISTENT_VAR}/devcontainer.json", + "name": "Missing Env Var Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json new file mode 100644 index 000000000..6988ba345 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localEnv:TEST_EXTENDS_DIR}/parent/devcontainer.json", + "name": "LocalEnv Extends Child" +} diff --git a/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json b/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json new file mode 100644 index 000000000..85f4a0dfc --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-localenv/parent/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_PARENT": "parent-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json new file mode 100644 index 000000000..8035b52b9 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/configs/parent/devcontainer.json @@ -0,0 +1,7 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu", + "remoteUser": "vscode", + "containerEnv": { + "FROM_PARENT": "workspace-parent-value" + } +} diff --git a/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json new file mode 100644 index 000000000..69ffd3669 --- /dev/null +++ b/e2e/tests/extends/testdata/varsub-workspace-folder/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "extends": "${localWorkspaceFolder}/configs/parent/devcontainer.json", + "name": "Workspace Folder Extends Child" +} diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go index 18e89113f..f3f8af3be 100644 --- a/pkg/devcontainer/config/extends_test.go +++ b/pkg/devcontainer/config/extends_test.go @@ -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 } diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index d81aeb977..aa7816007 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -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, @@ -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 { diff --git a/pkg/devcontainer/config/substitute.go b/pkg/devcontainer/config/substitute.go index 6d8e8c2a6..cd84ae883 100644 --- a/pkg/devcontainer/config/substitute.go +++ b/pkg/devcontainer/config/substitute.go @@ -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 @@ -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 }