Merge pull request #14292 from RaphaelFakhri/fix/compose-log-clean-project-name

fix(container): resolve compose project name when cleaning app logs
This commit is contained in:
Yuki Guo
2026-11-09 09:54:04 +08:00
committed by GitHub
2 changed files with 126 additions and 2 deletions
+43 -2
View File
@@ -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)
@@ -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)
}
})
}
}