Teststand
Some checks failed
Deploy KubeViz / deploy (push) Has been cancelled

This commit is contained in:
2026-03-01 07:40:49 +01:00
commit 1a0bbe9dfd
58 changed files with 7756 additions and 0 deletions

514
internal/graph/graph.go Normal file
View 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)
}
}

View File

@@ -0,0 +1,86 @@
package graph
import (
"testing"
"time"
"kubeviz/internal/model"
)
func TestBuildGraphIncludesServiceSelectorEdge(t *testing.T) {
ds := &model.Dataset{
Resources: map[string]*model.Resource{
"demo/Service/web": {
ID: "demo/Service/web",
Kind: "Service",
Name: "web",
Namespace: "demo",
Raw: map[string]any{
"spec": map[string]any{
"ports": []any{
map[string]any{"port": 80, "targetPort": 8080, "protocol": "TCP"},
},
},
},
WorkloadMeta: &model.WorkloadMetadata{
ServiceSelectors: map[string]string{"app": "web"},
},
CreatedAt: time.Now(),
},
"demo/Deployment/web": {
ID: "demo/Deployment/web",
Kind: "Deployment",
Name: "web",
Namespace: "demo",
WorkloadMeta: &model.WorkloadMetadata{
PodTemplateLabels: map[string]string{"app": "web"},
},
CreatedAt: time.Now(),
},
},
}
resp := BuildGraph(ds, Filters{}, map[string]string{})
if resp.Stats.TotalNodes != 2 {
t.Fatalf("expected 2 nodes, got %d", resp.Stats.TotalNodes)
}
found := false
for _, edge := range resp.Edges {
if edge.Source == "demo/Service/web" && edge.Target == "demo/Deployment/web" && edge.RelationType == "selects" {
if edge.Label == "" {
t.Fatalf("expected selects edge label with port/protocol")
}
found = true
}
}
if !found {
t.Fatalf("expected service selector edge not found")
}
}
func TestBuildGraphGroupingByNamespace(t *testing.T) {
ds := &model.Dataset{
Resources: map[string]*model.Resource{
"demo/Service/web": {ID: "demo/Service/web", Kind: "Service", Name: "web", Namespace: "demo", CreatedAt: time.Now()},
"demo/Deployment/web": {ID: "demo/Deployment/web", Kind: "Deployment", Name: "web", Namespace: "demo", CreatedAt: time.Now()},
"other/Deployment/other": {ID: "other/Deployment/other", Kind: "Deployment", Name: "other", Namespace: "other", CreatedAt: time.Now()},
},
}
resp := BuildGraph(ds, Filters{
GroupBy: "namespace",
CollapsedGroups: map[string]bool{
"demo": true,
},
}, map[string]string{})
hasDemoGroup := false
for _, n := range resp.Nodes {
if n.IsGroup && n.GroupBy == "namespace" && n.GroupKey == "demo" {
hasDemoGroup = true
}
}
if !hasDemoGroup {
t.Fatalf("expected collapsed namespace group node")
}
}