Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions e2e/tests/up-docker-compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -567,6 +570,152 @@ var _ = ginkgo.Describe(
gomega.Expect(containerWorkspaceFolderBasename).To(gomega.Equal("workspaces"))
}, ginkgo.SpecTimeout(framework.TimeoutLong()))

ginkgo.Context("compose project name resolution", func() {
// composeProjectLabel is the docker-compose label that records the
// project name selected at "up" time via --project-name.
const composeProjectLabel = "com.docker.compose.project"

// composeProjectFromContainer reads the resolved project name off
// the dev container's labels, mirroring how devsy derives it for
// stop/delete (getDockerComposeProject). The project name is the
// very thing under test here, so it is read directly from the dev
// container (located by devsy's devcontainer id label) rather than
// assumed to equal the sanitized workspace UID.
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 returns the inspected dev container, located by
// devsy's devcontainer id label so the lookup does not depend on
// the (possibly overridden) compose project 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)

// The workspace .env is gitignored, so it is not part of
// the committed testdata; write it at runtime so devsy
// picks up COMPOSE_PROJECT_NAME from <workspace>/.env.
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 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

Expand Down
52 changes: 47 additions & 5 deletions e2e/tests/up-docker-compose/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,22 +186,64 @@ func setupDockerProvider(binDir string) (*framework.Framework, error) {
return framework.SetupDockerProvider(binDir, "docker")
}

// findComposeContainer returns the compose container IDs for the given
// workspace and service. It first resolves the compose project name devsy
// actually used by reading it off the dev container's labels — the overridden
// dev service always carries devsy's devcontainer id label
// (dev.containers.id=<workspaceUID>), and every service in the project
// carries com.docker.compose.project — then queries by that project name plus
// compose's service label. Locating the project name from the running dev
// container (rather than assuming the sanitized workspace UID) keeps the lookup
// correct regardless of whether the name came from COMPOSE_PROJECT_NAME, a .env
// file, the top-level compose "name", or the sanitized-UID fallback
// (see pkg/devcontainer/compose.go#resolveComposeProjectName), so both the
// overridden dev service and any sidecar/non-dev services in the same project
// are found.
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. It falls back to the sanitized
// workspace UID (composeHelper.GetProjectName) when no dev container or project
// label is present, matching devsy's own project-name fallback.
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Compose project name from .env",
"dockerComposeFile": "../docker-compose.yaml",
"service": "app",
"workspaceFolder": "/workspaces"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
services:
app:
image: ghcr.io/devsy-org/test-images/base:ubuntu
command: sleep infinity
9 changes: 9 additions & 0 deletions pkg/compose/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ func (h *ComposeHelper) GetProjectName(runnerID string) string {
return h.toProjectName(runnerID)
}

// SanitizeProjectName lower-cases name and strips characters that are invalid
// in a Docker Compose project name, choosing the allowed-character set based
// on the detected compose version. It is the shared fallback used both by
// GetProjectName (for the random runner id) and by the precedence-aware
// resolver for any source that must be sanitized.
func (h *ComposeHelper) SanitizeProjectName(name string) string {
return h.toProjectName(name)
}

func (h *ComposeHelper) toProjectName(projectName string) string {
useNewProjectNameFormat, _ := h.useNewProjectName()
if !useNewProjectNameFormat {
Expand Down
75 changes: 75 additions & 0 deletions pkg/compose/projectname.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package compose

import (
"os"

"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)

// ComposeProjectNameEnv is the environment/file variable that Docker Compose
// uses to override the project name.
// See https://docs.docker.com/compose/how-tos/project-name/
const ComposeProjectNameEnv = "COMPOSE_PROJECT_NAME"

// ProjectNameFromEnvFiles returns the first non-empty COMPOSE_PROJECT_NAME
// value found in envFiles, scanning them in order. It mirrors Docker Compose's
// own behavior of letting the first .env file that defines the variable win.
// Files that do not exist are skipped, matching godotenv's handling when the
// caller pre-resolves paths.
func ProjectNameFromEnvFiles(envFiles []string) (string, error) {
for _, envFile := range envFiles {
env, err := godotenv.Read(envFile)
if err != nil {
// Missing files are not an error here; compose itself tolerates
// absent .env files. Any other read/parse error is surfaced.
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 resolution.
type composeNameFile struct {
Name string `yaml:"name"`
}

// TopLevelComposeName reads the top-level "name" key from the merged compose
// files. When multiple files are given, the last one that declares "name"
// wins, matching Docker Compose's override semantics for multiple -f files.
// It returns "" (with no error) when no file declares a name so callers can
// fall through to lower-precedence sources.
//
// The YAML top-level "name" is read explicitly (rather than trusting the
// project loaded by compose-go) so it is distinguishable from compose-go's
// directory-name fallback.
func TopLevelComposeName(composeFiles []string) (string, error) {
var name string
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 "", err
}
var parsed composeNameFile
if err := yaml.Unmarshal(b, &parsed); err != nil {
// A file may legitimately not be parseable as a standalone YAML
// mapping (e.g. it relies on compose interpolation). Defer to
// compose-go for those by skipping the explicit name read.
continue
}
if parsed.Name != "" {
name = parsed.Name
}
}
return name, nil
}
Loading
Loading