github.com/grafana/tanka@v0.26.1-0.20240506093700-c22cfc35c21a/pkg/process/extract.go (about) 1 package process 2 3 import ( 4 "errors" 5 "fmt" 6 "reflect" 7 "sort" 8 "strings" 9 10 "github.com/rs/zerolog/log" 11 "github.com/stretchr/objx" 12 "gopkg.in/yaml.v3" 13 14 "github.com/grafana/tanka/pkg/kubernetes/manifest" 15 ) 16 17 // Extract scans the raw Jsonnet evaluation result (JSON tree) for objects that 18 // look like Kubernetes objects and extracts those into a flat map, indexed by 19 // their path in the original JSON tree 20 func Extract(raw interface{}) (map[string]manifest.Manifest, error) { 21 extracted := make(map[string]manifest.Manifest) 22 if err := walkJSON(raw, extracted, nil); err != nil { 23 return nil, err 24 } 25 return extracted, nil 26 } 27 28 // walkJSON recurses into either a map or list, returning a list of all objects that look 29 // like kubernetes resources. We support resources at an arbitrary level of nesting, and 30 // return an error if a node is not walkable. 31 // 32 // Handling the different types is quite gross, so we split this method into a generic 33 // walkJSON, and then walkObj/walkList to handle the two different types of collection we 34 // support. 35 func walkJSON(ptr interface{}, extracted map[string]manifest.Manifest, path trace) error { 36 // check for known types 37 switch v := ptr.(type) { 38 case map[string]interface{}: 39 return walkObj(v, extracted, path) 40 case []interface{}: 41 return walkList(v, extracted, path) 42 } 43 44 log.Debug().Msgf("recursion ended on key %q of type %T which does not belong to a valid Kubernetes object", path.Name(), ptr) 45 return ErrorPrimitiveReached{ 46 path: path.Base(), 47 key: path.Name(), 48 primitive: ptr, 49 } 50 } 51 52 func walkList(list []interface{}, extracted map[string]manifest.Manifest, path trace) error { 53 for idx, value := range list { 54 err := walkJSON(value, extracted, append(path, fmt.Sprintf("[%d]", idx))) 55 if err != nil { 56 return err 57 } 58 } 59 return nil 60 } 61 62 func walkObj(obj objx.Map, extracted map[string]manifest.Manifest, path trace) error { 63 obj = obj.Exclude([]string{"__ksonnet"}) // remove our private ksonnet field 64 65 // This looks like a kubernetes manifest, so make one and return it 66 ok, manifestErr := isKubernetesManifest(obj) 67 if ok { 68 m, err := manifest.NewFromObj(obj) 69 var e *manifest.SchemaError 70 if errors.As(err, &e) { 71 e.Name = path.Full() 72 return e 73 } 74 75 extracted[path.Full()] = m 76 return nil 77 } 78 79 keys := make([]string, 0, len(obj)) 80 for k := range obj { 81 keys = append(keys, k) 82 } 83 sort.Strings(keys) 84 85 for _, key := range keys { 86 path := append(path, key) 87 if obj[key] == nil { // result from false if condition in Jsonnet 88 continue 89 } 90 err := walkJSON(obj[key], extracted, path) 91 if err != nil { 92 if err, ok := err.(ErrorPrimitiveReached); ok { 93 return err.WithContainingObj(obj, manifestErr) 94 } 95 96 return err 97 } 98 } 99 100 return nil 101 } 102 103 type trace []string 104 105 func (t trace) Full() string { 106 return "." + strings.Join(t, ".") 107 } 108 109 func (t trace) Base() string { 110 if len(t) > 0 { 111 t = t[:len(t)-1] 112 } 113 return "." + strings.Join(t, ".") 114 } 115 116 func (t trace) Name() string { 117 if len(t) > 0 { 118 return t[len(t)-1] 119 } 120 121 return "" 122 } 123 124 // ErrorPrimitiveReached occurs when walkJSON reaches the end of nested dicts without finding a valid Kubernetes manifest 125 type ErrorPrimitiveReached struct { 126 path, key string 127 primitive interface{} 128 containingObj objx.Map 129 containingObjErr error 130 } 131 132 func (e ErrorPrimitiveReached) WithContainingObj(obj objx.Map, err error) ErrorPrimitiveReached { 133 if e.containingObj == nil { 134 e.containingObj = obj 135 e.containingObjErr = err 136 } 137 return e 138 } 139 140 func (e ErrorPrimitiveReached) Error() string { 141 errMessage := fmt.Sprintf(`found invalid Kubernetes object (at %s): %s`, e.path, e.containingObjErr) 142 143 container, err := yaml.Marshal(e.containingObj) 144 if err != nil { 145 log.Error().Msgf("failed to marshal invalid Kubernetes object: %s", err) 146 } else { 147 errMessage += "\n\n" + string(container) 148 } 149 150 return errMessage 151 } 152 153 // isKubernetesManifest attempts to infer whether the given object is a valid 154 // kubernetes resource by verifying the presence of apiVersion and kind. These 155 // two fields are required for kubernetes to accept any resource. 156 // The error return value indicates the reason why the object is not a valid 157 func isKubernetesManifest(obj objx.Map) (ok bool, err error) { 158 checkAttribute := func(key string) error { 159 v := obj.Get(key) 160 if v.IsNil() { 161 return fmt.Errorf("missing attribute %q", key) 162 } 163 if !v.IsStr() { 164 return fmt.Errorf("attribute %q is not a string, it is a %s", key, reflect.TypeOf(v.Data())) 165 } 166 if v.Str() == "" { 167 return fmt.Errorf("attribute %q is empty", key) 168 } 169 return nil 170 } 171 172 if err := checkAttribute("apiVersion"); err != nil { 173 return false, err 174 } 175 if err := checkAttribute("kind"); err != nil { 176 return false, err 177 } 178 179 return true, nil 180 }