fix(container): reject ambiguous compose projects when cleaning app logs

When several projects use the same compose file, pick the project whose
name matches the request name, exactly or after normalization. If none
matches, return an error instead of the first project in the container
list, so log cleaning never stops and truncates another project.
This commit is contained in:
Raphael Fakhri
2026-11-06 20:55:58 +00:00
parent 391ca4fc49
commit 78c36875fc
2 changed files with 69 additions and 7 deletions
+23 -6
View File
@@ -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) {
+46 -1
View File
@@ -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)
}
})
}
}