This commit is contained in:
514
internal/graph/graph.go
Normal file
514
internal/graph/graph.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"kubeviz/internal/model"
|
||||
"kubeviz/internal/parser"
|
||||
)
|
||||
|
||||
type Filters struct {
|
||||
Namespace string
|
||||
Kind string
|
||||
Query string
|
||||
FocusID string
|
||||
Relations map[string]bool
|
||||
GroupBy string
|
||||
CollapsedGroups map[string]bool
|
||||
}
|
||||
|
||||
func BuildGraph(dataset *model.Dataset, filters Filters, healthHints map[string]string) model.GraphResponse {
|
||||
if dataset == nil {
|
||||
return model.GraphResponse{Stats: model.GraphStats{Kinds: map[string]int{}}}
|
||||
}
|
||||
|
||||
nodes := make([]model.GraphNode, 0, len(dataset.Resources))
|
||||
edges := make([]model.GraphEdge, 0)
|
||||
kindStats := map[string]int{}
|
||||
|
||||
for _, r := range dataset.Resources {
|
||||
health := "unknown"
|
||||
if h, ok := healthHints[r.ID]; ok {
|
||||
health = h
|
||||
}
|
||||
node := model.GraphNode{
|
||||
ID: r.ID,
|
||||
Kind: r.Kind,
|
||||
Name: r.Name,
|
||||
Namespace: r.Namespace,
|
||||
Labels: r.Labels,
|
||||
HealthHint: health,
|
||||
IsSensitive: r.IsSensitive,
|
||||
}
|
||||
if !matchesResourceFilters(node, filters) {
|
||||
continue
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
kindStats[node.Kind]++
|
||||
}
|
||||
|
||||
nodeSet := make(map[string]struct{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodeSet[n.ID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, r := range dataset.Resources {
|
||||
if _, ok := nodeSet[r.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
for _, ref := range r.References {
|
||||
target := resolveReferenceTarget(dataset, ref)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := nodeSet[target]; !ok {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, model.GraphEdge{
|
||||
Source: r.ID,
|
||||
Target: target,
|
||||
RelationType: ref.Relation,
|
||||
Label: edgeLabelForReference(r, ref),
|
||||
})
|
||||
}
|
||||
|
||||
for _, owner := range r.OwnerRefs {
|
||||
target := resolveOwner(dataset, r.Namespace, owner)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := nodeSet[target]; !ok {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, model.GraphEdge{Source: r.ID, Target: target, RelationType: "owns"})
|
||||
}
|
||||
}
|
||||
|
||||
edges = append(edges, inferServiceEdges(dataset, nodeSet)...)
|
||||
edges = dedupeEdges(edges)
|
||||
edges = filterEdges(edges, filters)
|
||||
|
||||
groups := []model.GraphGroup{}
|
||||
if filters.GroupBy == "namespace" || filters.GroupBy == "kind" {
|
||||
nodes, edges, groups = applyGrouping(nodes, edges, filters)
|
||||
kindStats = recomputeKindStats(nodes)
|
||||
}
|
||||
|
||||
if filters.FocusID != "" {
|
||||
nodes, edges = applyFocus(nodes, edges, filters.FocusID)
|
||||
kindStats = recomputeKindStats(nodes)
|
||||
}
|
||||
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
|
||||
sort.Slice(edges, func(i, j int) bool {
|
||||
if edges[i].Source != edges[j].Source {
|
||||
return edges[i].Source < edges[j].Source
|
||||
}
|
||||
if edges[i].Target != edges[j].Target {
|
||||
return edges[i].Target < edges[j].Target
|
||||
}
|
||||
if edges[i].RelationType != edges[j].RelationType {
|
||||
return edges[i].RelationType < edges[j].RelationType
|
||||
}
|
||||
return edges[i].Label < edges[j].Label
|
||||
})
|
||||
|
||||
return model.GraphResponse{
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
Groups: groups,
|
||||
Stats: model.GraphStats{
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(edges),
|
||||
Kinds: kindStats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyGrouping(nodes []model.GraphNode, edges []model.GraphEdge, filters Filters) ([]model.GraphNode, []model.GraphEdge, []model.GraphGroup) {
|
||||
groupMembers := map[string][]model.GraphNode{}
|
||||
for _, n := range nodes {
|
||||
key := groupKey(n, filters.GroupBy)
|
||||
groupMembers[key] = append(groupMembers[key], n)
|
||||
}
|
||||
|
||||
groups := make([]model.GraphGroup, 0, len(groupMembers))
|
||||
for key, members := range groupMembers {
|
||||
groups = append(groups, model.GraphGroup{
|
||||
Key: key,
|
||||
Label: key,
|
||||
Mode: filters.GroupBy,
|
||||
Count: len(members),
|
||||
Collapsed: filters.CollapsedGroups[key],
|
||||
})
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].Key < groups[j].Key })
|
||||
|
||||
visibleNodes := make(map[string]model.GraphNode)
|
||||
nodeToVisible := map[string]string{}
|
||||
|
||||
for key, members := range groupMembers {
|
||||
collapsed := filters.CollapsedGroups[key]
|
||||
if collapsed {
|
||||
groupID := groupNodeID(filters.GroupBy, key)
|
||||
health := "healthy"
|
||||
sensitive := false
|
||||
for _, m := range members {
|
||||
if m.HealthHint == "warning" {
|
||||
health = "warning"
|
||||
}
|
||||
if m.IsSensitive {
|
||||
sensitive = true
|
||||
}
|
||||
nodeToVisible[m.ID] = groupID
|
||||
}
|
||||
visibleNodes[groupID] = model.GraphNode{
|
||||
ID: groupID,
|
||||
Kind: strings.Title(filters.GroupBy) + "Group",
|
||||
Name: key,
|
||||
Namespace: "",
|
||||
HealthHint: health,
|
||||
IsSensitive: sensitive,
|
||||
IsGroup: true,
|
||||
GroupBy: filters.GroupBy,
|
||||
GroupKey: key,
|
||||
MemberCount: len(members),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
nodeToVisible[m.ID] = m.ID
|
||||
visibleNodes[m.ID] = m
|
||||
}
|
||||
}
|
||||
|
||||
outNodes := make([]model.GraphNode, 0, len(visibleNodes))
|
||||
for _, n := range visibleNodes {
|
||||
outNodes = append(outNodes, n)
|
||||
}
|
||||
|
||||
outEdges := make([]model.GraphEdge, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
src := nodeToVisible[e.Source]
|
||||
tgt := nodeToVisible[e.Target]
|
||||
if src == "" || tgt == "" || src == tgt {
|
||||
continue
|
||||
}
|
||||
outEdges = append(outEdges, model.GraphEdge{
|
||||
Source: src,
|
||||
Target: tgt,
|
||||
RelationType: e.RelationType,
|
||||
Label: e.Label,
|
||||
})
|
||||
}
|
||||
outEdges = dedupeEdges(outEdges)
|
||||
|
||||
return outNodes, outEdges, groups
|
||||
}
|
||||
|
||||
func groupKey(node model.GraphNode, mode string) string {
|
||||
switch mode {
|
||||
case "namespace":
|
||||
if node.Namespace == "" {
|
||||
return "(cluster-scoped)"
|
||||
}
|
||||
return node.Namespace
|
||||
case "kind":
|
||||
return node.Kind
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func groupNodeID(mode, key string) string {
|
||||
return "group/" + mode + "/" + key
|
||||
}
|
||||
|
||||
func resolveReferenceTarget(dataset *model.Dataset, ref model.ResourceReference) string {
|
||||
ns := ref.Namespace
|
||||
if ns == "" {
|
||||
ns = "default"
|
||||
}
|
||||
candidate := parser.ResourceID(ns, ref.Kind, ref.Name)
|
||||
if _, ok := dataset.Resources[candidate]; ok {
|
||||
return candidate
|
||||
}
|
||||
candidate = parser.ResourceID("", ref.Kind, ref.Name)
|
||||
if _, ok := dataset.Resources[candidate]; ok {
|
||||
return candidate
|
||||
}
|
||||
|
||||
matches := make([]string, 0)
|
||||
for _, r := range dataset.Resources {
|
||||
if r.Kind == ref.Kind && r.Name == ref.Name {
|
||||
matches = append(matches, r.ID)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveOwner(dataset *model.Dataset, namespace string, owner model.OwnerReference) string {
|
||||
candidate := parser.ResourceID(namespace, owner.Kind, owner.Name)
|
||||
if _, ok := dataset.Resources[candidate]; ok {
|
||||
return candidate
|
||||
}
|
||||
candidate = parser.ResourceID("", owner.Kind, owner.Name)
|
||||
if _, ok := dataset.Resources[candidate]; ok {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inferServiceEdges(dataset *model.Dataset, nodeSet map[string]struct{}) []model.GraphEdge {
|
||||
edges := []model.GraphEdge{}
|
||||
services := []*model.Resource{}
|
||||
workloads := []*model.Resource{}
|
||||
|
||||
for _, r := range dataset.Resources {
|
||||
switch r.Kind {
|
||||
case "Service":
|
||||
services = append(services, r)
|
||||
case "Deployment", "StatefulSet", "DaemonSet":
|
||||
workloads = append(workloads, r)
|
||||
}
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
if _, ok := nodeSet[svc.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if svc.WorkloadMeta == nil || len(svc.WorkloadMeta.ServiceSelectors) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, wl := range workloads {
|
||||
if _, ok := nodeSet[wl.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if wl.WorkloadMeta == nil || len(wl.WorkloadMeta.PodTemplateLabels) == 0 {
|
||||
continue
|
||||
}
|
||||
if svc.Namespace != wl.Namespace {
|
||||
continue
|
||||
}
|
||||
if selectorsMatch(svc.WorkloadMeta.ServiceSelectors, wl.WorkloadMeta.PodTemplateLabels) {
|
||||
edges = append(edges, model.GraphEdge{
|
||||
Source: svc.ID,
|
||||
Target: wl.ID,
|
||||
RelationType: "selects",
|
||||
Label: servicePortsLabel(svc),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
func selectorsMatch(selector, labels map[string]string) bool {
|
||||
for k, v := range selector {
|
||||
if labels[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchesResourceFilters(node model.GraphNode, filters Filters) bool {
|
||||
if filters.Namespace != "" && node.Namespace != filters.Namespace {
|
||||
return false
|
||||
}
|
||||
if filters.Kind != "" && !strings.EqualFold(node.Kind, filters.Kind) {
|
||||
return false
|
||||
}
|
||||
if filters.Query != "" {
|
||||
needle := strings.ToLower(filters.Query)
|
||||
hay := strings.ToLower(node.ID + " " + node.Kind + " " + node.Name + " " + node.Namespace)
|
||||
if !strings.Contains(hay, needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dedupeEdges(edges []model.GraphEdge) []model.GraphEdge {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]model.GraphEdge, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
key := edge.Source + "|" + edge.Target + "|" + edge.RelationType + "|" + edge.Label
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, edge)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterEdges(edges []model.GraphEdge, filters Filters) []model.GraphEdge {
|
||||
if len(filters.Relations) == 0 {
|
||||
return edges
|
||||
}
|
||||
out := make([]model.GraphEdge, 0, len(edges))
|
||||
for _, edge := range edges {
|
||||
if filters.Relations[edge.RelationType] {
|
||||
out = append(out, edge)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyFocus(nodes []model.GraphNode, edges []model.GraphEdge, focusID string) ([]model.GraphNode, []model.GraphEdge) {
|
||||
neighbor := map[string]struct{}{focusID: {}}
|
||||
for _, e := range edges {
|
||||
if e.Source == focusID {
|
||||
neighbor[e.Target] = struct{}{}
|
||||
}
|
||||
if e.Target == focusID {
|
||||
neighbor[e.Source] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
fNodes := make([]model.GraphNode, 0, len(neighbor))
|
||||
for _, n := range nodes {
|
||||
if _, ok := neighbor[n.ID]; ok {
|
||||
fNodes = append(fNodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
fEdges := make([]model.GraphEdge, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
_, src := neighbor[e.Source]
|
||||
_, dst := neighbor[e.Target]
|
||||
if src && dst {
|
||||
fEdges = append(fEdges, e)
|
||||
}
|
||||
}
|
||||
|
||||
return fNodes, fEdges
|
||||
}
|
||||
|
||||
func recomputeKindStats(nodes []model.GraphNode) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, n := range nodes {
|
||||
out[n.Kind]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func edgeLabelForReference(source *model.Resource, ref model.ResourceReference) string {
|
||||
if source == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch source.Kind {
|
||||
case "Ingress":
|
||||
if ref.Relation != "routesTo" {
|
||||
return ""
|
||||
}
|
||||
return ingressBackendPortLabel(source, ref.Name)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ingressBackendPortLabel(ingress *model.Resource, serviceName string) string {
|
||||
spec, _ := ingress.Raw["spec"].(map[string]any)
|
||||
if spec == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if backend, ok := spec["defaultBackend"].(map[string]any); ok {
|
||||
if name, label := ingressBackendServiceAndPort(backend); name == serviceName {
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
rules, _ := spec["rules"].([]any)
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
httpSpec, _ := rule["http"].(map[string]any)
|
||||
paths, _ := httpSpec["paths"].([]any)
|
||||
for _, p := range paths {
|
||||
pathEntry, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
backend, _ := pathEntry["backend"].(map[string]any)
|
||||
if name, label := ingressBackendServiceAndPort(backend); name == serviceName {
|
||||
return label
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ingressBackendServiceAndPort(backend map[string]any) (string, string) {
|
||||
service, _ := backend["service"].(map[string]any)
|
||||
if service == nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
name, _ := service["name"].(string)
|
||||
portMap, _ := service["port"].(map[string]any)
|
||||
if portMap == nil {
|
||||
return name, ""
|
||||
}
|
||||
if number, ok := portMap["number"]; ok {
|
||||
return name, fmt.Sprintf(":%v", number)
|
||||
}
|
||||
if pname, ok := portMap["name"].(string); ok && pname != "" {
|
||||
return name, ":" + pname
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
|
||||
func servicePortsLabel(service *model.Resource) string {
|
||||
spec, _ := service.Raw["spec"].(map[string]any)
|
||||
if spec == nil {
|
||||
return ""
|
||||
}
|
||||
ports, _ := spec["ports"].([]any)
|
||||
if len(ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
port, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
protocol, _ := port["protocol"].(string)
|
||||
if protocol == "" {
|
||||
protocol = "TCP"
|
||||
}
|
||||
external, hasPort := port["port"]
|
||||
if !hasPort {
|
||||
continue
|
||||
}
|
||||
target, hasTarget := port["targetPort"]
|
||||
if hasTarget {
|
||||
parts = append(parts, fmt.Sprintf("%s %v->%v", protocol, external, target))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %v", protocol, external))
|
||||
}
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return parts[0]
|
||||
default:
|
||||
return parts[0] + " +" + fmt.Sprintf("%d", len(parts)-1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user