@@ -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 }
0 commit comments