Skip to content

Commit 63a38a7

Browse files
committed
devcontainer: implement overrideFeatureInstallOrder
1 parent da95f80 commit 63a38a7

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

devcontainer/devcontainer.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type Spec struct {
4040
RemoteEnv map[string]string `json:"remoteEnv"`
4141
// Features is a map of feature names to feature configurations.
4242
Features map[string]any `json:"features"`
43+
// OverrideFeatureInstallOrder overrides the order in which features are
44+
// installed. Feature references not present in this list are installed
45+
// after the listed ones, in alphabetical order.
46+
OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder"`
4347
LifecycleScripts
4448

4549
// Deprecated but still frequently used...
@@ -234,15 +238,15 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
234238
featureDirectives := []string{}
235239
featureContexts := make(map[string]string)
236240

237-
// TODO: Respect the installation order outlined by the spec:
238-
// https://containers.dev/implementors/features/#installation-order
239-
featureOrder := []string{}
241+
featureOrder := make([]string, 0, len(s.Features))
240242
for featureRef := range s.Features {
241243
featureOrder = append(featureOrder, featureRef)
242244
}
243-
// It's critical we sort features prior to compilation so the Dockerfile
244-
// is deterministic which allows for caching.
245-
sort.Strings(featureOrder)
245+
// applyInstallOrder places explicitly ordered features first (in declared
246+
// order), then appends remaining features alphabetically. Alphabetical
247+
// ordering for unconstrained features is critical for Dockerfile
248+
// determinism, which allows for layer caching.
249+
featureOrder = applyInstallOrder(featureOrder, s.OverrideFeatureInstallOrder)
246250

247251
var lines []string
248252
for _, featureRefRaw := range featureOrder {
@@ -306,6 +310,34 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
306310
return strings.Join(lines, "\n"), featureContexts, err
307311
}
308312

313+
// applyInstallOrder returns features in the order specified by overrideOrder
314+
// first, then any remaining features in alphabetical order. Entries in
315+
// overrideOrder that don't match any feature are silently ignored.
316+
func applyInstallOrder(features []string, overrideOrder []string) []string {
317+
set := make(map[string]bool, len(features))
318+
for _, f := range features {
319+
set[f] = true
320+
}
321+
322+
ordered := make([]string, 0, len(features))
323+
for _, f := range overrideOrder {
324+
if set[f] {
325+
ordered = append(ordered, f)
326+
delete(set, f)
327+
}
328+
}
329+
330+
// Collect and sort the remainder for determinism.
331+
remaining := make([]string, 0, len(set))
332+
for _, f := range features {
333+
if set[f] {
334+
remaining = append(remaining, f)
335+
}
336+
}
337+
sort.Strings(remaining)
338+
return append(ordered, remaining...)
339+
}
340+
309341
// BuildArgsMap converts a slice of "KEY=VALUE" strings to a map.
310342
func BuildArgsMap(buildArgs []string) map[string]string {
311343
m := make(map[string]string, len(buildArgs))

devcontainer/devcontainer_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,97 @@ USER 1000`, params.DockerfileContent)
149149
})
150150
}
151151

152+
func TestCompileWithFeaturesOverrideInstallOrder(t *testing.T) {
153+
t.Parallel()
154+
registry := registrytest.New(t)
155+
featureOne := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/one:tomato", features.TarLayerMediaType, map[string]any{
156+
"install.sh": "hey",
157+
"devcontainer-feature.json": features.Spec{
158+
ID: "one",
159+
Version: "tomato",
160+
Name: "One",
161+
},
162+
})
163+
featureTwo := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/two:potato", features.TarLayerMediaType, map[string]any{
164+
"install.sh": "hey",
165+
"devcontainer-feature.json": features.Spec{
166+
ID: "two",
167+
Version: "potato",
168+
Name: "Two",
169+
},
170+
})
171+
featureThree := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/three:apple", features.TarLayerMediaType, map[string]any{
172+
"install.sh": "hey",
173+
"devcontainer-feature.json": features.Spec{
174+
ID: "three",
175+
Version: "apple",
176+
Name: "Three",
177+
},
178+
})
179+
180+
featureOneMD5 := md5.Sum([]byte(featureOne))
181+
featureOneDir := fmt.Sprintf("/.envbuilder/features/one-%x", featureOneMD5[:4])
182+
featureTwoMD5 := md5.Sum([]byte(featureTwo))
183+
featureTwoDir := fmt.Sprintf("/.envbuilder/features/two-%x", featureTwoMD5[:4])
184+
featureThreeMD5 := md5.Sum([]byte(featureThree))
185+
featureThreeDir := fmt.Sprintf("/.envbuilder/features/three-%x", featureThreeMD5[:4])
186+
187+
t.Run("OverrideReverseOrder", func(t *testing.T) {
188+
// featureThree then featureTwo are explicitly ordered first; featureOne
189+
// is unconstrained and falls to the alphabetical remainder.
190+
raw := `{
191+
"image": "localhost:5000/envbuilder-test-ubuntu:latest",
192+
"features": {
193+
"` + featureOne + `": {},
194+
"` + featureTwo + `": {},
195+
"` + featureThree + `": {}
196+
},
197+
"overrideFeatureInstallOrder": ["` + featureThree + `", "` + featureTwo + `"]
198+
}`
199+
dc, err := devcontainer.Parse([]byte(raw))
200+
require.NoError(t, err)
201+
fs := memfs.New()
202+
203+
params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
204+
require.NoError(t, err)
205+
206+
// featureThree and featureTwo come first (in override order),
207+
// then featureOne last (alphabetical remainder).
208+
require.Contains(t, params.DockerfileContent, "WORKDIR "+featureThreeDir+"\n")
209+
require.Contains(t, params.DockerfileContent, "WORKDIR "+featureTwoDir+"\n")
210+
require.Contains(t, params.DockerfileContent, "WORKDIR "+featureOneDir+"\n")
211+
212+
threeIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureThreeDir)
213+
twoIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTwoDir)
214+
oneIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureOneDir)
215+
require.Less(t, threeIdx, twoIdx, "three should be installed before two")
216+
require.Less(t, twoIdx, oneIdx, "two should be installed before one")
217+
})
218+
219+
t.Run("UnknownOverrideEntryIgnored", func(t *testing.T) {
220+
// An entry in overrideFeatureInstallOrder that doesn't match any
221+
// feature key should be silently ignored.
222+
raw := `{
223+
"image": "localhost:5000/envbuilder-test-ubuntu:latest",
224+
"features": {
225+
"` + featureOne + `": {},
226+
"` + featureTwo + `": {}
227+
},
228+
"overrideFeatureInstallOrder": ["does-not-exist", "` + featureTwo + `"]
229+
}`
230+
dc, err := devcontainer.Parse([]byte(raw))
231+
require.NoError(t, err)
232+
fs := memfs.New()
233+
234+
params, err := dc.Compile(fs, "", workingDir, "", "", false, stubLookupEnv)
235+
require.NoError(t, err)
236+
237+
twoIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureTwoDir)
238+
oneIdx := strings.Index(params.DockerfileContent, "WORKDIR "+featureOneDir)
239+
require.Less(t, twoIdx, oneIdx, "two should be installed before one")
240+
})
241+
}
242+
152243
func TestCompileDevContainer(t *testing.T) {
153244
t.Parallel()
154245
t.Run("WithImage", func(t *testing.T) {

docs/devcontainer-spec-support.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Feel free to [create a new issue](https://github.com/coder/envbuilder/issues/new
3333
| 🔴 | `securityOpt` | Security options to add to the container (for example, `seccomp=unconfined`). | - |
3434
| 🔴 | `mounts` | Add additional mounts to the container. | [#220](https://github.com/coder/envbuilder/issues/220) |
3535
| 🟢 | `features` | Features to be added to the devcontainer. | - |
36-
| 🔴 | `overrideFeatureInstallOrder` | Override the order in which features should be installed. | [#226](https://github.com/coder/envbuilder/issues/226) |
36+
| | `overrideFeatureInstallOrder` | Override the order in which features should be installed. | - |
3737
| 🟠 | `customizations` | Product-specific properties, e.g., _VS Code_ settings and extensions. | Workaround in [#43](https://github.com/coder/envbuilder/issues/43) |
3838

3939
## Image or Dockerfile

0 commit comments

Comments
 (0)