This commit is contained in:
524
internal/parser/parser.go
Normal file
524
internal/parser/parser.go
Normal file
@@ -0,0 +1,524 @@
|
||||
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
|
||||
}
|
||||
145
internal/parser/parser_test.go
Normal file
145
internal/parser/parser_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseManifestsMultiDocAndSecretRedaction(t *testing.T) {
|
||||
input := []byte(`
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
namespace: demo
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx
|
||||
env:
|
||||
- name: PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: app-secret
|
||||
key: password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: web
|
||||
namespace: demo
|
||||
spec:
|
||||
selector:
|
||||
app: web
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-secret
|
||||
namespace: demo
|
||||
data:
|
||||
password: c2VjcmV0
|
||||
`)
|
||||
|
||||
ds, err := ParseManifests(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifests returned error: %v", err)
|
||||
}
|
||||
if got, want := ds.Summary.Resources, 3; got != want {
|
||||
t.Fatalf("resource count mismatch: got %d want %d", got, want)
|
||||
}
|
||||
|
||||
sec := ds.Resources["demo/Secret/app-secret"]
|
||||
if sec == nil {
|
||||
t.Fatalf("secret not found")
|
||||
}
|
||||
if !sec.IsSensitive {
|
||||
t.Fatalf("secret should be sensitive")
|
||||
}
|
||||
data, ok := sec.Raw["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("secret data should exist")
|
||||
}
|
||||
if got := data["password"]; got != "<redacted>" {
|
||||
t.Fatalf("secret value was not redacted: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestsInvalidYAML(t *testing.T) {
|
||||
_, err := ParseManifests([]byte("apiVersion: v1\nkind"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for invalid yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeploymentWithEnvFromConfigMapRef(t *testing.T) {
|
||||
input := []byte(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kubeviz
|
||||
namespace: kubeviz
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kubeviz
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: kubeviz
|
||||
spec:
|
||||
containers:
|
||||
- name: kubeviz
|
||||
image: kubeviz:latest
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: kubeviz-config
|
||||
`)
|
||||
|
||||
ds, err := ParseManifests(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifests returned error: %v", err)
|
||||
}
|
||||
if got, want := ds.Summary.Resources, 1; got != want {
|
||||
t.Fatalf("resource count mismatch: got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIngressTLSHostsAndSecretName(t *testing.T) {
|
||||
input := []byte(`apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: kubeviz
|
||||
namespace: kubeviz
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- kubeviz.local
|
||||
secretName: kubeviz-tls
|
||||
rules:
|
||||
- host: kubeviz.local
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kubeviz
|
||||
port:
|
||||
number: 80
|
||||
`)
|
||||
|
||||
ds, err := ParseManifests(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifests returned error: %v", err)
|
||||
}
|
||||
if got, want := ds.Summary.Resources, 1; got != want {
|
||||
t.Fatalf("resource count mismatch: got %d want %d", got, want)
|
||||
}
|
||||
if len(ds.Summary.Issues) != 0 {
|
||||
t.Fatalf("expected no parse issues, got: %+v", ds.Summary.Issues)
|
||||
}
|
||||
}
|
||||
304
internal/parser/yamlmini.go
Normal file
304
internal/parser/yamlmini.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseYAMLDocuments(input []byte) ([]any, error) {
|
||||
raw := string(input)
|
||||
docsRaw := splitYAMLDocuments(raw)
|
||||
docs := make([]any, 0, len(docsRaw))
|
||||
for _, doc := range docsRaw {
|
||||
trimmed := strings.TrimSpace(doc)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("json parse failed: %w", err)
|
||||
}
|
||||
docs = append(docs, parsed)
|
||||
continue
|
||||
}
|
||||
parsed, err := parseYAMLDoc(trimmed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, parsed)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func splitYAMLDocuments(input string) []string {
|
||||
lines := strings.Split(input, "\n")
|
||||
parts := []string{}
|
||||
cur := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
parts = append(parts, strings.Join(cur, "\n"))
|
||||
cur = cur[:0]
|
||||
continue
|
||||
}
|
||||
cur = append(cur, line)
|
||||
}
|
||||
parts = append(parts, strings.Join(cur, "\n"))
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseYAMLDoc(doc string) (any, error) {
|
||||
lines := preprocessLines(doc)
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
v, next, err := parseBlock(lines, 0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next < len(lines) {
|
||||
return nil, fmt.Errorf("unexpected content near line %d", lines[next].num)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type yamlLine struct {
|
||||
num int
|
||||
indent int
|
||||
text string
|
||||
}
|
||||
|
||||
func preprocessLines(doc string) []yamlLine {
|
||||
raw := strings.Split(doc, "\n")
|
||||
out := make([]yamlLine, 0, len(raw))
|
||||
for i, line := range raw {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
clean := stripInlineComment(line)
|
||||
if strings.TrimSpace(clean) == "" {
|
||||
continue
|
||||
}
|
||||
indent := countIndent(clean)
|
||||
text := strings.TrimSpace(clean)
|
||||
out = append(out, yamlLine{num: i + 1, indent: indent, text: text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBlock(lines []yamlLine, idx int, indent int) (any, int, error) {
|
||||
if idx >= len(lines) {
|
||||
return nil, idx, nil
|
||||
}
|
||||
if lines[idx].indent < indent {
|
||||
return nil, idx, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lines[idx].text, "- ") || lines[idx].text == "-" {
|
||||
return parseList(lines, idx, indent)
|
||||
}
|
||||
return parseMap(lines, idx, indent)
|
||||
}
|
||||
|
||||
func parseMap(lines []yamlLine, idx int, indent int) (map[string]any, int, error) {
|
||||
out := map[string]any{}
|
||||
for idx < len(lines) {
|
||||
line := lines[idx]
|
||||
if line.indent < indent {
|
||||
break
|
||||
}
|
||||
if line.indent > indent {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line.text, "- ") || line.text == "-" {
|
||||
break
|
||||
}
|
||||
key, value, valid, hasValue := splitKeyValue(line.text)
|
||||
if !valid {
|
||||
return nil, idx, fmt.Errorf("line %d: invalid mapping", line.num)
|
||||
}
|
||||
if hasValue {
|
||||
out[key] = parseScalar(value)
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
|
||||
childIndent := indent + 2
|
||||
if idx+1 < len(lines) && lines[idx+1].indent >= indent {
|
||||
childIndent = lines[idx+1].indent
|
||||
}
|
||||
nested, next, err := parseBlock(lines, idx+1, childIndent)
|
||||
if err != nil {
|
||||
return nil, idx, err
|
||||
}
|
||||
out[key] = nested
|
||||
idx = next
|
||||
}
|
||||
return out, idx, nil
|
||||
}
|
||||
|
||||
func parseList(lines []yamlLine, idx int, indent int) ([]any, int, error) {
|
||||
out := []any{}
|
||||
for idx < len(lines) {
|
||||
line := lines[idx]
|
||||
if line.indent < indent {
|
||||
break
|
||||
}
|
||||
if line.indent > indent {
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(line.text, "-") {
|
||||
break
|
||||
}
|
||||
itemText := strings.TrimSpace(strings.TrimPrefix(line.text, "-"))
|
||||
idx++
|
||||
if itemText == "" {
|
||||
nested, next, err := parseBlock(lines, idx, indent+2)
|
||||
if err != nil {
|
||||
return nil, idx, err
|
||||
}
|
||||
out = append(out, nested)
|
||||
idx = next
|
||||
continue
|
||||
}
|
||||
|
||||
if k, v, valid, hasValue := splitKeyValue(itemText); valid {
|
||||
m := map[string]any{}
|
||||
if hasValue {
|
||||
m[k] = parseScalar(v)
|
||||
if idx < len(lines) && lines[idx].indent > indent {
|
||||
nested, next, err := parseBlock(lines, idx, indent+2)
|
||||
if err != nil {
|
||||
return nil, idx, err
|
||||
}
|
||||
if nestedMap, ok := nested.(map[string]any); ok {
|
||||
for nk, nv := range nestedMap {
|
||||
m[nk] = nv
|
||||
}
|
||||
}
|
||||
idx = next
|
||||
}
|
||||
} else {
|
||||
childIndent := indent + 2
|
||||
if idx < len(lines) && lines[idx].indent >= indent {
|
||||
childIndent = lines[idx].indent
|
||||
}
|
||||
nested, next, err := parseBlock(lines, idx, childIndent)
|
||||
if err != nil {
|
||||
return nil, idx, err
|
||||
}
|
||||
m[k] = nested
|
||||
idx = next
|
||||
// YAML list items may continue with sibling keys after an inline key without value:
|
||||
// - hosts:
|
||||
// - example.local
|
||||
// secretName: demo-tls
|
||||
if idx < len(lines) && lines[idx].indent >= childIndent && !strings.HasPrefix(lines[idx].text, "-") {
|
||||
extra, nextMap, err := parseMap(lines, idx, childIndent)
|
||||
if err != nil {
|
||||
return nil, idx, err
|
||||
}
|
||||
for ek, ev := range extra {
|
||||
m[ek] = ev
|
||||
}
|
||||
idx = nextMap
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, parseScalar(itemText))
|
||||
}
|
||||
return out, idx, nil
|
||||
}
|
||||
|
||||
func splitKeyValue(text string) (string, string, bool, bool) {
|
||||
parts := strings.SplitN(text, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false, false
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if key == "" {
|
||||
return "", "", false, false
|
||||
}
|
||||
if value == "" {
|
||||
return key, "", true, false
|
||||
}
|
||||
return key, value, true, true
|
||||
}
|
||||
|
||||
func parseScalar(s string) any {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") && len(s) >= 2 {
|
||||
return strings.Trim(s, "\"")
|
||||
}
|
||||
if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") && len(s) >= 2 {
|
||||
return strings.Trim(s, "'")
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if lower == "true" {
|
||||
return true
|
||||
}
|
||||
if lower == "false" {
|
||||
return false
|
||||
}
|
||||
if lower == "null" || s == "~" {
|
||||
return nil
|
||||
}
|
||||
if i, err := strconv.Atoi(s); err == nil {
|
||||
return i
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
if strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") {
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(s), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripInlineComment(line string) string {
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for i, r := range line {
|
||||
switch r {
|
||||
case '\'':
|
||||
if !inDouble {
|
||||
inSingle = !inSingle
|
||||
}
|
||||
case '"':
|
||||
if !inSingle {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
case '#':
|
||||
if !inSingle && !inDouble {
|
||||
if i == 0 || line[i-1] == ' ' || line[i-1] == '\t' {
|
||||
return strings.TrimRight(line[:i], " \t")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func countIndent(line string) int {
|
||||
count := 0
|
||||
for _, r := range line {
|
||||
if r == ' ' {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return count
|
||||
}
|
||||
Reference in New Issue
Block a user