Skip to content

Commit 5784366

Browse files
committed
Attempt to fix arg parsing
1 parent f60fff8 commit 5784366

File tree

3 files changed

+163
-18
lines changed

3 files changed

+163
-18
lines changed

devcontainer/devcontainer.go

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir string,
204204
// We should make a best-effort attempt to find the user.
205205
// Features must be executed as root, so we need to swap back
206206
// to the running user afterwards.
207-
params.User, err = UserFromDockerfile(params.DockerfileContent)
207+
params.User, err = UserFromDockerfile(params.DockerfileContent, buildArgs)
208208
if err != nil {
209209
return nil, fmt.Errorf("user from dockerfile: %w", err)
210210
}
@@ -308,12 +308,57 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
308308

309309
// UserFromDockerfile inspects the contents of a provided Dockerfile
310310
// and returns the user that will be used to run the container.
311-
func UserFromDockerfile(dockerfileContent string) (user string, err error) {
311+
// Optionally accepts build args that may override default values in the Dockerfile.
312+
func UserFromDockerfile(dockerfileContent string, buildArgs ...[]string) (user string, err error) {
313+
var args []string
314+
if len(buildArgs) > 0 {
315+
args = buildArgs[0]
316+
}
317+
312318
res, err := parser.Parse(strings.NewReader(dockerfileContent))
313319
if err != nil {
314320
return "", fmt.Errorf("parse dockerfile: %w", err)
315321
}
316322

323+
// Parse build args and ARG instructions to build the substitution context
324+
lexer := shell.NewLex('\\')
325+
326+
// Start with build args provided externally (e.g., from devcontainer.json)
327+
argsCopy := make([]string, len(args))
328+
copy(argsCopy, args)
329+
330+
// Parse build args into a map for easy lookup
331+
buildArgsMap := make(map[string]string)
332+
for _, arg := range args {
333+
if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
334+
buildArgsMap[parts[0]] = parts[1]
335+
}
336+
}
337+
338+
// Process ARG instructions to add default values if not overridden
339+
lines := strings.Split(dockerfileContent, "\n")
340+
for _, line := range lines {
341+
if arg, ok := strings.CutPrefix(line, "ARG "); ok {
342+
arg = strings.TrimSpace(arg)
343+
if strings.Contains(arg, "=") {
344+
parts := strings.SplitN(arg, "=", 2)
345+
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(argsCopy))
346+
if err != nil {
347+
return "", fmt.Errorf("processing %q: %w", line, err)
348+
}
349+
350+
// Only use the default value if no build arg was provided
351+
if _, exists := buildArgsMap[key]; !exists {
352+
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(argsCopy))
353+
if err != nil {
354+
return "", fmt.Errorf("processing %q: %w", line, err)
355+
}
356+
argsCopy = append(argsCopy, key+"="+val)
357+
}
358+
}
359+
}
360+
}
361+
317362
// Parse stages and user commands to determine the relevant user
318363
// from the final stage.
319364
var (
@@ -371,10 +416,16 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {
371416
}
372417

373418
// If we can't find a user command, try to find the user from
374-
// the image.
375-
ref, err := name.ParseReference(strings.TrimSpace(stage.BaseName))
419+
// the image. First, substitute any ARG variables in the image name.
420+
imageRef := stage.BaseName
421+
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(argsCopy))
376422
if err != nil {
377-
return "", fmt.Errorf("parse image ref %q: %w", stage.BaseName, err)
423+
return "", fmt.Errorf("processing image ref %q: %w", stage.BaseName, err)
424+
}
425+
426+
ref, err := name.ParseReference(strings.TrimSpace(imageRef))
427+
if err != nil {
428+
return "", fmt.Errorf("parse image ref %q: %w", imageRef, err)
378429
}
379430
user, err := UserFromImage(ref)
380431
if err != nil {
@@ -388,27 +439,50 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {
388439

389440
// ImageFromDockerfile inspects the contents of a provided Dockerfile
390441
// and returns the image that will be used to run the container.
391-
func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) {
392-
lexer := shell.NewLex('\\')
442+
// Optionally accepts build args that may override default values in the Dockerfile.
443+
func ImageFromDockerfile(dockerfileContent string, buildArgs ...[]string) (name.Reference, error) {
393444
var args []string
445+
if len(buildArgs) > 0 {
446+
args = buildArgs[0]
447+
}
448+
449+
lexer := shell.NewLex('\\')
450+
451+
// Start with build args provided externally (e.g., from devcontainer.json)
452+
// These have higher precedence than default values in ARG instructions
453+
argsCopy := make([]string, len(args))
454+
copy(argsCopy, args)
455+
456+
// Parse build args into a map for easy lookup
457+
buildArgsMap := make(map[string]string)
458+
for _, arg := range args {
459+
if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
460+
buildArgsMap[parts[0]] = parts[1]
461+
}
462+
}
463+
394464
var imageRef string
395465
lines := strings.Split(dockerfileContent, "\n")
396-
// Iterate over lines in reverse
466+
// Iterate over lines in reverse to find ARG declarations and FROM instruction
397467
for i := len(lines) - 1; i >= 0; i-- {
398468
line := lines[i]
399469
if arg, ok := strings.CutPrefix(line, "ARG "); ok {
400470
arg = strings.TrimSpace(arg)
401471
if strings.Contains(arg, "=") {
402472
parts := strings.SplitN(arg, "=", 2)
403-
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(args))
473+
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(argsCopy))
404474
if err != nil {
405475
return nil, fmt.Errorf("processing %q: %w", line, err)
406476
}
407-
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(args))
408-
if err != nil {
409-
return nil, fmt.Errorf("processing %q: %w", line, err)
477+
478+
// Only use the default value if no build arg was provided
479+
if _, exists := buildArgsMap[key]; !exists {
480+
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(argsCopy))
481+
if err != nil {
482+
return nil, fmt.Errorf("processing %q: %w", line, err)
483+
}
484+
argsCopy = append(argsCopy, key+"="+val)
410485
}
411-
args = append(args, key+"="+val)
412486
}
413487
continue
414488
}
@@ -421,7 +495,7 @@ func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) {
421495
if imageRef == "" {
422496
return nil, fmt.Errorf("no FROM directive found")
423497
}
424-
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(args))
498+
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(argsCopy))
425499
if err != nil {
426500
return nil, fmt.Errorf("processing %q: %w", imageRef, err)
427501
}

