Skip to content
Open
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
83 changes: 54 additions & 29 deletions pkg/commands/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,29 +243,14 @@ func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Contai
}
}

// Determine the local project name before merging services, since
// mergeServices needs it. We match compose service names against container
// labels to handle cases where the project name differs from the directory
// name (e.g. a `name:` directive in the compose file).
if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil {
for _, ctr := range containers {
if ctr.ProjectName == "" || ctr.ServiceName == "" {
continue
}
for _, svc := range composeServices {
if ctr.ServiceName == svc.Name {
c.LocalProjectName = ctr.ProjectName
break
}
}
if c.LocalProjectName != "" {
break
}
}
// Fall back to directory name
if c.LocalProjectName == "" && c.Config.ProjectDir != "" {
c.LocalProjectName = filepath.Base(c.Config.ProjectDir)
}
// The local project name is resolved by GetServices from compose itself
// (see below). If compose did not report one, fall back to the directory
// name, which is compose's own last-resort default. We deliberately do NOT
// guess it from the daemon-wide container list: when another project runs
// a service with the same name, the first container in list order wins and
// poisons every local service binding (see #842).
if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil && c.Config.ProjectDir != "" {
c.LocalProjectName = filepath.Base(c.Config.ProjectDir)
}

// Merge compose services (which include stopped services) with
Expand Down Expand Up @@ -436,7 +421,25 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
}

composeCommand := c.Config.UserConfig.CommandTemplates.DockerCompose
output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand))

// Ask compose itself for the resolved project name. `config --format json`
// carries the project name at the top level, resolved exactly the way
// compose resolves it at runtime: -p/--project-name, COMPOSE_PROJECT_NAME,
// the `name:` directive, then the directory name.
output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --format json", composeCommand))
if err == nil {
if projectName, names, jsonErr := parseComposeConfigJSON(output); jsonErr == nil {
if projectName != "" {
c.LocalProjectName = projectName
}
return c.buildServices(names), nil
}
}

// Fall back to the plain service list (e.g. docker-compose v1, which has
// no --format json). The project name stays unset and is resolved by the
// caller.
output, err = c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand))
if err != nil {
return nil, err
}
Expand All @@ -445,9 +448,32 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
// service1
// service2

lines := utils.SplitLines(output)
services := make([]*Service, len(lines))
for i, str := range lines {
return c.buildServices(utils.SplitLines(output)), nil
}

// parseComposeConfigJSON extracts the resolved project name and the sorted
// service names from `docker compose config --format json` output.
func parseComposeConfigJSON(output string) (string, []string, error) {
var parsed struct {
Name string `json:"name"`
Services map[string]json.RawMessage `json:"services"`
}
if err := json.Unmarshal([]byte(output), &parsed); err != nil {
return "", nil, err
}
names := make([]string, 0, len(parsed.Services))
for name := range parsed.Services {
names = append(names, name)
}
sort.Strings(names)
return parsed.Name, names, nil
}

// buildServices builds the service list, stamped with the current
// LocalProjectName.
func (c *DockerCommand) buildServices(names []string) []*Service {
services := make([]*Service, len(names))
for i, str := range names {
services[i] = &Service{
Name: str,
ID: c.LocalProjectName + "-" + str,
Expand All @@ -457,8 +483,7 @@ func (c *DockerCommand) GetServices() ([]*Service, error) {
DockerCommand: c,
}
}

return services, nil
return services
}

func (c *DockerCommand) RefreshContainerDetails(containers []*Container) error {
Expand Down
43 changes: 43 additions & 0 deletions pkg/commands/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,46 @@ func TestIsProjectScoped(t *testing.T) {
})
}
}

// TestParseComposeConfigJSON verifies we pull the resolved project name and
// service list out of `docker compose config --format json` output.
// Regression context: https://github.com/jesseduffield/lazydocker/issues/842
func TestParseComposeConfigJSON(t *testing.T) {
output := `{"name":"matomo","services":{"db":{"image":"mariadb"},"app":{"image":"matomo"}},"networks":{"default":{}},"volumes":{}}`
name, services, err := parseComposeConfigJSON(output)
assert.NoError(t, err)
assert.Equal(t, "matomo", name)
assert.Equal(t, []string{"app", "db"}, services)
}

func TestParseComposeConfigJSONInvalid(t *testing.T) {
_, _, err := parseComposeConfigJSON("not json at all")
assert.Error(t, err)
}

func TestParseComposeConfigJSONEmptyServices(t *testing.T) {
name, services, err := parseComposeConfigJSON(`{"name":"lonely","services":{}}`)
assert.NoError(t, err)
assert.Equal(t, "lonely", name)
assert.Empty(t, services)
}

// TestAssignContainersToServicesPicksOwnProject is a regression test for
// https://github.com/jesseduffield/lazydocker/issues/842: two compose projects
// running a service with the same name must not get each other's containers.
func TestAssignContainersToServicesPicksOwnProject(t *testing.T) {
c := &DockerCommand{LocalProjectName: "matomo"}

services := []*Service{
{Name: "app", ProjectName: "matomo"},
}
foreign := &Container{ID: "foreign-id", ServiceName: "app", ProjectName: "env2"}
local := &Container{ID: "local-id", ServiceName: "app", ProjectName: "matomo"}
// foreign container first in daemon list order, like the real bug
containers := []*Container{foreign, local}

c.assignContainersToServices(containers, services)

assert.NotNil(t, services[0].Container)
assert.Equal(t, "local-id", services[0].Container.ID)
}