Skip to content

Commit 7cab1bf

Browse files
committed
devcontainer: implement installsAfter and dependsOn feature ordering
1 parent 63a38a7 commit 7cab1bf

4 files changed

Lines changed: 1017 additions & 693 deletions

File tree

devcontainer/devcontainer.go

Lines changed: 166 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)