525 lines
14 KiB
Go
525 lines
14 KiB
Go
package parser
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"kubeviz/internal/model"
|
|
)
|
|
|
|
var clusterScopedKinds = map[string]bool{
|
|
"Namespace": true,
|
|
"Node": true,
|
|
"PersistentVolume": true,
|
|
"CustomResourceDefinition": true,
|
|
"ClusterRole": true,
|
|
"ClusterRoleBinding": true,
|
|
"MutatingWebhookConfiguration": true,
|
|
"ValidatingWebhookConfiguration": true,
|
|
"StorageClass": true,
|
|
"PriorityClass": true,
|
|
"APIService": true,
|
|
}
|
|
|
|
func ParseManifests(input []byte) (*model.Dataset, error) {
|
|
dataset := &model.Dataset{
|
|
Resources: make(map[string]*model.Resource),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
docs, err := parseYAMLDocuments(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for docNum, doc := range docs {
|
|
if doc == nil {
|
|
continue
|
|
}
|
|
if err := parseDocument(doc, docNum+1, dataset); err != nil {
|
|
dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{
|
|
Document: docNum + 1,
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
dataset.Summary.Resources = len(dataset.Resources)
|
|
dataset.ModifiedAt = time.Now()
|
|
return dataset, nil
|
|
}
|
|
|
|
func parseDocument(doc any, docNum int, dataset *model.Dataset) error {
|
|
normalized, ok := normalizeMap(doc).(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("document is not an object")
|
|
}
|
|
|
|
kind, _ := normalized["kind"].(string)
|
|
if kind == "List" {
|
|
items, _ := normalized["items"].([]any)
|
|
for idx, item := range items {
|
|
itemMap, ok := normalizeMap(item).(map[string]any)
|
|
if !ok {
|
|
dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{
|
|
Document: docNum,
|
|
Message: fmt.Sprintf("item %d is not an object", idx),
|
|
})
|
|
continue
|
|
}
|
|
res, err := normalizeResource(itemMap)
|
|
if err != nil {
|
|
dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{
|
|
Document: docNum,
|
|
Message: fmt.Sprintf("item %d: %v", idx, err),
|
|
})
|
|
continue
|
|
}
|
|
if _, exists := dataset.Resources[res.ID]; exists {
|
|
dataset.Duplicates = append(dataset.Duplicates, res.ID)
|
|
dataset.Summary.Issues = append(dataset.Summary.Issues, model.ParseIssue{
|
|
Document: docNum,
|
|
Message: fmt.Sprintf("duplicate resource id %q detected", res.ID),
|
|
})
|
|
}
|
|
dataset.Resources[res.ID] = res
|
|
}
|
|
return nil
|
|
}
|
|
|
|
res, err := normalizeResource(normalized)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, exists := dataset.Resources[res.ID]; exists {
|
|
dataset.Duplicates = append(dataset.Duplicates, res.ID)
|
|
}
|
|
dataset.Resources[res.ID] = res
|
|
return nil
|
|
}
|
|
|
|
func normalizeResource(raw map[string]any) (*model.Resource, error) {
|
|
apiVersion, _ := raw["apiVersion"].(string)
|
|
kind, _ := raw["kind"].(string)
|
|
meta, _ := raw["metadata"].(map[string]any)
|
|
name, _ := meta["name"].(string)
|
|
|
|
if apiVersion == "" {
|
|
return nil, fmt.Errorf("missing apiVersion")
|
|
}
|
|
if kind == "" {
|
|
return nil, fmt.Errorf("missing kind")
|
|
}
|
|
if name == "" {
|
|
return nil, fmt.Errorf("missing metadata.name")
|
|
}
|
|
|
|
namespace := "default"
|
|
clusterScoped := clusterScopedKinds[kind]
|
|
if ns, ok := meta["namespace"].(string); ok && ns != "" {
|
|
namespace = ns
|
|
}
|
|
if clusterScoped {
|
|
namespace = ""
|
|
}
|
|
|
|
labels := extractStringMap(meta["labels"])
|
|
id := resourceID(namespace, kind, name)
|
|
|
|
res := &model.Resource{
|
|
ID: id,
|
|
APIVersion: apiVersion,
|
|
Kind: kind,
|
|
Name: name,
|
|
Namespace: namespace,
|
|
ClusterScoped: clusterScoped,
|
|
Labels: labels,
|
|
Raw: deepCopy(raw),
|
|
IsSensitive: strings.EqualFold(kind, "Secret"),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if res.IsSensitive {
|
|
res.KeyNames = extractSecretKeyNames(raw)
|
|
redactSecretValues(res.Raw)
|
|
}
|
|
|
|
res.OwnerRefs = extractOwnerRefs(meta)
|
|
res.References = append(res.References, extractGenericRefs(raw, namespace)...)
|
|
res.References = append(res.References, extractTypedRefs(raw, kind, namespace)...)
|
|
res.WorkloadMeta = extractWorkloadMeta(raw, kind)
|
|
res.References = dedupeRefs(res.References)
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func resourceID(namespace, kind, name string) string {
|
|
if namespace == "" {
|
|
return fmt.Sprintf("%s/%s", kind, name)
|
|
}
|
|
return fmt.Sprintf("%s/%s/%s", namespace, kind, name)
|
|
}
|
|
|
|
func ResourceID(namespace, kind, name string) string {
|
|
return resourceID(namespace, kind, name)
|
|
}
|
|
|
|
func normalizeMap(v any) any {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
m := map[string]any{}
|
|
for k, val := range t {
|
|
m[k] = normalizeMap(val)
|
|
}
|
|
return m
|
|
case map[any]any:
|
|
m := map[string]any{}
|
|
for k, val := range t {
|
|
m[fmt.Sprint(k)] = normalizeMap(val)
|
|
}
|
|
return m
|
|
case []any:
|
|
out := make([]any, 0, len(t))
|
|
for _, item := range t {
|
|
out = append(out, normalizeMap(item))
|
|
}
|
|
return out
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
func extractStringMap(v any) map[string]string {
|
|
src, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make(map[string]string)
|
|
for k, val := range src {
|
|
if s, ok := val.(string); ok {
|
|
out[k] = s
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractOwnerRefs(meta map[string]any) []model.OwnerReference {
|
|
owners, _ := meta["ownerReferences"].([]any)
|
|
out := make([]model.OwnerReference, 0, len(owners))
|
|
for _, entry := range owners {
|
|
m, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
kind, _ := m["kind"].(string)
|
|
name, _ := m["name"].(string)
|
|
if kind == "" || name == "" {
|
|
continue
|
|
}
|
|
out = append(out, model.OwnerReference{Kind: kind, Name: name})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractSecretKeyNames(raw map[string]any) []string {
|
|
keys := map[string]struct{}{}
|
|
if data, ok := raw["data"].(map[string]any); ok {
|
|
for k := range data {
|
|
keys[k] = struct{}{}
|
|
}
|
|
}
|
|
if data, ok := raw["stringData"].(map[string]any); ok {
|
|
for k := range data {
|
|
keys[k] = struct{}{}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(keys))
|
|
for k := range keys {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func redactSecretValues(raw map[string]any) {
|
|
for _, key := range []string{"data", "stringData"} {
|
|
data, ok := raw[key].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for k := range data {
|
|
data[k] = "<redacted>"
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractWorkloadMeta(raw map[string]any, kind string) *model.WorkloadMetadata {
|
|
meta := &model.WorkloadMetadata{}
|
|
switch kind {
|
|
case "Deployment", "StatefulSet", "DaemonSet":
|
|
spec, _ := raw["spec"].(map[string]any)
|
|
tpl, _ := spec["template"].(map[string]any)
|
|
tplMeta, _ := tpl["metadata"].(map[string]any)
|
|
meta.PodTemplateLabels = extractStringMap(tplMeta["labels"])
|
|
case "Service":
|
|
spec, _ := raw["spec"].(map[string]any)
|
|
meta.ServiceSelectors = extractStringMap(spec["selector"])
|
|
}
|
|
|
|
if len(meta.PodTemplateLabels) == 0 && len(meta.ServiceSelectors) == 0 {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func extractTypedRefs(raw map[string]any, kind, defaultNamespace string) []model.ResourceReference {
|
|
refs := make([]model.ResourceReference, 0)
|
|
|
|
spec, _ := raw["spec"].(map[string]any)
|
|
if spec != nil {
|
|
refs = append(refs, extractRefsFromPodSpec(raw, defaultNamespace)...)
|
|
|
|
if kind == "Ingress" {
|
|
refs = append(refs, extractIngressRefs(spec, defaultNamespace)...)
|
|
}
|
|
if kind == "HorizontalPodAutoscaler" {
|
|
if target, ok := spec["scaleTargetRef"].(map[string]any); ok {
|
|
tKind, _ := target["kind"].(string)
|
|
tName, _ := target["name"].(string)
|
|
if tKind != "" && tName != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: tKind, Name: tName, Namespace: defaultNamespace, Relation: "scales"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return refs
|
|
}
|
|
|
|
func extractIngressRefs(spec map[string]any, namespace string) []model.ResourceReference {
|
|
refs := []model.ResourceReference{}
|
|
if backend, ok := spec["defaultBackend"].(map[string]any); ok {
|
|
if svc := serviceFromBackend(backend); svc != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "Service", Name: svc, Namespace: namespace, Relation: "routesTo"})
|
|
}
|
|
}
|
|
rules, _ := spec["rules"].([]any)
|
|
for _, r := range rules {
|
|
rule, ok := r.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
http, _ := rule["http"].(map[string]any)
|
|
paths, _ := http["paths"].([]any)
|
|
for _, p := range paths {
|
|
path, ok := p.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
backend, _ := path["backend"].(map[string]any)
|
|
if svc := serviceFromBackend(backend); svc != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "Service", Name: svc, Namespace: namespace, Relation: "routesTo"})
|
|
}
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func serviceFromBackend(backend map[string]any) string {
|
|
svc, _ := backend["service"].(map[string]any)
|
|
name, _ := svc["name"].(string)
|
|
return name
|
|
}
|
|
|
|
func extractRefsFromPodSpec(raw map[string]any, namespace string) []model.ResourceReference {
|
|
podSpec := findPodSpec(raw)
|
|
if podSpec == nil {
|
|
return nil
|
|
}
|
|
refs := []model.ResourceReference{}
|
|
if vols, ok := podSpec["volumes"].([]any); ok {
|
|
for _, v := range vols {
|
|
vol, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cm, ok := vol["configMap"].(map[string]any); ok {
|
|
if name, _ := cm["name"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "mounts"})
|
|
}
|
|
}
|
|
if sec, ok := vol["secret"].(map[string]any); ok {
|
|
if name, _ := sec["secretName"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "mounts"})
|
|
}
|
|
}
|
|
if pvc, ok := vol["persistentVolumeClaim"].(map[string]any); ok {
|
|
if name, _ := pvc["claimName"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "PersistentVolumeClaim", Name: name, Namespace: namespace, Relation: "mounts"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, containerType := range []string{"containers", "initContainers"} {
|
|
containers, _ := podSpec[containerType].([]any)
|
|
for _, c := range containers {
|
|
container, ok := c.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
env, _ := container["env"].([]any)
|
|
for _, e := range env {
|
|
envVar, ok := e.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
valueFrom, _ := envVar["valueFrom"].(map[string]any)
|
|
if cmRef, ok := valueFrom["configMapKeyRef"].(map[string]any); ok {
|
|
if name, _ := cmRef["name"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "references"})
|
|
}
|
|
}
|
|
if secRef, ok := valueFrom["secretKeyRef"].(map[string]any); ok {
|
|
if name, _ := secRef["name"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "references"})
|
|
}
|
|
}
|
|
}
|
|
envFrom, _ := container["envFrom"].([]any)
|
|
for _, ef := range envFrom {
|
|
entry, ok := ef.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cmRef, ok := entry["configMapRef"].(map[string]any); ok {
|
|
if name, _ := cmRef["name"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "ConfigMap", Name: name, Namespace: namespace, Relation: "references"})
|
|
}
|
|
}
|
|
if secRef, ok := entry["secretRef"].(map[string]any); ok {
|
|
if name, _ := secRef["name"].(string); name != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: "Secret", Name: name, Namespace: namespace, Relation: "references"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return refs
|
|
}
|
|
|
|
func findPodSpec(raw map[string]any) map[string]any {
|
|
spec, _ := raw["spec"].(map[string]any)
|
|
if spec == nil {
|
|
return nil
|
|
}
|
|
if template, ok := spec["template"].(map[string]any); ok {
|
|
if tplSpec, ok := template["spec"].(map[string]any); ok {
|
|
return tplSpec
|
|
}
|
|
}
|
|
if containers, ok := spec["containers"]; ok {
|
|
if _, valid := containers.([]any); valid {
|
|
return spec
|
|
}
|
|
}
|
|
if jobTemplate, ok := spec["jobTemplate"].(map[string]any); ok {
|
|
if jtSpec, ok := jobTemplate["spec"].(map[string]any); ok {
|
|
if template, ok := jtSpec["template"].(map[string]any); ok {
|
|
if tplSpec, ok := template["spec"].(map[string]any); ok {
|
|
return tplSpec
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractGenericRefs(raw map[string]any, namespace string) []model.ResourceReference {
|
|
refs := []model.ResourceReference{}
|
|
walkMap(raw, func(k string, v any) {
|
|
if strings.HasSuffix(k, "Name") {
|
|
if name, ok := v.(string); ok && name != "" {
|
|
kind := guessKindFromField(k)
|
|
if kind != "" {
|
|
refs = append(refs, model.ResourceReference{Kind: kind, Name: name, Namespace: namespace, Relation: "references"})
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return refs
|
|
}
|
|
|
|
func walkMap(v any, fn func(string, any)) {
|
|
switch m := v.(type) {
|
|
case map[string]any:
|
|
for k, value := range m {
|
|
fn(k, value)
|
|
walkMap(value, fn)
|
|
}
|
|
case []any:
|
|
for _, item := range m {
|
|
walkMap(item, fn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func guessKindFromField(field string) string {
|
|
lower := strings.ToLower(field)
|
|
switch {
|
|
case strings.Contains(lower, "secret"):
|
|
return "Secret"
|
|
case strings.Contains(lower, "configmap"):
|
|
return "ConfigMap"
|
|
case strings.Contains(lower, "service"):
|
|
return "Service"
|
|
case strings.Contains(lower, "claim"):
|
|
return "PersistentVolumeClaim"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func deepCopy(src map[string]any) map[string]any {
|
|
out := make(map[string]any, len(src))
|
|
for k, v := range src {
|
|
switch typed := v.(type) {
|
|
case map[string]any:
|
|
out[k] = deepCopy(typed)
|
|
case []any:
|
|
copied := make([]any, len(typed))
|
|
for i := range typed {
|
|
if m, ok := typed[i].(map[string]any); ok {
|
|
copied[i] = deepCopy(m)
|
|
} else {
|
|
copied[i] = typed[i]
|
|
}
|
|
}
|
|
out[k] = copied
|
|
default:
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeRefs(refs []model.ResourceReference) []model.ResourceReference {
|
|
seen := map[string]struct{}{}
|
|
out := make([]model.ResourceReference, 0, len(refs))
|
|
for _, ref := range refs {
|
|
key := fmt.Sprintf("%s|%s|%s|%s", ref.Kind, ref.Name, ref.Namespace, ref.Relation)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, ref)
|
|
}
|
|
return out
|
|
}
|