From 391ca4fc493a72706e5e4fce1b26619b18f7b1d1 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:42:02 +0000 Subject: [PATCH 1/2] fix(container): resolve compose project name when cleaning app logs --- agent/app/service/container_compose.go | 28 +++++++++++++-- agent/app/service/container_compose_test.go | 38 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 agent/app/service/container_compose_test.go diff --git a/agent/app/service/container_compose.go b/agent/app/service/container_compose.go index 25223542b..892bd43be 100644 --- a/agent/app/service/container_compose.go +++ b/agent/app/service/container_compose.go @@ -608,10 +608,11 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error { if err != nil { return err } + projectName := loadComposeProjectNameForLog(list, req.Name, req.Path) 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 +639,35 @@ 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. +func loadComposeProjectNameForLog(list []container.Summary, name, composePath string) string { + for _, item := range list { + if item.Labels[composeProjectLabel] == name { + return name + } + } + target := filepath.Clean(strings.TrimSpace(primaryComposePath(composePath))) + for _, item := range list { + projectName := item.Labels[composeProjectLabel] + if projectName == "" { + continue + } + configFile := strings.TrimSpace(primaryComposePath(item.Labels[composeConfigLabel])) + if configFile != "" && filepath.Clean(configFile) == target { + return projectName + } + } + return 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..a6f8a9655 --- /dev/null +++ b/agent/app/service/container_compose_test.go @@ -0,0 +1,38 @@ +package service + +import ( + "testing" + + "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: "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) { + if got := loadComposeProjectNameForLog(list, tt.inputName, tt.composePath); got != tt.want { + t.Fatalf("loadComposeProjectNameForLog(%q, %q) = %q, want %q", tt.inputName, tt.composePath, got, tt.want) + } + }) + } +} From 78c36875fc4650fb36a34df24ae3733dd6590cda Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:55:58 +0000 Subject: [PATCH 2/2] 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. --- agent/app/service/container_compose.go | 29 ++++++++++--- agent/app/service/container_compose_test.go | 47 ++++++++++++++++++++- 2 files changed, 69 insertions(+), 7 deletions(-) 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) + } + }) + } +}