diff --git a/devcontainer/devcontainer.go b/devcontainer/devcontainer.go
index ea07bfcd..00183f9b 100644
--- a/devcontainer/devcontainer.go
+++ b/devcontainer/devcontainer.go
@@ -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...
@@ -227,25 +231,38 @@ 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)
}
- 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.
+ //
+ // A worklist is used to recursively resolve dependsOn hard dependencies:
+ // each extracted feature's dependsOn entries are added to the worklist so
+ // that transitive dependencies are automatically fetched and installed,
+ // matching the spec requirement that the install set is the union of
+ // user-declared features and all their transitive dependsOn dependencies.
+ type extractedFeature struct {
+ featureRef string
+ featureName string
+ featureDir string
+ spec *features.Spec
+ opts map[string]any
+ // fromDep is true when this feature was added automatically to satisfy
+ // a dependsOn hard dependency (not explicitly listed by the user).
+ fromDep bool
}
- // 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
+ canonicalToRefs := make(map[string][]string, len(s.Features))
+
+ // extractOne extracts a single feature and registers it in the tables.
+ extractOne := func(featureRefRaw string, opts map[string]any, fromDep bool) error {
+ if _, already := extracted[featureRefRaw]; already {
+ return nil
+ }
var (
featureRef string
ok bool
@@ -253,44 +270,163 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
if _, featureRef, ok = strings.Cut(featureRefRaw, "./"); !ok {
featureRefParsed, err := name.ParseReference(featureRefRaw)
if err != nil {
- return "", nil, fmt.Errorf("parse feature ref %s: %w", featureRefRaw, err)
+ return fmt.Errorf("parse feature ref %s: %w", featureRefRaw, err)
}
featureRef = featureRefParsed.Context().Name()
}
- featureOpts := map[string]any{}
+ featureSha := md5.Sum([]byte(featureRefRaw))
+ featureName := fmt.Sprintf("%s-%x", filepath.Base(featureRef), featureSha[:4])
+ featureDir := filepath.Join(featuresDir, featureName)
+ if err := fs.MkdirAll(featureDir, 0o644); err != nil {
+ return err
+ }
+ spec, err := features.Extract(fs, devcontainerDir, featureDir, featureRefRaw)
+ if err != nil {
+ return fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
+ }
+ extracted[featureRefRaw] = &extractedFeature{
+ featureRef: featureRef,
+ featureName: featureName,
+ featureDir: featureDir,
+ spec: spec,
+ opts: opts,
+ fromDep: fromDep,
+ }
+ idToRef[spec.ID] = featureRefRaw
+ canonicalToRefs[featureRef] = append(canonicalToRefs[featureRef], featureRefRaw)
+ return nil
+ }
+
+ // Seed the worklist with user-declared features.
+ type workItem struct {
+ ref string
+ opts map[string]any
+ fromDep bool
+ }
+ worklist := make([]workItem, 0, len(s.Features))
+ for featureRefRaw := range s.Features {
+ opts := 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
+ opts["version"] = t
case map[string]any:
- featureOpts = t
+ opts = t
}
+ worklist = append(worklist, workItem{ref: featureRefRaw, opts: opts, fromDep: false})
+ }
- // It's important for caching that this directory is static.
- // If it changes on each run then the container will not be cached.
- //
- // devcontainers/cli has a very complex method of computing the feature
- // name from the feature reference. We're just going to hash it for simplicity.
- featureSha := md5.Sum([]byte(featureRefRaw))
- featureName := filepath.Base(featureRef)
- featureDir := filepath.Join(featuresDir, fmt.Sprintf("%s-%x", featureName, featureSha[:4]))
- if err := fs.MkdirAll(featureDir, 0o644); err != nil {
+ // Phase 1: extract all user-declared features. This populates idToRef and
+ // canonicalToRefs fully before we follow any dependsOn edges, so that dep
+ // refs expressed as feature IDs or canonical names can be resolved without
+ // trying to fetch them as bare OCI references.
+ for len(worklist) > 0 {
+ item := worklist[0]
+ worklist = worklist[1:]
+ if err := extractOne(item.ref, item.opts, item.fromDep); err != nil {
return "", nil, err
}
- spec, err := features.Extract(fs, devcontainerDir, featureDir, featureRefRaw)
- if err != nil {
- return "", nil, fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
+ }
+
+ // Phase 2: follow dependsOn for every extracted feature and auto-add any
+ // transitive deps that are not yet in the install set.
+ //
+ // depCovered returns true when depRef already maps to an extracted feature,
+ // checked by exact key, by feature ID (via idToRef), or by canonical name
+ // (via canonicalToRefs — handles "host/repo" matching "host/repo:latest").
+ depCovered := func(depRef string) bool {
+ if _, ok := extracted[depRef]; ok {
+ return true
}
- fromDirective, directive, err := spec.Compile(featureRef, featureName, featureDir, containerUser, remoteUser, useBuildContexts, featureOpts)
+ if ref, ok := idToRef[depRef]; ok {
+ if _, ok := extracted[ref]; ok {
+ return true
+ }
+ }
+ if refs, ok := canonicalToRefs[depRef]; ok && len(refs) > 0 {
+ return true
+ }
+ return false
+ }
+
+ // enqueueNewDeps adds any un-covered deps of ef to the worklist.
+ enqueueNewDeps := func(ef *extractedFeature) {
+ for depRef, depOpts := range ef.spec.DependsOn {
+ if depCovered(depRef) {
+ continue
+ }
+ // Use the full ref from idToRef if this is a bare feature ID.
+ resolvedRef := depRef
+ if ref, ok := idToRef[depRef]; ok {
+ resolvedRef = ref
+ }
+ depOptsCopy := make(map[string]any, len(depOpts))
+ for k, v := range depOpts {
+ depOptsCopy[k] = v
+ }
+ worklist = append(worklist, workItem{ref: resolvedRef, opts: depOptsCopy, fromDep: true})
+ }
+ }
+
+ for _, ef := range extracted {
+ enqueueNewDeps(ef)
+ }
+ for len(worklist) > 0 {
+ item := worklist[0]
+ worklist = worklist[1:]
+ if _, already := extracted[item.ref]; already {
+ continue
+ }
+ if err := extractOne(item.ref, item.opts, item.fromDep); err != nil {
+ return "", nil, err
+ }
+ enqueueNewDeps(extracted[item.ref])
+ }
+
+ canonicalToRef, ambiguousCanonicals := buildCanonicalToRef(canonicalToRefs)
+
+ // When build contexts are enabled, each canonical ref produces a Docker
+ // stage alias and context key. Duplicates would generate an invalid
+ // Dockerfile, so reject them early.
+ if useBuildContexts {
+ for canonical, refs := range ambiguousCanonicals {
+ return "", nil, fmt.Errorf("multiple configured features share canonical reference %q (%s); this produces duplicate build stages when build contexts are enabled", canonical, strings.Join(refs, ", "))
+ }
+ }
+
+ // Validate hard dependencies: every dependsOn entry must resolve to a
+ // feature in the extracted set. After the worklist above, all transitive
+ // dependencies that could be fetched as OCI refs are present; this catches
+ // the case where a dep ref is unresolvable (e.g. ambiguous canonical).
+ 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, canonicalToRef, ambiguousCanonicals, s.OverrideFeatureInstallOrder)
+ if 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]
+ 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)
}
}
@@ -303,7 +439,227 @@ 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.
+//
+// The algorithm follows the spec's round-based dependency sort:
+// 1. Build a DAG with dependsOn (hard) and installsAfter (soft) edges.
+// 2. Assign a roundPriority from overrideFeatureInstallOrder: the i-th entry
+// (0-based) receives priority (n - i), all others get 0.
+// 3. Execute rounds: each round, collect all features whose deps are fully
+// satisfied (in-degree 0). Of those, commit only the ones with the maximum
+// roundPriority. Tie-break within the committed set alphabetically.
+// Return uncommitted candidates to the worklist for the next round.
+// 4. Cycle → error.
+//
+// This correctly handles overrideFeatureInstallOrder: a pinned feature with
+// a free dependency cannot be committed until that dependency's round completes,
+// matching the spec requirement that overrides cannot "pull forward" a Feature
+// past its own dependency graph.
+//
+// IDs in installsAfter that don't map to a present feature are silently
+// ignored (soft-dep semantics).
+//
+// See https://containers.dev/implementors/features/#installation-order
+func resolveInstallOrder(refRaws []string, specs map[string]*features.Spec, idToRef, canonicalToRef map[string]string, ambiguousCanonicals map[string][]string, overrideOrder []string) ([]string, error) {
+ n := len(refRaws)
+ all := make(map[string]bool, n)
+ for _, r := range refRaws {
+ all[r] = true
+ }
+
+ // Assign roundPriority from overrideFeatureInstallOrder.
+ // Entry at index i gets priority (len - i) so earlier entries have higher
+ // priority.
+ roundPriority := make(map[string]int, len(overrideOrder))
+ pinnedSet := make(map[string]bool, len(overrideOrder))
+ for i, r := range overrideOrder {
+ if all[r] {
+ roundPriority[r] = len(overrideOrder) - i
+ pinnedSet[r] = true
+ }
+ }
+
+ // Build the dependency graph: inDegree and successors.
+ inDegree := make(map[string]int, n)
+ for _, r := range refRaws {
+ inDegree[r] = 0
+ }
+ // preds maps refRaw → set of refRaws it must follow.
+ preds := make(map[string]map[string]struct{}, n)
+ for _, r := range refRaws {
+ preds[r] = make(map[string]struct{})
+ }
+ addEdge := func(from, to string) {
+ // "from" must come after "to"
+ if _, ok := preds[from][to]; ok {
+ return
+ }
+ preds[from][to] = struct{}{}
+ inDegree[from]++
+ }
+
+ for _, r := range refRaws {
+ for dep := range specs[r].DependsOn {
+ predRef, ok, err := resolveDependencyRef(dep, specs, idToRef, canonicalToRef, ambiguousCanonicals)
+ if err != nil {
+ return nil, err
+ }
+ if !ok || !all[predRef] {
+ continue
+ }
+ addEdge(r, predRef)
+ }
+ // installsAfter is a soft dep: only respected when the feature is NOT
+ // in overrideFeatureInstallOrder. Pinned features have their install
+ // order dictated by the override list; their installsAfter hints are
+ // ignored per the spec ("soft dependencies are respected for Features
+ // not in overrideFeatureInstallOrder").
+ if pinnedSet[r] {
+ continue
+ }
+ for _, depID := range specs[r].InstallsAfter {
+ predRef, ok, err := resolveDependencyRef(depID, specs, idToRef, canonicalToRef, ambiguousCanonicals)
+ if err != nil {
+ return nil, err
+ }
+ if !ok || !all[predRef] {
+ // Soft dep: only applies when predecessor is in the install set.
+ continue
+ }
+ addEdge(r, predRef)
+ }
+ }
+
+ // successors maps predecessor → features that depend on it.
+ successors := make(map[string][]string, n)
+ for r, ps := range preds {
+ for p := range ps {
+ successors[p] = append(successors[p], r)
+ }
+ }
+
+ // Validate that overrideFeatureInstallOrder is consistent with the
+ // dependency graph: for any two pinned features A and B where A is listed
+ // before B in overrideOrder, A must not (transitively or directly) depend
+ // on B.
+ pinnedList := make([]string, 0, len(overrideOrder))
+ for _, r := range overrideOrder {
+ if all[r] {
+ pinnedList = append(pinnedList, r)
+ }
+ }
+ pinnedIndex := make(map[string]int, len(pinnedList))
+ for i, r := range pinnedList {
+ pinnedIndex[r] = i
+ }
+ for _, r := range pinnedList {
+ for dep := range specs[r].DependsOn {
+ depRef, ok, err := resolveDependencyRef(dep, specs, idToRef, canonicalToRef, ambiguousCanonicals)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ continue
+ }
+ if depIdx, isPinned := pinnedIndex[depRef]; isPinned {
+ if depIdx > pinnedIndex[r] {
+ return nil, fmt.Errorf("overrideFeatureInstallOrder violates dependsOn: %q must be installed before %q", depRef, r)
+ }
+ }
+ // If dep is not pinned, the round-based sort will handle it correctly
+ // by not committing r until dep is in installationOrder.
+ }
+ }
+
+ // Round-based sort (spec §3).
+ worklist := make(map[string]bool, n)
+ for _, r := range refRaws {
+ worklist[r] = true
+ }
+ installationOrder := make([]string, 0, n)
+ installed := make(map[string]bool, n)
+
+ for len(worklist) > 0 {
+ // Collect all candidates whose dependencies are fully installed.
+ round := make([]string, 0)
+ for r := range worklist {
+ if inDegree[r] == 0 {
+ round = append(round, r)
+ }
+ }
+ if len(round) == 0 {
+ // No progress — cycle.
+ cycled := make([]string, 0, len(worklist))
+ for r := range worklist {
+ cycled = append(cycled, r)
+ }
+ sort.Strings(cycled)
+ return nil, fmt.Errorf("cycle detected in feature dependency graph: %s", strings.Join(cycled, ", "))
+ }
+
+ // Find the maximum roundPriority among candidates.
+ maxPriority := 0
+ for _, r := range round {
+ if roundPriority[r] > maxPriority {
+ maxPriority = roundPriority[r]
+ }
+ }
+
+ // Commit only those with the max priority; return the rest to the
+ // worklist for subsequent rounds.
+ toCommit := make([]string, 0, len(round))
+ for _, r := range round {
+ if roundPriority[r] == maxPriority {
+ toCommit = append(toCommit, r)
+ }
+ }
+ sort.Strings(toCommit) // alphabetical tie-break within a round
+
+ for _, r := range toCommit {
+ installationOrder = append(installationOrder, r)
+ installed[r] = true
+ delete(worklist, r)
+ // Reduce in-degree for successors.
+ for _, succ := range successors[r] {
+ inDegree[succ]--
+ }
+ }
+ }
+
+ return installationOrder, nil
+}
+
+func resolveDependencyRef(dep string, specs map[string]*features.Spec, idToRef, canonicalToRef map[string]string, ambiguousCanonicals map[string][]string) (string, bool, error) {
+ if refRaw, ok := idToRef[dep]; ok {
+ return refRaw, true, nil
+ }
+ if _, ok := specs[dep]; ok {
+ return dep, true, nil
+ }
+ if refRaw, ok := canonicalToRef[dep]; ok {
+ return refRaw, true, nil
+ }
+ if refRaws, ok := ambiguousCanonicals[dep]; ok {
+ return "", false, fmt.Errorf("ambiguous canonical feature reference %q matches multiple configured features: %s", dep, strings.Join(refRaws, ", "))
+ }
+ return "", false, nil
+}
+
+func buildCanonicalToRef(canonicalToRefs map[string][]string) (map[string]string, map[string][]string) {
+ canonicalToRef := make(map[string]string, len(canonicalToRefs))
+ ambiguous := make(map[string][]string)
+ for canonicalRef, refRaws := range canonicalToRefs {
+ sort.Strings(refRaws)
+ if len(refRaws) > 1 {
+ ambiguous[canonicalRef] = refRaws
+ continue
+ }
+ canonicalToRef[canonicalRef] = refRaws[0]
+ }
+ return canonicalToRef, ambiguous
}
// BuildArgsMap converts a slice of "KEY=VALUE" strings to a map.
diff --git a/devcontainer/devcontainer_test.go b/devcontainer/devcontainer_test.go
index 5b7fe033..bc39c59a 100644
--- a/devcontainer/devcontainer_test.go
+++ b/devcontainer/devcontainer_test.go
@@ -95,9 +95,11 @@ func TestCompileWithFeatures(t *testing.T) {
fs := memfs.New()
featureOneMD5 := md5.Sum([]byte(featureOne))
- featureOneDir := fmt.Sprintf("/.envbuilder/features/one-%x", featureOneMD5[:4])
+ featureOneName := fmt.Sprintf("one-%x", featureOneMD5[:4])
+ featureOneDir := "/.envbuilder/features/" + featureOneName
featureTwoMD5 := md5.Sum([]byte(featureTwo))
- featureTwoDir := fmt.Sprintf("/.envbuilder/features/two-%x", featureTwoMD5[:4])
+ featureTwoName := fmt.Sprintf("two-%x", featureTwoMD5[:4])
+ featureTwoDir := "/.envbuilder/features/" + featureTwoName
t.Run("WithoutBuildContexts", func(t *testing.T) {
params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
@@ -123,23 +125,23 @@ USER 1000`, params.DockerfileContent)
registryHost := strings.TrimPrefix(registry, "http://")
- require.Equal(t, `FROM scratch AS envbuilder_feature_one
+ require.Equal(t, `FROM scratch AS envbuilder_feature_`+featureOneName+`
COPY --from=`+registryHost+`/coder/one / /
-FROM scratch AS envbuilder_feature_two
+FROM scratch AS envbuilder_feature_`+featureTwoName+`
COPY --from=`+registryHost+`/coder/two / /
FROM localhost:5000/envbuilder-test-codercom-code-server:latest
USER root
# Rust tomato - Example description!
-WORKDIR /.envbuilder/features/one
+WORKDIR /.envbuilder/features/`+featureOneName+`
ENV TOMATO=example
-RUN --mount=type=bind,from=envbuilder_feature_one,target=/.envbuilder/features/one,rw _CONTAINER_USER="1000" _REMOTE_USER="1000" ./install.sh
+RUN --mount=type=bind,from=envbuilder_feature_`+featureOneName+`,target=/.envbuilder/features/`+featureOneName+`,rw _CONTAINER_USER="1000" _REMOTE_USER="1000" ./install.sh
# Go potato - Example description!
-WORKDIR /.envbuilder/features/two
+WORKDIR /.envbuilder/features/`+featureTwoName+`
ENV POTATO=example
-RUN --mount=type=bind,from=envbuilder_feature_two,target=/.envbuilder/features/two,rw VERSION="potato" _CONTAINER_USER="1000" _REMOTE_USER="1000" ./install.sh
+RUN --mount=type=bind,from=envbuilder_feature_`+featureTwoName+`,target=/.envbuilder/features/`+featureTwoName+`,rw VERSION="potato" _CONTAINER_USER="1000" _REMOTE_USER="1000" ./install.sh
USER 1000`, params.DockerfileContent)
require.Equal(t, map[string]string{
@@ -149,6 +151,488 @@ USER 1000`, params.DockerfileContent)
})
}
+func TestCompileWithFeaturesOverrideInstallOrder(t *testing.T) {
+ t.Parallel()
+ registry := registrytest.New(t)
+ featureOne := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/one:tomato", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "one",
+ Version: "tomato",
+ Name: "One",
+ },
+ })
+ featureTwo := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/two:potato", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "two",
+ Version: "potato",
+ Name: "Two",
+ },
+ })
+ featureThree := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/three:apple", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "three",
+ Version: "apple",
+ Name: "Three",
+ },
+ })
+
+ featureOneMD5 := md5.Sum([]byte(featureOne))
+ featureOneDir := fmt.Sprintf("/.envbuilder/features/one-%x", featureOneMD5[:4])
+ featureTwoMD5 := md5.Sum([]byte(featureTwo))
+ featureTwoDir := fmt.Sprintf("/.envbuilder/features/two-%x", featureTwoMD5[:4])
+ featureThreeMD5 := md5.Sum([]byte(featureThree))
+ featureThreeDir := fmt.Sprintf("/.envbuilder/features/three-%x", featureThreeMD5[:4])
+
+ t.Run("OverrideReverseOrder", func(t *testing.T) {
+ // featureThree then featureTwo are explicitly ordered first; featureOne
+ // is unconstrained and falls to the alphabetical remainder.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureOne + `": {},
+ "` + featureTwo + `": {},
+ "` + featureThree + `": {}
+ },
+ "overrideFeatureInstallOrder": ["` + featureThree + `", "` + featureTwo + `"]
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ // featureThree and featureTwo come first (in override order),
+ // then featureOne last (alphabetical remainder).
+ require.Contains(t, params.DockerfileContent, "WORKDIR "+featureThreeDir+"\n")
+ require.Contains(t, params.DockerfileContent, "WORKDIR "+featureTwoDir+"\n")
+ require.Contains(t, params.DockerfileContent, "WORKDIR "+featureOneDir+"\n")
+
+ threeIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureThreeDir)
+ twoIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTwoDir)
+ oneIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureOneDir)
+ require.Less(t, threeIdx, twoIdx, "three should be installed before two")
+ require.Less(t, twoIdx, oneIdx, "two should be installed before one")
+ })
+
+ t.Run("UnknownOverrideEntryIgnored", func(t *testing.T) {
+ // An entry in overrideFeatureInstallOrder that doesn't match any
+ // feature key should be silently ignored.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureOne + `": {},
+ "` + featureTwo + `": {}
+ },
+ "overrideFeatureInstallOrder": ["does-not-exist", "` + featureTwo + `"]
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ twoIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTwoDir)
+ oneIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureOneDir)
+ require.Less(t, twoIdx, oneIdx, "two should be installed before one")
+ })
+}
+
+func TestCompileWithFeaturesInstallsAfter(t *testing.T) {
+ t.Parallel()
+ registry := registrytest.New(t)
+
+ // featureBase has no deps.
+ featureBase := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/base:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "base",
+ Version: "1.0.0",
+ Name: "Base",
+ },
+ })
+ // featureTop declares installsAfter: ["base"], so it must come after featureBase.
+ featureTop := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/top:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "top",
+ Version: "1.0.0",
+ Name: "Top",
+ InstallsAfter: []string{"base"},
+ },
+ })
+ baseRef, err := name.ParseReference(featureBase)
+ require.NoError(t, err)
+ baseCanonical := baseRef.Context().Name()
+ featureTopByRef := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/top-by-ref:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "top-by-ref",
+ Version: "1.0.0",
+ Name: "TopByRef",
+ InstallsAfter: []string{baseCanonical},
+ },
+ })
+
+ featureBaseMD5 := md5.Sum([]byte(featureBase))
+ featureBaseDir := fmt.Sprintf("/.envbuilder/features/base-%x", featureBaseMD5[:4])
+ featureTopMD5 := md5.Sum([]byte(featureTop))
+ featureTopDir := fmt.Sprintf("/.envbuilder/features/top-%x", featureTopMD5[:4])
+ featureTopByRefMD5 := md5.Sum([]byte(featureTopByRef))
+ featureTopByRefDir := fmt.Sprintf("/.envbuilder/features/top-by-ref-%x", featureTopByRefMD5[:4])
+
+ t.Run("InstallsAfterRespected", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureTop + `": {},
+ "` + featureBase + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ baseIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureBaseDir)
+ topIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTopDir)
+ require.Greater(t, baseIdx, -1, "base feature should be present")
+ require.Greater(t, topIdx, -1, "top feature should be present")
+ require.Less(t, baseIdx, topIdx, "base should be installed before top (installsAfter)")
+ })
+
+ t.Run("InstallsAfterAbsentDepIgnored", func(t *testing.T) {
+ // featureTop declares installsAfter: ["base"], but base is not in features.
+ // This is a soft dep — should succeed with just featureTop.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureTop + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err, "absent installsAfter dep should not cause an error")
+ })
+
+ t.Run("OverrideWinsOverInstallsAfter", func(t *testing.T) {
+ // overrideFeatureInstallOrder forces top before base, contradicting
+ // top's installsAfter declaration. Override takes precedence.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureTop + `": {},
+ "` + featureBase + `": {}
+ },
+ "overrideFeatureInstallOrder": ["` + featureTop + `", "` + featureBase + `"]
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ topIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTopDir)
+ baseIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureBaseDir)
+ require.Greater(t, topIdx, -1, "top feature should be present")
+ require.Greater(t, baseIdx, -1, "base feature should be present")
+ require.Less(t, topIdx, baseIdx, "override should force top before base")
+ })
+
+ t.Run("InstallsAfterCanonicalRefRespected", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureTopByRef + `": {},
+ "` + featureBase + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ baseIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureBaseDir)
+ topIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTopByRefDir)
+ require.Greater(t, baseIdx, -1, "base feature should be present")
+ require.Greater(t, topIdx, -1, "top-by-ref feature should be present")
+ require.Less(t, baseIdx, topIdx, "base should be installed before top-by-ref (installsAfter by canonical ref)")
+ })
+}
+
+func TestCompileWithFeaturesDependsOn(t *testing.T) {
+ t.Parallel()
+ registry := registrytest.New(t)
+
+ featureA := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/a:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "a",
+ Version: "1.0.0",
+ Name: "A",
+ },
+ })
+ // featureB hard-depends on featureA. The dependsOn key uses the full OCI
+ // reference of featureA so that auto-add (DependsOnAutoAdded) can fetch it
+ // from the registry when it is not explicitly declared in features.
+ featureB := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/b:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "b",
+ Version: "1.0.0",
+ Name: "B",
+ DependsOn: map[string]map[string]any{featureA: {}},
+ },
+ })
+ featureEarly := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/aaa-early:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "early",
+ Version: "1.0.0",
+ Name: "Early",
+ DependsOn: map[string]map[string]any{"late": {}},
+ },
+ })
+ featureLate := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/zzz-late:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "late",
+ Version: "1.0.0",
+ Name: "Late",
+ },
+ })
+ lateRef, err := name.ParseReference(featureLate)
+ require.NoError(t, err)
+ lateCanonical := lateRef.Context().Name()
+ featureByRef := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/by-ref:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "by-ref",
+ Version: "1.0.0",
+ Name: "ByRef",
+ DependsOn: map[string]map[string]any{lateCanonical: {}},
+ },
+ })
+ featureLateV1 := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/zzz-late:1.0.0", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "late-v1",
+ Version: "1.0.0",
+ Name: "LateV1",
+ },
+ })
+ featureLateV2 := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/zzz-late:2.0.0", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "late-v2",
+ Version: "2.0.0",
+ Name: "LateV2",
+ },
+ })
+
+ featureEarlyMD5 := md5.Sum([]byte(featureEarly))
+ featureEarlyDir := fmt.Sprintf("/.envbuilder/features/aaa-early-%x", featureEarlyMD5[:4])
+ featureLateMD5 := md5.Sum([]byte(featureLate))
+ featureLateDir := fmt.Sprintf("/.envbuilder/features/zzz-late-%x", featureLateMD5[:4])
+
+ t.Run("DependsOnSatisfied", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureA + `": {},
+ "` + featureB + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+ })
+
+ t.Run("DependsOnAutoAdded", func(t *testing.T) {
+ // featureB requires featureA, but featureA is not explicitly listed.
+ // Per spec, featureA should be automatically fetched and added to the
+ // install set.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureB + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ // featureA (dep of featureB) must be present and installed before featureB.
+ featureAMD5 := md5.Sum([]byte(featureA))
+ featureADir := fmt.Sprintf("/.envbuilder/features/a-%x", featureAMD5[:4])
+ featureBMD5 := md5.Sum([]byte(featureB))
+ featureBDir := fmt.Sprintf("/.envbuilder/features/b-%x", featureBMD5[:4])
+ aIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureADir)
+ bIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureBDir)
+ require.Greater(t, aIdx, -1, "auto-added featureA should be present in Dockerfile")
+ require.Greater(t, bIdx, -1, "featureB should be present in Dockerfile")
+ require.Less(t, aIdx, bIdx, "featureA should be installed before featureB (dependsOn)")
+ })
+
+ t.Run("DependsOnEnforcesInstallOrder", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureEarly + `": {},
+ "` + featureLate + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ earlyIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureEarlyDir)
+ lateIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureLateDir)
+ require.Greater(t, earlyIdx, -1, "early feature should be present")
+ require.Greater(t, lateIdx, -1, "late feature should be present")
+ require.Less(t, lateIdx, earlyIdx, "late should be installed before early due to dependsOn")
+ })
+
+ t.Run("OverridePinnedFreeDependsOnSucceeds", func(t *testing.T) {
+ // featureEarly is pinned via overrideFeatureInstallOrder; it depends on
+ // featureLate which is in the free (topo-sorted) set. Per spec, the
+ // free set is installed before pinned features, so this is valid.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureEarly + `": {},
+ "` + featureLate + `": {}
+ },
+ "overrideFeatureInstallOrder": ["` + featureEarly + `"]
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+
+ // featureLate (free/topo) must appear before featureEarly (pinned).
+ earlyIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureEarlyDir)
+ lateIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureLateDir)
+ require.Greater(t, earlyIdx, -1, "early feature should be present")
+ require.Greater(t, lateIdx, -1, "late feature should be present")
+ require.Less(t, lateIdx, earlyIdx, "late (free) must be installed before early (pinned)")
+ })
+
+ t.Run("OverridePinnedBeforePinnedDepErrors", func(t *testing.T) {
+ // Both featureEarly and featureLate are pinned, but featureEarly
+ // (which depends on featureLate) is listed first — a true violation.
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureEarly + `": {},
+ "` + featureLate + `": {}
+ },
+ "overrideFeatureInstallOrder": ["` + featureEarly + `", "` + featureLate + `"]
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.ErrorContains(t, err, "overrideFeatureInstallOrder violates dependsOn")
+ })
+
+ t.Run("DependsOnCanonicalRefResolved", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureByRef + `": {},
+ "` + featureLate + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.NoError(t, err)
+ })
+
+ t.Run("DependsOnCanonicalRefAmbiguousErrors", func(t *testing.T) {
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureByRef + `": {},
+ "` + featureLateV1 + `": {},
+ "` + featureLateV2 + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.ErrorContains(t, err, "ambiguous canonical feature reference")
+ })
+}
+
+func TestResolveInstallOrderCycleDetection(t *testing.T) {
+ t.Parallel()
+ registry := registrytest.New(t)
+
+ // featureX installsAfter featureY, featureY installsAfter featureX — a cycle.
+ featureX := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/x:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "x",
+ Version: "1.0.0",
+ Name: "X",
+ InstallsAfter: []string{"y"},
+ },
+ })
+ featureY := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/y:latest", features.TarLayerMediaType, map[string]any{
+ "install.sh": "hey",
+ "devcontainer-feature.json": features.Spec{
+ ID: "y",
+ Version: "1.0.0",
+ Name: "Y",
+ InstallsAfter: []string{"x"},
+ },
+ })
+
+ raw := `{
+ "image": "localhost:5000/envbuilder-test-ubuntu:latest",
+ "features": {
+ "` + featureX + `": {},
+ "` + featureY + `": {}
+ }
+}`
+ dc, err := devcontainer.Parse([]byte(raw))
+ require.NoError(t, err)
+ fs := memfs.New()
+
+ _, err = dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
+ require.ErrorContains(t, err, "cycle detected")
+}
+
func TestCompileDevContainer(t *testing.T) {
t.Parallel()
t.Run("WithImage", func(t *testing.T) {
diff --git a/devcontainer/features/features.go b/devcontainer/features/features.go
index bb044d5f..5d340ed3 100644
--- a/devcontainer/features/features.go
+++ b/devcontainer/features/features.go
@@ -188,6 +188,17 @@ type Spec struct {
Keywords []string `json:"keywords"`
Options map[string]Option `json:"options"`
ContainerEnv map[string]string `json:"containerEnv"`
+ // InstallsAfter is a soft ordering hint: this feature prefers to be
+ // installed after the listed feature IDs or references, but will not
+ // fail if they are absent.
+ // See https://containers.dev/implementors/features/#installation-order
+ InstallsAfter []string `json:"installsAfter"`
+ // DependsOn is a hard dependency: this feature requires the listed
+ // feature IDs or references to also be present in the feature set.
+ // Each key is a feature reference and the value is the options object
+ // for that feature (same semantics as the features object in devcontainer.json).
+ // See https://containers.dev/implementors/features/#installation-order
+ DependsOn map[string]map[string]any `json:"dependsOn"`
}
// Extract unpacks the feature from the image and returns a set of lines
diff --git a/devcontainer/install_order_internal_test.go b/devcontainer/install_order_internal_test.go
new file mode 100644
index 00000000..17ab9fea
--- /dev/null
+++ b/devcontainer/install_order_internal_test.go
@@ -0,0 +1,156 @@
+package devcontainer
+
+import (
+ "testing"
+
+ "github.com/coder/envbuilder/devcontainer/features"
+ "github.com/stretchr/testify/require"
+)
+
+// These tests intentionally live in package devcontainer (not
+// devcontainer_test) so they can exercise unexported helper behavior directly.
+// The external-package tests in devcontainer_test.go continue to validate
+// user-facing behavior through the public API.
+
+func TestBuildCanonicalToRefUnique(t *testing.T) {
+ t.Parallel()
+
+ canonicalToRefs := map[string][]string{
+ "ghcr.io/example/features/a": {"ghcr.io/example/features/a:1"},
+ "ghcr.io/example/features/b": {"ghcr.io/example/features/b:2"},
+ }
+
+ canonicalToRef, ambiguous := buildCanonicalToRef(canonicalToRefs)
+ require.Empty(t, ambiguous)
+ require.Equal(t, "ghcr.io/example/features/a:1", canonicalToRef["ghcr.io/example/features/a"])
+ require.Equal(t, "ghcr.io/example/features/b:2", canonicalToRef["ghcr.io/example/features/b"])
+}
+
+func TestBuildCanonicalToRefAmbiguousDeferred(t *testing.T) {
+ t.Parallel()
+
+ canonicalToRefs := map[string][]string{
+ "ghcr.io/example/features/late": {
+ "ghcr.io/example/features/late:2.0.0",
+ "ghcr.io/example/features/late:1.0.0",
+ },
+ }
+
+ canonicalToRef, ambiguous := buildCanonicalToRef(canonicalToRefs)
+ // buildCanonicalToRef no longer errors; ambiguity is deferred.
+ require.Empty(t, canonicalToRef)
+ require.Contains(t, ambiguous, "ghcr.io/example/features/late")
+ require.Equal(t, []string{
+ "ghcr.io/example/features/late:1.0.0",
+ "ghcr.io/example/features/late:2.0.0",
+ }, ambiguous["ghcr.io/example/features/late"])
+
+ // Ambiguity error surfaces only when the canonical is actually resolved.
+ specs := map[string]*features.Spec{}
+ idToRef := map[string]string{}
+ _, _, err := resolveDependencyRef("ghcr.io/example/features/late", specs, idToRef, canonicalToRef, ambiguous)
+ require.ErrorContains(t, err, "ambiguous canonical feature reference \"ghcr.io/example/features/late\"")
+ require.ErrorContains(t, err, "ghcr.io/example/features/late:1.0.0, ghcr.io/example/features/late:2.0.0")
+}
+
+// TestResolveInstallOrderPinnedFreeDepOK confirms that a pinned feature whose
+// dependsOn target is in the free (topo-sorted) set does NOT produce an error.
+// The free set is always installed before pinned features, so the ordering
+// constraint is automatically satisfied.
+func TestResolveInstallOrderPinnedFreeDepOK(t *testing.T) {
+ t.Parallel()
+
+ // early depends on late. early is pinned via overrideOrder; late is free.
+ specs := map[string]*features.Spec{
+ "early:latest": {ID: "early", Version: "1.0.0", Name: "Early", DependsOn: map[string]map[string]any{"late": {}}},
+ "late:latest": {ID: "late", Version: "1.0.0", Name: "Late"},
+ }
+ idToRef := map[string]string{
+ "early": "early:latest",
+ "late": "late:latest",
+ }
+
+ order, err := resolveInstallOrder(
+ []string{"early:latest", "late:latest"},
+ specs, idToRef,
+ map[string]string{}, map[string][]string{},
+ []string{"early:latest"}, // pin early, leaving late free
+ )
+ require.NoError(t, err)
+ // late (free/topo) must precede early (pinned).
+ lateIdx := -1
+ earlyIdx := -1
+ for i, r := range order {
+ if r == "late:latest" {
+ lateIdx = i
+ }
+ if r == "early:latest" {
+ earlyIdx = i
+ }
+ }
+ require.Greater(t, lateIdx, -1, "late should be in output")
+ require.Greater(t, earlyIdx, -1, "early should be in output")
+ require.Less(t, lateIdx, earlyIdx, "late (free) must come before early (pinned)")
+}
+
+// TestResolveInstallOrderBothPinnedViolationErrors confirms that when both the
+// dependent feature AND its dependency are pinned and ordered incorrectly, an
+// error is returned.
+func TestResolveInstallOrderBothPinnedViolationErrors(t *testing.T) {
+ t.Parallel()
+
+ specs := map[string]*features.Spec{
+ "early:latest": {ID: "early", Version: "1.0.0", Name: "Early", DependsOn: map[string]map[string]any{"late": {}}},
+ "late:latest": {ID: "late", Version: "1.0.0", Name: "Late"},
+ }
+ idToRef := map[string]string{
+ "early": "early:latest",
+ "late": "late:latest",
+ }
+
+ _, err := resolveInstallOrder(
+ []string{"early:latest", "late:latest"},
+ specs, idToRef,
+ map[string]string{}, map[string][]string{},
+ []string{"early:latest", "late:latest"}, // both pinned, wrong order
+ )
+ require.ErrorContains(t, err, "overrideFeatureInstallOrder violates dependsOn")
+}
+
+// TestResolveInstallOrderPinnedIgnoresInstallsAfter confirms that a pinned
+// feature's installsAfter hints are ignored: the override takes precedence
+// over soft dependencies per spec.
+func TestResolveInstallOrderPinnedIgnoresInstallsAfter(t *testing.T) {
+ t.Parallel()
+
+ // top declares installsAfter: ["base"], but top is pinned first in the
+ // override. The override should win; base must come AFTER top.
+ specs := map[string]*features.Spec{
+ "top:latest": {ID: "top", Version: "1.0.0", Name: "Top", InstallsAfter: []string{"base"}},
+ "base:latest": {ID: "base", Version: "1.0.0", Name: "Base"},
+ }
+ idToRef := map[string]string{
+ "top": "top:latest",
+ "base": "base:latest",
+ }
+
+ order, err := resolveInstallOrder(
+ []string{"top:latest", "base:latest"},
+ specs, idToRef,
+ map[string]string{}, map[string][]string{},
+ []string{"top:latest", "base:latest"}, // override: top first
+ )
+ require.NoError(t, err)
+ topIdx, baseIdx := -1, -1
+ for i, r := range order {
+ if r == "top:latest" {
+ topIdx = i
+ }
+ if r == "base:latest" {
+ baseIdx = i
+ }
+ }
+ require.Greater(t, topIdx, -1, "top should be in output")
+ require.Greater(t, baseIdx, -1, "base should be in output")
+ require.Less(t, topIdx, baseIdx, "override must place top before base despite installsAfter")
+}
diff --git a/docs/devcontainer-spec-support.md b/docs/devcontainer-spec-support.md
index dd8bf0ff..bf358b15 100644
--- a/docs/devcontainer-spec-support.md
+++ b/docs/devcontainer-spec-support.md
@@ -1,123 +1,123 @@
-# Support for Dev Container Specification
-
-> Refer to the full Dev Container specification [here](https://containers.dev/implementors/json_reference/) for more information on the below options.
-
-The symbols in the first column indicate the support status:
-
-- 🟢 Fully supported.
-- 🟠 Partially supported.
-- 🔴 Not currently supported.
-
-The last column indicates any currently existing GitHub issue for tracking support for this feature.
-Feel free to [create a new issue](https://github.com/coder/envbuilder/issues/new) if you'd like Envbuilder to support a particular feature.
-
-## General
-
-| Status | Name | Description | Known Issues |
-| ------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
-| 🔴 | `name` | Human-friendly name for the dev container. | - |
-| 🔴 | `forwardPorts` | Port forwarding allows exposing container ports to the host, making services accessible. | [#48](https://github.com/coder/envbuilder/issues/48) |
-| 🔴 | `portsAttributes` | Set port attributes for a `host:port`. | - |
-| 🔴 | `otherPortsAttributes` | Other options for ports not configured using `portsAttributes`. | - |
-| 🟢 | `containerEnv` | Environment variables to set inside the container. | - |
-| 🟢 | `remoteEnv` | Override environment variables for tools, but not the whole container. | - |
-| 🟢\* | `remoteUser` | Override the user for tools, but not the whole container.
\*_Refer to [choosing a target user](./users.md#choosing-a-target-user), as behaviour may diverge from the spec._ | - |
-| 🟢\* | `containerUser` | Override the user for all operations run inside the container.
\*_Refer to [choosing a target user](./users.md#choosing-a-target-user), as behaviour may diverge from the spec._ | - |
-| 🔴 | `updateRemoteUserUID` | Update the devcontainer UID/GID to match the local user. | - |
-| 🔴 | `userEnvProbe` | Shell to use when probing for user environment variables. | - |
-| 🔴 | `overrideCommand` | Override the default sleep command to be run by supporting services. | - |
-| 🔴 | `shutdownAction` | Action to take when supporting tools are closed or shut down. | - |
-| 🔴 | `init` | Adds a tiny init process to the container. | [#221](https://github.com/coder/envbuilder/issues/221) |
-| 🔴 | `privileged` | Whether the container should be run in privileged mode. | - |
-| 🔴 | `capAdd` | Capabilities to add to the container (for example, `SYS_PTRACE`). | - |
-| 🔴 | `securityOpt` | Security options to add to the container (for example, `seccomp=unconfined`). | - |
-| 🔴 | `mounts` | Add additional mounts to the container. | [#220](https://github.com/coder/envbuilder/issues/220) |
-| 🟢 | `features` | Features to be added to the devcontainer. | - |
-| 🔴 | `overrideFeatureInstallOrder` | Override the order in which features should be installed. | [#226](https://github.com/coder/envbuilder/issues/226) |
-| 🟠 | `customizations` | Product-specific properties, e.g., _VS Code_ settings and extensions. | Workaround in [#43](https://github.com/coder/envbuilder/issues/43) |
-
-## Image or Dockerfile
-
-| Status | Name | Description | Known Issues |
-| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------- | ------------ |
-| 🟢 | `image` | Name of an image to run. | - |
-| 🟢 | `build.dockerfile` | Path to a Dockerfile to build relative to `devcontainer.json`. | - |
-| 🟢 | `build.context` | Path to the build context relative to `devcontainer.json`. | - |
-| 🟢 | `build.args` | Build args to use when building the Dockerfile. | - |
-| 🔴 | `build.options` | Build options to pass to the Docker daemon. Envbuilder does not use a Docker daemon, so this is not relevant. | - |
-| 🟢 | `build.target` | Target to be passed when building the Dockerfile. | - |
-| 🟢 | `build.cacheFrom` | Images to use as caches when building the Dockerfile. | - |
-| 🔴 | `appPort` | Ports to be published locally when the container is running. | - |
-| 🔴 | `workspaceMount` | Overrides the default local mount point for the workspace when the container is created. | - |
-| 🔴 | `workspaceFolder` | Default path to open when connecting to the container. | - |
-
-## Docker Compose
-
-| Status | Name | Description | Known Issues |
-| ------ | ------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ |
-| 🔴 | `dockerComposeFile` | Path to a Docker Compose file related to the `devcontainer.json`. | [#236](https://github.com/coder/envbuilder/issues/236) |
-| 🔴 | `service` | Name of the Docker Compose service to which supporting tools should connect. | [#236](https://github.com/coder/envbuilder/issues/236) |
-| 🔴 | `runServices` | Docker Compose services to automatically start. | [#236](https://github.com/coder/envbuilder/issues/236) |
-
-## Lifecycle Scripts
-
-| Status | Name | Description | Known Issues |
-| ------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
-| 🔴 | `initializeCommand` | Command to run on the host machine when creating the container. | [#395](https://github.com/coder/envbuilder/issues/395) |
-| 🟢 | `onCreateCommand` | Command to run inside container after first start. | |
-| 🟢 | `updateContentCommand` | Command to run after `onCreateCommand` inside container. | |
-| 🟢 | `postCreateCommand` | Command to run after `updateContentCommand` inside container. | |
-| 🟢\* | `postStartCommand` | Command to run each time the container is started.
\*_This may be specified by `ENVBUILDER_POST_START_SCRIPT`, in which case it is the responsibility of `ENVBUILDER_INIT_COMMAND` to run it._ | |
-| 🔴 | `postAttachCommand` | Command to run each time a tool attaches to the container. | |
-| 🔴 | `waitFor` | Specify the lifecycle command tools should wait to complete before connecting. | |
-
-## Minimum Host Requirements
-
-| Status | Name | Description | Known Issues |
-| ------ | -------------------------- | -------------------------------- | ------------ |
-| 🔴 | `hostRequirements.cpus` | Minimum number of CPUs required. | - |
-| 🔴 | `hostRequirements.memory` | Minimum memory requirements. | - |
-| 🔴 | `hostRequirements.storage` | Minimum storage requirements. | - |
-| 🔴 | `hostRequirements.gpu` | Whether a GPU is required. | - |
-
-## Variable Substitution
-
-| Status | Name | Description | Known Issues |
-| ------ | ------------------------------------- | --------------------------------------------------- | ------------ |
-| 🟢 | `${localEnv:VARIABLE_NAME}` | Environment variable on the host machine. | - |
-| 🟢 | `${containerEnv:VARIABLE_NAME}` | Existing environment variable inside the container. | - |
-| 🟢 | `${localWorkspaceFolder}` | Path to the local workspace folder. | - |
-| 🟢 | `${containerWorkspaceFolder}` | Path to the workspace folder inside the container. | - |
-| 🟢 | `${localWorkspaceFolderBasename}` | Base name of `localWorkspaceFolder`. | - |
-| 🟢 | `${containerWorkspaceFolderBasename}` | Base name of `containerWorkspaceFolder`. | - |
-| 🔴 | `${devcontainerId}` | A stable unique identifier for the devcontainer. | - |
-
-## Features
-
-| Status | Name | Description | Known Issues |
-| ------ | ------------------------ | ------------------------------------------------------------ | ------------ |
-| 🟢 | `id` | Feature identifier | - |
-| � | `version` | Feature version | - |
-| 🟢 | `name` | Feature version | - |
-| 🟢 | `description` | Description | - |
-| 🟢 | `documentationURL` | Feature documentation URL | - |
-| 🟢 | `licenseURL` | Feature license URL | - |
-| 🟢 | `keywords` | Feature keywords | - |
-| 🟢 | `options` | Map of options passed to the feature | - |
-| 🟢 | `options[*].type` | Types of the option | - |
-| 🟢 | `options[*].proposals` | Suggested values of the option | - |
-| 🟢 | `options[*].enum` | Allowed string values of the option | - |
-| 🟢 | `options[*].default` | Default value of the option | - |
-| 🟢 | `options[*].description` | Description of the option | - |
-| 🟢 | `containerEnv` | Environment variables to override | - |
-| 🔴 | `privileged` | Set privileged mode for the container if the feature is used | - |
-| 🔴 | `init` | Add `tiny init` when the feature is used | - |
-| 🔴 | `capAdd` | Capabilities to add when the feature is used | - |
-| 🔴 | `securityOpt` | Security options to add when the feature is used | - |
-| 🔴 | `entrypoint` | Override entrypoint when the feature is used | - |
-| 🔴 | `customizations` | Product-specific properties to add when the feature is used | - |
-| 🔴 | `dependsOn` | Define a hard dependency on other features | - |
-| 🔴 | `installsAfter` | Define a soft dependency on other features | - |
-| 🔴 | `legacyIds` | Used when renaming a feature | - |
-| 🔴 | `deprecated` | Whether the feature is deprecated | - |
-| 🔴 | `mounts` | Cross-orchestrator mounts to add to the container | - |
+# Support for Dev Container Specification
+
+> Refer to the full Dev Container specification [here](https://containers.dev/implementors/json_reference/) for more information on the below options.
+
+The symbols in the first column indicate the support status:
+
+- 🟢 Fully supported.
+- 🟠 Partially supported.
+- 🔴 Not currently supported.
+
+The last column indicates any currently existing GitHub issue for tracking support for this feature.
+Feel free to [create a new issue](https://github.com/coder/envbuilder/issues/new) if you'd like Envbuilder to support a particular feature.
+
+## General
+
+| Status | Name | Description | Known Issues |
+| ------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| 🔴 | `name` | Human-friendly name for the dev container. | - |
+| 🔴 | `forwardPorts` | Port forwarding allows exposing container ports to the host, making services accessible. | [#48](https://github.com/coder/envbuilder/issues/48) |
+| 🔴 | `portsAttributes` | Set port attributes for a `host:port`. | - |
+| 🔴 | `otherPortsAttributes` | Other options for ports not configured using `portsAttributes`. | - |
+| 🟢 | `containerEnv` | Environment variables to set inside the container. | - |
+| 🟢 | `remoteEnv` | Override environment variables for tools, but not the whole container. | - |
+| 🟢\* | `remoteUser` | Override the user for tools, but not the whole container.
\*_Refer to [choosing a target user](./users.md#choosing-a-target-user), as behaviour may diverge from the spec._ | - |
+| 🟢\* | `containerUser` | Override the user for all operations run inside the container.
\*_Refer to [choosing a target user](./users.md#choosing-a-target-user), as behaviour may diverge from the spec._ | - |
+| 🔴 | `updateRemoteUserUID` | Update the devcontainer UID/GID to match the local user. | - |
+| 🔴 | `userEnvProbe` | Shell to use when probing for user environment variables. | - |
+| 🔴 | `overrideCommand` | Override the default sleep command to be run by supporting services. | - |
+| 🔴 | `shutdownAction` | Action to take when supporting tools are closed or shut down. | - |
+| 🔴 | `init` | Adds a tiny init process to the container. | [#221](https://github.com/coder/envbuilder/issues/221) |
+| 🔴 | `privileged` | Whether the container should be run in privileged mode. | - |
+| 🔴 | `capAdd` | Capabilities to add to the container (for example, `SYS_PTRACE`). | - |
+| 🔴 | `securityOpt` | Security options to add to the container (for example, `seccomp=unconfined`). | - |
+| 🔴 | `mounts` | Add additional mounts to the container. | [#220](https://github.com/coder/envbuilder/issues/220) |
+| 🟢 | `features` | Features to be added to the devcontainer. | - |
+| � | `overrideFeatureInstallOrder` | Override the order in which features should be installed. | - |
+| 🟠 | `customizations` | Product-specific properties, e.g., _VS Code_ settings and extensions. | Workaround in [#43](https://github.com/coder/envbuilder/issues/43) |
+
+## Image or Dockerfile
+
+| Status | Name | Description | Known Issues |
+| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------- | ------------ |
+| 🟢 | `image` | Name of an image to run. | - |
+| 🟢 | `build.dockerfile` | Path to a Dockerfile to build relative to `devcontainer.json`. | - |
+| 🟢 | `build.context` | Path to the build context relative to `devcontainer.json`. | - |
+| 🟢 | `build.args` | Build args to use when building the Dockerfile. | - |
+| 🔴 | `build.options` | Build options to pass to the Docker daemon. Envbuilder does not use a Docker daemon, so this is not relevant. | - |
+| 🟢 | `build.target` | Target to be passed when building the Dockerfile. | - |
+| 🟢 | `build.cacheFrom` | Images to use as caches when building the Dockerfile. | - |
+| 🔴 | `appPort` | Ports to be published locally when the container is running. | - |
+| 🔴 | `workspaceMount` | Overrides the default local mount point for the workspace when the container is created. | - |
+| 🔴 | `workspaceFolder` | Default path to open when connecting to the container. | - |
+
+## Docker Compose
+
+| Status | Name | Description | Known Issues |
+| ------ | ------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ |
+| 🔴 | `dockerComposeFile` | Path to a Docker Compose file related to the `devcontainer.json`. | [#236](https://github.com/coder/envbuilder/issues/236) |
+| 🔴 | `service` | Name of the Docker Compose service to which supporting tools should connect. | [#236](https://github.com/coder/envbuilder/issues/236) |
+| 🔴 | `runServices` | Docker Compose services to automatically start. | [#236](https://github.com/coder/envbuilder/issues/236) |
+
+## Lifecycle Scripts
+
+| Status | Name | Description | Known Issues |
+| ------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| 🔴 | `initializeCommand` | Command to run on the host machine when creating the container. | [#395](https://github.com/coder/envbuilder/issues/395) |
+| 🟢 | `onCreateCommand` | Command to run inside container after first start. | |
+| 🟢 | `updateContentCommand` | Command to run after `onCreateCommand` inside container. | |
+| 🟢 | `postCreateCommand` | Command to run after `updateContentCommand` inside container. | |
+| 🟢\* | `postStartCommand` | Command to run each time the container is started.
\*_This may be specified by `ENVBUILDER_POST_START_SCRIPT`, in which case it is the responsibility of `ENVBUILDER_INIT_COMMAND` to run it._ | |
+| 🔴 | `postAttachCommand` | Command to run each time a tool attaches to the container. | |
+| 🔴 | `waitFor` | Specify the lifecycle command tools should wait to complete before connecting. | |
+
+## Minimum Host Requirements
+
+| Status | Name | Description | Known Issues |
+| ------ | -------------------------- | -------------------------------- | ------------ |
+| 🔴 | `hostRequirements.cpus` | Minimum number of CPUs required. | - |
+| 🔴 | `hostRequirements.memory` | Minimum memory requirements. | - |
+| 🔴 | `hostRequirements.storage` | Minimum storage requirements. | - |
+| 🔴 | `hostRequirements.gpu` | Whether a GPU is required. | - |
+
+## Variable Substitution
+
+| Status | Name | Description | Known Issues |
+| ------ | ------------------------------------- | --------------------------------------------------- | ------------ |
+| 🟢 | `${localEnv:VARIABLE_NAME}` | Environment variable on the host machine. | - |
+| 🟢 | `${containerEnv:VARIABLE_NAME}` | Existing environment variable inside the container. | - |
+| 🟢 | `${localWorkspaceFolder}` | Path to the local workspace folder. | - |
+| 🟢 | `${containerWorkspaceFolder}` | Path to the workspace folder inside the container. | - |
+| 🟢 | `${localWorkspaceFolderBasename}` | Base name of `localWorkspaceFolder`. | - |
+| 🟢 | `${containerWorkspaceFolderBasename}` | Base name of `containerWorkspaceFolder`. | - |
+| 🔴 | `${devcontainerId}` | A stable unique identifier for the devcontainer. | - |
+
+## Features
+
+| Status | Name | Description | Known Issues |
+| ------ | ------------------------ | ------------------------------------------------------------ | ------------ |
+| 🟢 | `id` | Feature identifier | - |
+| � | `version` | Feature version | - |
+| 🟢 | `name` | Feature version | - |
+| 🟢 | `description` | Description | - |
+| 🟢 | `documentationURL` | Feature documentation URL | - |
+| 🟢 | `licenseURL` | Feature license URL | - |
+| 🟢 | `keywords` | Feature keywords | - |
+| 🟢 | `options` | Map of options passed to the feature | - |
+| 🟢 | `options[*].type` | Types of the option | - |
+| 🟢 | `options[*].proposals` | Suggested values of the option | - |
+| 🟢 | `options[*].enum` | Allowed string values of the option | - |
+| 🟢 | `options[*].default` | Default value of the option | - |
+| 🟢 | `options[*].description` | Description of the option | - |
+| 🟢 | `containerEnv` | Environment variables to override | - |
+| 🔴 | `privileged` | Set privileged mode for the container if the feature is used | - |
+| 🔴 | `init` | Add `tiny init` when the feature is used | - |
+| 🔴 | `capAdd` | Capabilities to add when the feature is used | - |
+| 🔴 | `securityOpt` | Security options to add when the feature is used | - |
+| 🔴 | `entrypoint` | Override entrypoint when the feature is used | - |
+| 🔴 | `customizations` | Product-specific properties to add when the feature is used | - |
+| � | `dependsOn` | Define a hard dependency on other features | - |
+| � | `installsAfter` | Define a soft dependency on other features | - |
+| 🔴 | `legacyIds` | Used when renaming a feature | - |
+| 🔴 | `deprecated` | Whether the feature is deprecated | - |
+| 🔴 | `mounts` | Cross-orchestrator mounts to add to the container | - |