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
21 changes: 21 additions & 0 deletions e2e/tests/up/testdata/docker-varsub-scope/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "VarSubScope",
"image": "ghcr.io/devsy-org/test-images/go:1",
"containerEnv": {
"SHOULD_BE_LITERAL_CWF": "${containerWorkspaceFolder}",
"SHOULD_BE_LITERAL_CWFB": "${containerWorkspaceFolderBasename}",
"SHOULD_BE_LITERAL_CENV": "${containerEnv:PATH}",
"SHOULD_RESOLVE_LOCAL": "${localWorkspaceFolder}"
},
"remoteEnv": {
"REMOTE_CWF": "${containerWorkspaceFolder}",
"REMOTE_CWFB": "${containerWorkspaceFolderBasename}",
"REMOTE_CENV": "${containerEnv:PATH}",
"REMOTE_LOCAL": "${localWorkspaceFolder}"
},
"postCreateCommand": [
"sh",
"-c",
"echo -n \"${SHOULD_BE_LITERAL_CWF}\" > $HOME/container-env-cwf.out && echo -n \"${SHOULD_BE_LITERAL_CWFB}\" > $HOME/container-env-cwfb.out && echo -n \"${SHOULD_BE_LITERAL_CENV}\" > $HOME/container-env-cenv.out && echo -n \"${SHOULD_RESOLVE_LOCAL}\" > $HOME/container-env-local.out && echo -n \"${REMOTE_CWF}\" > $HOME/remote-env-cwf.out && echo -n \"${REMOTE_CWFB}\" > $HOME/remote-env-cwfb.out && echo -n \"${REMOTE_CENV}\" > $HOME/remote-env-cenv.out && echo -n \"${REMOTE_LOCAL}\" > $HOME/remote-env-local.out"
]
}
123 changes: 123 additions & 0 deletions e2e/tests/up/varsub_scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package up

import (
"context"
"os"
"path/filepath"

"github.com/devsy-org/devsy/e2e/framework"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

var _ = ginkgo.Describe(
"testing variable substitution phase-aware scoping",
ginkgo.Label("up-varsub-scope"),
func() {
var dtc *dockerTestContext

ginkgo.BeforeEach(func(ctx context.Context) {
var err error
dtc = &dockerTestContext{}
dtc.initialDir, err = os.Getwd()
framework.ExpectNoError(err)

dtc.f, err = setupDockerProvider(
filepath.Join(dtc.initialDir, "bin"), "docker",
)
framework.ExpectNoError(err)
})

ginkgo.It(
"containerEnv preserves container-scoped vars as literals",
func(ctx context.Context) {
tempDir, err := dtc.setupAndUp(ctx, "tests/up/testdata/docker-varsub-scope")
framework.ExpectNoError(err)

workspace, err := dtc.f.FindWorkspace(ctx, tempDir)
framework.ExpectNoError(err)

// containerEnv: ${containerWorkspaceFolder} should be preserved as literal
// and resolved at runtime by the shell from the actual env var.
cwf, err := dtc.execSSHCapture(ctx, workspace.ID, "cat $HOME/container-env-cwf.out")
framework.ExpectNoError(err)
gomega.Expect(cwf).To(gomega.ContainSubstring("/workspaces/"),
"containerEnv containerWorkspaceFolder should resolve at runtime via shell")

// containerEnv: ${containerWorkspaceFolderBasename} should be preserved as literal
cwfb, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/container-env-cwfb.out",
)
framework.ExpectNoError(err)
gomega.Expect(cwfb).To(gomega.Equal(filepath.Base(tempDir)),
"containerEnv containerWorkspaceFolderBasename should resolve at runtime via shell")

// containerEnv: ${containerEnv:PATH} should remain literal
cenv, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/container-env-cenv.out",
)
framework.ExpectNoError(err)
gomega.Expect(cenv).To(gomega.ContainSubstring("/usr/local/bin"),
"containerEnv containerEnv:PATH should resolve at runtime via SubstituteContainerEnv")

// containerEnv: ${localWorkspaceFolder} should resolve during substitution
local, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/container-env-local.out",
)
framework.ExpectNoError(err)
gomega.Expect(framework.CleanString(local)).
To(gomega.Equal(framework.CleanString(tempDir)),
"containerEnv localWorkspaceFolder should resolve at substitution time")

// remoteEnv: ${containerWorkspaceFolder} should fully resolve
remoteCwf, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/remote-env-cwf.out",
)
framework.ExpectNoError(err)
gomega.Expect(framework.CleanString(remoteCwf)).
To(gomega.ContainSubstring("/workspaces/"),
"remoteEnv containerWorkspaceFolder should resolve at substitution time")

// remoteEnv: ${containerWorkspaceFolderBasename} should fully resolve
remoteCwfb, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/remote-env-cwfb.out",
)
framework.ExpectNoError(err)
gomega.Expect(remoteCwfb).To(gomega.Equal(filepath.Base(tempDir)),
"remoteEnv containerWorkspaceFolderBasename should resolve at substitution time")

