Skip to content
123 changes: 91 additions & 32 deletions devcontainer/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir string,
// We should make a best-effort attempt to find the user.
// Features must be executed as root, so we need to swap back
// to the running user afterwards.
params.User, err = UserFromDockerfile(params.DockerfileContent)
params.User, err = UserFromDockerfile(params.DockerfileContent, buildArgs)
if err != nil {
return nil, fmt.Errorf("user from dockerfile: %w", err)
}
Expand Down Expand Up @@ -306,14 +306,75 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
return strings.Join(lines, "\n"), featureContexts, err
}

// buildArgsWithDefaults merges external build args with ARG defaults from a Dockerfile.
// External args take precedence over Dockerfile defaults.
func buildArgsWithDefaults(dockerfileContent string, externalArgs []string) ([]string, error) {
lexer := shell.NewLex('\\')

// Start with external args (these have highest precedence)
result := make([]string, len(externalArgs))
copy(result, externalArgs)

// Build a set of externally-provided arg names for quick lookup
externalArgNames := make(map[string]struct{})
for _, arg := range externalArgs {
if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
externalArgNames[parts[0]] = struct{}{}
}
}

// Process ARG instructions to add default values if not overridden
for _, line := range strings.Split(dockerfileContent, "\n") {
arg, ok := strings.CutPrefix(line, "ARG ")
if !ok {
continue
}
arg = strings.TrimSpace(arg)
if !strings.Contains(arg, "=") {
continue
}

parts := strings.SplitN(arg, "=", 2)
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(result))
if err != nil {
return nil, fmt.Errorf("processing %q: %w", line, err)
}

// Only use the default value if no external arg was provided
if _, exists := externalArgNames[key]; exists {
continue
}

val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(result))
if err != nil {
return nil, fmt.Errorf("processing %q: %w", line, err)
}
result = append(result, key+"="+val)
}

return result, nil
}

