From 9711164ff7009fdf07df4f0beedbfb8c327dc39e Mon Sep 17 00:00:00 2001 From: zuoxuesong-worker Date: Mon, 25 Aug 2025 17:15:17 +0800 Subject: [PATCH] feature: abandan file cycle import (#2721) * feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com feature: abandan file cycle import Signed-off-by: xuesongzuo@yunify.com * fix: add comment Signed-off-by: redscholar --------- Signed-off-by: xuesongzuo@yunify.com Signed-off-by: redscholar Co-authored-by: redscholar --- pkg/project/git.go | 10 +-- pkg/project/local.go | 11 +-- pkg/project/project.go | 18 +++-- pkg/utils/graph.go | 86 +++++++++++++++++++++++ pkg/utils/graph_test.go | 151 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 12 deletions(-) create mode 100644 pkg/utils/graph.go create mode 100644 pkg/utils/graph_test.go diff --git a/pkg/project/git.go b/pkg/project/git.go index ec98022a..6d6cedd1 100644 --- a/pkg/project/git.go +++ b/pkg/project/git.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" _const "github.com/kubesphere/kubekey/v4/pkg/const" + "github.com/kubesphere/kubekey/v4/pkg/utils" ) func newGitProject(ctx context.Context, playbook kkcorev1.Playbook, update bool) (Project, error) { @@ -65,10 +66,11 @@ func newGitProject(ctx context.Context, playbook kkcorev1.Playbook, update bool) } return &project{ - FS: os.DirFS(filepath.Join(projectDir, playbook.Spec.Project.Name)), - basePlaybook: playbook.Spec.Playbook, - Playbook: &kkprojectv1.Playbook{}, - config: playbook.Spec.Config.Value(), + FS: os.DirFS(filepath.Join(projectDir, playbook.Spec.Project.Name)), + basePlaybook: playbook.Spec.Playbook, + Playbook: &kkprojectv1.Playbook{}, + config: playbook.Spec.Config.Value(), + playbookGraph: utils.NewKahnGraph(), }, nil } diff --git a/pkg/project/local.go b/pkg/project/local.go index f3c91545..26a858db 100644 --- a/pkg/project/local.go +++ b/pkg/project/local.go @@ -23,6 +23,8 @@ import ( "github.com/cockroachdb/errors" kkcorev1 "github.com/kubesphere/kubekey/api/core/v1" kkprojectv1 "github.com/kubesphere/kubekey/api/project/v1" + + "github.com/kubesphere/kubekey/v4/pkg/utils" ) func newLocalProject(playbook kkcorev1.Playbook) (Project, error) { @@ -47,9 +49,10 @@ func newLocalProject(playbook kkcorev1.Playbook) (Project, error) { } return &project{ - FS: os.DirFS(projectPath), - basePlaybook: relPath, - Playbook: &kkprojectv1.Playbook{}, - config: playbook.Spec.Config.Value(), + FS: os.DirFS(projectPath), + basePlaybook: relPath, + Playbook: &kkprojectv1.Playbook{}, + config: playbook.Spec.Config.Value(), + playbookGraph: utils.NewKahnGraph(), }, nil } diff --git a/pkg/project/project.go b/pkg/project/project.go index 85faf8a6..4953463c 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -78,7 +78,8 @@ type project struct { basePlaybook string *kkprojectv1.Playbook - config map[string]any + config map[string]any + playbookGraph *utils.KahnGraph } // ReadFile reads and returns the contents of the file at the given path @@ -105,7 +106,7 @@ func (f *project) WalkDir(path string, fn fs.WalkDirFunc) error { func (f *project) MarshalPlaybook() (*kkprojectv1.Playbook, error) { f.Playbook = &kkprojectv1.Playbook{} // convert playbook to kkprojectv1.Playbook - if err := f.loadPlaybook(f.basePlaybook); err != nil { + if err := f.loadPlaybook("", f.basePlaybook); err != nil { return nil, err } // validate playbook @@ -117,8 +118,13 @@ func (f *project) MarshalPlaybook() (*kkprojectv1.Playbook, error) { } // loadPlaybook loads a playbook and all its included playbooks into a single playbook -func (f *project) loadPlaybook(basePlaybook string) error { +func (f *project) loadPlaybook(fromPlayBook, basePlaybook string) error { // baseDir is the local ansible project dir which playbook belong to + if f.playbookGraph.AddEdgeAndCheckCycle(fromPlayBook, basePlaybook) { + // play book cycle imported + return errors.Errorf("failed to import %s because it cause a cycle import", basePlaybook) + } + pbData, err := fs.ReadFile(f.FS, basePlaybook) if err != nil { return errors.Wrapf(err, "failed to read playbook %q", basePlaybook) @@ -173,7 +179,11 @@ func (f *project) dealImportPlaybook(p kkprojectv1.Play, basePlaybook string) er if importPlaybook == "" { return errors.Errorf("failed to find import_playbook %q base on %q. it's should be:\n %s", p.ImportPlaybook, basePlaybook, PathFormatImportPlaybook) } - if err := f.loadPlaybook(importPlaybook); err != nil { + if basePlaybook == importPlaybook { + // play book import self + return errors.Errorf("failed to import %s because it is already imported", p.ImportPlaybook) + } + if err := f.loadPlaybook(basePlaybook, importPlaybook); err != nil { return err } } diff --git a/pkg/utils/graph.go b/pkg/utils/graph.go new file mode 100644 index 00000000..b4c438ff --- /dev/null +++ b/pkg/utils/graph.go @@ -0,0 +1,86 @@ +package utils + +// KahnGraph represents a directed graph and provides efficient cycle detection using Kahn's algorithm. +// Kahn's algorithm repeatedly removes nodes with in-degree 0 (i.e., nodes with no dependencies). +// If a cycle exists, nodes in the cycle will never have in-degree 0, so the algorithm cannot process all nodes, thus detecting the presence of a cycle. +type KahnGraph struct { + // edges is an adjacency list: each node stores all its outgoing edges (target nodes). + // key: source node, value: slice of target nodes + edges map[string][]string + + // indegree records the in-degree (number of incoming edges) for each node. + // key: node name, value: in-degree count + indegree map[string]int +} + +// NewKahnGraph initializes and returns an empty KahnGraph. +func NewKahnGraph() *KahnGraph { + return &KahnGraph{ + edges: make(map[string][]string), + indegree: make(map[string]int), + } +} + +// AddEdgeAndCheckCycle adds a directed edge from node a to node b and immediately checks if a cycle is formed. +// Parameters: +// - a: source node +// - b: target node +// +// Returns: +// - true if adding the edge creates a cycle; false otherwise +func (g *KahnGraph) AddEdgeAndCheckCycle(a, b string) bool { + // Add an edge from a to b + g.edges[a] = append(g.edges[a], b) + + // Increment the in-degree of b (one more edge points to b) + g.indegree[b]++ + + // Ensure a is present in the in-degree map (initialize to 0 if not present) + if _, exists := g.indegree[a]; !exists { + g.indegree[a] = 0 + } + + // After adding the new edge, check if a cycle exists + return g.hasCycle() +} + +// hasCycle determines whether the current graph contains a cycle. +// Returns: +// - true if a cycle exists; false otherwise +func (g *KahnGraph) hasCycle() bool { + // Make a copy of the in-degree map to avoid modifying the original data + indegreeCopy := make(map[string]int, len(g.indegree)) + for node, degree := range g.indegree { + indegreeCopy[node] = degree + } + + // Initialize a queue to collect all nodes with in-degree 0 + queue := make([]string, 0) + for node, degree := range indegreeCopy { + if degree == 0 { + queue = append(queue, node) + } + } + + processed := 0 // Count of nodes processed by topological sort + + // Process all nodes with in-degree 0 and update their neighbors' in-degree + for len(queue) > 0 { + // Dequeue the first node + node := queue[0] + queue = queue[1:] + processed++ + + // For each neighbor, decrement its in-degree + for _, neighbor := range g.edges[node] { + indegreeCopy[neighbor]-- + // If neighbor's in-degree becomes 0, add it to the queue + if indegreeCopy[neighbor] == 0 { + queue = append(queue, neighbor) + } + } + } + + // If all nodes are processed, there is no cycle; otherwise, a cycle exists + return processed != len(indegreeCopy) +} diff --git a/pkg/utils/graph_test.go b/pkg/utils/graph_test.go new file mode 100644 index 00000000..1bbbb4a7 --- /dev/null +++ b/pkg/utils/graph_test.go @@ -0,0 +1,151 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGraphHasCycle(t *testing.T) { + testcases := []struct { + name string + graph KahnGraph + except bool + }{ + { + // a -> b + name: "Single head, single depth, no cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b"}, + }, + indegree: map[string]int{ + "a": 0, + "b": 1, + }, + }, + except: false, + }, + { + // a -> a + name: "Single head, single depth, has cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"a"}, + }, + indegree: map[string]int{ + "a": 1, + }, + }, + except: true, + }, + { + // a -> b + // a -> c + name: "Multiple heads, single depth, no cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b", "c"}, + }, + indegree: map[string]int{ + "a": 0, + "b": 1, + "c": 1, + }, + }, + except: false, + }, + { + // a -> b + // a -> a + name: "Multiple heads, single depth, has cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b", "a"}, + }, + indegree: map[string]int{ + "a": 1, + "b": 1, + }, + }, + except: true, + }, + { + // a -> b -> c + name: "Single head, multiple depth, no cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b"}, + "b": {"c"}, + }, + indegree: map[string]int{ + "a": 0, + "b": 1, + "c": 1, + }, + }, + except: false, + }, + { + // a -> b -> a + name: "Single head, multiple depth, has cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b"}, + "b": {"a"}, + }, + indegree: map[string]int{ + "a": 1, + "b": 1, + }, + }, + except: true, + }, + { + // a -> b + // a -> c -> d + // a -> d -> b + name: "Multiple heads, multiple depth, no cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b", "c", "d"}, + "c": {"d"}, + "d": {"b"}, + }, + indegree: map[string]int{ + "a": 0, + "b": 2, + "c": 1, + "d": 2, + }, + }, + except: false, + }, + { + // a -> b + // a -> c -> d + // a -> d -> c + name: "Multiple heads, multiple depth, has cycle", + graph: KahnGraph{ + edges: map[string][]string{ + "a": {"b", "c", "d"}, + "c": {"d"}, + "d": {"c"}, + }, + indegree: map[string]int{ + "a": 0, + "b": 1, + "c": 2, + "d": 2, + }, + }, + except: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.except, tc.graph.hasCycle()) + }) + } +}