@@ -231,25 +231,23 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
231231 }
232232
233233 featuresDir := filepath .Join (scratchDir , "features" )
234- err := fs .MkdirAll (featuresDir , 0o644 )
235- if err != nil {
234+ if err := fs .MkdirAll (featuresDir , 0o644 ); err != nil {
236235 return "" , nil , fmt .Errorf ("create features directory: %w" , err )
237236 }
238- featureDirectives := []string {}
239- featureContexts := make (map [string ]string )
240237
241- featureOrder := make ([]string , 0 , len (s .Features ))
242- for featureRef := range s .Features {
243- featureOrder = append (featureOrder , featureRef )
238+ // Pass 1: resolve each raw ref to its canonical featureRef and extract
239+ // the feature spec. We need all specs before we can resolve ordering
240+ // since installsAfter/dependsOn live inside devcontainer-feature.json.
241+ type extractedFeature struct {
242+ featureRef string
243+ featureName string
244+ featureDir string
245+ spec * features.Spec
246+ opts map [string ]any
244247 }
245- // applyInstallOrder places explicitly ordered features first (in declared
246- // order), then appends remaining features alphabetically. Alphabetical
247- // ordering for unconstrained features is critical for Dockerfile
248- // determinism, which allows for layer caching.
249- featureOrder = applyInstallOrder (featureOrder , s .OverrideFeatureInstallOrder )
250-
251- var lines []string
252- for _ , featureRefRaw := range featureOrder {
248+ extracted := make (map [string ]* extractedFeature , len (s .Features ))
249+ idToRef := make (map [string ]string , len (s .Features )) // feature ID → refRaw
250+ for featureRefRaw := range s .Features {
253251 var (
254252 featureRef string
255253 ok bool
@@ -265,7 +263,7 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
265263 featureOpts := map [string ]any {}
266264 switch t := s .Features [featureRefRaw ].(type ) {
267265 case string :
268- // As a shorthand, the value of the `features`` property can be provided as a
266+ // As a shorthand, the value of the `features` property can be provided as a
269267 // single string. This string is mapped to an option called version.
270268 // https://containers.dev/implementors/features/#devcontainer-json-properties
271269 featureOpts ["version" ] = t
@@ -288,13 +286,46 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
288286 if err != nil {
289287 return "" , nil , fmt .Errorf ("extract feature %s: %w" , featureRefRaw , err )
290288 }
291- fromDirective , directive , err := spec .Compile (featureRef , featureName , featureDir , containerUser , remoteUser , useBuildContexts , featureOpts )
289+ extracted [featureRefRaw ] = & extractedFeature {
290+ featureRef : featureRef ,
291+ featureName : featureName ,
292+ featureDir : featureDir ,
293+ spec : spec ,
294+ opts : featureOpts ,
295+ }
296+ idToRef [spec .ID ] = featureRefRaw
297+ }
298+
299+ // Resolve installation order, then validate hard dependencies.
300+ refRaws := make ([]string , 0 , len (extracted ))
301+ for refRaw := range extracted {
302+ refRaws = append (refRaws , refRaw )
303+ }
304+ specsByRef := make (map [string ]* features.Spec , len (extracted ))
305+ for refRaw , ef := range extracted {
306+ specsByRef [refRaw ] = ef .spec
307+ }
308+ featureOrder , err := resolveInstallOrder (refRaws , specsByRef , idToRef , s .OverrideFeatureInstallOrder )
309+ if err != nil {
310+ return "" , nil , err
311+ }
312+ if err := validateDependencies (specsByRef , idToRef ); err != nil {
313+ return "" , nil , err
314+ }
315+
316+ // Pass 2: compile Dockerfile directives in the resolved order.
317+ featureDirectives := make ([]string , 0 , len (featureOrder ))
318+ featureContexts := make (map [string ]string )
319+ var lines []string
320+ for _ , featureRefRaw := range featureOrder {
321+ ef := extracted [featureRefRaw ]
322+ fromDirective , directive , err := ef .spec .Compile (ef .featureRef , ef .featureName , ef .featureDir , containerUser , remoteUser , useBuildContexts , ef .opts )
292323 if err != nil {
293324 return "" , nil , fmt .Errorf ("compile feature %s: %w" , featureRefRaw , err )
294325 }
295326 featureDirectives = append (featureDirectives , directive )
296327 if useBuildContexts {
297- featureContexts [featureRef ] = featureDir
328+ featureContexts [ef . featureRef ] = ef . featureDir
298329 lines = append (lines , fromDirective )
299330 }
300331 }
@@ -307,35 +338,132 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
307338 // we're going to run as root.
308339 lines = append (lines , fmt .Sprintf ("USER %s" , remoteUser ))
309340 }
310- return strings .Join (lines , "\n " ), featureContexts , err
341+ return strings .Join (lines , "\n " ), featureContexts , nil
311342}
312343
313- // applyInstallOrder returns features in the order specified by overrideOrder
314- // first, then any remaining features in alphabetical order. Entries in
315- // overrideOrder that don't match any feature are silently ignored.
316- func applyInstallOrder (features []string , overrideOrder []string ) []string {
317- set := make (map [string ]bool , len (features ))
318- for _ , f := range features {
319- set [f ] = true
344+ // resolveInstallOrder determines the final feature installation order.
345+ //
346+ // Priority (highest to lowest):
347+ // 1. overrideOrder entries (in declared order) — user override wins unconditionally
348+ // 2. installsAfter edges from devcontainer-feature.json — soft ordering via
349+ // Kahn's topological sort on the unconstrained remainder
350+ // 3. Alphabetical — tie-breaking for determinism and layer cache stability
351+ //
352+ // IDs in installsAfter that don't map to a feature present in the set are
353+ // silently ignored (soft-dep semantics). Returns an error if a cycle is
354+ // detected among the installsAfter edges.
355+ //
356+ // See https://containers.dev/implementors/features/#installation-order
357+ func resolveInstallOrder (refRaws []string , specs map [string ]* features.Spec , idToRef map [string ]string , overrideOrder []string ) ([]string , error ) {
358+ // Step 1: lock in override entries (in declared order), removing them
359+ // from the free set so they are not subject to topo sorting.
360+ free := make (map [string ]bool , len (refRaws ))
361+ for _ , r := range refRaws {
362+ free [r ] = true
363+ }
364+ pinned := make ([]string , 0 , len (overrideOrder ))
365+ for _ , r := range overrideOrder {
366+ if free [r ] {
367+ pinned = append (pinned , r )
368+ delete (free , r )
369+ }
370+ }
371+
372+ // Step 2: topological sort (Kahn's algorithm) on the free remainder,
373+ // driven by installsAfter edges. An edge A→B means "B must come before A".
374+ // Edges pointing outside the free set are ignored.
375+ inDegree := make (map [string ]int , len (free ))
376+ deps := make (map [string ][]string , len (free )) // refRaw → refRaws it must follow
377+ for r := range free {
378+ inDegree [r ] = 0
379+ }
380+ for r := range free {
381+ for _ , depID := range specs [r ].InstallsAfter {
382+ // Resolve the ID or ref to a refRaw present in the free set.
383+ predRef , ok := idToRef [depID ]
384+ if ! ok {
385+ // depID might itself be a raw ref rather than a short ID.
386+ if free [depID ] {
387+ predRef = depID
388+ ok = true
389+ }
390+ }
391+ if ! ok || ! free [predRef ] {
392+ // Predecessor absent or overridden — soft dep, skip.
393+ continue
394+ }
395+ deps [r ] = append (deps [r ], predRef )
396+ inDegree [r ]++
397+ }
320398 }
321399
322- ordered := make ([]string , 0 , len (features ))
323- for _ , f := range overrideOrder {
324- if set [f ] {
325- ordered = append (ordered , f )
326- delete (set , f )
400+ // Seed the ready queue with all zero-in-degree nodes, sorted alphabetically
401+ // so tie-breaking is deterministic.
402+ ready := make ([]string , 0 , len (free ))
403+ for r := range free {
404+ if inDegree [r ] == 0 {
405+ ready = append (ready , r )
327406 }
328407 }
408+ sort .Strings (ready )
329409
330- // Collect and sort the remainder for determinism.
331- remaining := make ([]string , 0 , len (set ))
332- for _ , f := range features {
333- if set [f ] {
334- remaining = append (remaining , f )
410+ sorted := make ([]string , 0 , len (free ))
411+ // successors maps predecessor → features that depend on it.
412+ successors := make (map [string ][]string , len (free ))
413+ for r , preds := range deps {
414+ for _ , pred := range preds {
415+ successors [pred ] = append (successors [pred ], r )
416+ }
417+ }
418+ for len (ready ) > 0 {
419+ // Pop the first (alphabetically smallest) ready node.
420+ r := ready [0 ]
421+ ready = ready [1 :]
422+ sorted = append (sorted , r )
423+ // Reduce in-degree for all features that installsAfter r.
424+ newReady := []string {}
425+ for _ , succ := range successors [r ] {
426+ inDegree [succ ]--
427+ if inDegree [succ ] == 0 {
428+ newReady = append (newReady , succ )
429+ }
430+ }
431+ // Insert new ready nodes in sorted position to preserve alphabetical
432+ // tie-breaking across the entire queue.
433+ sort .Strings (newReady )
434+ ready = append (ready , newReady ... )
435+ sort .Strings (ready )
436+ }
437+
438+ if len (sorted ) != len (free ) {
439+ // Cycle detected — identify the offending features.
440+ cycled := make ([]string , 0 )
441+ for r := range free {
442+ if inDegree [r ] > 0 {
443+ cycled = append (cycled , r )
444+ }
445+ }
446+ sort .Strings (cycled )
447+ return nil , fmt .Errorf ("cycle detected in feature installsAfter dependencies: %s" , strings .Join (cycled , ", " ))
448+ }
449+
450+ return append (pinned , sorted ... ), nil
451+ }
452+
453+ // validateDependencies checks that every hard dependency declared via
454+ // dependsOn in a feature's devcontainer-feature.json is satisfied by the
455+ // set of installed features.
456+ func validateDependencies (specs map [string ]* features.Spec , idToRef map [string ]string ) error {
457+ for refRaw , spec := range specs {
458+ for _ , depID := range spec .DependsOn {
459+ _ , byID := idToRef [depID ]
460+ _ , byRef := specs [depID ]
461+ if ! byID && ! byRef {
462+ return fmt .Errorf ("feature %q (%s) requires feature %q which is not included" , spec .ID , refRaw , depID )
463+ }
335464 }
336465 }
337- sort .Strings (remaining )
338- return append (ordered , remaining ... )
466+ return nil
339467}
340468
341469// BuildArgsMap converts a slice of "KEY=VALUE" strings to a map.
0 commit comments