diff --git a/go.mod b/go.mod index d23b2444f95..0d7ad5015c5 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/elazarl/goproxy v0.0.0-20190911111923-ecfe977594f1 // indirect github.com/evanphx/json-patch v4.5.0+incompatible // indirect github.com/go-openapi/spec v0.19.3 + github.com/goccy/go-graphviz v0.0.5 github.com/gogo/protobuf v1.3.1 github.com/golang/mock v1.2.0 github.com/golang/protobuf v1.3.2 diff --git a/go.sum b/go.sum index 0bbea41c18f..cf3292e99ce 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea h1:n2Ltr3SrfQlf/9nOna1DoGKxLx3qTSI8Ttl6Xrqp6mw= github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/corona10/goimagehash v1.0.2 h1:pUfB0LnsJASMPGEZLj7tGY251vF+qLGqOgEP4rUs6kA= +github.com/corona10/goimagehash v1.0.2/go.mod h1:/l9umBhvcHQXVtQO1V6Gp1yD20STawkhRnnX0D1bvVI= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= @@ -123,6 +125,8 @@ github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5I github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -147,12 +151,16 @@ github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-graphviz v0.0.5 h1:qcjgvNiYbLyfLAq9LvyYBJ7sNMbQh9w4FoAzBDrYhYw= +github.com/goccy/go-graphviz v0.0.5/go.mod h1:wXVsXxmyMQU6TN3zGRttjNn3h+iCAS7xQFC6TlNvLhk= github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903 h1:LbsanbbD6LieFkXbj9YNNBupiGHJgFeLpO0j0Fza1h8= @@ -207,6 +215,7 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= github.com/j-keck/arping v1.0.0 h1:DN6Wy73IeadEEo5xVCgEp+ZGn2xmAypggxj8mtxXBD0= github.com/j-keck/arping v1.0.0/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -268,6 +277,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -284,8 +295,9 @@ github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.m github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= @@ -380,12 +392,15 @@ golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495 h1:I6A9Ag9FpEKOjcKrRNjQkPHawoXIhKyTGfvvjFAiiAk= golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/pkg/graphviz/traceflow.go b/pkg/graphviz/traceflow.go new file mode 100644 index 00000000000..5dc2f931b3a --- /dev/null +++ b/pkg/graphviz/traceflow.go @@ -0,0 +1,265 @@ +package graphviz + +import ( + "bytes" + "fmt" + "log" + "strings" + + "github.com/goccy/go-graphviz" + "github.com/goccy/go-graphviz/cgraph" + + opsv1alpha1 "github.com/vmware-tanzu/antrea/pkg/apis/ops/v1alpha1" +) + +var ( + clusterSrcName = "cluster_source" + clusterDstName = "cluster_dest" +) + +func genSubGraph(graph *cgraph.Graph, result *opsv1alpha1.NodeResult, firstNodeName string, dir cgraph.DirType, addNodeNum int) []*cgraph.Node { + var nodes []*cgraph.Node + obs := result.Observations + + // Show the name of cluster. + if len(result.Node) > 0 { + graph.SetLabel(result.Node) + if dir == cgraph.ForwardDir { + graph.SetLabelJust(cgraph.LeftJust) + } else { + graph.SetLabelJust(cgraph.RightJust) + } + } + + // Construct the first node. Show it only if we know the name of it. + node, _ := graph.CreateNode(firstNodeName) + nodes = append(nodes, node) + if len(firstNodeName) > 0 { + node.SetFontColor("white") + node.SetColor("royalblue1") + node.SetStyle(cgraph.FilledNodeStyle) + } else { + node.SetStyle("invis") + } + + // Reorder the observations according to the direction of edges. + if dir == cgraph.BackDir { + for i := len(obs)/2 - 1; i >= 0; i-- { + opp := len(obs) - 1 - i + obs[i], obs[opp] = obs[opp], obs[i] + } + } + + // Draw the actual observations of traceflow. + for _, o := range obs { + // Construct node and edge. + nodeName := fmt.Sprintf("%s_%d", graph.Name(), len(nodes)) + node, _ := graph.CreateNode(nodeName) + node.SetLabel(string(o.Component)) + node.SetShape(cgraph.BoxShape) + node.SetStyle(cgraph.RoundedNodeStyle + "," + cgraph.FilledNodeStyle) + node.SetFontColor("white") + nodes = append(nodes, node) + if len(nodes) > 1 { + edgeName := fmt.Sprintf("%s_%d", graph.Name(), len(nodes)) + edge, _ := graph.CreateEdge(edgeName, nodes[len(nodes)-2], nodes[len(nodes)-1]) + edge.SetDir(dir) + edge.SetPenWidth(3.0) + edge.SetColor("limegreen") + if len(nodes) == 2 { + edge.SetMinLen(1 + addNodeNum) + } else { + edge.SetMinLen(1) + } + if o.Action == opsv1alpha1.Dropped && dir == cgraph.BackDir { + edge.SetStyle("invis") + } + } + // Set the pattern of node. + labelStr := string(o.Component) + if len(o.ComponentInfo) > 0 { + labelStr += "\n" + o.ComponentInfo + } + labelStr += "\n" + string(o.Action) + if o.Component == opsv1alpha1.NetworkPolicy && len(o.NetworkPolicy) > 0 { + labelStr += "\nNetpol: " + o.NetworkPolicy + } + if o.Action == opsv1alpha1.Dropped { + node.SetColor("violet") + } else { + node.SetColor("turquoise3") + if len(o.TunnelDstIP) > 0 { + labelStr += "\nTo: " + o.TunnelDstIP + } + } + node.SetLabel(labelStr) + } + return nodes +} + +func isSender(result opsv1alpha1.NodeResult) bool { + if len(result.Observations) == 0 { + return false + } + if result.Observations[0].Component != opsv1alpha1.SpoofGuard || result.Observations[0].Action != opsv1alpha1.Forwarded { + return false + } + return true +} + +func isReceiver(result opsv1alpha1.NodeResult) bool { + if len(result.Observations) == 0 { + return false + } + if result.Observations[0].Component != opsv1alpha1.Forwarding || result.Observations[0].Action != opsv1alpha1.Received { + return false + } + return true +} + +func getNodeResult(tf *opsv1alpha1.Traceflow, fn func(result opsv1alpha1.NodeResult) bool) *opsv1alpha1.NodeResult { + for _, result := range tf.Status.Results { + if fn(result) { + return &result + } + } + return nil +} + +func getSrcNodeName(tf *opsv1alpha1.Traceflow) string { + if len(tf.Spec.Source.Namespace) > 0 && len(tf.Spec.Source.Pod) > 0 { + return tf.Spec.Source.Namespace + "/" + tf.Spec.Source.Pod + } + return "" +} + +func getDstNodeName(tf *opsv1alpha1.Traceflow) string { + if len(tf.Spec.Destination.Namespace) > 0 && len(tf.Spec.Destination.Pod) > 0 { + return tf.Spec.Destination.Namespace + "/" + tf.Spec.Destination.Pod + } + return "" +} + +// In Graphviz, clusters are surrounded by a pair of "{}" with string "subgraph ClusterName" before them. +// The function finds the start and end index of specific cluster. +func findClusterString(graphStr string, clusterName string) (startIndex int, endIndex int) { + startIndex = strings.Index(graphStr, "subgraph "+clusterName) + if startIndex == -1 { + return 0, 1 + } + endIndex = startIndex + for graphStr[endIndex] != '{' { + endIndex++ + } + // Depth represents the number of "{" minus the number of "}" from the start index of current cluster to endIndex. + // When depth is zero for the first time, it indicates that we successfully find the end index of the cluster. + depth := 1 + for depth > 0 { + endIndex++ + if graphStr[endIndex] == '{' { + depth++ + } + if graphStr[endIndex] == '}' { + depth-- + } + } + return startIndex, endIndex + 1 +} + +func genOutput(g *graphviz.Graphviz, graph *cgraph.Graph, isSingleCluster bool) string { + var buf bytes.Buffer + if err := g.Render(graph, "dot", &buf); err != nil { + log.Fatal(err) + } + if err := graph.Close(); err != nil { + log.Fatal(err) + } + g.Close() + + str := buf.String() + if isSingleCluster { + return str + } + // In Graphviz, cluster is a subgraph which is surrounded by a rectangle and the nodes belonging to the cluster are drawn together. + // Swap source and destination cluster if destination cluster appears before source cluster. + srcStartIdx, srcEndIdx := findClusterString(str, clusterSrcName) + dstStartIdx, dstEndIdx := findClusterString(str, clusterDstName) + if dstEndIdx <= srcStartIdx { + return str[:dstStartIdx] + str[srcStartIdx:srcEndIdx] + str[dstEndIdx:srcStartIdx] + str[dstStartIdx:dstEndIdx] + str[srcEndIdx:] + } + return str +} + +func GenGraph(tf *opsv1alpha1.Traceflow) string { + var err error + g := graphviz.New() + graph, err := g.Graph() + if err != nil { + log.Fatal(err) + } + graph.SetCenter(true) + graph.SetLabel(tf.Name) + graph.SetLabelLocation(cgraph.TopLocation) + + senderRst := getNodeResult(tf, isSender) + receiverRst := getNodeResult(tf, isReceiver) + if tf == nil || senderRst == nil || tf.Status.Phase != opsv1alpha1.Succeeded || len(senderRst.Observations) == 0 { + return genOutput(g, graph, true) + } + + cluster1 := graph.SubGraph(clusterSrcName, 1) + cluster1.SetStyle(cgraph.FilledGraphStyle) + // Handle single node traceflow. + if receiverRst == nil { + nodes := genSubGraph(cluster1, senderRst, getSrcNodeName(tf), cgraph.ForwardDir, 0) + // Draw the destination pod and involved edge. + edgeName := fmt.Sprintf("%s_%d", cluster1.Name(), len(senderRst.Observations)) + if len(nodes) == 0 { + return genOutput(g, graph, true) + } + switch senderRst.Observations[len(senderRst.Observations)-1].Action { + // If the last action of the sender is FORWARDED, + // then the packet has been sent out by sender, implying that there is a disconnection. + case opsv1alpha1.Forwarded: + lastNode, _ := graph.CreateNode(getDstNodeName(tf)) + edge, _ := graph.CreateEdge(edgeName, nodes[len(nodes)-1], lastNode) + edge.SetColor("red") + edge.SetStyle(cgraph.DashedEdgeStyle) + case opsv1alpha1.Delivered: + lastNode, _ := cluster1.CreateNode(getDstNodeName(tf)) + edge, _ := cluster1.CreateEdge(edgeName, nodes[len(nodes)-1], lastNode) + edge.SetPenWidth(3.0) + lastNode.SetFontColor("white") + lastNode.SetColor("royalblue1") + lastNode.SetStyle(cgraph.FilledNodeStyle) + edge.SetColor("limegreen") + } + return genOutput(g, graph, true) + } + + // Make the graph centered by equalizing the number of nodes on both sides. + var nodeNum int + if len(senderRst.Observations) > len(receiverRst.Observations) { + nodeNum = len(senderRst.Observations) + } else { + nodeNum = len(receiverRst.Observations) + } + + // Draw the nodes for the sender. + nodes1 := genSubGraph(cluster1, senderRst, getSrcNodeName(tf), cgraph.ForwardDir, nodeNum-len(senderRst.Observations)) + + // Draw the nodes for the receiver. + cluster2 := graph.SubGraph(clusterDstName, 1) + cluster2.SetStyle(cgraph.FilledGraphStyle) + nodes2 := genSubGraph(cluster2, receiverRst, getDstNodeName(tf), cgraph.BackDir, nodeNum-len(receiverRst.Observations)) + + // Draw the cross-cluster edge. + if len(nodes1) > 0 && len(nodes2) > 0 { + edge, _ := graph.CreateEdge("cross_node", nodes1[len(nodes1)-1], nodes2[len(nodes2)-1]) + edge.SetConstraint(false) + edge.SetPenWidth(3.0) + edge.SetColor("limegreen") + } + + return genOutput(g, graph, false) +} diff --git a/plugins/octant/Makefile b/plugins/octant/Makefile index c3e025ae6c7..dde6fe099a7 100755 --- a/plugins/octant/Makefile +++ b/plugins/octant/Makefile @@ -7,6 +7,11 @@ antrea-octant-plugin: @mkdir -p $(BINDIR) GOOS=linux $(GO) build -o $(BINDIR) github.com/vmware-tanzu/antrea/plugins/octant/cmd/antrea-octant-plugin +.PHONY: antrea-traceflow-plugin +antrea-traceflow-plugin: + @mkdir -p $(BINDIR) + GOOS=linux $(GO) build -o $(BINDIR) github.com/vmware-tanzu/antrea/plugins/octant/cmd/antrea-traceflow-plugin + .PHONY: octant-plugins octant-plugins: @mkdir -p $(BINDIR) diff --git a/plugins/octant/cmd/antrea-traceflow-plugin/main.go b/plugins/octant/cmd/antrea-traceflow-plugin/main.go new file mode 100644 index 00000000000..4235bf5d4ef --- /dev/null +++ b/plugins/octant/cmd/antrea-traceflow-plugin/main.go @@ -0,0 +1,404 @@ +package main + +import ( + "context" + "log" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/vmware-tanzu/octant/pkg/navigation" + "github.com/vmware-tanzu/octant/pkg/plugin" + "github.com/vmware-tanzu/octant/pkg/plugin/service" + "github.com/vmware-tanzu/octant/pkg/view/component" + "github.com/vmware-tanzu/octant/pkg/view/flexlayout" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" + + opsv1alpha1 "github.com/vmware-tanzu/antrea/pkg/apis/ops/v1alpha1" + clientset "github.com/vmware-tanzu/antrea/pkg/client/clientset/versioned" + "github.com/vmware-tanzu/antrea/pkg/graphviz" +) + +var ( + pluginName = "antreaTraceflowPlugin" + addTfAction = pluginName + "/addTf" + showGraphAction = pluginName + "/showGraphAction" + client *clientset.Clientset = nil + kubeConfig = "KUBECONFIG" +) + +const ( + tfNameCol = "Trace" + srcNamespaceCol = "Source Namespace" + srcPodCol = "Source Pod" + srcPortCol = "Source Port" + dstNamespaceCol = "Destination Namespace" + dstPodCol = "Destination Pod" + dstPortCol = "Destination Port" + protocolCol = "Protocol" + crdCol = "Detailed Information" + phaseCol = "Phase" + ageCol = "Age" + + octantTraceflowCRDPath = "/cluster-overview/custom-resources/traceflows.ops.antrea.tanzu.vmware.com/v1alpha1/" + + namespaceStrPattern = `[a-z0-9]([-a-z0-9]*[a-z0-9])?` + podStrPattern = `[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*` + + TIME_FORMAT_YYYYMMDD_HHMMSS = "20060102-150405" +) + +// According to code in Antrea agent and controller, default protocol is ICMP if protocol is not inputted by users. +const ( + ICMPProtocol int32 = 1 + TCPProtocol int32 = 6 + UDPProtocol int32 = 17 +) + +var supportedProtocols = map[string]int32{ + "ICMP": ICMPProtocol, + "TCP": TCPProtocol, + "UDP": UDPProtocol, +} + +// This is antrea-traceflow-plugin. +func main() { + localPlugin := newTraceflowPlugin() + + // Remove the prefix from the go logger since Octant will print logs with timestamps. + log.SetPrefix("") + + capabilities := &plugin.Capabilities{ + ActionNames: []string{addTfAction, showGraphAction}, + IsModule: true, + } + + // Set up what should happen when Octant calls this plugin. + options := []service.PluginOption{ + service.WithActionHandler(localPlugin.actionHandler), + service.WithNavigation(localPlugin.navHandler, localPlugin.initRoutes), + } + + p, err := service.Register(pluginName, "A plugin that starts Antrea Traceflow sessions to trace packets "+ + "in the Antrea network and draws graphs for the result packet flows.", capabilities, options...) + if err != nil { + log.Fatal(err) + } + + log.Printf(pluginName + " is starting") + p.Serve() +} + +type traceflowPlugin struct { + client *clientset.Clientset + graph string + latestTfName string +} + +func newTraceflowPlugin() *traceflowPlugin { + config, err := clientcmd.BuildConfigFromFlags("", os.Getenv(kubeConfig)) + if err != nil { + log.Fatalf("Failed to build kubeConfig %v", err) + } + client, err = clientset.NewForConfig(config) + if err != nil { + log.Fatalf("Failed to create K8s client for %s: %v", pluginName, err) + } + return &traceflowPlugin{ + client: client, + graph: "", + latestTfName: "", + } +} + +func (a *traceflowPlugin) navHandler(request *service.NavigationRequest) (navigation.Navigation, error) { + return navigation.Navigation{ + Title: "Trace Flow", + Path: request.GeneratePath("components"), + IconName: "cloud", + }, nil +} + +func (a *traceflowPlugin) regExpMatch(pattern, str string) bool { + match, err := regexp.MatchString(pattern, str) + if err != nil { + log.Printf("Failed to judge srcPod string pattern: %s", err) + return false + } + if !match { + log.Printf("Failed to match string %s and regExp pattern %s", str, pattern) + return false + } + return true +} + +func (a *traceflowPlugin) actionHandler(request *service.ActionRequest) error { + actionName, err := request.Payload.String("action") + if err != nil { + log.Printf("Failed to get input at string: %s", err) + return nil + } + + switch actionName { + case addTfAction: + srcNamespace, err := request.Payload.String(srcNamespaceCol) + if err != nil { + log.Printf("Failed to get srcNamespace at string : %s", err) + } + if match := a.regExpMatch(namespaceStrPattern, srcNamespace); !match { + return nil + } + + srcPod, err := request.Payload.String(srcPodCol) + if err != nil { + log.Printf("Failed to get srcPod at string : %s", err) + } + if match := a.regExpMatch(podStrPattern, srcPod); !match { + return nil + } + + srcPort, err := request.Payload.String(srcPortCol) + if err != nil { + log.Printf("Failed to get srcPort at string : %s", err) + } + + dstNamespace, err := request.Payload.String(dstNamespaceCol) + if err != nil { + log.Printf("Failed to get dstNamespace at string : %s", err) + } + if match := a.regExpMatch(namespaceStrPattern, dstNamespace); !match { + return nil + } + + dstPod, err := request.Payload.String(dstPodCol) + if err != nil { + log.Printf("Failed to get dstPod at string : %s", err) + } + if match := a.regExpMatch(podStrPattern, dstPod); !match { + return nil + } + + dstPort, err := request.Payload.String(dstPortCol) + if err != nil { + log.Printf("Failed to get dstPort at string : %s", err) + } + + protocol, err := request.Payload.String(protocolCol) + if err != nil { + log.Printf("Failed to get dstPod at string : %s", err) + } + protocol = strings.ToUpper(protocol) + + // Judge whether the name of trace flow is duplicated. + // If it is, then the user creates more than one traceflows in one second, which is not allowed. + tfName := srcPod + "-" + dstPod + "-" + time.Now().Format(TIME_FORMAT_YYYYMMDD_HHMMSS) + ctx := context.Background() + tfOld, err := a.client.OpsV1alpha1().Traceflows().Get(ctx, tfName, metav1.GetOptions{}) + if err != nil { + log.Printf("Failed to get traceflow \"%s\", detailed error: %s", tfName, err) + } + if tfOld.Name == tfName { + log.Printf("Duplicate traceflow \"%s\": same source pod and destination pod in less than one second"+ + ": %+v. ", tfName, tfOld) + return nil + } + + tf := &opsv1alpha1.Traceflow{ + ObjectMeta: metav1.ObjectMeta{ + Name: tfName, + }, + Spec: opsv1alpha1.TraceflowSpec{ + Source: opsv1alpha1.Source{ + Namespace: srcNamespace, + Pod: srcPod, + }, + Destination: opsv1alpha1.Destination{ + Namespace: dstNamespace, + Pod: dstPod, + }, + Packet: opsv1alpha1.Packet{ + IPHeader: opsv1alpha1.IPHeader{ + Protocol: supportedProtocols[protocol], + }, + }, + }, + } + var sport, dport int + if srcPort != "" { + sport, err = strconv.Atoi(srcPort) + if err != nil { + log.Printf("Failed to get source port: %s", err) + return nil + } + } + if dstPort != "" { + dport, err = strconv.Atoi(dstPort) + if err != nil { + log.Printf("Failed to get destination port: %s", err) + return nil + } + } + switch tf.Spec.Packet.IPHeader.Protocol { + case TCPProtocol: + { + tf.Spec.Packet.TransportHeader.TCP = &opsv1alpha1.TCPHeader{ + SrcPort: int32(sport), + DstPort: int32(dport), + } + } + case UDPProtocol: + { + tf.Spec.Packet.TransportHeader.UDP = &opsv1alpha1.UDPHeader{ + SrcPort: int32(sport), + DstPort: int32(dport), + } + } + case ICMPProtocol: + { + tf.Spec.Packet.TransportHeader.ICMP = &opsv1alpha1.ICMPEchoRequestHeader{ + ID: 0, + Sequence: 0, + } + } + } + tf, err = a.client.OpsV1alpha1().Traceflows().Create(ctx, tf, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create traceflow CRD \"%s\", err: %s", tfName, err) + return nil + } + log.Printf("Create traceflow CRD \"%s\" successfully, Traceflow Results: %+v", tfName, tf) + a.latestTfName = tf.Name + a.graph = graphviz.GenGraph(tf) + return nil + case showGraphAction: + name, err := request.Payload.String("name") + if err != nil { + log.Printf("Failed to get name at string : %w", err) + return nil + } + // Invoke GenGraph to show + ctx := context.Background() + tf, err := a.client.OpsV1alpha1().Traceflows().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + log.Printf("Failed to get traceflow CRD \"%s\", err: %s ", name, err) + return nil + } + log.Printf("Get traceflow CRD \"%s\" successfully, Traceflow Results: %+v", name, tf) + a.latestTfName = tf.Name + a.graph = graphviz.GenGraph(tf) + return nil + default: + log.Fatalf("Failed to find defined handler after receiving action request for %s", pluginName) + return nil + } +} + +func (a *traceflowPlugin) initRoutes(router *service.Router) { + router.HandleFunc("/components", a.traceflowHandler) +} + +func (a *traceflowPlugin) traceflowHandler(request service.Request) (component.ContentResponse, error) { + layout := flexlayout.New() + card := component.NewCard(component.TitleFromString("Antrea Traceflow")) + form := component.Form{Fields: []component.FormField{ + component.NewFormFieldText(srcNamespaceCol, srcNamespaceCol, ""), + component.NewFormFieldText(srcPodCol, srcPodCol, ""), + component.NewFormFieldText(srcPortCol, srcPortCol, ""), + component.NewFormFieldText(dstNamespaceCol, dstNamespaceCol, ""), + component.NewFormFieldText(dstPodCol, dstPodCol, ""), + component.NewFormFieldText(dstPortCol, dstPortCol, ""), + component.NewFormFieldText(protocolCol, protocolCol, ""), + component.NewFormFieldHidden("action", addTfAction), + }} + addTf := component.Action{ + Name: "Start New Trace", + Title: "Start New Trace", + Form: form, + } + graphForm := component.Form{Fields: []component.FormField{ + component.NewFormFieldText("name", "name", ""), + component.NewFormFieldHidden("action", showGraphAction), + }} + genGraph := component.Action{ + Name: "Generate Trace Graph", + Title: "Generate Trace Graph", + Form: graphForm, + } + card.SetBody(component.NewText("")) + card.AddAction(addTf) + card.AddAction(genGraph) + + graphCard := component.NewCard(component.TitleFromString("Antrea Traceflow Graph")) + if a.latestTfName != "" { + // Invoke GenGraph to show + log.Printf("Latest tf name %v, generating content from CRD...", a.latestTfName) + ctx := context.Background() + tf, err := a.client.OpsV1alpha1().Traceflows().Get(ctx, a.latestTfName, metav1.GetOptions{}) + if err != nil { + log.Printf("Failed to generate content from CRD, latest tf name %v, err: %s", a.latestTfName, err) + return component.ContentResponse{}, nil + } + log.Printf("Traceflow Results: %+v", tf) + a.graph = graphviz.GenGraph(tf) + log.Printf("Latest tf name %v, generate content from CRD successfully", a.latestTfName) + } + if a.graph != "" { + graphCard.SetBody(component.NewGraphviz(a.graph)) + } else { + graphCard.SetBody(component.NewText("")) + } + listSection := layout.AddSection() + err := listSection.Add(card, component.WidthFull) + if err != nil { + log.Printf("Failed to add card to section: %s", err) + return component.ContentResponse{}, nil + } + if a.graph != "" { + err = listSection.Add(graphCard, component.WidthFull) + if err != nil { + log.Printf("Failed to add graphCard to section: %s", err) + return component.ContentResponse{}, nil + } + } + + tfCols := component.NewTableCols(tfNameCol, srcNamespaceCol, srcPodCol, dstNamespaceCol, dstPodCol, crdCol, phaseCol, ageCol) + tfTable := component.NewTableWithRows("Trace List", "", tfCols, a.getTfRows()) + return component.ContentResponse{ + Title: component.TitleFromString("Antrea Traceflow"), + Components: []component.Component{ + layout.ToComponent("Antrea Traceflow"), + tfTable, + }, + }, nil +} + +// getTfRows gets rows for displaying Controller information +func (a *traceflowPlugin) getTfRows() []component.TableRow { + ctx := context.Background() + tfs, err := client.OpsV1alpha1().Traceflows().List(ctx, metav1.ListOptions{}) + if err != nil { + log.Fatalf("Failed to get Traceflows %v", err) + return nil + } + sort.Slice(tfs.Items, func(p, q int) bool { + return tfs.Items[p].CreationTimestamp.Unix() > tfs.Items[q].CreationTimestamp.Unix() + }) + tfRows := make([]component.TableRow, 0) + for _, tf := range tfs.Items { + tfRows = append(tfRows, component.TableRow{ + tfNameCol: component.NewText(tf.Name), + srcNamespaceCol: component.NewText(tf.Spec.Source.Namespace), + srcPodCol: component.NewText(tf.Spec.Source.Pod), + dstNamespaceCol: component.NewText(tf.Spec.Destination.Namespace), + dstPodCol: component.NewText(tf.Spec.Destination.Pod), + crdCol: component.NewLink(tf.Name, tf.Name, octantTraceflowCRDPath+tf.Name), + phaseCol: component.NewText(string(tf.Status.Phase)), + ageCol: component.NewTimestamp(tf.CreationTimestamp.Time), + }) + } + return tfRows +} diff --git a/plugins/octant/go.sum b/plugins/octant/go.sum index 4b111d59e4d..13a5c133bdc 100644 --- a/plugins/octant/go.sum +++ b/plugins/octant/go.sum @@ -99,6 +99,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/corona10/goimagehash v1.0.2 h1:pUfB0LnsJASMPGEZLj7tGY251vF+qLGqOgEP4rUs6kA= +github.com/corona10/goimagehash v1.0.2/go.mod h1:/l9umBhvcHQXVtQO1V6Gp1yD20STawkhRnnX0D1bvVI= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= @@ -139,6 +141,8 @@ github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLi github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5IcNrDCXZ6OoTAWu7M= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -197,6 +201,8 @@ github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85n github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-graphviz v0.0.5 h1:qcjgvNiYbLyfLAq9LvyYBJ7sNMbQh9w4FoAzBDrYhYw= +github.com/goccy/go-graphviz v0.0.5/go.mod h1:wXVsXxmyMQU6TN3zGRttjNn3h+iCAS7xQFC6TlNvLhk= github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -204,6 +210,8 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -338,6 +346,8 @@ github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8m github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= @@ -480,11 +490,14 @@ golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200204104054-c9f3fb736b72/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=