diff --git a/envbuilder.go b/envbuilder.go index 7ae8936f..5a85f306 100644 --- a/envbuilder.go +++ b/envbuilder.go @@ -127,6 +127,11 @@ type Options struct { // DevcontainerDir. This can be used in cases where one wants // to substitute an edited devcontainer.json file for the one // that exists in the repo. + // If neither `DevcontainerDir` nor `DevcontainerJSONPath` is provided, + // envbuilder will browse following directories to locate it: + // 1. `.devcontainer/devcontainer.json` + // 2. `.devcontainer.json` + // 3. `.devcontainer//devcontainer.json` DevcontainerJSONPath string `env:"DEVCONTAINER_JSON_PATH"` // DockerfilePath is a relative path to the Dockerfile that @@ -422,22 +427,11 @@ func Run(ctx context.Context, options Options) error { if options.DockerfilePath == "" { // Only look for a devcontainer if a Dockerfile wasn't specified. // devcontainer is a standard, so it's reasonable to be the default. - devcontainerDir := options.DevcontainerDir - if devcontainerDir == "" { - devcontainerDir = ".devcontainer" - } - if !filepath.IsAbs(devcontainerDir) { - devcontainerDir = filepath.Join(options.WorkspaceFolder, devcontainerDir) - } - devcontainerPath := options.DevcontainerJSONPath - if devcontainerPath == "" { - devcontainerPath = "devcontainer.json" - } - if !filepath.IsAbs(devcontainerPath) { - devcontainerPath = filepath.Join(devcontainerDir, devcontainerPath) - } - _, err := options.Filesystem.Stat(devcontainerPath) - if err == nil { + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(options) + if err != nil { + logf(codersdk.LogLevelError, "Failed to locate devcontainer.json: %s", err.Error()) + logf(codersdk.LogLevelError, "Falling back to the default image...") + } else { // We know a devcontainer exists. // Let's parse it and use it! file, err := options.Filesystem.Open(devcontainerPath) @@ -1201,3 +1195,70 @@ type osfsWithChmod struct { func (fs *osfsWithChmod) Chmod(name string, mode os.FileMode) error { return os.Chmod(name, mode) } + +func findDevcontainerJSON(options Options) (string, string, error) { + // 0. Check if custom devcontainer directory or path is provided. + if options.DevcontainerDir != "" || options.DevcontainerJSONPath != "" { + devcontainerDir := options.DevcontainerDir + if devcontainerDir == "" { + devcontainerDir = ".devcontainer" + } + // If `devcontainerDir` is not an absolute path, assume it is relative to the workspace folder. + if !filepath.IsAbs(devcontainerDir) { + devcontainerDir = filepath.Join(options.WorkspaceFolder, devcontainerDir) + } + + // An absolute location always takes a precedence. + devcontainerPath := options.DevcontainerJSONPath + if filepath.IsAbs(devcontainerPath) { + return options.DevcontainerJSONPath, devcontainerDir, nil + } + // If an override is not provided, assume it is just `devcontainer.json`. + if devcontainerPath == "" { + devcontainerPath = "devcontainer.json" + } + + if !filepath.IsAbs(devcontainerPath) { + devcontainerPath = filepath.Join(devcontainerDir, devcontainerPath) + } + return devcontainerPath, devcontainerDir, nil + } + + // 1. Check `options.WorkspaceFolder`/.devcontainer/devcontainer.json. + location := filepath.Join(options.WorkspaceFolder, ".devcontainer", "devcontainer.json") + if _, err := options.Filesystem.Stat(location); err == nil { + return location, filepath.Dir(location), nil + } + + // 2. Check `options.WorkspaceFolder`/devcontainer.json. + location = filepath.Join(options.WorkspaceFolder, "devcontainer.json") + if _, err := options.Filesystem.Stat(location); err == nil { + return location, filepath.Dir(location), nil + } + + // 3. Check every folder: `options.WorkspaceFolder`/.devcontainer//devcontainer.json. + devcontainerDir := filepath.Join(options.WorkspaceFolder, ".devcontainer") + + fileInfos, err := options.Filesystem.ReadDir(devcontainerDir) + if err != nil { + return "", "", err + } + + logf := options.Logger + for _, fileInfo := range fileInfos { + if !fileInfo.IsDir() { + logf(codersdk.LogLevelDebug, `%s is a file`, fileInfo.Name()) + continue + } + + location := filepath.Join(devcontainerDir, fileInfo.Name(), "devcontainer.json") + if _, err := options.Filesystem.Stat(location); err != nil { + logf(codersdk.LogLevelDebug, `stat %s failed: %s`, location, err.Error()) + continue + } + + return location, filepath.Dir(location), nil + } + + return "", "", errors.New("can't find devcontainer.json, is it a correct spec?") +} diff --git a/envbuilder_internal_test.go b/envbuilder_internal_test.go new file mode 100644 index 00000000..d9fd3cb9 --- /dev/null +++ b/envbuilder_internal_test.go @@ -0,0 +1,154 @@ +package envbuilder + +import ( + "testing" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindDevcontainerJSON(t *testing.T) { + t.Parallel() + + t.Run("empty filesystem", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + + // when + _, _, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + }) + + // then + require.Error(t, err) + }) + + t.Run("devcontainers.json is missing", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace/.devcontainer", 0600) + require.NoError(t, err) + + // when + _, _, err = findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + }) + + // then + require.Error(t, err) + }) + + t.Run("default configuration", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace/.devcontainer", 0600) + require.NoError(t, err) + fs.Create("/workspace/.devcontainer/devcontainer.json") + + // when + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "/workspace/.devcontainer/devcontainer.json", devcontainerPath) + assert.Equal(t, "/workspace/.devcontainer", devcontainerDir) + }) + + t.Run("overridden .devcontainer directory", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace/experimental-devcontainer", 0600) + require.NoError(t, err) + fs.Create("/workspace/experimental-devcontainer/devcontainer.json") + + // when + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + DevcontainerDir: "experimental-devcontainer", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "/workspace/experimental-devcontainer/devcontainer.json", devcontainerPath) + assert.Equal(t, "/workspace/experimental-devcontainer", devcontainerDir) + }) + + t.Run("overridden devcontainer.json path", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace/.devcontainer", 0600) + require.NoError(t, err) + fs.Create("/workspace/.devcontainer/experimental.json") + + // when + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + DevcontainerJSONPath: "experimental.json", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "/workspace/.devcontainer/experimental.json", devcontainerPath) + assert.Equal(t, "/workspace/.devcontainer", devcontainerDir) + }) + + t.Run("devcontainer.json in workspace root", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace", 0600) + require.NoError(t, err) + fs.Create("/workspace/devcontainer.json") + + // when + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "/workspace/devcontainer.json", devcontainerPath) + assert.Equal(t, "/workspace", devcontainerDir) + }) + + t.Run("devcontainer.json in subfolder of .devcontainer", func(t *testing.T) { + t.Parallel() + + // given + fs := memfs.New() + err := fs.MkdirAll("/workspace/.devcontainer/sample", 0600) + require.NoError(t, err) + fs.Create("/workspace/.devcontainer/sample/devcontainer.json") + + // when + devcontainerPath, devcontainerDir, err := findDevcontainerJSON(Options{ + Filesystem: fs, + WorkspaceFolder: "/workspace", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "/workspace/.devcontainer/sample/devcontainer.json", devcontainerPath) + assert.Equal(t, "/workspace/.devcontainer/sample", devcontainerDir) + }) +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 2bab8e0c..53439025 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -273,6 +273,53 @@ func TestBuildFromDevcontainerInCustomPath(t *testing.T) { require.Equal(t, "hello", strings.TrimSpace(output)) } +func TestBuildFromDevcontainerInSubfolder(t *testing.T) { + t.Parallel() + + // Ensures that a Git repository with a devcontainer.json is cloned and built. + url := createGitServer(t, gitServerOptions{ + files: map[string]string{ + ".devcontainer/subfolder/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + ".devcontainer/subfolder/Dockerfile": "FROM ubuntu", + }, + }) + ctr, err := runEnvbuilder(t, options{env: []string{ + "GIT_URL=" + url, + }}) + require.NoError(t, err) + + output := execContainer(t, ctr, "echo hello") + require.Equal(t, "hello", strings.TrimSpace(output)) +} +func TestBuildFromDevcontainerInRoot(t *testing.T) { + t.Parallel() + + // Ensures that a Git repository with a devcontainer.json is cloned and built. + url := createGitServer(t, gitServerOptions{ + files: map[string]string{ + "devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + "Dockerfile": "FROM ubuntu", + }, + }) + ctr, err := runEnvbuilder(t, options{env: []string{ + "GIT_URL=" + url, + }}) + require.NoError(t, err) + + output := execContainer(t, ctr, "echo hello") + require.Equal(t, "hello", strings.TrimSpace(output)) +} + func TestBuildCustomCertificates(t *testing.T) { srv := httptest.NewTLSServer(createGitHandler(t, gitServerOptions{ files: map[string]string{