Skip to content

Commit f5bd1f4

Browse files
hext-devclaude
andcommitted
refactor: extract buildArgsWithDefaults helper to reduce duplication
This commit improves the code quality of twsl's ARG parsing fix by extracting the duplicated logic into a reusable helper function. Changes: - Add buildArgsWithDefaults() helper that merges external build args with ARG defaults from a Dockerfile, respecting precedence rules - Refactor UserFromDockerfile() to use the new helper - Refactor ImageFromDockerfile() to use the new helper and simplify the FROM instruction finding logic - Remove debug fmt.Println statements from tests The original fix (PR coder#463) correctly addressed issue coder#455 where build args from devcontainer.json were not being used when resolving image references in Dockerfiles with ARG variables. This refactoring reduces code duplication while preserving the fix's behavior. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent f8b71cf commit f5bd1f4

File tree

2 files changed

+65
-84
lines changed

2 files changed

+65
-84
lines changed

devcontainer/devcontainer.go

Lines changed: 65 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,55 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
306306
return strings.Join(lines, "\n"), featureContexts, err
307307
}
308308

309+
// buildArgsWithDefaults merges external build args with ARG defaults from a Dockerfile.
310+
// External args take precedence over Dockerfile defaults.
311+
func buildArgsWithDefaults(dockerfileContent string, externalArgs []string) ([]string, error) {
312+
lexer := shell.NewLex('\\')
313+
314+
// Start with external args (these have highest precedence)
315+
result := make([]string, len(externalArgs))
316+
copy(result, externalArgs)
317+
318+
// Build a set of externally-provided arg names for quick lookup
319+
externalArgNames := make(map[string]struct{})
320+
for _, arg := range externalArgs {
321+
if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
322+
externalArgNames[parts[0]] = struct{}{}
323+
}
324+
}
325+
326+
// Process ARG instructions to add default values if not overridden
327+
for _, line := range strings.Split(dockerfileContent, "\n") {
328+
arg, ok := strings.CutPrefix(line, "ARG ")
329+
if !ok {
330+
continue
331+
}
332+
arg = strings.TrimSpace(arg)
333+
if !strings.Contains(arg, "=") {
334+
continue
335+
}
336+
337+
parts := strings.SplitN(arg, "=", 2)
338+
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(result))
339+
if err != nil {
340+
return nil, fmt.Errorf("processing %q: %w", line, err)
341+
}
342+
343+
// Only use the default value if no external arg was provided
344+
if _, exists := externalArgNames[key]; exists {
345+
continue
346+
}
347+
348+
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(result))
349+
if err != nil {
350+
return nil, fmt.Errorf("processing %q: %w", line, err)
351+
}
352+
result = append(result, key+"="+val)
353+
}
354+
355+
return result, nil
356+
}
357+
309358
// UserFromDockerfile inspects the contents of a provided Dockerfile
310359
// and returns the user that will be used to run the container.
311360
// Optionally accepts build args that may override default values in the Dockerfile.
@@ -320,44 +369,11 @@ func UserFromDockerfile(dockerfileContent string, buildArgs ...[]string) (user s
320369
return "", fmt.Errorf("parse dockerfile: %w", err)
321370
}
322371

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-
}
372+
resolvedArgs, err := buildArgsWithDefaults(dockerfileContent, args)
373+
if err != nil {
374+
return "", err
360375
}
376+
lexer := shell.NewLex('\\')
361377

362378
// Parse stages and user commands to determine the relevant user
363379
// from the final stage.
@@ -418,7 +434,7 @@ func UserFromDockerfile(dockerfileContent string, buildArgs ...[]string) (user s
418434
// If we can't find a user command, try to find the user from
419435
// the image. First, substitute any ARG variables in the image name.
420436
imageRef := stage.BaseName
421-
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(argsCopy))
437+
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(resolvedArgs))
422438
if err != nil {
423439
return "", fmt.Errorf("processing image ref %q: %w", stage.BaseName, err)
424440
}
@@ -446,56 +462,25 @@ func ImageFromDockerfile(dockerfileContent string, buildArgs ...[]string) (name.
446462
args = buildArgs[0]
447463
}
448464

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-
}
465+
resolvedArgs, err := buildArgsWithDefaults(dockerfileContent, args)
466+
if err != nil {
467+
return nil, err
462468
}
463469

