Skip to content

Commit

Permalink
Support providing feature directories in build contexts
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aaronlehmann committed Mar 29, 2024
1 parent 6a88184 commit 54b0690
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 26 deletions.
30 changes: 17 additions & 13 deletions devcontainer/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type Compiled struct {
DockerfilePath string
DockerfileContent string
BuildContext string
FeatureContexts map[string]string
BuildArgs []string

User string
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand All @@ -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"}
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions devcontainer/devcontainer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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)
Expand All @@ -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])
Expand Down
14 changes: 11 additions & 3 deletions devcontainer/features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 := ""
Expand All @@ -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)
Expand Down
21 changes: 15 additions & 6 deletions devcontainer/features/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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))
})
}
2 changes: 1 addition & 1 deletion envbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 54b0690

Please sign in to comment.