Repository: coder/envbuilder
Pull request #117 (2024-03-29 22:07:24)

Support providing feature directories in build contexts
When running a build within envbuilder, files created on the filesystem by this Go code are available at the same paths within the container build process. However, if external code calls into the `devcontainer` package to produce a Dockerfile to use for a non-kaniko-based build, the resulting Dockerfile needs to mount the feature directories into the relevant RUN stages in order to access the feature install script and associated files.


Patch:
diff --git a/devcontainer/devcontainer.go b/devcontainer/devcontainer.go
index 6d94d0a..b7d4ad5 100644
--- a/devcontainer/devcontainer.go
+++ b/devcontainer/devcontainer.go
@@ -65,6 +65,7 @@ type Compiled struct {
 	DockerfilePath    string
 	DockerfileContent string
 	BuildContext      string
+	FeatureContexts   map[string]string
 	BuildArgs         []string
 
 	User         string
@@ -130,7 +131,7 @@ func (s Spec) HasDockerfile() bool {
 // devcontainerDir is the path to the directory where the devcontainer.json file
 // is located. scratchDir is the path to the directory where the Dockerfile will
 // be written to if one doesn't exist.
-func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir, fallbackDockerfile, workspaceFolder string) (*Compiled, error) {
+func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir string, fallbackDockerfile, workspaceFolder string, useBuildContexts bool) (*Compiled, error) {
 	params := &Compiled{
 		User:         s.ContainerUser,
 		ContainerEnv: s.ContainerEnv,
@@ -213,25 +214,26 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir, fallbac
 	if remoteUser == "" {
 		remoteUser = params.User
 	}
-	params.DockerfileContent, err = s.compileFeatures(fs, devcontainerDir, scratchDir, params.User, remoteUser, params.DockerfileContent)
+	params.DockerfileContent, params.FeatureContexts, err = s.compileFeatures(fs, devcontainerDir, scratchDir, params.User, remoteUser, params.DockerfileContent, useBuildContexts)
 	if err != nil {
 		return nil, err
 	}
 	return params, nil
 }
 
-func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir, containerUser, remoteUser, dockerfileContent string) (string, error) {
+func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir string, containerUser, remoteUser, dockerfileContent string, useBuildContexts bool) (string, map[string]string, error) {
 	// If there are no features, we don't need to do anything!
 	if len(s.Features) == 0 {
-		return dockerfileContent, nil
+		return dockerfileContent, nil, nil
 	}
 
 	featuresDir := filepath.Join(scratchDir, "features")
 	err := fs.MkdirAll(featuresDir, 0644)
 	if err != nil {
-		return "", fmt.Errorf("create features directory: %w", err)
+		return "", nil, fmt.Errorf("create features directory: %w", err)
 	}
 	featureDirectives := []string{}
+	featureContexts := make(map[string]string)
 
 	// TODO: Respect the installation order outlined by the spec:
 	// https://containers.dev/implementors/features/#installation-order
@@ -251,7 +253,7 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir,
 		if _, featureRef, ok = strings.Cut(featureRefRaw, "./"); !ok {
 			featureRefParsed, err := name.NewTag(featureRefRaw)
 			if err != nil {
-				return "", fmt.Errorf("parse feature ref %s: %w", featureRefRaw, err)
+				return "", nil, fmt.Errorf("parse feature ref %s: %w", featureRefRaw, err)
 			}
 			featureRef = featureRefParsed.Repository.Name()
 		}
@@ -275,19 +277,21 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir,
 		featureSha := md5.Sum([]byte(featureRefRaw))
 		featureName := filepath.Base(featureRef)
 		featureDir := filepath.Join(featuresDir, fmt.Sprintf("%s-%x", featureName, featureSha[:4]))
-		err = fs.MkdirAll(featureDir, 0644)
-		if err != nil {
-			return "", err
+		if err := fs.MkdirAll(featureDir, 0644); err != nil {
+			return "", nil, err
 		}
 		spec, err := features.Extract(fs, devcontainerDir, featureDir, featureRefRaw)
 		if err != nil {
-			return "", fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
+			return "", nil, fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
 		}
-		directive, err := spec.Compile(containerUser, remoteUser, featureOpts)
+		directive, err := spec.Compile(featureName, containerUser, remoteUser, useBuildContexts, featureOpts)
 		if err != nil {
-			return "", fmt.Errorf("compile feature %s: %w", featureRefRaw, err)
+			return "", nil, fmt.Errorf("compile feature %s: %w", featureRefRaw, err)
 		}
 		featureDirectives = append(featureDirectives, directive)
+		if useBuildContexts {
+			featureContexts[featureName] = featureDir
+		}
 	}
 
 	lines := []string{"\nUSER root"}
@@ -297,7 +301,7 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir,
 		// we're going to run as root.
 		lines = append(lines, fmt.Sprintf("USER %s", remoteUser))
 	}
-	return strings.Join(append([]string{dockerfileContent}, lines...), "\n"), err
+	return strings.Join(append([]string{dockerfileContent}, lines...), "\n"), featureContexts, err
 }
 
 // UserFromDockerfile inspects the contents of a provided Dockerfile
