diff --git a/controller/activities/graph.go b/controller/activities/graph.go index 33d1a14..4098959 100644 --- a/controller/activities/graph.go +++ b/controller/activities/graph.go @@ -3,29 +3,56 @@ package activities import ( "context" "fmt" - "strconv" "strings" - - "github.com/awalterschulze/gographviz" ) -// Node represents a node in the graph. -type Node struct { - Name string - // Attributes can be added here if needed +// Graph represents a simple directed graph with efficient operations. +type Graph struct { + Nodes map[string]bool `json:"nodes"` + Edges map[string][]string `json:"edges"` // source -> []destinations +} + +// NewGraph creates a new empty graph. +func NewGraph() *Graph { + return &Graph{ + Nodes: make(map[string]bool), + Edges: make(map[string][]string), + } } -// Edge represents a directed edge in the graph. -type Edge struct { - Src string - Dest string - // Attributes can be added here if needed +// AddNode adds a node to the graph. +func (g *Graph) AddNode(name string) { + g.Nodes[name] = true } -// Graph represents a serializable graph. -type Graph struct { - Nodes []*Node - Edges []*Edge +// AddEdge adds a directed edge from src to dest. +func (g *Graph) AddEdge(src, dest string) { + g.AddNode(src) + g.AddNode(dest) + g.Edges[src] = append(g.Edges[src], dest) +} + +// GetNodes returns all node names. +func (g *Graph) GetNodes() []string { + nodes := make([]string, 0, len(g.Nodes)) + for name := range g.Nodes { + nodes = append(nodes, name) + } + return nodes +} + +// NodeCount returns the number of nodes in the graph. +func (g *Graph) NodeCount() int { + return len(g.Nodes) +} + +// EdgeCount returns the number of edges in the graph. +func (g *Graph) EdgeCount() int { + count := 0 + for _, dests := range g.Edges { + count += len(dests) + } + return count } // PruneGraph takes a graph and a list of changed nodes, and returns a new graph @@ -33,8 +60,10 @@ type Graph struct { func PruneGraph(ctx context.Context, graph *Graph, changed []string) (*Graph, error) { // Build reverse dependency map: target -> dependents dependents := make(map[string][]string) - for _, edge := range graph.Edges { - dependents[edge.Dest] = append(dependents[edge.Dest], edge.Src) + for src, dests := range graph.Edges { + for _, dest := range dests { + dependents[dest] = append(dependents[dest], src) + } } // Collect all nodes to keep (changed + all that depend on them) @@ -49,91 +78,115 @@ func PruneGraph(ctx context.Context, graph *Graph, changed []string) (*Graph, er visit(dep) } } + + // Only visit nodes that actually exist in the graph for _, nodeName := range changed { - // Make sure we have the node in the graph before visiting - var found bool - for _, n := range graph.Nodes { - if n.Name == nodeName { - found = true - break - } - } - if found { + if graph.Nodes[nodeName] { visit(nodeName) } } - // Reconstruct pruned graph - prunedGraph := &Graph{Nodes: []*Node{}, Edges: []*Edge{}} - for _, node := range graph.Nodes { - if keep[node.Name] { - prunedGraph.Nodes = append(prunedGraph.Nodes, node) - } + // Create pruned graph + prunedGraph := NewGraph() + for node := range keep { + prunedGraph.AddNode(node) } - for _, edge := range graph.Edges { - if keep[edge.Src] && keep[edge.Dest] { - prunedGraph.Edges = append(prunedGraph.Edges, edge) + for src, dests := range graph.Edges { + if keep[src] { + for _, dest := range dests { + if keep[dest] { + prunedGraph.AddEdge(src, dest) + } + } } } return prunedGraph, nil } -// NewGraphFromDot creates a Graph from a DOT string. +// NewGraphFromDot creates a Graph from a DOT string using a simple parser. func NewGraphFromDot(dot string) (*Graph, error) { - ast, err := gographviz.ParseString(dot) - if err != nil { - return nil, fmt.Errorf("failed to parse DOT string: %w", err) - } + graph := NewGraph() - g := gographviz.NewGraph() - if err := gographviz.Analyse(ast, g); err != nil { - return nil, fmt.Errorf("failed to analyse graph: %w", err) - } - - graph := &Graph{Nodes: []*Node{}, Edges: []*Edge{}} - nodeSet := make(map[string]bool) - - addNode := func(name string) { - if !nodeSet[name] { - graph.Nodes = append(graph.Nodes, &Node{Name: name}) - nodeSet[name] = true + lines := strings.Split(dot, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || line == "digraph {" || line == "}" { + continue } - } - unquote := func(s string) string { - res, err := strconv.Unquote(s) - if err != nil { - return s + // Remove trailing semicolon if present + line = strings.TrimSuffix(line, ";") + line = strings.TrimSpace(line) + + // Parse edges: "A" -> "B" + if strings.Contains(line, "->") { + parts := strings.Split(line, "->") + if len(parts) == 2 { + src := extractQuotedString(strings.TrimSpace(parts[0])) + dest := extractQuotedString(strings.TrimSpace(parts[1])) + if src != "" && dest != "" { + graph.AddEdge(src, dest) + } + } + } else { + // Parse standalone nodes: "C" + nodeName := extractQuotedString(line) + if nodeName != "" { + graph.AddNode(nodeName) + } } - return res } - for _, edge := range g.Edges.Edges { - src := unquote(edge.Src) - dest := unquote(edge.Dst) - addNode(src) - addNode(dest) - graph.Edges = append(graph.Edges, &Edge{Src: src, Dest: dest}) - } + return graph, nil +} - for _, node := range g.Nodes.Nodes { - name := unquote(node.Name) - addNode(name) +// extractQuotedString extracts the content between quotes from a string like "hello" +func extractQuotedString(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] } - return graph, nil + return "" } // ToDot converts a Graph to a DOT string. func (g *Graph) ToDot() string { var b strings.Builder b.WriteString("digraph {\n") - for _, edge := range g.Edges { - b.WriteString(fmt.Sprintf(" %q -> %q;\n", edge.Src, edge.Dest)) + + // Write edges first (they implicitly declare nodes) + for src, dests := range g.Edges { + for _, dest := range dests { + b.WriteString(fmt.Sprintf(" %q -> %q;\n", src, dest)) + } } - for _, node := range g.Nodes { - b.WriteString(fmt.Sprintf(" %q;\n", node.Name)) + + // Write standalone nodes (nodes without edges) + for node := range g.Nodes { + hasEdge := false + // Check if node appears in any edge + if _, exists := g.Edges[node]; exists { + hasEdge = true + } + if !hasEdge { + for _, dests := range g.Edges { + for _, dest := range dests { + if dest == node { + hasEdge = true + break + } + } + if hasEdge { + break + } + } + } + if !hasEdge { + b.WriteString(fmt.Sprintf(" %q;\n", node)) + } } + b.WriteString("}") return b.String() } @@ -148,23 +201,25 @@ func (g *Graph) TopologicalSort() [][]string { inDegree := make(map[string]int) // Initialize all nodes with in-degree 0 - for _, node := range g.Nodes { - inDegree[node.Name] = 0 - adjList[node.Name] = []string{} + for node := range g.Nodes { + inDegree[node] = 0 + adjList[node] = []string{} } // Build the graph and calculate in-degrees // Edge from Src to Dest means Src depends on Dest // So Dest should run before Src - for _, edge := range g.Edges { - adjList[edge.Dest] = append(adjList[edge.Dest], edge.Src) - inDegree[edge.Src]++ + for src, dests := range g.Edges { + for _, dest := range dests { + adjList[dest] = append(adjList[dest], src) + inDegree[src]++ + } } var levels [][]string remaining := make(map[string]bool) - for _, node := range g.Nodes { - remaining[node.Name] = true + for node := range g.Nodes { + remaining[node] = true } // Process nodes level by level diff --git a/controller/activities/graph_test.go b/controller/activities/graph_test.go index 44c4a8b..f9d2b0a 100644 --- a/controller/activities/graph_test.go +++ b/controller/activities/graph_test.go @@ -27,56 +27,56 @@ digraph { name string changed []string expectedNodes []string - expectedEdges []Edge + expectedEdges map[string][]string }{ { name: "Prune to C and its dependencies", changed: []string{"C"}, expectedNodes: []string{"A", "B", "C", "D"}, - expectedEdges: []Edge{ - {Src: "A", Dest: "B"}, - {Src: "B", Dest: "C"}, - {Src: "D", Dest: "B"}, + expectedEdges: map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "D": {"B"}, }, }, { name: "Prune to F and its dependencies", changed: []string{"F"}, expectedNodes: []string{"E", "F"}, - expectedEdges: []Edge{ - {Src: "E", Dest: "F"}, + expectedEdges: map[string][]string{ + "E": {"F"}, }, }, { name: "Prune to B and its dependencies", changed: []string{"B"}, expectedNodes: []string{"A", "B", "D"}, - expectedEdges: []Edge{ - {Src: "A", Dest: "B"}, - {Src: "D", Dest: "B"}, + expectedEdges: map[string][]string{ + "A": {"B"}, + "D": {"B"}, }, }, { name: "No nodes changed", changed: []string{}, expectedNodes: []string{}, - expectedEdges: []Edge{}, + expectedEdges: map[string][]string{}, }, { name: "Changed node not in graph", changed: []string{"Z"}, expectedNodes: []string{}, - expectedEdges: []Edge{}, + expectedEdges: map[string][]string{}, }, { name: "Multiple changed nodes", changed: []string{"C", "F"}, expectedNodes: []string{"A", "B", "C", "D", "E", "F"}, - expectedEdges: []Edge{ - {Src: "A", Dest: "B"}, - {Src: "B", Dest: "C"}, - {Src: "D", Dest: "B"}, - {Src: "E", Dest: "F"}, + expectedEdges: map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "D": {"B"}, + "E": {"F"}, }, }, } @@ -88,10 +88,7 @@ digraph { t.Fatalf("PruneGraph failed: %v", err) } - prunedNodes := make([]string, 0, len(prunedGraph.Nodes)) - for _, n := range prunedGraph.Nodes { - prunedNodes = append(prunedNodes, n.Name) - } + prunedNodes := prunedGraph.GetNodes() sort.Strings(prunedNodes) sort.Strings(tc.expectedNodes) @@ -99,27 +96,9 @@ digraph { t.Errorf("Expected nodes %v, but got %v", tc.expectedNodes, prunedNodes) } - prunedEdges := make([]Edge, len(prunedGraph.Edges)) - for i, e := range prunedGraph.Edges { - prunedEdges[i] = *e - } - - // Sort edges for consistent comparison - sort.Slice(prunedEdges, func(i, j int) bool { - if prunedEdges[i].Src != prunedEdges[j].Src { - return prunedEdges[i].Src < prunedEdges[j].Src - } - return prunedEdges[i].Dest < prunedEdges[j].Dest - }) - sort.Slice(tc.expectedEdges, func(i, j int) bool { - if tc.expectedEdges[i].Src != tc.expectedEdges[j].Src { - return tc.expectedEdges[i].Src < tc.expectedEdges[j].Src - } - return tc.expectedEdges[i].Dest < tc.expectedEdges[j].Dest - }) - - if !reflect.DeepEqual(prunedEdges, tc.expectedEdges) { - t.Errorf("Expected edges %v, but got %v", tc.expectedEdges, prunedEdges) + // Compare edges + if !reflect.DeepEqual(prunedGraph.Edges, tc.expectedEdges) { + t.Errorf("Expected edges %v, but got %v", tc.expectedEdges, prunedGraph.Edges) } }) } @@ -271,8 +250,8 @@ func TestPruneGraphRealWorld(t *testing.T) { "xshare/azuresql" -> "core"; "xshare/azuresqlusers" ; "xshare/azuresqlusers" -> "xshare/azuresql"; -}` - +} +` graph, err := NewGraphFromDot(realWorldDot) if err != nil { t.Fatalf("Failed to create graph from real-world DOT: %v", err) @@ -284,22 +263,20 @@ func TestPruneGraphRealWorld(t *testing.T) { t.Fatalf("PruneGraph failed: %v", err) } + // Expected result: // digraph { // "bootstrap-va" -> "cluster-va"; // "dems-cluster-identity" -> "cluster-va"; // "pes/keyvault" -> "dems-cluster-identity"; // } expectedNodes := []string{"bootstrap-va", "cluster-va", "dems-cluster-identity", "pes/keyvault"} - expectedEdges := []Edge{ - {Src: "bootstrap-va", Dest: "cluster-va"}, - {Src: "dems-cluster-identity", Dest: "cluster-va"}, - {Src: "pes/keyvault", Dest: "dems-cluster-identity"}, + expectedEdges := map[string][]string{ + "bootstrap-va": {"cluster-va"}, + "dems-cluster-identity": {"cluster-va"}, + "pes/keyvault": {"dems-cluster-identity"}, } - prunedNodes := make([]string, 0, len(prunedGraph.Nodes)) - for _, n := range prunedGraph.Nodes { - prunedNodes = append(prunedNodes, n.Name) - } + prunedNodes := prunedGraph.GetNodes() sort.Strings(prunedNodes) sort.Strings(expectedNodes) @@ -307,27 +284,8 @@ func TestPruneGraphRealWorld(t *testing.T) { t.Errorf("Expected nodes %v, but got %v", expectedNodes, prunedNodes) } - prunedEdges := make([]Edge, len(prunedGraph.Edges)) - for i, e := range prunedGraph.Edges { - prunedEdges[i] = *e - } - - // Sort edges for consistent comparison - sort.Slice(prunedEdges, func(i, j int) bool { - if prunedEdges[i].Src != prunedEdges[j].Src { - return prunedEdges[i].Src < prunedEdges[j].Src - } - return prunedEdges[i].Dest < prunedEdges[j].Dest - }) - sort.Slice(expectedEdges, func(i, j int) bool { - if expectedEdges[i].Src != expectedEdges[j].Src { - return expectedEdges[i].Src < expectedEdges[j].Src - } - return expectedEdges[i].Dest < expectedEdges[j].Dest - }) - - if !reflect.DeepEqual(prunedEdges, expectedEdges) { - t.Errorf("Expected edges %v, but got %v", expectedEdges, prunedEdges) + if !reflect.DeepEqual(prunedGraph.Edges, expectedEdges) { + t.Errorf("Expected edges %v, but got %v", expectedEdges, prunedGraph.Edges) } } @@ -335,15 +293,15 @@ func TestTopologicalSort(t *testing.T) { testCases := []struct { name string nodes []string - edges []Edge + edges map[string][]string expectedLevels [][]string }{ { name: "Simple linear dependency", nodes: []string{"A", "B", "C"}, - edges: []Edge{ - {Src: "B", Dest: "A"}, - {Src: "C", Dest: "B"}, + edges: map[string][]string{ + "B": {"A"}, + "C": {"B"}, }, expectedLevels: [][]string{ {"A"}, @@ -354,10 +312,9 @@ func TestTopologicalSort(t *testing.T) { { name: "Parallel dependencies", nodes: []string{"A", "B", "C", "D"}, - edges: []Edge{ - {Src: "C", Dest: "A"}, - {Src: "C", Dest: "B"}, - {Src: "D", Dest: "C"}, + edges: map[string][]string{ + "C": {"A", "B"}, + "D": {"C"}, }, expectedLevels: [][]string{ {"A", "B"}, @@ -368,13 +325,11 @@ func TestTopologicalSort(t *testing.T) { { name: "Complex dependency graph", nodes: []string{"A", "B", "C", "D", "E", "F"}, - edges: []Edge{ - {Src: "C", Dest: "A"}, - {Src: "C", Dest: "B"}, - {Src: "D", Dest: "C"}, - {Src: "E", Dest: "C"}, - {Src: "F", Dest: "D"}, - {Src: "F", Dest: "E"}, + edges: map[string][]string{ + "C": {"A", "B"}, + "D": {"C"}, + "E": {"C"}, + "F": {"D", "E"}, }, expectedLevels: [][]string{ {"A", "B"}, @@ -386,20 +341,20 @@ func TestTopologicalSort(t *testing.T) { { name: "No dependencies", nodes: []string{"A", "B", "C"}, - edges: []Edge{}, + edges: map[string][]string{}, expectedLevels: [][]string{{"A", "B", "C"}}, }, { name: "Single node", nodes: []string{"A"}, - edges: []Edge{}, + edges: map[string][]string{}, expectedLevels: [][]string{{"A"}}, }, { name: "Real world example: bootstrap depends on cluster", nodes: []string{"bootstrap", "cluster"}, - edges: []Edge{ - {Src: "bootstrap", Dest: "cluster"}, + edges: map[string][]string{ + "bootstrap": {"cluster"}, }, expectedLevels: [][]string{ {"cluster"}, @@ -411,17 +366,16 @@ func TestTopologicalSort(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create graph - graph := &Graph{ - Nodes: make([]*Node, len(tc.nodes)), - Edges: make([]*Edge, len(tc.edges)), - } + graph := NewGraph() - for i, nodeName := range tc.nodes { - graph.Nodes[i] = &Node{Name: nodeName} + for _, nodeName := range tc.nodes { + graph.AddNode(nodeName) } - for i, edge := range tc.edges { - graph.Edges[i] = &Edge{Src: edge.Src, Dest: edge.Dest} + for src, dests := range tc.edges { + for _, dest := range dests { + graph.AddEdge(src, dest) + } } // Get topological sort diff --git a/controller/go.mod b/controller/go.mod index 012fe03..a3e5b30 100644 --- a/controller/go.mod +++ b/controller/go.mod @@ -3,7 +3,6 @@ module cloudlab/controller go 1.24.3 require ( - github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/go-git/go-git/v5 v5.16.0 go.temporal.io/sdk v1.34.0 ) diff --git a/controller/go.sum b/controller/go.sum index c536418..16d56d5 100644 --- a/controller/go.sum +++ b/controller/go.sum @@ -11,8 +11,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= -github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= diff --git a/controller/workflows/infra.go b/controller/workflows/infra.go index 9970bbf..3640dc9 100644 --- a/controller/workflows/infra.go +++ b/controller/workflows/infra.go @@ -5,6 +5,7 @@ import ( "time" "cloudlab/controller/activities" + "go.temporal.io/sdk/workflow" ) @@ -58,7 +59,7 @@ func Infra(ctx workflow.Context, input InfraInputs) (*activities.Graph, error) { return nil, err } - logger.Info("Infra workflow completed graph pruning.", "nodes", len(prunedGraph.Nodes), "edges", len(prunedGraph.Edges)) + logger.Info("Infra workflow completed graph pruning.", "nodes", prunedGraph.NodeCount(), "edges", prunedGraph.EdgeCount()) // Get dependency levels for parallel execution dependencyLevels := prunedGraph.TopologicalSort() @@ -93,7 +94,7 @@ func Infra(ctx workflow.Context, input InfraInputs) (*activities.Graph, error) { logger.Info("Completed terragrunt apply for dependency level", "level", levelIndex, "modules", level) } - logger.Info("Infra workflow completed successfully.", "totalLevels", len(dependencyLevels), "appliedModules", len(prunedGraph.Nodes)) + logger.Info("Infra workflow completed successfully.", "totalLevels", len(dependencyLevels), "appliedModules", prunedGraph.NodeCount()) return prunedGraph, nil }