Repository: compose-spec/compose-go
Pull request #709 (2024-11-06 15:07:33)

fix(reset): Improve cycle detector
Improved the cycle detector and added one more test case.  

https://github.com/docker/compose/issues/12247#issuecomment-2459965906



Patch:
diff --git a/loader/reset.go b/loader/reset.go
index 7f18f1b..33ea4c2 100644
--- a/loader/reset.go
+++ b/loader/reset.go
@@ -28,11 +28,12 @@ import (
 type ResetProcessor struct {
 	target       interface{}
 	paths        []tree.Path
-	visitedNodes map[*yaml.Node]string
+	visitedNodes map[*yaml.Node][]string
 }
 
 // UnmarshalYAML implement yaml.Unmarshaler
 func (p *ResetProcessor) UnmarshalYAML(value *yaml.Node) error {
+	p.visitedNodes = make(map[*yaml.Node][]string)
 	resolved, err := p.resolveReset(value, tree.NewPath())
 	p.visitedNodes = nil
 	if err != nil {
@@ -144,20 +145,46 @@ func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error {
 }
 
 func (p *ResetProcessor) checkForCycle(node *yaml.Node, path tree.Path) error {
-	if p.visitedNodes == nil {
-		p.visitedNodes = make(map[*yaml.Node]string)
-	}
+	paths := p.visitedNodes[node]
+	pathStr := path.String()
 
-	// Check for cycle by seeing if the node has already been visited at this path
-	if previousPath, found := p.visitedNodes[node]; found {
-		// If the current node has been visited, we have a cycle if the previous path is a prefix
-		if strings.HasPrefix(path.String(), strings.TrimRight(previousPath, "<<")) {
-			return fmt.Errorf("cycle detected at path: %s", previousPath)
+	for _, prevPath := range paths {
+		// If we're visiting the exact same path, it's not a cycle
+		if pathStr == prevPath {
+			continue
 		}
-	}
 
-	// Mark the current node as visited
-	p.visitedNodes[node] = path.String()
+		// If either path is using a merge key, it's legitimate YAML merging
+		if strings.Contains(prevPath, "<<") || strings.Contains(pathStr, "<<") {
+			continue
+		}
 
+		// Only consider it a cycle if one path is contained within the other
+		// and they're not in different service definitions
+		if (strings.HasPrefix(pathStr, prevPath+".") ||
+			strings.HasPrefix(prevPath, pathStr+".")) &&
+			!areInDifferentServices(pathStr, prevPath) {
+			return fmt.Errorf("cycle detected: node at path %s references node at path %s", pathStr, prevPath)
+		}
+	}
+
+	p.visitedNodes[node] = append(paths, pathStr)
 	return nil
 }
+
+// areInDifferentServices checks if two paths are in different service definitions
+func areInDifferentServices(path1, path2 string) bool {
+	// Split paths into components
+	parts1 := strings.Split(path1, ".")
+	parts2 := strings.Split(path2, ".")
+
+	// Look for the services component and compare the service names
+	for i := 0; i < len(parts1) && i < len(parts2); i++ {
+		if parts1[i] == "services" && i+1 < len(parts1) &&
+			parts2[i] == "services" && i+1 < len(parts2) {
+			// If they're different services, it's not a cycle
+			return parts1[i+1] != parts2[i+1]
+		}
+	}
+	return false
+}


Test patch:
diff --git a/loader/reset_test.go b/loader/reset_test.go
index 1b45932..0a30b5b 100644
--- a/loader/reset_test.go
+++ b/loader/reset_test.go
@@ -90,7 +90,7 @@ func TestResetCycle(t *testing.T) {
 		errorMsg    string
 	}{
 		{
-			name: "no cycle",
+			name: "simple_alias_no_cycle",
 			config: `
 name: test
 services:
@@ -99,10 +99,9 @@ services:
   a2: *a
 `,
 			expectError: false,
-			errorMsg:    "",
 		},
 		{
-			name: "no cycle reversed",
+			name: "simple_alias_reversed_no_cycle",
 			config: `
 name: test
 services:
@@ -111,12 +110,11 @@ services:
   a: *a
 `,
 			expectError: false,
-			errorMsg:    "",
 		},
 		{
-			name: "no cycle 2",
+			name: "nested_merge_no_cycle",
 			config: `
-name: blah
+name: test
 x-templates:
   x-gluetun: &gluetun
     environment: &gluetun_env
@@ -131,17 +129,40 @@ x-templates:
       <<: *gluetun_env_pia
 `,
 			expectError: false,
-			errorMsg:    "",
 		},
 		{
-			name: "healthcheck_cycle",
+			name: "multiple_services_common_config",
+			config: `
+name: test
+x-common:
+  &common
+  restart: unless-stopped
+
+services:
+  backend:
+    <<: *common
+    image: alpine:latest
+
+  backend-static:
+    <<: *common
+    image: alpine:latest
+
+  backend-worker:
+    <<: *common
+    image: alpine:latest
+`,
+			expectError: false,
+		},
+		{
+			name: "direct_self_reference_cycle",
 			config: `
+name: test
 x-healthcheck: &healthcheck
   egress-service:
     <<: *healthcheck
 `,
 			expectError: true,
-			errorMsg:    "cycle detected at path: x-healthcheck.egress-service",
+			errorMsg:    "cycle detected: node at path x-healthcheck.egress-service.egress-service references node at path x-healthcheck.egress-service",
 		},
 	}