diff --git a/devcontainer/features/features.go b/devcontainer/features/features.go
index bc2d86d..d3a4184 100644
--- a/devcontainer/features/features.go
+++ b/devcontainer/features/features.go
@@ -194,7 +194,7 @@ 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(containerUser, remoteUser string, options map[string]any) (string, error) {
+func (s *Spec) Compile(featureName, containerUser, remoteUser string, useBuildContexts bool, 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
@@ -219,7 +219,11 @@ func (s *Spec) Compile(containerUser, remoteUser string, options map[string]any)
 	// regardless of map iteration order.
 	sort.Strings(runDirective)
 	// See https://containers.dev/implementors/features/#invoking-installsh
-	runDirective = append([]string{"RUN"}, runDirective...)
+	if useBuildContexts {
+		runDirective = append([]string{"RUN", "--mount=type=bind,from=" + featureName + ",target=/envbuilder-features/" + featureName + ",rw"}, runDirective...)
+	} else {
+		runDirective = append([]string{"RUN"}, runDirective...)
+	}
 	runDirective = append(runDirective, "./install.sh")
 
 	comment := ""
@@ -236,7 +240,11 @@ func (s *Spec) Compile(containerUser, remoteUser string, options map[string]any)
 	if comment != "" {
 		lines = append(lines, comment)
 	}
-	lines = append(lines, "WORKDIR "+s.Directory)
+	if useBuildContexts {
+		lines = append(lines, "WORKDIR /envbuilder-features/"+featureName)
+	} else {
+		lines = append(lines, "WORKDIR "+s.Directory)
+	}
 	envKeys := make([]string, 0, len(s.ContainerEnv))
 	for key := range s.ContainerEnv {
 		envKeys = append(envKeys, key)
diff --git a/envbuilder.go b/envbuilder.go
index 160ca3f..e9bf5bb 100644
--- a/envbuilder.go
+++ b/envbuilder.go
@@ -460,7 +460,7 @@ func Run(ctx context.Context, options Options) error {
 					logf(codersdk.LogLevelInfo, "No Dockerfile or image specified; falling back to the default image...")
 					fallbackDockerfile = defaultParams.DockerfilePath
 				}
-				buildParams, err = devContainer.Compile(options.Filesystem, devcontainerDir, MagicDir, fallbackDockerfile, options.WorkspaceFolder)
+				buildParams, err = devContainer.Compile(options.Filesystem, devcontainerDir, MagicDir, fallbackDockerfile, options.WorkspaceFolder, false)
 				if err != nil {
 					return fmt.Errorf("compile devcontainer.json: %w", err)
 				}


Test patch:
diff --git a/devcontainer/devcontainer_test.go b/devcontainer/devcontainer_test.go
index d8c558d..13054c3 100644
--- a/devcontainer/devcontainer_test.go
+++ b/devcontainer/devcontainer_test.go
@@ -87,7 +87,7 @@ func TestCompileWithFeatures(t *testing.T) {
 	dc, err := devcontainer.Parse([]byte(raw))
 	require.NoError(t, err)
 	fs := memfs.New()
-	params, err := dc.Compile(fs, "", magicDir, "", "")
+	params, err := dc.Compile(fs, "", magicDir, "", "", false)
 	require.NoError(t, err)
 
 	// We have to SHA because we get a different MD5 every time!
@@ -118,7 +118,7 @@ func TestCompileDevContainer(t *testing.T) {
 		dc := &devcontainer.Spec{
 			Image: "codercom/code-server:latest",
 		}
-		params, err := dc.Compile(fs, "", magicDir, "", "")
+		params, err := dc.Compile(fs, "", magicDir, "", "", false)
 		require.NoError(t, err)
 		require.Equal(t, filepath.Join(magicDir, "Dockerfile"), params.DockerfilePath)
 		require.Equal(t, magicDir, params.BuildContext)
@@ -144,7 +144,7 @@ func TestCompileDevContainer(t *testing.T) {
 		_, err = io.WriteString(file, "FROM ubuntu")
 		require.NoError(t, err)
 		_ = file.Close()
-		params, err := dc.Compile(fs, dcDir, magicDir, "", "/var/workspace")
+		params, err := dc.Compile(fs, dcDir, magicDir, "", "/var/workspace", false)
 		require.NoError(t, err)
 		require.Equal(t, "ARG1=value1", params.BuildArgs[0])
 		require.Equal(t, "ARG2=workspace", params.BuildArgs[1])
diff --git a/devcontainer/features/features_test.go b/devcontainer/features/features_test.go
index d6b9db0..03f8783 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("containerUser", "remoteUser", map[string]any{
+		_, err := spec.Compile("test", "containerUser", "remoteUser", false, map[string]any{
 			"unknown": "value",
 		})
 		require.ErrorContains(t, err, "unknown option")
@@ -83,7 +83,7 @@ func TestCompile(t *testing.T) {
 		spec := &features.Spec{
 			Directory: "/",
 		}
-		directive, err := spec.Compile("containerUser", "remoteUser", nil)
+		directive, err := spec.Compile("test", "containerUser", "remoteUser", false, nil)
 		require.NoError(t, err)
 		require.Equal(t, "WORKDIR /\nRUN _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive))
 	})
@@ -95,7 +95,7 @@ func TestCompile(t *testing.T) {
 				"FOO": "bar",
 			},
 		}
-		directive, err := spec.Compile("containerUser", "remoteUser", nil)
+		directive, err := spec.Compile("test", "containerUser", "remoteUser", false, nil)
 		require.NoError(t, err)
 		require.Equal(t, "WORKDIR /\nENV FOO=bar\nRUN _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive))
 	})
@@ -109,8 +109,17 @@ func TestCompile(t *testing.T) {
 				},
 			},
 		}
-		directive, err := spec.Compile("containerUser", "remoteUser", nil)
+		directive, err := spec.Compile("test", "containerUser", "remoteUser", false, nil)
 		require.NoError(t, err)
 		require.Equal(t, "WORKDIR /\nRUN FOO=\"bar\" _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive))
 	})
+	t.Run("BuildContext", func(t *testing.T) {
+		t.Parallel()
+		spec := &features.Spec{
+			Directory: "/",
+		}
+		directive, err := spec.Compile("test", "containerUser", "remoteUser", true, nil)
+		require.NoError(t, err)
+		require.Equal(t, "WORKDIR /envbuilder-features/test\nRUN --mount=type=bind,from=test,target=/envbuilder-features/test,rw _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive))
+	})
 }