mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 11:48:04 +00:00
feat: add Relations tab with force-directed resource dependency graph (#96)
Add a new "Relations" tab after "Images" that visualizes resource dependencies within a Helm release as an interactive force-directed graph. Detects relationships via ownerReferences, *Ref fields, volumes, env refs, service selectors, ingress backends, and RBAC bindings. External resources appear as dashed oval ghost nodes. Color-coded by resource category. Closes #96 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
pkg/dashboard/objects/relations_test.go
Normal file
184
pkg/dashboard/objects/relations_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package objects
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testManifest = `
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: my-config
|
||||
data:
|
||||
key: value
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: my-secret
|
||||
type: Opaque
|
||||
data:
|
||||
password: cGFzcw==
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: my-sa
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
serviceAccountName: my-sa
|
||||
containers:
|
||||
- name: main
|
||||
image: nginx:latest
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: my-config
|
||||
env:
|
||||
- name: DB_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: my-secret
|
||||
key: password
|
||||
volumes:
|
||||
- name: config-vol
|
||||
configMap:
|
||||
name: my-config
|
||||
- name: secret-vol
|
||||
secret:
|
||||
secretName: external-secret
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: my-service
|
||||
spec:
|
||||
selector:
|
||||
app: my-app
|
||||
ports:
|
||||
- port: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-ingress
|
||||
spec:
|
||||
rules:
|
||||
- host: example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: my-service
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- secretName: tls-cert
|
||||
hosts:
|
||||
- example.com
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: my-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: my-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: my-sa
|
||||
namespace: default
|
||||
`
|
||||
|
||||
func TestExtractRelations(t *testing.T) {
|
||||
graph := ExtractRelations(testManifest)
|
||||
|
||||
// Check nodes
|
||||
nodeMap := map[string]RelationNode{}
|
||||
for _, n := range graph.Nodes {
|
||||
nodeMap[n.ID] = n
|
||||
}
|
||||
|
||||
// In-release nodes
|
||||
inReleaseExpected := []string{
|
||||
"ConfigMap/my-config",
|
||||
"Secret/my-secret",
|
||||
"ServiceAccount/my-sa",
|
||||
"Deployment/my-app",
|
||||
"Service/my-service",
|
||||
"Ingress/my-ingress",
|
||||
"ClusterRoleBinding/my-binding",
|
||||
}
|
||||
for _, id := range inReleaseExpected {
|
||||
n, ok := nodeMap[id]
|
||||
if !ok {
|
||||
t.Errorf("missing in-release node %s", id)
|
||||
continue
|
||||
}
|
||||
if !n.InRelease {
|
||||
t.Errorf("node %s should be inRelease=true", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost nodes (external references)
|
||||
ghostExpected := []string{
|
||||
"Secret/external-secret",
|
||||
"Secret/tls-cert",
|
||||
"ClusterRole/my-role",
|
||||
}
|
||||
for _, id := range ghostExpected {
|
||||
n, ok := nodeMap[id]
|
||||
if !ok {
|
||||
t.Errorf("missing ghost node %s", id)
|
||||
continue
|
||||
}
|
||||
if n.InRelease {
|
||||
t.Errorf("node %s should be inRelease=false", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Check edges
|
||||
edgeSet := map[string]bool{}
|
||||
for _, e := range graph.Edges {
|
||||
key := e.Source + " -" + e.Type + "-> " + e.Target
|
||||
edgeSet[key] = true
|
||||
}
|
||||
|
||||
expectedEdges := []string{
|
||||
"Deployment/my-app -volume-> ConfigMap/my-config",
|
||||
"Deployment/my-app -volume-> Secret/external-secret",
|
||||
"Deployment/my-app -envRef-> ConfigMap/my-config",
|
||||
"Deployment/my-app -envRef-> Secret/my-secret",
|
||||
"Deployment/my-app -serviceAccount-> ServiceAccount/my-sa",
|
||||
"Service/my-service -selector-> Deployment/my-app",
|
||||
"Ingress/my-ingress -ingressBackend-> Service/my-service",
|
||||
"Ingress/my-ingress -tlsSecret-> Secret/tls-cert",
|
||||
"ClusterRoleBinding/my-binding -roleBinding-> ClusterRole/my-role",
|
||||
"ClusterRoleBinding/my-binding -roleBinding-> ServiceAccount/my-sa",
|
||||
}
|
||||
for _, e := range expectedEdges {
|
||||
if !edgeSet[e] {
|
||||
t.Errorf("missing edge: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Nodes: %d, Edges: %d", len(graph.Nodes), len(graph.Edges))
|
||||
for _, e := range graph.Edges {
|
||||
t.Logf(" %s --%s--> %s", e.Source, e.Type, e.Target)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user