// remoteEnv: ${containerEnv:PATH} resolved via SubstituteContainerEnv
remoteCenv, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/remote-env-cenv.out",
)
framework.ExpectNoError(err)
gomega.Expect(remoteCenv).To(gomega.ContainSubstring("/usr/local/bin"),
"remoteEnv containerEnv:PATH should resolve via SubstituteContainerEnv")

// remoteEnv: ${localWorkspaceFolder} should fully resolve
remoteLocal, err := dtc.execSSHCapture(
ctx,
workspace.ID,
"cat $HOME/remote-env-local.out",
)
framework.ExpectNoError(err)
gomega.Expect(framework.CleanString(remoteLocal)).
To(gomega.Equal(framework.CleanString(tempDir)),
"remoteEnv localWorkspaceFolder should resolve at substitution time")
},
ginkgo.SpecTimeout(framework.TimeoutShort()),
)
},
)
9 changes: 8 additions & 1 deletion pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,14 @@ func (r *runner) substitute(
WorkspaceMount: workspaceMount,
}

// substitute & load
// Substitute applies phase-aware variable scoping per the devcontainer spec
// (https://containers.dev/implementors/reference/ — "Variables in devcontainer.json"):
// - Pre-container fields (containerEnv) only resolve local-scoped variables
// (devcontainerId, localEnv, localWorkspaceFolder, localWorkspaceFolderBasename).
// - Post-container fields (remoteEnv, lifecycle commands, etc.) additionally
// resolve containerWorkspaceFolder and containerWorkspaceFolderBasename.
// - containerEnv references (${containerEnv:VAR}) are resolved later via
// SubstituteContainerEnv after the container is running.
parsedConfig := &config.DevContainerConfig{}
err := config.Substitute(substitutionContext, rawParsedConfig, parsedConfig)
if err != nil {
Expand Down
51 changes: 46 additions & 5 deletions pkg/devcontainer/config/substitute.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"maps"
"math/big"
"path/filepath"
"regexp"
Expand All @@ -13,7 +14,10 @@ import (
"github.com/devsy-org/devsy/pkg/hash"
)

const devContainerIDLength = 20
const (
devContainerIDLength = 20
containerEnvField = "containerEnv"
)

type ReplaceFunction func(match, variable string, args []string) string

Expand All @@ -35,6 +39,11 @@ type SubstitutionContext struct {
GidMap []string `json:"GidMap,omitempty"`
}

// preContainerFields lists devcontainer.json keys that are evaluated before
// the container exists. These fields must not resolve container-scoped
// variables (containerWorkspaceFolder, containerWorkspaceFolderBasename).
var preContainerFields = []string{containerEnvField}

func Substitute(substitutionCtx *SubstitutionContext, config any, out any) error {
newVal := map[string]any{}
err := Convert(config, &newVal)
Expand All @@ -60,9 +69,27 @@ func Substitute(substitutionCtx *SubstitutionContext, config any, out any) error
},
)
}
retVal := substitute0(newVal, func(match, variable string, args []string) string {

// Two-pass substitution: pre-container fields get a restricted replacer
// that preserves container-scoped variables as literals.
fullReplace := func(match, variable string, args []string) string {
return replaceWithContext(isWindows, substitutionCtx, match, variable, args)
})
}
preFieldValues := map[string]any{}
for _, key := range preContainerFields {
if fieldVal, ok := newVal[key]; ok {
preFieldValues[key] = substitute0(fieldVal, restrictedReplace(fullReplace))
delete(newVal, key)
}
}

// Full substitution for remaining fields.
retVal := substitute0(newVal, fullReplace)

// Merge pre-container fields back into the result.
if retMap, ok := retVal.(map[string]any); ok {
maps.Copy(retMap, preFieldValues)
}

err = Convert(retVal, out)
if err != nil {
Expand Down Expand Up @@ -98,7 +125,7 @@ func replaceWithContainerEnv(
args []string,
) string {
switch variable {
case "containerEnv":
case containerEnvField:
return lookupValue(false, containerEnv, args, match)
default:
return match
Expand Down Expand Up @@ -139,13 +166,27 @@ func replaceWithContext(
return filepath.Base(substitutionCtx.ContainerWorkspaceFolder)
}
return match
case "containerEnv":
case containerEnvField:
return match
default:
return match
}
}

// restrictedReplace wraps a ReplaceFunction to preserve container-scoped
// variables (containerWorkspaceFolder, containerWorkspaceFolderBasename,
// containerEnv) as literals for pre-container field substitution.
func restrictedReplace(fallback ReplaceFunction) ReplaceFunction {
return func(match, variable string, args []string) string {
switch variable {
case "containerWorkspaceFolder", "containerWorkspaceFolderBasename", containerEnvField:
return match
default:
return fallback(match, variable, args)
}
}
}

func lookupValue(isWindows bool, env map[string]string, args []string, match string) string {
if len(args) > 0 {
envVariableName := args[0]
Expand Down
Loading
Loading