diff --git a/e2e/tests/up-docker-compose/config.go b/e2e/tests/up-docker-compose/config.go index 616a861ff..16cc1389e 100644 --- a/e2e/tests/up-docker-compose/config.go +++ b/e2e/tests/up-docker-compose/config.go @@ -14,8 +14,11 @@ import ( "github.com/devsy-org/devsy/e2e/framework" "github.com/devsy-org/devsy/pkg/compose" + pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" docker "github.com/devsy-org/devsy/pkg/docker" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/docker/docker/api/types/container" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -567,6 +570,168 @@ var _ = ginkgo.Describe( gomega.Expect(containerWorkspaceFolderBasename).To(gomega.Equal("workspaces")) }, ginkgo.SpecTimeout(framework.TimeoutLong())) + ginkgo.Context("compose project name resolution", func() { + const composeProjectLabel = "com.docker.compose.project" + + composeProjectFromContainer := func( + ctx context.Context, + workspace *provider2.Workspace, + ) string { + name, err := composeProjectForWorkspace( + ctx, tc.dockerHelper, tc.composeHelper, workspace.UID, + ) + framework.ExpectNoError(err) + gomega.Expect(name).To(gomega.Not(gomega.BeEmpty()), + "dev container has a "+composeProjectLabel+" label") + return name + } + + devContainer := func( + ctx context.Context, + workspace *provider2.Workspace, + ) *container.InspectResponse { + ids, err := tc.dockerHelper.FindContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), + }) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1), + "exactly one dev container for the workspace") + detail, err := tc.inspectContainer(ctx, ids) + framework.ExpectNoError(err) + return detail + } + + ginkgo.It("uses the top-level compose name as project name", func(ctx context.Context) { + _, workspace, err := tc.setupAndStartWorkspace( + ctx, + "tests/up-docker-compose/testdata/docker-compose-v2-with-name", + ) + framework.ExpectNoError(err) + + gomega.Expect(composeProjectFromContainer(ctx, workspace)). + To(gomega.Equal("testproject"), + "project name comes from the top-level compose name field") + }, ginkgo.SpecTimeout(framework.TimeoutLong())) + + ginkgo.It( + "uses COMPOSE_PROJECT_NAME from .env as project name", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up-docker-compose/testdata/docker-compose-project-name-envfile", + tc.initialDir, tc.f, + ) + framework.ExpectNoError(err) + + framework.ExpectNoError( + os.WriteFile( + filepath.Join(tempDir, ".env"), + []byte("COMPOSE_PROJECT_NAME=devsy-e2e-envfile-project\n"), + 0o600, + ), + ) + + framework.ExpectNoError(tc.f.DevsyUp(ctx, tempDir)) + + workspace, err := tc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Expect(composeProjectFromContainer(ctx, workspace)). + To(gomega.Equal("devsy-e2e-envfile-project"), + "project name comes from COMPOSE_PROJECT_NAME in .env") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + + ginkgo.It( + "uses COMPOSE_PROJECT_NAME from a .env colocated with the compose file", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up-docker-compose/testdata/docker-compose-nested-envfile", + tc.initialDir, tc.f, + ) + framework.ExpectNoError(err) + + framework.ExpectNoError( + os.WriteFile( + filepath.Join(tempDir, "dockerfiles", ".env"), + []byte("COMPOSE_PROJECT_NAME=devsy-e2e-subdir-envfile-project\n"), + 0o600, + ), + ) + + framework.ExpectNoError(tc.f.DevsyUp(ctx, tempDir)) + + workspace, err := tc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Expect(composeProjectFromContainer(ctx, workspace)). + To(gomega.Equal("devsy-e2e-subdir-envfile-project"), + "project name comes from COMPOSE_PROJECT_NAME in the "+ + "compose file's own directory, not just the workspace root") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + + ginkgo.It( + "uses COMPOSE_PROJECT_NAME from the shell as project name", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up-docker-compose/testdata/docker-compose", tc.initialDir, tc.f, + ) + framework.ExpectNoError(err) + + framework.ExpectNoError( + os.Setenv(compose.ComposeProjectNameEnv, "devsy-e2e-shell-project"), + ) + ginkgo.DeferCleanup(func() { + framework.ExpectNoError(os.Unsetenv(compose.ComposeProjectNameEnv)) + }) + + err = tc.f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + workspace, err := tc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + gomega.Expect(composeProjectFromContainer(ctx, workspace)). + To(gomega.Equal("devsy-e2e-shell-project"), + "shell COMPOSE_PROJECT_NAME overrides all other sources") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + + ginkgo.It( + "reuses the existing compose project on a second up", + func(ctx context.Context) { + tempDir, err := setupWorkspace( + "tests/up-docker-compose/testdata/docker-compose-v2-with-name", + tc.initialDir, tc.f, + ) + framework.ExpectNoError(err) + + err = tc.f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + workspace, err := tc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + firstProject := composeProjectFromContainer(ctx, workspace) + firstID := devContainer(ctx, workspace).ID + + err = tc.f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + secondProject := composeProjectFromContainer(ctx, workspace) + secondID := devContainer(ctx, workspace).ID + + gomega.Expect(secondProject).To(gomega.Equal(firstProject), + "second up reuses the same compose project name") + gomega.Expect(secondID).To(gomega.Equal(firstID), + "second up reuses the same container (no separate project spawned)") + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + }) + ginkgo.Context("host requirements enforcement", func() { var btc *baseTestContext diff --git a/e2e/tests/up-docker-compose/helper.go b/e2e/tests/up-docker-compose/helper.go index 6343329f7..ac8c6faf6 100644 --- a/e2e/tests/up-docker-compose/helper.go +++ b/e2e/tests/up-docker-compose/helper.go @@ -186,22 +186,53 @@ func setupDockerProvider(binDir string) (*framework.Framework, error) { return framework.SetupDockerProvider(binDir, "docker") } +// findComposeContainer returns the compose container IDs for a workspace's +// service, resolving devsy's actual project name first since it may not be +// the sanitized workspace UID. func findComposeContainer( ctx context.Context, dockerHelper *docker.DockerHelper, composeHelper *compose.ComposeHelper, workspaceUID, serviceName string, ) ([]string, error) { + projectName, err := composeProjectForWorkspace(ctx, dockerHelper, composeHelper, workspaceUID) + if err != nil { + return nil, err + } return dockerHelper.FindContainer(ctx, []string{ - fmt.Sprintf( - "%s=%s", - pkgconfig.ComposeProjectLabel, - composeHelper.GetProjectName(workspaceUID), - ), + fmt.Sprintf("%s=%s", pkgconfig.ComposeProjectLabel, projectName), fmt.Sprintf("%s=%s", pkgconfig.ComposeServiceLabel, serviceName), }) } +// composeProjectForWorkspace resolves the compose project name devsy used +// for the workspace by inspecting its dev container's labels, falling back +// to the sanitized workspace UID when none is found. +func composeProjectForWorkspace( + ctx context.Context, + dockerHelper *docker.DockerHelper, + composeHelper *compose.ComposeHelper, + workspaceUID string, +) (string, error) { + devIDs, err := dockerHelper.FindContainer(ctx, []string{ + fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspaceUID), + }) + if err != nil { + return "", err + } + if len(devIDs) == 0 { + return composeHelper.GetProjectName(workspaceUID), nil + } + var dev []container.InspectResponse + if err := dockerHelper.Inspect(ctx, devIDs[:1], "container", &dev); err != nil { + return "", err + } + if name, ok := dev[0].Config.Labels[pkgconfig.ComposeProjectLabel]; ok { + return name, nil + } + return composeHelper.GetProjectName(workspaceUID), nil +} + func devsyUpAndFindWorkspace( ctx context.Context, f *framework.Framework, diff --git a/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/.devcontainer/devcontainer.json b/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/.devcontainer/devcontainer.json new file mode 100644 index 000000000..3a37d80e7 --- /dev/null +++ b/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "name": "Compose project name from subdirectory .env", + "dockerComposeFile": "../dockerfiles/docker-compose.yaml", + "service": "app", + "workspaceFolder": "/workspaces" +} diff --git a/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/dockerfiles/docker-compose.yaml b/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/dockerfiles/docker-compose.yaml new file mode 100644 index 000000000..26308259e --- /dev/null +++ b/e2e/tests/up-docker-compose/testdata/docker-compose-nested-envfile/dockerfiles/docker-compose.yaml @@ -0,0 +1,4 @@ +services: + app: + image: ghcr.io/devsy-org/test-images/base:ubuntu + command: sleep infinity diff --git a/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/.devcontainer/devcontainer.json b/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/.devcontainer/devcontainer.json new file mode 100644 index 000000000..b7cb8bc09 --- /dev/null +++ b/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "name": "Compose project name from .env", + "dockerComposeFile": "../docker-compose.yaml", + "service": "app", + "workspaceFolder": "/workspaces" +} diff --git a/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/docker-compose.yaml b/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/docker-compose.yaml new file mode 100644 index 000000000..26308259e --- /dev/null +++ b/e2e/tests/up-docker-compose/testdata/docker-compose-project-name-envfile/docker-compose.yaml @@ -0,0 +1,4 @@ +services: + app: + image: ghcr.io/devsy-org/test-images/base:ubuntu + command: sleep infinity diff --git a/pkg/compose/helper.go b/pkg/compose/helper.go index d910f6e98..aed737561 100644 --- a/pkg/compose/helper.go +++ b/pkg/compose/helper.go @@ -364,6 +364,13 @@ func (h *ComposeHelper) GetProjectName(runnerID string) string { return h.toProjectName(runnerID) } +// SanitizeProjectName exposes toProjectName's compose-version-aware +// sanitization for callers outside this package (e.g. the devsy runner id +// fallback for compose project names). +func (h *ComposeHelper) SanitizeProjectName(name string) string { + return h.toProjectName(name) +} + func (h *ComposeHelper) toProjectName(projectName string) string { useNewProjectNameFormat, _ := h.useNewProjectName() if !useNewProjectNameFormat { diff --git a/pkg/compose/projectname.go b/pkg/compose/projectname.go new file mode 100644 index 000000000..fb5f66841 --- /dev/null +++ b/pkg/compose/projectname.go @@ -0,0 +1,60 @@ +package compose + +import ( + "os" + + "github.com/joho/godotenv" + "gopkg.in/yaml.v3" +) + +// ComposeProjectNameEnv is the environment/file variable Docker Compose uses +// to override the project name: https://docs.docker.com/compose/how-tos/project-name/ +const ComposeProjectNameEnv = "COMPOSE_PROJECT_NAME" + +// ProjectNameFromEnvFiles returns the first non-empty COMPOSE_PROJECT_NAME +// found scanning envFiles in order. Missing files are skipped. +func ProjectNameFromEnvFiles(envFiles []string) (string, error) { + for _, envFile := range envFiles { + env, err := godotenv.Read(envFile) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", err + } + if name := env[ComposeProjectNameEnv]; name != "" { + return name, nil + } + } + return "", nil +} + +// composeNameFile is the subset of a compose file relevant to name detection. +type composeNameFile struct { + Name string `yaml:"name"` +} + +// ComposeFilesDeclareName reports whether any compose file declares a +// top-level "name" (mirroring compose-go's own isNamed check). Presence is +// all that's needed: the caller uses the already-loaded project's +// interpolated Name when this is true. +func ComposeFilesDeclareName(composeFiles []string) (bool, error) { + for _, file := range composeFiles { + // #nosec G304 -- file paths are resolved from the trusted devcontainer config. + b, err := os.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + continue + } + return false, err + } + var parsed composeNameFile + if err := yaml.Unmarshal(b, &parsed); err != nil { + continue + } + if parsed.Name != "" { + return true, nil + } + } + return false, nil +} diff --git a/pkg/compose/projectname_test.go b/pkg/compose/projectname_test.go new file mode 100644 index 000000000..e4e900d32 --- /dev/null +++ b/pkg/compose/projectname_test.go @@ -0,0 +1,127 @@ +package compose + +import ( + "os" + "path/filepath" + "testing" +) + +const noNameBody = "services:\n web: { image: nginx }\n" + +func writeTestFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +func TestProjectNameFromEnvFiles(t *testing.T) { + dir := t.TempDir() + first := writeTestFile(t, dir, "a.env", "COMPOSE_PROJECT_NAME=first\nOTHER=1\n") + second := writeTestFile(t, dir, "b.env", "COMPOSE_PROJECT_NAME=second\n") + none := writeTestFile(t, dir, "none.env", "OTHER=1\n") + set := writeTestFile(t, dir, "set.env", "COMPOSE_PROJECT_NAME=found\n") + emptyA := writeTestFile(t, dir, "empty-a.env", "OTHER=1\n") + emptyB := writeTestFile(t, dir, "empty-b.env", "FOO=bar\n") + + cases := []struct { + name string + files []string + want string + wantErr bool + }{ + {name: "first non-empty value in order", files: []string{first, second}, want: "first"}, + {name: "skips files without the variable", files: []string{none, set}, want: "found"}, + {name: "empty when none define it", files: []string{emptyA, emptyB}, want: ""}, + { + name: "skips missing files without error", + files: []string{filepath.Join(dir, "nope.env")}, + want: "", + }, + {name: "empty input returns empty", files: nil, want: ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ProjectNameFromEnvFiles(tc.files) + if tc.wantErr { + if err == nil { + t.Fatal("want error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestComposeFilesDeclareName(t *testing.T) { + dir := t.TempDir() + single := writeTestFile(t, dir, "single.yml", "name: myapp\nservices: {}\n") + noname := writeTestFile(t, dir, "noname.yml", noNameBody) + named := writeTestFile(t, dir, "named.yml", "name: named-app\nservices: {}\n") + noNameA := writeTestFile(t, dir, "no-name-a.yml", noNameBody) + noNameB := writeTestFile(t, dir, "no-name-b.yml", noNameBody) + bad := writeTestFile(t, dir, "bad.yml", "name: [unterminated\n") + good := writeTestFile(t, dir, "good.yml", "name: good-app\nservices: {}\n") + interpolated := writeTestFile( + t, dir, "interpolated.yml", "name: ${COMPOSE_PROJECT_NAME:-myapp}\nservices: {}\n", + ) + + cases := []struct { + name string + files []string + want bool + }{ + {name: "single file with name", files: []string{single}, want: true}, + { + name: "skips files without name and finds one that has it", + files: []string{noname, named}, + want: true, + }, + {name: "false when no file declares name", files: []string{noNameA, noNameB}, want: false}, + { + name: "skips missing files without error", + files: []string{filepath.Join(dir, "missing.yml")}, + want: false, + }, + {name: "ignores unparseable YAML gracefully", files: []string{bad, good}, want: true}, + { + name: "raw declaration counts even when unresolved (compose-go interpolates the value)", + files: []string{interpolated}, + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ComposeFilesDeclareName(tc.files) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestSanitizeProjectName(t *testing.T) { + h := &ComposeHelper{Version: "2.30.0"} + cases := []struct{ in, want string }{ + {"MyApp", "myapp"}, + {"foo bar baz", "foobarbaz"}, + {"UPPER-Case_99", "upper-case_99"}, + {"café", "caf"}, + } + for _, c := range cases { + if got := h.SanitizeProjectName(c.in); got != c.want { + t.Errorf("SanitizeProjectName(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index 1be2f9f15..f19179803 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "os" - "path" "path/filepath" + "slices" "strings" composetypes "github.com/compose-spec/compose-go/v2/types" @@ -160,13 +160,19 @@ func (r *runner) deleteDockerCompose( func (r *runner) dockerComposeProjectFiles( parsedConfig *config.SubstitutedConfig, ) (composeProjectFiles, error) { - envFiles := r.getEnvFiles() + // baseEnvFiles are needed to resolve $COMPOSE_FILE and COMPOSE_PROJECT_NAME + // before the compose files themselves are known. + baseEnvFiles := r.baseEnvFiles(parsedConfig) - composeFiles, err := r.getDockerComposeFilePaths(parsedConfig, envFiles) + composeFiles, err := r.getDockerComposeFilePaths(parsedConfig, baseEnvFiles) if err != nil { return composeProjectFiles{}, fmt.Errorf("get docker compose file paths: %w", err) } + // Colocated with the compose files, so only known once they are resolved; + // they cannot drive $COMPOSE_FILE but can still set COMPOSE_PROJECT_NAME. + envFiles := appendEnvFiles(baseEnvFiles, composeDirEnvFiles(composeFiles)) + var args []string for _, configFile := range composeFiles { args = append(args, "-f", configFile) @@ -217,8 +223,8 @@ func (r *runner) runDockerCompose( return r.finalizeComposeContainer(ctx, runParams, project, containerDetails) } -// loadComposeProject loads the docker compose project from the resolved compose -// and env files and names it after the workspace. +// loadComposeProject loads the docker compose project and resolves its +// project name (see resolveComposeProjectName). func (r *runner) loadComposeProject( ctx context.Context, composeHelper *compose.ComposeHelper, @@ -234,7 +240,12 @@ func (r *runner) loadComposeProject( if err != nil { return nil, fmt.Errorf("load docker compose project: %w", err) } - project.Name = composeHelper.GetProjectName(r.id) + + name, err := r.resolveComposeProjectName(composeHelper, project, projFiles) + if err != nil { + return nil, fmt.Errorf("resolve compose project name: %w", err) + } + project.Name = name log.Debugf("Loaded project %s", project.Name) if err := validateRunServices(parsedConfig.Config.RunServices, project); err != nil { @@ -244,6 +255,48 @@ func (r *runner) loadComposeProject( return project, nil } +// resolveComposeProjectName trusts compose-go's own resolved project.Name +// when the user named the project explicitly (shell env, .env file, or a +// compose file's top-level "name"); otherwise it substitutes the sanitized +// random runner id for compose-go's directory-basename default. It never +// consults devcontainer.json's "name", which is a UI display name per the +// Dev Containers spec, not a compose project name. +func (r *runner) resolveComposeProjectName( + composeHelper *compose.ComposeHelper, + project *composetypes.Project, + projFiles composeProjectFiles, +) (string, error) { + named, err := hasExplicitComposeProjectName(projFiles) + if err != nil { + return "", err + } + if named { + return project.Name, nil + } + return composeHelper.SanitizeProjectName(r.id), nil +} + +// hasExplicitComposeProjectName reports whether the user named the compose +// project via the shell, a .env file, or a compose file's top-level "name", +// mirroring compose-go's own precedence: a present (even empty) shell +// COMPOSE_PROJECT_NAME wins or loses on its own and blocks .env files. +func hasExplicitComposeProjectName(projFiles composeProjectFiles) (bool, error) { + if shellValue, ok := os.LookupEnv(compose.ComposeProjectNameEnv); ok { + if shellValue != "" { + return true, nil + } + return compose.ComposeFilesDeclareName(projFiles.composeFiles) + } + envName, err := compose.ProjectNameFromEnvFiles(projFiles.envFiles) + if err != nil { + return false, err + } + if envName != "" { + return true, nil + } + return compose.ComposeFilesDeclareName(projFiles.composeFiles) +} + // composeContainerParams groups the inputs for ensuring a running compose dev // container. type composeContainerParams struct { @@ -601,16 +654,65 @@ func composeFileFromEnv(envFiles []string) (string, error) { return "", nil } -func (r *runner) getEnvFiles() []string { +// baseEnvFiles collects the .env files that exist before the compose files are +// resolved: the devcontainer config directory's .env (e.g. +// .devcontainer/.env) and the workspace root .env. They feed both +// $COMPOSE_FILE resolution and COMPOSE_PROJECT_NAME. +func (r *runner) baseEnvFiles(parsedConfig *config.SubstitutedConfig) []string { var envFiles []string - envFile := path.Join(r.localWorkspaceFolder, ".env") - envFileStat, err := os.Stat(envFile) + if parsedConfig != nil && parsedConfig.Config != nil && parsedConfig.Config.Origin != "" { + envFiles = appendExistingEnvFile( + envFiles, + filepath.Join(filepath.Dir(parsedConfig.Config.Origin), ".env"), + ) + } + envFiles = appendExistingEnvFile(envFiles, filepath.Join(r.localWorkspaceFolder, ".env")) + return envFiles +} + +// composeDirEnvFiles returns the .env file colocated with each compose file's +// directory. These are added after the compose files are resolved so they can +// contribute COMPOSE_PROJECT_NAME (they cannot drive $COMPOSE_FILE, which is +// resolved earlier). +func composeDirEnvFiles(composeFiles []string) []string { + var envFiles []string + for _, composeFile := range composeFiles { + envFiles = appendExistingEnvFile(envFiles, filepath.Join(filepath.Dir(composeFile), ".env")) + } + return envFiles +} + +// appendExistingEnvFile appends path to envFiles when it is a regular file that +// is not already in the list. +func appendExistingEnvFile(envFiles []string, path string) []string { + if slices.Contains(envFiles, path) { + return envFiles + } + envFileStat, err := os.Stat(path) if err == nil && envFileStat.Mode().IsRegular() { - envFiles = append(envFiles, envFile) + return append(envFiles, path) } return envFiles } +// appendEnvFiles appends new files to base, skipping duplicates already present +// while preserving first-seen order. +func appendEnvFiles(base, additional []string) []string { + seen := make(map[string]bool, len(base)) + for _, f := range base { + seen[f] = true + } + out := make([]string, len(base)) + copy(out, base) + for _, f := range additional { + if !seen[f] { + seen[f] = true + out = append(out, f) + } + } + return out +} + // resolveComposeServiceImage looks up the named devcontainer service in the // compose project and determines its original image name, falling back to the // compose default image when the service does not declare one. diff --git a/pkg/devcontainer/compose_projectname_test.go b/pkg/devcontainer/compose_projectname_test.go new file mode 100644 index 000000000..710fe2249 --- /dev/null +++ b/pkg/devcontainer/compose_projectname_test.go @@ -0,0 +1,262 @@ +package devcontainer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/compose" + "github.com/devsy-org/devsy/pkg/devcontainer/config" +) + +const ( + testComposeVersion = "2.30.0" + testRunnerID = "random-runner-id" + testEnvName = "env-name" + testYamlName = "yaml-name" +) + +// newResolveRunner builds a runner wired to a temp workspace with the devcontainer +// config rooted at /.devcontainer/devcontainer.json. +func newResolveRunner(t *testing.T) (*runner, string) { + t.Helper() + ws := t.TempDir() + configDir := filepath.Join(ws, ".devcontainer") + if err := os.MkdirAll(configDir, 0o750); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + r := &runner{ + localWorkspaceFolder: ws, + id: testRunnerID, + } + return r, configDir +} + +func writeComposeFile(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + writeFile(t, path, body) + return path +} + +// resolveProject loads a compose-go project the same way runDockerCompose +// does, then resolves its project name. +func resolveProject(t *testing.T, r *runner, configDir, composeFile string) (string, error) { + t.Helper() + helper := &compose.ComposeHelper{Version: testComposeVersion} + projFiles := composeProjectFiles{composeFiles: []string{composeFile}} + if envFile := filepath.Join(configDir, ".env"); fileExists(envFile) { + projFiles.envFiles = []string{envFile} + } + project, err := compose.LoadDockerComposeProject( + t.Context(), projFiles.composeFiles, projFiles.envFiles, + ) + if err != nil { + return "", err + } + return r.resolveComposeProjectName(helper, project, projFiles) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// unsetComposeProjectNameEnv ensures COMPOSE_PROJECT_NAME is absent from the +// environment (not merely empty: compose-go treats a shell variable that is +// present-but-empty differently from one that was never exported). +func unsetComposeProjectNameEnv(t *testing.T) { + t.Helper() + prev, wasSet := os.LookupEnv(compose.ComposeProjectNameEnv) + if err := os.Unsetenv(compose.ComposeProjectNameEnv); err != nil { + t.Fatalf("unsetenv: %v", err) + } + t.Cleanup(func() { + if wasSet { + if err := os.Setenv(compose.ComposeProjectNameEnv, prev); err != nil { + t.Fatalf("restore env: %v", err) + } + } + }) +} + +func TestResolveComposeProjectName(t *testing.T) { + cases := []struct { + name string + envName string + envBody string + yamlBody string + want string + }{ + { + name: "shell env overrides everything", + envName: "shell-name", + want: "shell-name", + yamlBody: "name: " + testYamlName + "\nservices: {}\n", + envBody: "COMPOSE_PROJECT_NAME=" + testEnvName + "\n", + }, + { + name: "env file overrides yaml name", + want: testEnvName, + envBody: "COMPOSE_PROJECT_NAME=" + testEnvName + "\n", + yamlBody: "name: " + testYamlName + "\nservices: {}\n", + }, + { + name: "yaml name used when no env", + want: testYamlName, + yamlBody: "name: " + testYamlName + "\nservices: {}\n", + }, + { + name: "interpolated yaml name is resolved, not read raw", + want: testYamlName, + yamlBody: "name: ${UNSET_NAME_VAR:-" + testYamlName + "}\nservices: {}\n", + }, + { + name: "falls back to sanitized runner id when no source", + want: testRunnerID, + yamlBody: "services: {}\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.envName != "" { + t.Setenv(compose.ComposeProjectNameEnv, tc.envName) + } else { + unsetComposeProjectNameEnv(t) + } + r, configDir := newResolveRunner(t) + composeFile := writeComposeFile(t, configDir, "compose.yml", tc.yamlBody) + if tc.envBody != "" { + writeComposeFile(t, configDir, ".env", tc.envBody) + } + got, err := resolveProject(t, r, configDir, composeFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestResolveComposeProjectName_ShellEnvSetEmptyBlocksEnvFile(t *testing.T) { + t.Setenv(compose.ComposeProjectNameEnv, "") + r, configDir := newResolveRunner(t) + composeFile := writeComposeFile( + t, configDir, "compose.yml", "name: "+testYamlName+"\nservices: {}\n", + ) + writeComposeFile(t, configDir, ".env", "COMPOSE_PROJECT_NAME="+testEnvName+"\n") + + got, err := resolveProject(t, r, configDir, composeFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != testYamlName { + t.Errorf("got %q, want %q", got, testYamlName) + } +} + +func TestResolveComposeProjectName_MultiFileLastNameWins(t *testing.T) { + unsetComposeProjectNameEnv(t) + r, configDir := newResolveRunner(t) + base := writeComposeFile(t, configDir, "base.yml", "name: base-app\nservices: {}\n") + over := writeComposeFile(t, configDir, "override.yml", "name: override-app\nservices: {}\n") + + helper := &compose.ComposeHelper{Version: testComposeVersion} + projFiles := composeProjectFiles{composeFiles: []string{base, over}} + project, err := compose.LoadDockerComposeProject(t.Context(), projFiles.composeFiles, nil) + if err != nil { + t.Fatalf("load project: %v", err) + } + got, err := r.resolveComposeProjectName(helper, project, projFiles) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "override-app" { + t.Errorf("got %q, want %q", got, "override-app") + } +} + +func TestBaseEnvFiles(t *testing.T) { + t.Run("collects config dir and workspace root .env", func(t *testing.T) { + r, configDir := newResolveRunner(t) + writeComposeFile(t, configDir, ".env", "COMPOSE_PROJECT_NAME=dc\n") + writeComposeFile(t, r.localWorkspaceFolder, ".env", "OTHER=1\n") + parsed := &config.SubstitutedConfig{Config: &config.DevContainerConfig{}} + parsed.Config.Origin = filepath.Join(configDir, "devcontainer.json") + + got := r.baseEnvFiles(parsed) + if len(got) != 2 { + t.Fatalf("got %d env files, want 2: %v", len(got), got) + } + if got[0] != filepath.Join(configDir, ".env") { + t.Errorf("first = %q, want config dir .env", got[0]) + } + if got[1] != filepath.Join(r.localWorkspaceFolder, ".env") { + t.Errorf("second = %q, want workspace root .env", got[1]) + } + }) + + t.Run("deduplicates when config dir is the workspace root", func(t *testing.T) { + ws := t.TempDir() + r := &runner{localWorkspaceFolder: ws, id: testRunnerID} + writeComposeFile(t, ws, ".env", "OTHER=1\n") + parsed := &config.SubstitutedConfig{Config: &config.DevContainerConfig{}} + parsed.Config.Origin = filepath.Join(ws, ".devcontainer.json") + + got := r.baseEnvFiles(parsed) + if len(got) != 1 { + t.Fatalf("got %d env files, want 1 (deduped): %v", len(got), got) + } + }) + + t.Run("skips missing files", func(t *testing.T) { + r, configDir := newResolveRunner(t) + parsed := &config.SubstitutedConfig{Config: &config.DevContainerConfig{}} + parsed.Config.Origin = filepath.Join(configDir, "devcontainer.json") + + if got := r.baseEnvFiles(parsed); len(got) != 0 { + t.Fatalf("got %d env files, want 0: %v", len(got), got) + } + }) +} + +func TestComposeDirEnvFiles(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "dockerfiles") + if err := os.MkdirAll(sub, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + composeA := writeComposeFile(t, sub, "docker-compose.yml", "services: {}\n") + composeB := writeComposeFile(t, dir, "compose.dev.yml", "services: {}\n") + writeComposeFile(t, sub, ".env", "COMPOSE_PROJECT_NAME=x\n") + writeComposeFile(t, dir, ".env", "COMPOSE_PROJECT_NAME=y\n") + + got := composeDirEnvFiles([]string{composeA, composeB}) + if len(got) != 2 { + t.Fatalf("got %d env files, want 2: %v", len(got), got) + } + if got[0] != filepath.Join(sub, ".env") { + t.Errorf("first = %q, want dockerfiles/.env", got[0]) + } + if got[1] != filepath.Join(dir, ".env") { + t.Errorf("second = %q, want root .env", got[1]) + } +} + +func TestAppendEnvFilesDeduplicates(t *testing.T) { + const envA, envB, envC = "/a.env", "/b.env", "/c.env" + base := []string{envA, envB} + additional := []string{envB, envC, envA} + got := appendEnvFiles(base, additional) + want := []string{envA, envB, envC} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i, v := range want { + if got[i] != v { + t.Errorf("index %d = %q, want %q", i, got[i], v) + } + } +} diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/devcontainer-json.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/devcontainer-json.mdx index f7bdf3b27..355050a71 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/devcontainer-json.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/devcontainer-json.mdx @@ -65,6 +65,30 @@ RUN apt-get update && apt-get install vim For more information about how to write Dockerfiles, please visit the [official documentation](https://docs.docker.com/engine/reference/builder/) +### Using Docker Compose + +To use Docker Compose for your configuration, reference one or more compose files via `dockerComposeFile` and pick the service Devsy should treat as the dev container: + +```json +{ + "dockerComposeFile": ["docker-compose.yml", "docker-compose.dev.yml"], + "service": "backend" +} +``` + +#### Compose project name + +Devsy resolves the Docker Compose project name using the same precedence Docker Compose itself uses, so `devsy workspace up` reuses an already-running compose project instead of spawning a separate one: + +1. `COMPOSE_PROJECT_NAME` set in the current shell session. +2. `COMPOSE_PROJECT_NAME` from an `.env` file — checked in the devcontainer config directory (e.g. `.devcontainer/.env`), the workspace root, and each resolved compose file's own directory. +3. The top-level `name:` field in the compose files (when multiple files are given, the last one that declares it wins). +4. Otherwise, a name derived from the workspace, sanitized for Docker Compose. + + +`devcontainer.json`'s `name` field is a display name for the UI (per the [Dev Containers spec](https://containers.dev/implementors/json_reference/#general-fields)) and is never used to name the Docker Compose project. To reuse an existing compose project, set `COMPOSE_PROJECT_NAME` (shell or `.env`) or a top-level `name:` in your compose file — the same options [VS Code documents](https://code.visualstudio.com/remote/advancedcontainers/set-docker-compose-project-name) for this purpose. + + When using a `docker-compose.yml`-based `devcontainer.json` (via `dockerComposeFile`), a `platform` pin set on the compose service (or under `build.platforms`) is correctly honored during the build, including on arch-restricted base images.