From 54b0690efc80795e8f97b8144a267264b4b8968c Mon Sep 17 00:00:00 2001 From: Aaron Lehmann Date: Fri, 29 Mar 2024 11:21:29 -0700 Subject: [PATCH] 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. --- devcontainer/devcontainer.go | 30 +++++++++++++++----------- devcontainer/devcontainer_test.go | 6 +++--- devcontainer/features/features.go | 14 +++++++++--- devcontainer/features/features_test.go | 21 ++++++++++++------ envbuilder.go | 2 +- 5 files changed, 47 insertions(+), 26 deletions(-) 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/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.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/devcontainer/features/features_test.go b/devcontainer/features/features_test.go index d6b9db0..6986e85 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,9 +83,9 @@ 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)) + 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)) }) t.Run("ContainerEnv", func(t *testing.T) { t.Parallel() @@ -95,9 +95,9 @@ 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)) + require.Equal(t, "WORKDIR /envbuilder-features/test\nENV FOO=bar\nRUN --mount=type=bind,from=test,target=/envbuilder-features/test,rw _CONTAINER_USER=\"containerUser\" _REMOTE_USER=\"remoteUser\" ./install.sh", strings.TrimSpace(directive)) }) t.Run("OptionsEnv", func(t *testing.T) { t.Parallel() @@ -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)) + }) } 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) }