diff --git a/agent/app/service/container_compose.go b/agent/app/service/container_compose.go index 25223542b..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,10 +609,14 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error { if err != nil { return err } + 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 { - if name != req.Name { + if name != projectName { continue } containerItem, err := client.ContainerInspect(ctx, item.ID) @@ -638,12 +643,48 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error { } } return u.ComposeOperation(dto.ComposeOperation{ - Name: req.Name, + Name: projectName, Path: req.Path, Operation: "restart", }) } +// 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. +// 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, nil + } + } + target := filepath.Clean(strings.TrimSpace(primaryComposePath(composePath))) + var candidates []string + for _, item := range list { + projectName := item.Labels[composeProjectLabel] + if projectName == "" || slices.Contains(candidates, projectName) { + continue + } + configFile := strings.TrimSpace(primaryComposePath(item.Labels[composeConfigLabel])) + if configFile != "" && filepath.Clean(configFile) == target { + candidates = append(candidates, projectName) + } + } + 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) { envFilePath := path.Join(path.Dir(name), ".env") file, err := os.ReadFile(envFilePath) diff --git a/agent/app/service/container_compose_test.go b/agent/app/service/container_compose_test.go new file mode 100644 index 000000000..c191dea73 --- /dev/null +++ b/agent/app/service/container_compose_test.go @@ -0,0 +1,83 @@ +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", + }}, + {Labels: map[string]string{ + composeProjectLabel: "demo", + composeConfigLabel: "/opt/1panel/docker/compose/demo/docker-compose.yml,/opt/1panel/docker/compose/demo/override.yml", + }}, + } + tests := []struct { + name string + inputName string + composePath string + want string + }{ + {name: "display name differs in case", inputName: "OpenClaw", composePath: "/opt/1panel/apps/openclaw/OpenClaw/docker-compose.yml", want: "openclaw"}, + {name: "project name matches", inputName: "demo", composePath: "/opt/1panel/docker/compose/demo/docker-compose.yml", want: "demo"}, + {name: "multiple compose files", inputName: "Demo", composePath: "/opt/1panel/docker/compose/demo/docker-compose.yml", want: "demo"}, + {name: "no matching container", inputName: "other", composePath: "/opt/1panel/apps/other/docker-compose.yml", want: "other"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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) + } + }) + } +}