this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: use struct for graph

+429 -68
+107 -23
controller/activities/graph.go
··· 1 1 package activities 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 6 + "strconv" 5 7 "strings" 6 8 7 9 "github.com/awalterschulze/gographviz" 8 10 ) 9 11 10 - func pruneGraph(dot string, changed []string) (string, error) { 11 - ast, err := gographviz.ParseString(dot) 12 - if err != nil { 13 - return "", fmt.Errorf("Parse DOT failed: %w", err) 14 - } 15 - graph := gographviz.NewGraph() 16 - if err := gographviz.Analyse(ast, graph); err != nil { 17 - return "", fmt.Errorf("Graph analysis failed: %w", err) 18 - } 12 + // Node represents a node in the graph. 13 + type Node struct { 14 + Name string 15 + // Attributes can be added here if needed 16 + } 17 + 18 + // Edge represents a directed edge in the graph. 19 + type Edge struct { 20 + Src string 21 + Dest string 22 + // Attributes can be added here if needed 23 + } 24 + 25 + // Graph represents a serializable graph. 26 + type Graph struct { 27 + Nodes []*Node 28 + Edges []*Edge 29 + } 19 30 31 + // PruneGraph takes a graph and a list of changed nodes, and returns a new graph 32 + // containing only the changed nodes and their dependents. 33 + func PruneGraph(ctx context.Context, graph *Graph, changed []string) (*Graph, error) { 20 34 // Build reverse dependency map: target -> dependents 21 - dependents := map[string][]string{} 22 - for _, edge := range graph.Edges.Edges { 23 - dependents[edge.Dst] = append(dependents[edge.Dst], edge.Src) 35 + dependents := make(map[string][]string) 36 + for _, edge := range graph.Edges { 37 + dependents[edge.Dest] = append(dependents[edge.Dest], edge.Src) 24 38 } 25 39 26 40 // Collect all nodes to keep (changed + all that depend on them) 27 - keep := map[string]bool{} 41 + keep := make(map[string]bool) 28 42 var visit func(string) 29 43 visit = func(node string) { 30 44 if keep[node] { ··· 35 49 visit(dep) 36 50 } 37 51 } 38 - for _, node := range changed { 39 - visit(node) 52 + for _, nodeName := range changed { 53 + // Make sure we have the node in the graph before visiting 54 + var found bool 55 + for _, n := range graph.Nodes { 56 + if n.Name == nodeName { 57 + found = true 58 + break 59 + } 60 + } 61 + if found { 62 + visit(nodeName) 63 + } 40 64 } 41 65 42 - // Reconstruct pruned DOT graph 66 + // Reconstruct pruned graph 67 + prunedGraph := &Graph{Nodes: []*Node{}, Edges: []*Edge{}} 68 + for _, node := range graph.Nodes { 69 + if keep[node.Name] { 70 + prunedGraph.Nodes = append(prunedGraph.Nodes, node) 71 + } 72 + } 73 + for _, edge := range graph.Edges { 74 + if keep[edge.Src] && keep[edge.Dest] { 75 + prunedGraph.Edges = append(prunedGraph.Edges, edge) 76 + } 77 + } 78 + 79 + return prunedGraph, nil 80 + } 81 + 82 + // NewGraphFromDot creates a Graph from a DOT string. 83 + func NewGraphFromDot(dot string) (*Graph, error) { 84 + ast, err := gographviz.ParseString(dot) 85 + if err != nil { 86 + return nil, fmt.Errorf("failed to parse DOT string: %w", err) 87 + } 88 + 89 + g := gographviz.NewGraph() 90 + if err := gographviz.Analyse(ast, g); err != nil { 91 + return nil, fmt.Errorf("failed to analyse graph: %w", err) 92 + } 93 + 94 + graph := &Graph{Nodes: []*Node{}, Edges: []*Edge{}} 95 + nodeSet := make(map[string]bool) 96 + 97 + addNode := func(name string) { 98 + if !nodeSet[name] { 99 + graph.Nodes = append(graph.Nodes, &Node{Name: name}) 100 + nodeSet[name] = true 101 + } 102 + } 103 + 104 + unquote := func(s string) string { 105 + res, err := strconv.Unquote(s) 106 + if err != nil { 107 + return s 108 + } 109 + return res 110 + } 111 + 112 + for _, edge := range g.Edges.Edges { 113 + src := unquote(edge.Src) 114 + dest := unquote(edge.Dst) 115 + addNode(src) 116 + addNode(dest) 117 + graph.Edges = append(graph.Edges, &Edge{Src: src, Dest: dest}) 118 + } 119 + 120 + for _, node := range g.Nodes.Nodes { 121 + name := unquote(node.Name) 122 + addNode(name) 123 + } 124 + return graph, nil 125 + } 126 + 127 + // ToDot converts a Graph to a DOT string. 128 + func (g *Graph) ToDot() string { 43 129 var b strings.Builder 44 130 b.WriteString("digraph {\n") 45 - for _, edge := range graph.Edges.Edges { 46 - if keep[edge.Src] && keep[edge.Dst] { 47 - b.WriteString(fmt.Sprintf(" %s -> %s;\n", edge.Src, edge.Dst)) 48 - } 131 + for _, edge := range g.Edges { 132 + b.WriteString(fmt.Sprintf(" %q -> %q;\n", edge.Src, edge.Dest)) 49 133 } 50 - for node := range keep { 51 - b.WriteString(fmt.Sprintf(" %s ;\n", node)) 134 + for _, node := range g.Nodes { 135 + b.WriteString(fmt.Sprintf(" %q;\n", node.Name)) 52 136 } 53 137 b.WriteString("}") 54 - return b.String(), nil 138 + return b.String() 55 139 }
+314 -43
controller/activities/graph_test.go
··· 1 1 package activities 2 2 3 3 import ( 4 - "strings" 4 + "context" 5 + "reflect" 6 + "sort" 5 7 "testing" 6 8 ) 7 9 8 - func TestPruneGraph(t *testing.T) { 9 - input := ` 10 + func TestPruneGraphSimple(t *testing.T) { 11 + dot := ` 10 12 digraph { 11 - "azure-policies" ; 12 - "azure-vm-disk-backups/backup-policies/daily-30-day-retention" ; 13 - "azure-vm-disk-backups/backup-policies/daily-30-day-retention" -> "cloud"; 14 - "azure-vm-disk-backups/backup-policies/daily-30-day-retention" -> "azure-vm-disk-backups/backup-vault"; 15 - "azure-vm-disk-backups/backup-vault" ; 16 - "azure-vm-disk-backups/backup-vault" -> "cloud"; 17 - "cloud" ; 18 - "ecomnet-vng" ; 19 - "generated-secrets" ; 20 - "generated-secrets" -> "topology"; 21 - "il4/generated-secrets" ; 22 - "il4/generated-secrets" -> "topology"; 23 - "legacy-bridge" ; 24 - "legacy-bridge" -> "topology"; 25 - "legacy-bridge" -> "network"; 26 - "local-distribution" ; 27 - "network" ; 28 - "secrets" ; 29 - "secrets" -> "topology"; 30 - "topology" ; 13 + "A" -> "B"; 14 + "B" -> "C"; 15 + "D" -> "B"; 16 + "E" -> "F"; 17 + "C"; 18 + "F"; 19 + } 20 + ` 21 + graph, err := NewGraphFromDot(dot) 22 + if err != nil { 23 + t.Fatalf("Failed to create graph from dot: %v", err) 24 + } 25 + 26 + testCases := []struct { 27 + name string 28 + changed []string 29 + expectedNodes []string 30 + expectedEdges []Edge 31 + }{ 32 + { 33 + name: "Prune to C and its dependencies", 34 + changed: []string{"C"}, 35 + expectedNodes: []string{"A", "B", "C", "D"}, 36 + expectedEdges: []Edge{ 37 + {Src: "A", Dest: "B"}, 38 + {Src: "B", Dest: "C"}, 39 + {Src: "D", Dest: "B"}, 40 + }, 41 + }, 42 + { 43 + name: "Prune to F and its dependencies", 44 + changed: []string{"F"}, 45 + expectedNodes: []string{"E", "F"}, 46 + expectedEdges: []Edge{ 47 + {Src: "E", Dest: "F"}, 48 + }, 49 + }, 50 + { 51 + name: "Prune to B and its dependencies", 52 + changed: []string{"B"}, 53 + expectedNodes: []string{"A", "B", "D"}, 54 + expectedEdges: []Edge{ 55 + {Src: "A", Dest: "B"}, 56 + {Src: "D", Dest: "B"}, 57 + }, 58 + }, 59 + { 60 + name: "No nodes changed", 61 + changed: []string{}, 62 + expectedNodes: []string{}, 63 + expectedEdges: []Edge{}, 64 + }, 65 + { 66 + name: "Changed node not in graph", 67 + changed: []string{"Z"}, 68 + expectedNodes: []string{}, 69 + expectedEdges: []Edge{}, 70 + }, 71 + { 72 + name: "Multiple changed nodes", 73 + changed: []string{"C", "F"}, 74 + expectedNodes: []string{"A", "B", "C", "D", "E", "F"}, 75 + expectedEdges: []Edge{ 76 + {Src: "A", Dest: "B"}, 77 + {Src: "B", Dest: "C"}, 78 + {Src: "D", Dest: "B"}, 79 + {Src: "E", Dest: "F"}, 80 + }, 81 + }, 82 + } 83 + 84 + for _, tc := range testCases { 85 + t.Run(tc.name, func(t *testing.T) { 86 + prunedGraph, err := PruneGraph(context.Background(), graph, tc.changed) 87 + if err != nil { 88 + t.Fatalf("PruneGraph failed: %v", err) 89 + } 90 + 91 + prunedNodes := make([]string, 0, len(prunedGraph.Nodes)) 92 + for _, n := range prunedGraph.Nodes { 93 + prunedNodes = append(prunedNodes, n.Name) 94 + } 95 + sort.Strings(prunedNodes) 96 + sort.Strings(tc.expectedNodes) 97 + 98 + if !reflect.DeepEqual(prunedNodes, tc.expectedNodes) { 99 + t.Errorf("Expected nodes %v, but got %v", tc.expectedNodes, prunedNodes) 100 + } 101 + 102 + prunedEdges := make([]Edge, len(prunedGraph.Edges)) 103 + for i, e := range prunedGraph.Edges { 104 + prunedEdges[i] = *e 105 + } 106 + 107 + // Sort edges for consistent comparison 108 + sort.Slice(prunedEdges, func(i, j int) bool { 109 + if prunedEdges[i].Src != prunedEdges[j].Src { 110 + return prunedEdges[i].Src < prunedEdges[j].Src 111 + } 112 + return prunedEdges[i].Dest < prunedEdges[j].Dest 113 + }) 114 + sort.Slice(tc.expectedEdges, func(i, j int) bool { 115 + if tc.expectedEdges[i].Src != tc.expectedEdges[j].Src { 116 + return tc.expectedEdges[i].Src < tc.expectedEdges[j].Src 117 + } 118 + return tc.expectedEdges[i].Dest < tc.expectedEdges[j].Dest 119 + }) 120 + 121 + if !reflect.DeepEqual(prunedEdges, tc.expectedEdges) { 122 + t.Errorf("Expected edges %v, but got %v", tc.expectedEdges, prunedEdges) 123 + } 124 + }) 125 + } 126 + } 127 + 128 + func TestPruneGraphRealWorld(t *testing.T) { 129 + realWorldDot := `digraph { 130 + "aks-windows-node-exporter" ; 131 + "azuresql" ; 132 + "azuresql" -> "core"; 133 + "azuresqlusers" ; 134 + "azuresqlusers" -> "azuresql"; 135 + "bootstrap-va" ; 136 + "bootstrap-va" -> "cluster-va"; 137 + "bootstrap2-va" ; 138 + "bootstrap2-va" -> "cluster2-va"; 139 + "cluster-va" ; 140 + "cluster-va" -> "core"; 141 + "cluster2-va" ; 142 + "core" ; 143 + "db/auror-integration" ; 144 + "db/auror-integration" -> "core"; 145 + "db/auror-integration" -> "azuresql"; 146 + "db/doc-chat" ; 147 + "db/doc-chat" -> "core"; 148 + "db/doc-chat" -> "azuresql"; 149 + "dems-cluster-identity" ; 150 + "dems-cluster-identity" -> "cluster-va"; 151 + "dems-search-grpc/cosmosdb-cassandra" ; 152 + "dems-search-grpc/cosmosdb-cassandra" -> "core"; 153 + "doc-chat/openai" ; 154 + "doc-chat/openai" -> "core"; 155 + "doc-chat/openai-fallback" ; 156 + "doc-chat/openai-fallback" -> "core"; 157 + "doc-chat/search-service-va" ; 158 + "doc-chat/search-service-va" -> "core"; 159 + "ecom/arkham-hsm-als-endpoint" ; 160 + "ecom/arkham-hsm-legacy-endpoint" ; 161 + "ecom/redis" ; 162 + "ecom/redis" -> "core"; 163 + "ecom/redis-case" ; 164 + "ecom/redis-case" -> "core"; 165 + "ecom/redis-legacy-endpoint" ; 166 + "ecom/redis-legacy-endpoint" -> "core"; 167 + "ecom/redis-legacy-endpoint" -> "ecom/redis"; 168 + "ecom/redis-sharon" ; 169 + "ecom/redis-sharon" -> "core"; 170 + "ecom/redis-webhooks-premium" ; 171 + "ecom/redis-webhooks-premium" -> "core"; 172 + "endpoints/azuresql-legacy-endpoint-tx" ; 173 + "endpoints/azuresql-legacy-endpoint-tx" -> "core"; 174 + "endpoints/azuresql-legacy-endpoint-tx" -> "azuresql"; 175 + "endpoints/azuresql-legacy-endpoint-va" ; 176 + "endpoints/azuresql-legacy-endpoint-va" -> "core"; 177 + "endpoints/azuresql-legacy-endpoint-va" -> "azuresql"; 178 + "endpoints/storage-accounts" ; 179 + "endpoints/storage-accounts" -> "storage-accounts/ingestion"; 180 + "enterprise/app-identity/auror" ; 181 + "enterprise/app-identity/auror" -> "core"; 182 + "enterprise/keyvault/auror" ; 183 + "enterprise/keyvault/auror" -> "core"; 184 + "enterprise/keyvault/auror" -> "enterprise/app-identity/auror"; 185 + "enterprise/redis/auror" ; 186 + "enterprise/redis/auror" -> "core"; 187 + "espio/az-openai" ; 188 + "espio/az-openai" -> "core"; 189 + "espio/espio-redis" ; 190 + "espio/espio-redis" -> "core"; 191 + "espio/openai" ; 192 + "espio/openai-b" ; 193 + "espio/openai-b" -> "core"; 194 + "eventgrid-subscription" ; 195 + "eventgrid-subscription" -> "core"; 196 + "evp/hyperscale" ; 197 + "evp/hyperscale" -> "core"; 198 + "evp/hyperscaleusers" ; 199 + "evp/hyperscaleusers" -> "evp/hyperscale"; 200 + "performance/lakehouse" ; 201 + "performance/lakehouse" -> "core"; 202 + "performance/redis-jarvis" ; 203 + "performance/redis-jarvis" -> "core"; 204 + "performance/redis-pipeline" ; 205 + "performance/redis-pipeline" -> "core"; 206 + "performance/redis-starhopper" ; 207 + "performance/redis-starhopper" -> "core"; 208 + "pes/keyvault" ; 209 + "pes/keyvault" -> "core"; 210 + "pes/keyvault" -> "dems-cluster-identity"; 211 + "ratelimit/redis" ; 212 + "ratelimit/redis" -> "core"; 213 + "sage/datafactory" ; 214 + "sage/datafactory" -> "core"; 215 + "sage/datafactory/alerts" ; 216 + "sage/datafactory/alerts" -> "sage/datafactory"; 217 + "sage/datafactory/alerts" -> "core"; 218 + "sage/datafactory/evidence-domain-migration-internal-pipeline" ; 219 + "sage/datafactory/evidence-domain-migration-internal-pipeline" -> "sage/datafactory"; 220 + "sage/datafactory/evidence-domain-migration-main-pipeline" ; 221 + "sage/datafactory/evidence-domain-migration-main-pipeline" -> "sage/datafactory"; 222 + "sage/endpoints/hyperscale-legacy-endpoint-tx" ; 223 + "sage/endpoints/hyperscale-legacy-endpoint-tx" -> "core"; 224 + "sage/endpoints/hyperscale-legacy-endpoint-tx" -> "sage/hyperscale"; 225 + "sage/endpoints/hyperscale-legacy-endpoint-va" ; 226 + "sage/endpoints/hyperscale-legacy-endpoint-va" -> "core"; 227 + "sage/endpoints/hyperscale-legacy-endpoint-va" -> "sage/hyperscale"; 228 + "sage/hyperscale" ; 229 + "sage/hyperscale" -> "core"; 230 + "sage/hyperscale/named-replica" ; 231 + "sage/hyperscale/named-replica" -> "sage/hyperscale"; 232 + "sage/hyperscale/named-replica" -> "core"; 233 + "sage/hyperscaleusers" ; 234 + "sage/hyperscaleusers" -> "sage/hyperscale"; 235 + "sage/redis" ; 236 + "sage/redis" -> "core"; 237 + "servicebus-premium" ; 238 + "servicebus-premium" -> "core"; 239 + "sonic/rev-storage" ; 240 + "sonic/rev-storage" -> "core"; 241 + "sonic/sonic" ; 242 + "sonic/sonic" -> "core"; 243 + "sonic/sonic-redis" ; 244 + "sonic/sonic-redis" -> "core"; 245 + "sonic/translation" ; 246 + "sonic/translation" -> "core"; 247 + "storage-accounts/case-share" ; 248 + "storage-accounts/ingestion" ; 249 + "storage-accounts/rtiworker" ; 250 + "storage-accounts/sage" ; 251 + "system-status/cosmosdb-cassandra" ; 252 + "system-status/cosmosdb-cassandra" -> "core"; 253 + "user-settings/cosmosdb-cassandra" ; 254 + "user-settings/cosmosdb-cassandra" -> "core"; 255 + "visionsearchpoc/vision" ; 256 + "visionsearchpoc/vision" -> "core"; 257 + "visualization/cosmosdb-cassandra" ; 258 + "visualization/cosmosdb-cassandra" -> "core"; 259 + "visualization/redis-cluster" ; 260 + "visualization/redis-cluster" -> "core"; 261 + "visualization/redis-cluster-rtm" ; 262 + "visualization/redis-cluster-rtm" -> "core"; 263 + "vm-apps/lsln-500" ; 264 + "vm-apps/lsln-500" -> "core"; 265 + "vm-apps/lsln-500" -> "vm-apps/solr8-j11-lb"; 266 + "vm-apps/solr8-j11-lb" ; 267 + "vm-apps/solr8-j11-lb" -> "core"; 268 + "webhooks/cosmosdb-cassandra-dispatch" ; 269 + "webhooks/cosmosdb-cassandra-dispatch" -> "core"; 270 + "xshare/azuresql" ; 271 + "xshare/azuresql" -> "core"; 272 + "xshare/azuresqlusers" ; 273 + "xshare/azuresqlusers" -> "xshare/azuresql"; 31 274 }` 32 275 33 - expectedContains := []string{ 34 - `"azure-vm-disk-backups/backup-policies/daily-30-day-retention"`, 35 - `"azure-vm-disk-backups/backup-policies/daily-30-day-retention" -> "azure-vm-disk-backups/backup-vault"`, 36 - `"azure-vm-disk-backups/backup-vault"`, 37 - `"topology"`, 38 - `"generated-secrets" -> "topology"`, 39 - `"il4/generated-secrets" -> "topology"`, 40 - `"legacy-bridge" -> "topology"`, 41 - `"secrets" -> "topology"`, 42 - `"local-distribution"`, 276 + graph, err := NewGraphFromDot(realWorldDot) 277 + if err != nil { 278 + t.Fatalf("Failed to create graph from real-world DOT: %v", err) 279 + } 280 + 281 + // Test case: cluster-va changed 282 + prunedGraph, err := PruneGraph(context.Background(), graph, []string{"cluster-va"}) 283 + if err != nil { 284 + t.Fatalf("PruneGraph failed: %v", err) 285 + } 286 + 287 + // digraph { 288 + // "bootstrap-va" -> "cluster-va"; 289 + // "dems-cluster-identity" -> "cluster-va"; 290 + // "pes/keyvault" -> "dems-cluster-identity"; 291 + // } 292 + expectedNodes := []string{"bootstrap-va", "cluster-va", "dems-cluster-identity", "pes/keyvault"} 293 + expectedEdges := []Edge{ 294 + {Src: "bootstrap-va", Dest: "cluster-va"}, 295 + {Src: "dems-cluster-identity", Dest: "cluster-va"}, 296 + {Src: "pes/keyvault", Dest: "dems-cluster-identity"}, 297 + } 298 + 299 + prunedNodes := make([]string, 0, len(prunedGraph.Nodes)) 300 + for _, n := range prunedGraph.Nodes { 301 + prunedNodes = append(prunedNodes, n.Name) 43 302 } 303 + sort.Strings(prunedNodes) 304 + sort.Strings(expectedNodes) 44 305 45 - changed := []string{ 46 - `"azure-vm-disk-backups/backup-vault"`, 47 - `"topology"`, 48 - `"local-distribution"`, 306 + if !reflect.DeepEqual(prunedNodes, expectedNodes) { 307 + t.Errorf("Expected nodes %v, but got %v", expectedNodes, prunedNodes) 49 308 } 50 309 51 - pruned, err := pruneGraph(input, changed) 52 - if err != nil { 53 - t.Fatalf("unexpected error: %v", err) 310 + prunedEdges := make([]Edge, len(prunedGraph.Edges)) 311 + for i, e := range prunedGraph.Edges { 312 + prunedEdges[i] = *e 54 313 } 55 314 56 - for _, expected := range expectedContains { 57 - if !strings.Contains(pruned, expected) { 58 - t.Errorf("expected pruned output to contain: %s", expected) 315 + // Sort edges for consistent comparison 316 + sort.Slice(prunedEdges, func(i, j int) bool { 317 + if prunedEdges[i].Src != prunedEdges[j].Src { 318 + return prunedEdges[i].Src < prunedEdges[j].Src 59 319 } 320 + return prunedEdges[i].Dest < prunedEdges[j].Dest 321 + }) 322 + sort.Slice(expectedEdges, func(i, j int) bool { 323 + if expectedEdges[i].Src != expectedEdges[j].Src { 324 + return expectedEdges[i].Src < expectedEdges[j].Src 325 + } 326 + return expectedEdges[i].Dest < expectedEdges[j].Dest 327 + }) 328 + 329 + if !reflect.DeepEqual(prunedEdges, expectedEdges) { 330 + t.Errorf("Expected edges %v, but got %v", expectedEdges, prunedEdges) 60 331 } 61 332 }
+1
controller/activities/graph_types.go
··· 1 + package activities
+7 -2
controller/activities/terragrunt.go
··· 27 27 28 28 logger.Info("Parsing Terragrunt DAG graph") 29 29 30 - pruned, err := pruneGraph(dotGraph, changedFiles) 30 + graph, err := NewGraphFromDot(dotGraph) 31 + if err != nil { 32 + return "", fmt.Errorf("failed to parse dot graph: %w", err) 33 + } 34 + 35 + prunedGraph, err := PruneGraph(ctx, graph, changedFiles) 31 36 if err != nil { 32 37 return "", fmt.Errorf("failed to prune dependency graph: %w", err) 33 38 } 34 39 35 - return pruned, nil 40 + return prunedGraph.ToDot(), nil 36 41 }