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  }