devcontainer/devcontainer_test.go

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,13 +224,76 @@ func TestImageFromDockerfile(t *testing.T) {
224224
func TestImageFromDockerfileWithArgs(t *testing.T) {
225225
t.Parallel()
226226
for _, tc := range []struct {
227+
default_image string
228+
content string
229+
image string
230+
}{{
231+
default_image: "mcr.microsoft.com/devcontainers/python:1-3-bookworm",
232+
content: "ARG VARIANT=3-bookworm\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}",
233+
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
234+
}, {
235+
default_image: "mcr.microsoft.com/devcontainers/python:1-3.10",
236+
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT",
237+
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
238+
}, {
239+
default_image: "mcr.microsoft.com/devcontainers/python:1-3.10",
240+
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT\nUSER app",
241+
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
242+
}} {
243+
tc := tc
244+
t.Run(tc.image, func(t *testing.T) {
245+
t.Parallel()
246+
dc := &devcontainer.Spec{
247+
Build: devcontainer.BuildSpec{
248+
Dockerfile: "Dockerfile",
249+
Context: ".",
250+
Args: map[string]string{
251+
"VARIANT": "3.11-bookworm",
252+
},
253+
},
254+
}
255+
fs := memfs.New()
256+
dcDir := "/workspaces/coder/.devcontainer"
257+
err := fs.MkdirAll(dcDir, 0o755)
258+
require.NoError(t, err)
259+
file, err := fs.OpenFile(filepath.Join(dcDir, "Dockerfile"), os.O_CREATE|os.O_WRONLY, 0o644)
260+
require.NoError(t, err)
261+
_, err = io.WriteString(file, tc.content)
262+
require.NoError(t, err)
263+
_ = file.Close()
264+
params, err := dc.Compile(fs, dcDir, workingDir, "", "/var/workspace", false, stubLookupEnv)
265+
require.NoError(t, err)
266+
require.Equal(t, "VARIANT=3.11-bookworm", params.BuildArgs[0])
267+
require.Equal(t, params.DockerfileContent, tc.content)
268+
ref, err := devcontainer.ImageFromDockerfile(tc.content, params.BuildArgs)
269+
require.NoError(t, err)
270+
require.Equal(t, tc.image, ref.Name())
271+
// Test without args (using defaults)
272+
fmt.Println("Testing ImageFromDockerfile without args...")
273+
ref1, err := devcontainer.ImageFromDockerfile(tc.content)
274+
require.NoError(t, err)
275+
require.Equal(t, tc.default_image, ref1.Name())
276+
})
277+
}
278+
}
279+
280+
func TestUserFromDockerfileWithArgs(t *testing.T) {
281+
t.Parallel()
282+
for _, tc := range []struct {
283+
user string
227284
content string
228285
image string
229286
}{{
287+
user: "root",
230288
content: "ARG VARIANT=3-bookworm\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}",
231289
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
232290
}, {
233-
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT ",
291+
user: "root",
292+
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT",
293+
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
294+
}, {
295+
user: "app",
296+
content: "ARG VARIANT=\"3.10\"\nFROM mcr.microsoft.com/devcontainers/python:1-$VARIANT\nUSER app",
234297
image: "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
235298
}} {
236299
tc := tc
@@ -258,9 +321,17 @@ func TestImageFromDockerfileWithArgs(t *testing.T) {
258321
require.NoError(t, err)
259322
require.Equal(t, "VARIANT=3.11-bookworm", params.BuildArgs[0])
260323
require.Equal(t, params.DockerfileContent, tc.content)
261-
ref, err := devcontainer.ImageFromDockerfile(tc.content)
324+
// Test UserFromDockerfile without args
325+
fmt.Println("\nTesting UserFromDockerfile without args...")
326+
user1, err := devcontainer.UserFromDockerfile(tc.content)
262327
require.NoError(t, err)
263-
require.Equal(t, tc.image, ref.Name())
328+
require.Equal(t, tc.user, user1)
329+
// Test UserFromDockerfile with args
330+
fmt.Println("\nTesting UserFromDockerfile with args...")
331+
user2, err := devcontainer.UserFromDockerfile(tc.content, params.BuildArgs)
332+
require.NoError(t, err)
333+
require.Equal(t, tc.user, user2)
334+
264335
})
265336
}
266337
}

envbuilder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
493493
defer cleanupBuildContext()
494494
if runtimeData.Built && opts.SkipRebuild {
495495
endStage := startStage("🏗️ Skipping build because of cache...")
496-
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent)
496+
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent, buildParams.BuildArgs)
497497
if err != nil {
498498
return nil, fmt.Errorf("image from dockerfile: %w", err)
499499
}

0 commit comments

Comments
 (0)