470+
// Find the FROM instruction
464471
var imageRef string
465-
lines := strings.Split(dockerfileContent, "\n")
466-
// Iterate over lines in reverse to find ARG declarations and FROM instruction
467-
for i := len(lines) - 1; i >= 0; i-- {
468-
line := lines[i]
469-
if arg, ok := strings.CutPrefix(line, "ARG "); ok {
470-
arg = strings.TrimSpace(arg)
471-
if strings.Contains(arg, "=") {
472-
parts := strings.SplitN(arg, "=", 2)
473-
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(argsCopy))
474-
if err != nil {
475-
return nil, fmt.Errorf("processing %q: %w", line, err)
476-
}
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)
485-
}
486-
}
487-
continue
488-
}
489-
if imageRef == "" {
490-
if fromArgs, ok := strings.CutPrefix(line, "FROM "); ok {
491-
imageRef = fromArgs
492-
}
472+
for _, line := range strings.Split(dockerfileContent, "\n") {
473+
if fromArgs, ok := strings.CutPrefix(line, "FROM "); ok {
474+
imageRef = fromArgs
475+
break
493476
}
494477
}
495478
if imageRef == "" {
496479
return nil, fmt.Errorf("no FROM directive found")
497480
}
498-
imageRef, _, err := lexer.ProcessWord(imageRef, shell.EnvsFromSlice(argsCopy))
481+
482+
lexer := shell.NewLex('\\')
483+
imageRef, _, err = lexer.ProcessWord(imageRef, shell.EnvsFromSlice(resolvedArgs))
499484
if err != nil {
500485
return nil, fmt.Errorf("processing %q: %w", imageRef, err)
501486
}

devcontainer/devcontainer_test.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,6 @@ func TestImageFromDockerfileWithArgs(t *testing.T) {
269269
require.NoError(t, err)
270270
require.Equal(t, tc.image, ref.Name())
271271
// Test without args (using defaults)
272-
fmt.Println("Testing ImageFromDockerfile without args...")
273272
ref1, err := devcontainer.ImageFromDockerfile(tc.content)
274273
require.NoError(t, err)
275274
require.Equal(t, tc.default_image, ref1.Name())
@@ -322,12 +321,10 @@ func TestUserFromDockerfileWithArgs(t *testing.T) {
322321
require.Equal(t, "VARIANT=3.11-bookworm", params.BuildArgs[0])
323322
require.Equal(t, params.DockerfileContent, tc.content)
324323
// Test UserFromDockerfile without args
325-
fmt.Println("\nTesting UserFromDockerfile without args...")
326324
user1, err := devcontainer.UserFromDockerfile(tc.content)
327325
require.NoError(t, err)
328326
require.Equal(t, tc.user, user1)
329327
// Test UserFromDockerfile with args
330-
fmt.Println("\nTesting UserFromDockerfile with args...")
331328
user2, err := devcontainer.UserFromDockerfile(tc.content, params.BuildArgs)
332329
require.NoError(t, err)
333330
require.Equal(t, tc.user, user2)
@@ -427,7 +424,6 @@ func TestUserFrom(t *testing.T) {
427424
require.NoError(t, err)
428425
parsed.Path = "coder/test:" + tag
429426
ref, err := name.ParseReference(strings.TrimPrefix(parsed.String(), "http://"))
430-
fmt.Println(ref)
431427
require.NoError(t, err)
432428
err = remote.Write(ref, image)
433429
require.NoError(t, err)

0 commit comments

Comments
 (0)