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 | - |