| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| // loggraphdiff is a tool for interpreting changes to the Terraform graph | |
| // based on the simple graph printing format used in the TF_LOG=trace log | |
| // output from Terraform, which looks like this: | |
| // | |
| // aws_instance.b (destroy) - *terraform.NodeDestroyResourceInstance | |
| // aws_instance.b (prepare state) - *terraform.NodeApplyableResource | |
| // provider.aws - *terraform.NodeApplyableProvider | |
| // aws_instance.b (prepare state) - *terraform.NodeApplyableResource | |
| // provider.aws - *terraform.NodeApplyableProvider | |
| // module.child.aws_instance.a (destroy) - *terraform.NodeDestroyResourceInstance | |
| // module.child.aws_instance.a (prepare state) - *terraform.NodeApplyableResource | |
| // module.child.output.a_output - *terraform.NodeApplyableOutput | |
| // provider.aws - *terraform.NodeApplyableProvider | |
| // module.child.aws_instance.a (prepare state) - *terraform.NodeApplyableResource | |
| // provider.aws - *terraform.NodeApplyableProvider | |
| // module.child.output.a_output - *terraform.NodeApplyableOutput | |
| // module.child.aws_instance.a (prepare state) - *terraform.NodeApplyableResource | |
| // provider.aws - *terraform.NodeApplyableProvider | |
| // | |
| // It takes the names of two files containing this style of output and | |
| // produces a single graph description in graphviz format that shows the | |
| // differences between the two graphs: nodes and edges which are only in the | |
| // first graph are shown in red, while those only in the second graph are | |
| // shown in green. This color combination is not useful for those who are | |
| // red/green color blind, so the result can be adjusted by replacing the | |
| // keywords "red" and "green" with a combination that the user is able to | |
| // distinguish. | |
| package main | |
| import ( | |
| "bufio" | |
| "fmt" | |
| "log" | |
| "os" | |
| "sort" | |
| "strings" | |
| ) | |
| type Graph struct { | |
| nodes map[string]struct{} | |
| edges map[[2]string]struct{} | |
| } | |
| func main() { | |
| if len(os.Args) != 3 { | |
| log.Fatal("usage: loggraphdiff <old-graph-file> <new-graph-file>") | |
| } | |
| old, err := readGraph(os.Args[1]) | |
| if err != nil { | |
| log.Fatalf("failed to read %s: %s", os.Args[1], err) | |
| } | |
| new, err := readGraph(os.Args[2]) | |
| if err != nil { | |
| log.Fatalf("failed to read %s: %s", os.Args[1], err) | |
| } | |
| var nodes []string | |
| for n := range old.nodes { | |
| nodes = append(nodes, n) | |
| } | |
| for n := range new.nodes { | |
| if _, exists := old.nodes[n]; !exists { | |
| nodes = append(nodes, n) | |
| } | |
| } | |
| sort.Strings(nodes) | |
| var edges [][2]string | |
| for e := range old.edges { | |
| edges = append(edges, e) | |
| } | |
| for e := range new.edges { | |
| if _, exists := old.edges[e]; !exists { | |
| edges = append(edges, e) | |
| } | |
| } | |
| sort.Slice(edges, func(i, j int) bool { | |
| if edges[i][0] != edges[j][0] { | |
| return edges[i][0] < edges[j][0] | |
| } | |
| return edges[i][1] < edges[j][1] | |
| }) | |
| fmt.Println("digraph G {") | |
| fmt.Print(" rankdir = \"BT\";\n\n") | |
| for _, n := range nodes { | |
| var attrs string | |
| _, inOld := old.nodes[n] | |
| _, inNew := new.nodes[n] | |
| switch { | |
| case inOld && inNew: | |
| // no attrs required | |
| case inOld: | |
| attrs = " [color=red]" | |
| case inNew: | |
| attrs = " [color=green]" | |
| } | |
| fmt.Printf(" %q%s;\n", n, attrs) | |
| } | |
| fmt.Println("") | |
| for _, e := range edges { | |
| var attrs string | |
| _, inOld := old.edges[e] | |
| _, inNew := new.edges[e] | |
| switch { | |
| case inOld && inNew: | |
| // no attrs required | |
| case inOld: | |
| attrs = " [color=red]" | |
| case inNew: | |
| attrs = " [color=green]" | |
| } | |
| fmt.Printf(" %q -> %q%s;\n", e[0], e[1], attrs) | |
| } | |
| fmt.Println("}") | |
| } | |
| func readGraph(fn string) (Graph, error) { | |
| ret := Graph{ | |
| nodes: map[string]struct{}{}, | |
| edges: map[[2]string]struct{}{}, | |
| } | |
| r, err := os.Open(fn) | |
| if err != nil { | |
| return ret, err | |
| } | |
| sc := bufio.NewScanner(r) | |
| var latestNode string | |
| for sc.Scan() { | |
| l := sc.Text() | |
| dash := strings.Index(l, " - ") | |
| if dash == -1 { | |
| // invalid line, so we'll ignore it | |
| continue | |
| } | |
| name := l[:dash] | |
| if strings.HasPrefix(name, " ") { | |
| // It's an edge | |
| name = name[2:] | |
| edge := [2]string{latestNode, name} | |
| ret.edges[edge] = struct{}{} | |
| } else { | |
| // It's a node | |
| latestNode = name | |
| ret.nodes[name] = struct{}{} | |
| } | |
| } | |
| return ret, nil | |
| } |