Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 179 additions & 19 deletions devcontainer/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ type Spec struct {
RemoteEnv map[string]string `json:"remoteEnv"`
// Features is a map of feature names to feature configurations.
Features map[string]any `json:"features"`
// OverrideFeatureInstallOrder overrides the order in which features are
// installed. Feature references not present in this list are installed
// after the listed ones, in alphabetical order.
OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder"`
LifecycleScripts

// Deprecated but still frequently used...
Expand Down Expand Up @@ -227,25 +231,23 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
}

featuresDir := filepath.Join(scratchDir, "features")
err := fs.MkdirAll(featuresDir, 0o644)
if err != nil {
if err := fs.MkdirAll(featuresDir, 0o644); err != nil {
return "", nil, fmt.Errorf("create features directory: %w", err)
}
Comment thread
prulloac marked this conversation as resolved.
Outdated
featureDirectives := []string{}
featureContexts := make(map[string]string)

// TODO: Respect the installation order outlined by the spec:
// https://containers.dev/implementors/features/#installation-order
featureOrder := []string{}
for featureRef := range s.Features {
featureOrder = append(featureOrder, featureRef)
// Pass 1: resolve each raw ref to its canonical featureRef and extract
// the feature spec. We need all specs before we can resolve ordering
// since installsAfter/dependsOn live inside devcontainer-feature.json.
type extractedFeature struct {
featureRef string
featureName string
featureDir string
spec *features.Spec
opts map[string]any
}
// It's critical we sort features prior to compilation so the Dockerfile
// is deterministic which allows for caching.
sort.Strings(featureOrder)

var lines []string
for _, featureRefRaw := range featureOrder {
extracted := make(map[string]*extractedFeature, len(s.Features))
idToRef := make(map[string]string, len(s.Features)) // feature ID → refRaw
for featureRefRaw := range s.Features {
var (
featureRef string
ok bool
Expand All @@ -261,7 +263,7 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
featureOpts := map[string]any{}
switch t := s.Features[featureRefRaw].(type) {
case string:
// As a shorthand, the value of the `features`` property can be provided as a
// As a shorthand, the value of the `features` property can be provided as a
// single string. This string is mapped to an option called version.
// https://containers.dev/implementors/features/#devcontainer-json-properties
featureOpts["version"] = t
Expand All @@ -284,13 +286,46 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
if err != nil {
return "", nil, fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
}
fromDirective, directive, err := spec.Compile(featureRef, featureName, featureDir, containerUser, remoteUser, useBuildContexts, featureOpts)
extracted[featureRefRaw] = &extractedFeature{
featureRef: featureRef,
featureName: featureName,
featureDir: featureDir,
spec: spec,
opts: featureOpts,
}
idToRef[spec.ID] = featureRefRaw
}

// Resolve installation order, then validate hard dependencies.
refRaws := make([]string, 0, len(extracted))
for refRaw := range extracted {
refRaws = append(refRaws, refRaw)
}
specsByRef := make(map[string]*features.Spec, len(extracted))
for refRaw, ef := range extracted {
specsByRef[refRaw] = ef.spec
}
featureOrder, err := resolveInstallOrder(refRaws, specsByRef, idToRef, s.OverrideFeatureInstallOrder)
if err != nil {
return "", nil, err
}
if err := validateDependencies(specsByRef, idToRef); err != nil {
return "", nil, err
}

// Pass 2: compile Dockerfile directives in the resolved order.
featureDirectives := make([]string, 0, len(featureOrder))
featureContexts := make(map[string]string)
var lines []string
for _, featureRefRaw := range featureOrder {
ef := extracted[featureRefRaw]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note ef := extracted[featureRefRaw] returns nil if the key is missing. The next line ef.spec.Compile(...) would panic. In practice this is safe today because resolveInstallOrder operates on the same key set, but there's no defensive check at the boundary between two independent functions. A one-line nil guard would prevent a confusing nil-pointer panic if a future refactor breaks the invariant. (Netero)

🤖

fromDirective, directive, err := ef.spec.Compile(ef.featureRef, ef.featureName, ef.featureDir, containerUser, remoteUser, useBuildContexts, ef.opts)
if err != nil {
return "", nil, fmt.Errorf("compile feature %s: %w", featureRefRaw, err)
}
featureDirectives = append(featureDirectives, directive)
if useBuildContexts {
featureContexts[featureRef] = featureDir
featureContexts[ef.featureRef] = ef.featureDir
lines = append(lines, fromDirective)
}
}
Expand All @@ -303,7 +338,132 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
// we're going to run as root.
lines = append(lines, fmt.Sprintf("USER %s", remoteUser))
}
return strings.Join(lines, "\n"), featureContexts, err
return strings.Join(lines, "\n"), featureContexts, nil
}

// resolveInstallOrder determines the final feature installation order.
//
// Priority (highest to lowest):
// 1. overrideOrder entries (in declared order) — user override wins unconditionally
// 2. installsAfter edges from devcontainer-feature.json — soft ordering via
// Kahn's topological sort on the unconstrained remainder
// 3. Alphabetical — tie-breaking for determinism and layer cache stability
//
// IDs in installsAfter that don't map to a feature present in the set are
// silently ignored (soft-dep semantics). Returns an error if a cycle is
// detected among the installsAfter edges.
//
// See https://containers.dev/implementors/features/#installation-order
func resolveInstallOrder(refRaws []string, specs map[string]*features.Spec, idToRef map[string]string, overrideOrder []string) ([]string, error) {
// Step 1: lock in override entries (in declared order), removing them
// from the free set so they are not subject to topo sorting.
free := make(map[string]bool, len(refRaws))
for _, r := range refRaws {
free[r] = true
}
pinned := make([]string, 0, len(overrideOrder))
for _, r := range overrideOrder {
if free[r] {
pinned = append(pinned, r)
delete(free, r)
}
}

// Step 2: topological sort (Kahn's algorithm) on the free remainder,
// driven by installsAfter edges. An edge A→B means "B must come before A".
// Edges pointing outside the free set are ignored.
inDegree := make(map[string]int, len(free))
deps := make(map[string][]string, len(free)) // refRaw → refRaws it must follow
for r := range free {
Comment thread
prulloac marked this conversation as resolved.
Outdated
inDegree[r] = 0
}
for r := range free {
for _, depID := range specs[r].InstallsAfter {
// Resolve the ID or ref to a refRaw present in the free set.
predRef, ok := idToRef[depID]
if !ok {
// depID might itself be a raw ref rather than a short ID.
if free[depID] {
predRef = depID
ok = true
}
}
if !ok || !free[predRef] {
// Predecessor absent or overridden — soft dep, skip.
Comment thread
prulloac marked this conversation as resolved.
Outdated
continue
}
deps[r] = append(deps[r], predRef)
inDegree[r]++
}
}

// Seed the ready queue with all zero-in-degree nodes, sorted alphabetically
// so tie-breaking is deterministic.
ready := make([]string, 0, len(free))
for r := range free {
if inDegree[r] == 0 {
ready = append(ready, r)
}
}
sort.Strings(ready)

sorted := make([]string, 0, len(free))
// successors maps predecessor → features that depend on it.
successors := make(map[string][]string, len(free))
for r, preds := range deps {
for _, pred := range preds {
successors[pred] = append(successors[pred], r)
}
}
for len(ready) > 0 {
// Pop the first (alphabetically smallest) ready node.
r := ready[0]
ready = ready[1:]
sorted = append(sorted, r)
// Reduce in-degree for all features that installsAfter r.
newReady := []string{}
for _, succ := range successors[r] {
inDegree[succ]--
if inDegree[succ] == 0 {
newReady = append(newReady, succ)
}
}
// Insert new ready nodes in sorted position to preserve alphabetical
// tie-breaking across the entire queue.
sort.Strings(newReady)
ready = append(ready, newReady...)
sort.Strings(ready)
}

if len(sorted) != len(free) {
// Cycle detected — identify the offending features.
cycled := make([]string, 0)
for r := range free {
if inDegree[r] > 0 {
cycled = append(cycled, r)
}
}
sort.Strings(cycled)
return nil, fmt.Errorf("cycle detected in feature installsAfter dependencies: %s", strings.Join(cycled, ", "))
}

return append(pinned, sorted...), nil
}

// validateDependencies checks that every hard dependency declared via
// dependsOn in a feature's devcontainer-feature.json is satisfied by the
// set of installed features.
func validateDependencies(specs map[string]*features.Spec, idToRef map[string]string) error {
for refRaw, spec := range specs {
for _, depID := range spec.DependsOn {
_, byID := idToRef[depID]
_, byRef := specs[depID]
if !byID && !byRef {
return fmt.Errorf("feature %q (%s) requires feature %q which is not included", spec.ID, refRaw, depID)
}
}
Comment thread
prulloac marked this conversation as resolved.
Outdated
}
return nil
}

// BuildArgsMap converts a slice of "KEY=VALUE" strings to a map.
Expand Down
Loading
Loading