diff --git a/devcontainer/devcontainer.go b/devcontainer/devcontainer.go index 34ae747a..3519d351 100644 --- a/devcontainer/devcontainer.go +++ b/devcontainer/devcontainer.go @@ -29,10 +29,11 @@ func Parse(content []byte) (*Spec, error) { } type Spec struct { - Image string `json:"image"` - Build BuildSpec `json:"build"` - RemoteUser string `json:"remoteUser"` - RemoteEnv map[string]string `json:"remoteEnv"` + Image string `json:"image"` + Build BuildSpec `json:"build"` + RemoteUser string `json:"remoteUser"` + ContainerUser string `json:"containerUser"` + RemoteEnv map[string]string `json:"remoteEnv"` // Features is a map of feature names to feature configurations. Features map[string]any `json:"features"` LifecycleScripts @@ -89,7 +90,7 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir, fallbac env = append(env, key+"="+value) } params := &Compiled{ - User: s.RemoteUser, + User: s.ContainerUser, Env: env, } @@ -164,14 +165,18 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir, fallbac return nil, fmt.Errorf("get user from image: %w", err) } } - params.DockerfileContent, err = s.compileFeatures(fs, scratchDir, params.User, params.DockerfileContent) + remoteUser := s.RemoteUser + if remoteUser == "" { + remoteUser = params.User + } + params.DockerfileContent, err = s.compileFeatures(fs, scratchDir, params.User, remoteUser, params.DockerfileContent) if err != nil { return nil, err } return params, nil } -func (s *Spec) compileFeatures(fs billy.Filesystem, scratchDir, remoteUser, dockerfileContent string) (string, error) { +func (s *Spec) compileFeatures(fs billy.Filesystem, scratchDir, containerUser, remoteUser, dockerfileContent string) (string, error) { // If there are no features, we don't need to do anything! if len(s.Features) == 0 { return dockerfileContent, nil @@ -227,7 +232,7 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, scratchDir, remoteUser, dock if err != nil { return "", fmt.Errorf("extract feature %s: %w", featureRefRaw, err) } - directive, err := spec.Compile(featureOpts) + directive, err := spec.Compile(containerUser, remoteUser, featureOpts) if err != nil { return "", fmt.Errorf("compile feature %s: %w", featureRefRaw, err) } diff --git a/devcontainer/features/features.go b/devcontainer/features/features.go index 71ece0b6..6c19550c 100644 --- a/devcontainer/features/features.go +++ b/devcontainer/features/features.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/go-git/go-billy/v5" @@ -169,8 +170,14 @@ type Spec struct { // Extract unpacks the feature from the image and returns a set of lines // that should be appended to a Dockerfile to install the feature. -func (s *Spec) Compile(options map[string]any) (string, error) { - var runDirective []string +func (s *Spec) Compile(containerUser, remoteUser string, options map[string]any) (string, error) { + // TODO not sure how we figure out _(REMOTE|CONTAINER)_USER_HOME + // as per the feature spec. + // See https://containers.dev/implementors/features/#user-env-var + runDirective := []string{ + "_CONTAINER_USER=" + strconv.Quote(containerUser), + "_REMOTE_USER=" + strconv.Quote(remoteUser), + } for key, value := range s.Options { strValue := fmt.Sprint(value.Default) provided, ok := options[key] @@ -179,7 +186,7 @@ func (s *Spec) Compile(options map[string]any) (string, error) { // delete so we can check if there are any unknown options delete(options, key) } - runDirective = append(runDirective, fmt.Sprintf(`%s="%s"`, convertOptionNameToEnv(key), strValue)) + runDirective = append(runDirective, fmt.Sprintf(`%s=%q`, convertOptionNameToEnv(key), strValue)) } if len(options) > 0 { return "", fmt.Errorf("unknown option: %v", options) diff --git a/devcontainer/features/features_test.go b/devcontainer/features/features_test.go index dc5cf560..d17e9711 100644 --- a/devcontainer/features/features_test.go +++ b/devcontainer/features/features_test.go @@ -73,7 +73,7 @@ func TestCompile(t *testing.T) { t.Run("UnknownOption", func(t *testing.T) { t.Parallel() spec := &features.Spec{} - _, err := spec.Compile(map[string]any{ + _, err := spec.Compile("containerUser", "remoteUser", map[string]any{ "unknown": "value", }) require.ErrorContains(t, err, "unknown option") @@ -83,9 +83,9 @@ func TestCompile(t *testing.T) { spec := &features.Spec{ Directory: "/", } - directive, err := spec.Compile(nil) + directive, err := spec.Compile("containerUser", "remoteUser", nil) require.NoError(t, err) - require.Equal(t, "WORKDIR /\nRUN ./install.sh", strings.TrimSpace(directive)) + require.Equal(t, "WORKDIR /\nRUN _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive)) }) t.Run("ContainerEnv", func(t *testing.T) { t.Parallel() @@ -95,9 +95,9 @@ func TestCompile(t *testing.T) { "FOO": "bar", }, } - directive, err := spec.Compile(nil) + directive, err := spec.Compile("containerUser", "remoteUser", nil) require.NoError(t, err) - require.Equal(t, "WORKDIR /\nENV FOO=bar\nRUN ./install.sh", strings.TrimSpace(directive)) + require.Equal(t, "WORKDIR /\nENV FOO=bar\nRUN _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive)) }) t.Run("OptionsEnv", func(t *testing.T) { t.Parallel() @@ -109,8 +109,8 @@ func TestCompile(t *testing.T) { }, }, } - directive, err := spec.Compile(nil) + directive, err := spec.Compile("containerUser", "remoteUser", nil) require.NoError(t, err) - require.Equal(t, "WORKDIR /\nRUN FOO=\"bar\" ./install.sh", strings.TrimSpace(directive)) + require.Equal(t, "WORKDIR /\nRUN FOO=\"bar\" _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive)) }) }