diff --git a/agent/app/service/container_compose.go b/agent/app/service/container_compose.go index 892bd43be..73b2f3eaa 100644 --- a/agent/app/service/container_compose.go +++ b/agent/app/service/container_compose.go @@ -9,6 +9,7 @@ import ( "os/exec" "path" "path/filepath" + "slices" "sort" "strings" "time" @@ -608,7 +609,10 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error { if err != nil { return err } - projectName := loadComposeProjectNameForLog(list, req.Name, req.Path) + projectName, err := loadComposeProjectNameForLog(list, req.Name, req.Path) + if err != nil { + return err + } ctx := context.Background() for _, item := range list { if name, ok := item.Labels[composeProjectLabel]; ok { @@ -648,24 +652,37 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error { // loadComposeProjectNameForLog returns the project name that Compose uses for // the containers of composePath. Apps and runtimes pass their display name, // which can differ from the project name, for example in letter case. -func loadComposeProjectNameForLog(list []container.Summary, name, composePath string) string { +// If several projects use composePath and none of them matches name, it +// returns an error rather than picking one of them. +func loadComposeProjectNameForLog(list []container.Summary, name, composePath string) (string, error) { for _, item := range list { if item.Labels[composeProjectLabel] == name { - return name + return name, nil } } target := filepath.Clean(strings.TrimSpace(primaryComposePath(composePath))) + var candidates []string for _, item := range list { projectName := item.Labels[composeProjectLabel] - if projectName == "" { + if projectName == "" || slices.Contains(candidates, projectName) { continue } configFile := strings.TrimSpace(primaryComposePath(item.Labels[composeConfigLabel])) if configFile != "" && filepath.Clean(configFile) == target { - return projectName + candidates = append(candidates, projectName) } } - return name + switch len(candidates) { + case 0: + return name, nil + case 1: + return candidates[0], nil + } + if normalized := normalizeComposeProjectName(name); slices.Contains(candidates, normalized) { + return normalized, nil + } + slices.Sort(candidates) + return "", fmt.Errorf("compose file %s is used by multiple projects (%s), cannot determine the project of %s", target, strings.Join(candidates, ", "), name) } func (u *ContainerService) LoadComposeEnv(name string) (string, error) { diff --git a/agent/app/service/container_compose_test.go b/agent/app/service/container_compose_test.go index a6f8a9655..c191dea73 100644 --- a/agent/app/service/container_compose_test.go +++ b/agent/app/service/container_compose_test.go @@ -3,11 +3,16 @@ package service import ( "testing" + "github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/docker/docker/api/types/container" ) func TestLoadComposeProjectNameForLog(t *testing.T) { list := []container.Summary{ + {Labels: map[string]string{ + composeProjectLabel: "openclaw", + composeConfigLabel: "/opt/1panel/apps/openclaw/OpenClaw/docker-compose.yml", + }}, {Labels: map[string]string{ composeProjectLabel: "openclaw", composeConfigLabel: "/opt/1panel/apps/openclaw/OpenClaw/docker-compose.yml", @@ -30,9 +35,49 @@ func TestLoadComposeProjectNameForLog(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := loadComposeProjectNameForLog(list, tt.inputName, tt.composePath); got != tt.want { + got, err := loadComposeProjectNameForLog(list, tt.inputName, tt.composePath) + if err != nil { + t.Fatalf("loadComposeProjectNameForLog(%q, %q) returned error: %v", tt.inputName, tt.composePath, err) + } + if got != tt.want { t.Fatalf("loadComposeProjectNameForLog(%q, %q) = %q, want %q", tt.inputName, tt.composePath, got, tt.want) } }) } } + +func TestLoadComposeProjectNameForLogSharedComposeFile(t *testing.T) { + re.Init() + const composePath = "/opt/1panel/apps/openclaw/OpenClaw/docker-compose.yml" + openclaw := container.Summary{Labels: map[string]string{ + composeProjectLabel: "openclaw", + composeConfigLabel: composePath, + }} + sandbox := container.Summary{Labels: map[string]string{ + composeProjectLabel: "sandbox", + composeConfigLabel: composePath, + }} + orders := map[string][]container.Summary{ + "openclaw first": {openclaw, sandbox}, + "sandbox first": {sandbox, openclaw}, + } + for order, list := range orders { + t.Run(order, func(t *testing.T) { + // The display name normalizes to one of the projects. + got, err := loadComposeProjectNameForLog(list, "OpenClaw", composePath) + if err != nil || got != "openclaw" { + t.Fatalf("display name: got %q, %v, want %q", got, err, "openclaw") + } + // An exact project name selects that project. + got, err = loadComposeProjectNameForLog(list, "sandbox", composePath) + if err != nil || got != "sandbox" { + t.Fatalf("project name: got %q, %v, want %q", got, err, "sandbox") + } + // A name that matches neither project is ambiguous. + got, err = loadComposeProjectNameForLog(list, "My App", composePath) + if err == nil { + t.Fatalf("ambiguous name: got %q, want an error", got) + } + }) + } +}