// UserFromDockerfile inspects the contents of a provided Dockerfile
// and returns the user that will be used to run the container.
func UserFromDockerfile(dockerfileContent string) (user string, err error) {
// Optionally accepts build args that may override default values in the Dockerfile.
func UserFromDockerfile(dockerfileContent string, buildArgs ...[]string) (user string, err error) {
var args []string
if len(buildArgs) > 0 {
args = buildArgs[0]
}

res, err := parser.Parse(strings.NewReader(dockerfileContent))
if err != nil {
return "", fmt.Errorf("parse dockerfile: %w", err)
}

resolvedArgs, err := buildArgsWithDefaults(dockerfileContent, args)
if err != nil {
return "", err
}
lexer := shell.NewLex('\\')

// Parse stages and user commands to determine the relevant user
// from the final stage.
var (
Expand Down Expand Up @@ -371,10 +432,16 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {
}

// If we can't find a user command, try to find the user from
// the image.
ref, err := name.ParseReference(strings.TrimSpace(stage.BaseName))
// the image. First, substitute any ARG variables in the image name.
imageRef := stage.BaseName
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(resolvedArgs))
if err != nil {
return "", fmt.Errorf("processing image ref %q: %w", stage.BaseName, err)
}

ref, err := name.ParseReference(strings.TrimSpace(imageRef))
if err != nil {
return "", fmt.Errorf("parse image ref %q: %w", stage.BaseName, err)
return "", fmt.Errorf("parse image ref %q: %w", imageRef, err)
}
user, err := UserFromImage(ref)
if err != nil {
Expand All @@ -388,40 +455,32 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {

// ImageFromDockerfile inspects the contents of a provided Dockerfile
// and returns the image that will be used to run the container.
func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) {
lexer := shell.NewLex('\\')
// Optionally accepts build args that may override default values in the Dockerfile.
func ImageFromDockerfile(dockerfileContent string, buildArgs ...[]string) (name.Reference, error) {
var args []string
if len(buildArgs) > 0 {
args = buildArgs[0]
}

resolvedArgs, err := buildArgsWithDefaults(dockerfileContent, args)
if err != nil {
return nil, err
}

// Find the FROM instruction
var imageRef string
lines := strings.Split(dockerfileContent, "\n")
// Iterate over lines in reverse
for i := len(lines) - 1; i >= 0; i-- {
line := lines[i]
if arg, ok := strings.CutPrefix(line, "ARG "); ok {
arg = strings.TrimSpace(arg)
if strings.Contains(arg, "=") {
parts := strings.SplitN(arg, "=", 2)
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(args))
if err != nil {
return nil, fmt.Errorf("processing %q: %w", line, err)
}
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(args))
if err != nil {
return nil, fmt.Errorf("processing %q: %w", line, err)
}
args = append(args, key+"="+val)
}
continue
}
if imageRef == "" {
if fromArgs, ok := strings.CutPrefix(line, "FROM "); ok {
imageRef = fromArgs
}
for _, line := range strings.Split(dockerfileContent, "\n") {
if fromArgs, ok := strings.CutPrefix(line, "FROM "); ok {
imageRef = fromArgs
break
}
}
if imageRef == "" {
return nil, fmt.Errorf("no FROM directive found")
}
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(args))

lexer := shell.NewLex('\\')
imageRef, _, err = lexer.ProcessWord(imageRef, shell.EnvsFromSlice(resolvedArgs))
if err != nil {
return nil, fmt.Errorf("processing %q: %w", imageRef, err)
}
Expand Down
115 changes: 114 additions & 1 deletion devcontainer/devcontainer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ func TestImageFromDockerfile(t *testing.T) {
}, {
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:0-${VARIANT}",
image: "mcr.microsoft.com/devcontainers/python:0-3.10",
}, {
content: "ARG VARIANT=3-bookworm\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}",
image: "mcr.microsoft.com/devcontainers/python:1-3-bookworm",
}, {
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:0-$VARIANT ",
image: "mcr.microsoft.com/devcontainers/python:0-3.10",
Expand All @@ -218,6 +221,117 @@ func TestImageFromDockerfile(t *testing.T) {
}
}

func TestImageFromDockerfileWithArgs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
default_image string
content string
image string
}{{
default_image: "mcr.microsoft.com/devcontainers/python:1-3-bookworm",
content: "ARG VARIANT=3-bookworm\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}, {
default_image: "mcr.microsoft.com/devcontainers/python:1-3.10",
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}, {
default_image: "mcr.microsoft.com/devcontainers/python:1-3.10",
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT\nUSER app",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}} {
tc := tc
t.Run(tc.image, func(t *testing.T) {
t.Parallel()
dc := &devcontainer.Spec{
Build: devcontainer.BuildSpec{
Dockerfile: "Dockerfile",
Context: ".",
Args: map[string]string{
"VARIANT": "3.11-bookworm",
},
},
}
fs := memfs.New()
dcDir := "/workspaces/coder/.devcontainer"
err := fs.MkdirAll(dcDir, 0o755)
require.NoError(t, err)
file, err := fs.OpenFile(filepath.Join(dcDir, "Dockerfile"), os.O_CREATE|os.O_WRONLY, 0o644)
require.NoError(t, err)
_, err = io.WriteString(file, tc.content)
require.NoError(t, err)
_ = file.Close()
params, err := dc.Compile(fs, dcDir, workingDir, "", "/var/workspace", false, stubLookupEnv)
require.NoError(t, err)
require.Equal(t, "VARIANT=3.11-bookworm", params.BuildArgs[0])
require.Equal(t, params.DockerfileContent, tc.content)
ref, err := devcontainer.ImageFromDockerfile(tc.content, params.BuildArgs)
require.NoError(t, err)
require.Equal(t, tc.image, ref.Name())
// Test without args (using defaults)
ref1, err := devcontainer.ImageFromDockerfile(tc.content)
require.NoError(t, err)
require.Equal(t, tc.default_image, ref1.Name())
})
}
}

func TestUserFromDockerfileWithArgs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
user string
content string
image string
}{{
user: "root",
content: "ARG VARIANT=3-bookworm\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}, {
user: "root",
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}, {
user: "app",
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT\nUSER app",
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
}} {
tc := tc
t.Run(tc.image, func(t *testing.T) {
t.Parallel()
dc := &devcontainer.Spec{
Build: devcontainer.BuildSpec{
Dockerfile: "Dockerfile",
Context: ".",
Args: map[string]string{
"VARIANT": "3.11-bookworm",
},
},
}
fs := memfs.New()
dcDir := "/workspaces/coder/.devcontainer"
err := fs.MkdirAll(dcDir, 0o755)
require.NoError(t, err)
file, err := fs.OpenFile(filepath.Join(dcDir, "Dockerfile"), os.O_CREATE|os.O_WRONLY, 0o644)
require.NoError(t, err)
_, err = io.WriteString(file, tc.content)
require.NoError(t, err)
_ = file.Close()
params, err := dc.Compile(fs, dcDir, workingDir, "", "/var/workspace", false, stubLookupEnv)
require.NoError(t, err)
require.Equal(t, "VARIANT=3.11-bookworm", params.BuildArgs[0])
require.Equal(t, params.DockerfileContent, tc.content)
// Test UserFromDockerfile without args
user1, err := devcontainer.UserFromDockerfile(tc.content)
require.NoError(t, err)
require.Equal(t, tc.user, user1)
// Test UserFromDockerfile with args
user2, err := devcontainer.UserFromDockerfile(tc.content, params.BuildArgs)
require.NoError(t, err)
require.Equal(t, tc.user, user2)
})
}
}

func TestUserFrom(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -310,7 +424,6 @@ func TestUserFrom(t *testing.T) {
require.NoError(t, err)
parsed.Path = "coder/test:" + tag
ref, err := name.ParseReference(strings.TrimPrefix(parsed.String(), "http://"))
fmt.Println(ref)
require.NoError(t, err)
err = remote.Write(ref, image)
require.NoError(t, err)
Expand Down
2 changes: 1 addition & 1 deletion envbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
defer cleanupBuildContext()
if runtimeData.Built && opts.SkipRebuild {
endStage := startStage("🏗️ Skipping build because of cache...")
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent)
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent, buildParams.BuildArgs)
if err != nil {
return nil, fmt.Errorf("image from dockerfile: %w", err)
}
Expand Down