Skip to content

Commit ece89cd

Browse files
authored
feat: locate devcontainer.json in multiple places (#134)
1 parent ea695d0 commit ece89cd

File tree

3 files changed

+278
-16
lines changed

3 files changed

+278
-16
lines changed

envbuilder.go

+77-16
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ type Options struct {
127127
// DevcontainerDir. This can be used in cases where one wants
128128
// to substitute an edited devcontainer.json file for the one
129129
// that exists in the repo.
130+
// If neither `DevcontainerDir` nor `DevcontainerJSONPath` is provided,
131+
// envbuilder will browse following directories to locate it:
132+
// 1. `.devcontainer/devcontainer.json`
133+
// 2. `.devcontainer.json`
134+
// 3. `.devcontainer/<folder>/devcontainer.json`
130135
DevcontainerJSONPath string `env:"DEVCONTAINER_JSON_PATH"`
131136

132137
// DockerfilePath is a relative path to the Dockerfile that
@@ -422,22 +427,11 @@ func Run(ctx context.Context, options Options) error {
422427
if options.DockerfilePath == "" {
423428
// Only look for a devcontainer if a Dockerfile wasn't specified.
424429
// devcontainer is a standard, so it's reasonable to be the default.
425-
devcontainerDir := options.DevcontainerDir
426-
if devcontainerDir == "" {
427-
devcontainerDir = ".devcontainer"
428-
}
429-
if !filepath.IsAbs(devcontainerDir) {
430-
devcontainerDir = filepath.Join(options.WorkspaceFolder, devcontainerDir)
431-
}
432-
devcontainerPath := options.DevcontainerJSONPath
433-
if devcontainerPath == "" {
434-
devcontainerPath = "devcontainer.json"
435-
}
436-
if !filepath.IsAbs(devcontainerPath) {
437-
devcontainerPath = filepath.Join(devcontainerDir, devcontainerPath)
438-
}
439-
_, err := options.Filesystem.Stat(devcontainerPath)
440-
if err == nil {
430+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(options)
431+
if err != nil {
432+
logf(codersdk.LogLevelError, "Failed to locate devcontainer.json: %s", err.Error())
433+
logf(codersdk.LogLevelError, "Falling back to the default image...")
434+
} else {
441435
// We know a devcontainer exists.
442436
// Let's parse it and use it!
443437
file, err := options.Filesystem.Open(devcontainerPath)
@@ -1201,3 +1195,70 @@ type osfsWithChmod struct {
12011195
func (fs *osfsWithChmod) Chmod(name string, mode os.FileMode) error {
12021196
return os.Chmod(name, mode)
12031197
}
1198+
1199+
func findDevcontainerJSON(options Options) (string, string, error) {
1200+
// 0. Check if custom devcontainer directory or path is provided.
1201+
if options.DevcontainerDir != "" || options.DevcontainerJSONPath != "" {
1202+
devcontainerDir := options.DevcontainerDir
1203+
if devcontainerDir == "" {
1204+
devcontainerDir = ".devcontainer"
1205+
}
1206+
// If `devcontainerDir` is not an absolute path, assume it is relative to the workspace folder.
1207+
if !filepath.IsAbs(devcontainerDir) {
1208+
devcontainerDir = filepath.Join(options.WorkspaceFolder, devcontainerDir)
1209+
}
1210+
1211+
// An absolute location always takes a precedence.
1212+
devcontainerPath := options.DevcontainerJSONPath
1213+
if filepath.IsAbs(devcontainerPath) {
1214+
return options.DevcontainerJSONPath, devcontainerDir, nil
1215+
}
1216+
// If an override is not provided, assume it is just `devcontainer.json`.
1217+
if devcontainerPath == "" {
1218+
devcontainerPath = "devcontainer.json"
1219+
}
1220+
1221+
if !filepath.IsAbs(devcontainerPath) {
1222+
devcontainerPath = filepath.Join(devcontainerDir, devcontainerPath)
1223+
}
1224+
return devcontainerPath, devcontainerDir, nil
1225+
}
1226+
1227+
// 1. Check `options.WorkspaceFolder`/.devcontainer/devcontainer.json.
1228+
location := filepath.Join(options.WorkspaceFolder, ".devcontainer", "devcontainer.json")
1229+
if _, err := options.Filesystem.Stat(location); err == nil {
1230+
return location, filepath.Dir(location), nil
1231+
}
1232+
1233+
// 2. Check `options.WorkspaceFolder`/devcontainer.json.
1234+
location = filepath.Join(options.WorkspaceFolder, "devcontainer.json")
1235+
if _, err := options.Filesystem.Stat(location); err == nil {
1236+
return location, filepath.Dir(location), nil
1237+
}
1238+
1239+
// 3. Check every folder: `options.WorkspaceFolder`/.devcontainer/<folder>/devcontainer.json.
1240+
devcontainerDir := filepath.Join(options.WorkspaceFolder, ".devcontainer")
1241+
1242+
fileInfos, err := options.Filesystem.ReadDir(devcontainerDir)
1243+
if err != nil {
1244+
return "", "", err
1245+
}
1246+
1247+
logf := options.Logger
1248+
for _, fileInfo := range fileInfos {
1249+
if !fileInfo.IsDir() {
1250+
logf(codersdk.LogLevelDebug, `%s is a file`, fileInfo.Name())
1251+
continue
1252+
}
1253+
1254+
location := filepath.Join(devcontainerDir, fileInfo.Name(), "devcontainer.json")
1255+
if _, err := options.Filesystem.Stat(location); err != nil {
1256+
logf(codersdk.LogLevelDebug, `stat %s failed: %s`, location, err.Error())
1257+
continue
1258+
}
1259+
1260+
return location, filepath.Dir(location), nil
1261+
}
1262+
1263+
return "", "", errors.New("can't find devcontainer.json, is it a correct spec?")
1264+
}

envbuilder_internal_test.go

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package envbuilder
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-git/go-billy/v5/memfs"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestFindDevcontainerJSON(t *testing.T) {
12+
t.Parallel()
13+
14+
t.Run("empty filesystem", func(t *testing.T) {
15+
t.Parallel()
16+
17+
// given
18+
fs := memfs.New()
19+
20+
// when
21+
_, _, err := findDevcontainerJSON(Options{
22+
Filesystem: fs,
23+
WorkspaceFolder: "/workspace",
24+
})
25+
26+
// then
27+
require.Error(t, err)
28+
})
29+
30+
t.Run("devcontainers.json is missing", func(t *testing.T) {
31+
t.Parallel()
32+
33+
// given
34+
fs := memfs.New()
35+
err := fs.MkdirAll("/workspace/.devcontainer", 0600)
36+
require.NoError(t, err)
37+
38+
// when
39+
_, _, err = findDevcontainerJSON(Options{
40+
Filesystem: fs,
41+
WorkspaceFolder: "/workspace",
42+
})
43+
44+
// then
45+
require.Error(t, err)
46+
})
47+
48+
t.Run("default configuration", func(t *testing.T) {
49+
t.Parallel()
50+
51+
// given
52+
fs := memfs.New()
53+
err := fs.MkdirAll("/workspace/.devcontainer", 0600)
54+
require.NoError(t, err)
55+
fs.Create("/workspace/.devcontainer/devcontainer.json")
56+
57+
// when
58+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{
59+
Filesystem: fs,
60+
WorkspaceFolder: "/workspace",
61+
})
62+
63+
// then
64+
require.NoError(t, err)
65+
assert.Equal(t, "/workspace/.devcontainer/devcontainer.json", devcontainerPath)
66+
assert.Equal(t, "/workspace/.devcontainer", devcontainerDir)
67+
})
68+
69+
t.Run("overridden .devcontainer directory", func(t *testing.T) {
70+
t.Parallel()
71+
72+
// given
73+
fs := memfs.New()
74+
err := fs.MkdirAll("/workspace/experimental-devcontainer", 0600)
75+
require.NoError(t, err)
76+
fs.Create("/workspace/experimental-devcontainer/devcontainer.json")
77+
78+
// when
79+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{
80+
Filesystem: fs,
81+
WorkspaceFolder: "/workspace",
82+
DevcontainerDir: "experimental-devcontainer",
83+
})
84+
85+
// then
86+
require.NoError(t, err)
87+
assert.Equal(t, "/workspace/experimental-devcontainer/devcontainer.json", devcontainerPath)
88+
assert.Equal(t, "/workspace/experimental-devcontainer", devcontainerDir)
89+
})
90+
91+
t.Run("overridden devcontainer.json path", func(t *testing.T) {
92+
t.Parallel()
93+
94+
// given
95+
fs := memfs.New()
96+
err := fs.MkdirAll("/workspace/.devcontainer", 0600)
97+
require.NoError(t, err)
98+
fs.Create("/workspace/.devcontainer/experimental.json")
99+
100+
// when
101+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{
102+
Filesystem: fs,
103+
WorkspaceFolder: "/workspace",
104+
DevcontainerJSONPath: "experimental.json",
105+
})
106+
107+
// then
108+
require.NoError(t, err)
109+
assert.Equal(t, "/workspace/.devcontainer/experimental.json", devcontainerPath)
110+
assert.Equal(t, "/workspace/.devcontainer", devcontainerDir)
111+
})
112+
113+
t.Run("devcontainer.json in workspace root", func(t *testing.T) {
114+
t.Parallel()
115+
116+
// given
117+
fs := memfs.New()
118+
err := fs.MkdirAll("/workspace", 0600)
119+
require.NoError(t, err)
120+
fs.Create("/workspace/devcontainer.json")
121+
122+
// when
123+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{
124+
Filesystem: fs,
125+
WorkspaceFolder: "/workspace",
126+
})
127+
128+
// then
129+
require.NoError(t, err)
130+
assert.Equal(t, "/workspace/devcontainer.json", devcontainerPath)
131+
assert.Equal(t, "/workspace", devcontainerDir)
132+
})
133+
134+
t.Run("devcontainer.json in subfolder of .devcontainer", func(t *testing.T) {
135+
t.Parallel()
136+
137+
// given
138+
fs := memfs.New()
139+
err := fs.MkdirAll("/workspace/.devcontainer/sample", 0600)
140+
require.NoError(t, err)
141+
fs.Create("/workspace/.devcontainer/sample/devcontainer.json")
142+
143+
// when
144+
devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{
145+
Filesystem: fs,
146+
WorkspaceFolder: "/workspace",
147+
})
148+
149+
// then
150+
require.NoError(t, err)
151+
assert.Equal(t, "/workspace/.devcontainer/sample/devcontainer.json", devcontainerPath)
152+
assert.Equal(t, "/workspace/.devcontainer/sample", devcontainerDir)
153+
})
154+
}

integration/integration_test.go

+47
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,53 @@ func TestBuildFromDevcontainerInCustomPath(t *testing.T) {
273273
require.Equal(t, "hello", strings.TrimSpace(output))
274274
}
275275

276+
func TestBuildFromDevcontainerInSubfolder(t *testing.T) {
277+
t.Parallel()
278+
279+
// Ensures that a Git repository with a devcontainer.json is cloned and built.
280+
url := createGitServer(t, gitServerOptions{
281+
files: map[string]string{
282+
".devcontainer/subfolder/devcontainer.json": `{
283+
"name": "Test",
284+
"build": {
285+
"dockerfile": "Dockerfile"
286+
},
287+
}`,
288+
".devcontainer/subfolder/Dockerfile": "FROM ubuntu",
289+
},
290+
})
291+
ctr, err := runEnvbuilder(t, options{env: []string{
292+
"GIT_URL=" + url,
293+
}})
294+
require.NoError(t, err)
295+
296+
output := execContainer(t, ctr, "echo hello")
297+
require.Equal(t, "hello", strings.TrimSpace(output))
298+
}
299+
func TestBuildFromDevcontainerInRoot(t *testing.T) {
300+
t.Parallel()
301+
302+
// Ensures that a Git repository with a devcontainer.json is cloned and built.
303+
url := createGitServer(t, gitServerOptions{
304+
files: map[string]string{
305+
"devcontainer.json": `{
306+
"name": "Test",
307+
"build": {
308+
"dockerfile": "Dockerfile"
309+
},
310+
}`,
311+
"Dockerfile": "FROM ubuntu",
312+
},
313+
})
314+
ctr, err := runEnvbuilder(t, options{env: []string{
315+
"GIT_URL=" + url,
316+
}})
317+
require.NoError(t, err)
318+
319+
output := execContainer(t, ctr, "echo hello")
320+
require.Equal(t, "hello", strings.TrimSpace(output))
321+
}
322+
276323
func TestBuildCustomCertificates(t *testing.T) {
277324
srv := httptest.NewTLSServer(createGitHandler(t, gitServerOptions{
278325
files: map[string]string{

0 commit comments

Comments
 (0)