// Package normalizer contains the logic for the kpt fn so that it can be // leveraged in non-kpt contexts package normalizer import ( "fmt" "sort" "strings" "sync" "sigs.k8s.io/kustomize/kyaml/kio/filters" "sigs.k8s.io/kustomize/kyaml/kio/kioutil" "sigs.k8s.io/kustomize/kyaml/yaml" "edge-infra.dev/pkg/edge/gitops/fn/v1alpha1" ) // x is a structure that protects concurrent read/writes to the external kyaml library. // // A RWMutex (read/write mutex) is used since write locks shouldn't happen after all yaml // objects have been seen once. var x = struct { sync.RWMutex // ff is simply a zeroed filters.FormatFilter struct. ff filters.FormatFilter // For reference, some example values are provided for the whitelist objects. // // The Apis and Kinds whitelist values are set to nil since the library only checks the map for the key's existence. // The Fields whitelist key is a json path to the sequence field. The Fields whitelist value is the associative key. // // Since the whitelists are maps, we don't need to use pointers to the objects, which is nice. wlsApis map[string]interface{} // yaml.WhitelistedListSortApis["kubernetes-client.io/v1"] = nil wlsKinds map[string]interface{} // yaml.WhitelistedListSortKinds["ExternalSecret"] = nil wlsFields map[string]string // yaml.WhitelistedListSortFields[".spec.data"] = "name" }{ wlsApis: yaml.WhitelistedListSortApis, wlsKinds: yaml.WhitelistedListSortKinds, wlsFields: yaml.WhitelistedListSortFields, } func init() { x.Lock() defer x.Unlock() const skey = "secretKey" for _, key := range yaml.AssociativeSequenceKeys { if key == skey { // key is already in yaml.AssociativeSequenceKeys return } } yaml.AssociativeSequenceKeys = append(yaml.AssociativeSequenceKeys, skey) } // setWhitelistedListSortMaps dynamically sets the following global maps from the kyaml library in a threadsafe manner: // - yaml.WhitelistedListSortApis // - yaml.WhitelistedListSortKinds // - yaml.WhitelistedListSortFields func setWhitelistedListSortMaps(nodes []*yaml.RNode) error { // Provide a detailed error when field values conflict. const msg = "WhitelistedListSortFields contains key %q with value %q, but another yaml document contains conflicting value %q for that key." var ( apis = make(map[string]bool) kinds = make(map[string]bool) fields = make(map[string]string) fieldErrs *conflictingWhitelistedListSortFieldsError // The hasApis, hasKinds, and hasFields boolians remain true if the whitelists do not need to be modified. hasApis = true hasKinds = true hasFields = true ) for _, node := range nodes { meta, err := node.GetMeta() if err != nil { return err } apis[meta.TypeMeta.APIVersion] = true kinds[meta.TypeMeta.Kind] = true findSequenceNodeFieldsRecursive(node.YNode(), "", fields) } // Read lock to see if writing is necessary. x.RLock() for api := range apis { if _, found := x.wlsApis[api]; !found { hasApis = false } } for kind := range kinds { if _, found := x.wlsKinds[kind]; !found { hasKinds = false } } for field, value := range fields { if existing, found := x.wlsFields[field]; !found { hasFields = false } else if value != existing { var info = fmt.Sprintf(msg, field, existing, value) if fieldErrs == nil { fieldErrs = &conflictingWhitelistedListSortFieldsError{ info: make(map[string]interface{}), } } fieldErrs.info[info] = nil } } x.RUnlock() // Exit early before write locking. if fieldErrs != nil { return fieldErrs } else if hasApis && hasKinds && hasFields { return nil } // Whitelist the yaml information using the write lock. // // Compared to write locking, overwriting all the values for the three maps is negligible performance wise, so we lock once // and overwrite everything even if only 1 value needs to be added for one of the maps. x.Lock() defer x.Unlock() for api := range apis { x.wlsApis[api] = nil } for kind := range kinds { x.wlsKinds[kind] = nil } for field, value := range fields { if existing, found := x.wlsFields[field]; !found { x.wlsFields[field] = value } else if value != existing { // Recheck that the fields did not go bad since we unlocked before writing. var info = fmt.Sprintf(msg, field, existing, value) if fieldErrs == nil { fieldErrs = &conflictingWhitelistedListSortFieldsError{ info: make(map[string]interface{}), } } fieldErrs.info[info] = nil } } if fieldErrs != nil { return fieldErrs } return nil } // findSequenceNodeFieldsRecursive dynamically fills the provided 'fields' map with sequence node json paths and their associative keys. // // To run this function, provide an empty string for 'path' and a non-nil map for 'fields'. This function panics if you provide a nil map. func findSequenceNodeFieldsRecursive(node *yaml.Node, path string, fields map[string]string) { switch node.Kind { case yaml.SequenceNode: // GetAssociativeKey checks the yaml.AssociativeSequenceKeys variable which only contains "name". // // In the future we may need to write to that variable. If that happens, we'll need to protect it with a RWMutex like we do with the whitelists. if v := yaml.NewRNode(node).GetAssociativeKey(); v != "" { fields[path] = v // ex fields[".spec.data"] = "name" } case yaml.MappingNode: // MappingNode Content is strange. The node.Content object has two entries per node. // // The first entry contains the name of the field. // // The second entry contains the values of the field. for i := range node.Content { if i%2 != 0 { // Skip every other node in Content. continue } var fieldName = node.Content[i].Value // Create the next json path for the mapping nodes. Only mapping nodes go in a json path. var nextPath = fmt.Sprintf("%s.%s", path, fieldName) findSequenceNodeFieldsRecursive(node.Content[i+1], nextPath, fields) } } } // Normalization modifies the yaml.RNode slice for consistent serialization. Without normalization, yaml.RNode fields are // serialized in mostly the same order they were deserialized (or constructed), and with inconsistencies in sequence nodes. // // Normalize also sorts the slice lexiographically according to the kioutil.PathAnnotation and then by the serialized yaml for // nodes with identical paths. The order of Normalize output must be kept when nodes with identical paths are joined together. // // NOTE: To use the Normalize function on nodes whose kioutil.PathAnnotation has been stripped, you must only provide 'input' // nodes with the same destination path. func Normalize(input []*yaml.RNode) error { err := setWhitelistedListSortMaps(input) if err != nil { return err } // Normalize using the format filter. Need to use the read lock since the format filter touches the whitelists. x.RLock() _, err = x.ff.Filter(input) x.RUnlock() if err != nil { return err } // Sort the order of the nodes so that yaml with concatenated documents are serialized in the same order of documents. sortRNodes(input) // err = sanitizePathAnnotations(input) // if err != nil { // return err // } // Add quotes to cpu limits quoteCPULimitsForNodes(input) return nil } func quoteCPULimitsForNodes(input []*yaml.RNode) { for i := range input { quoteCPULimits(input[i].YNode()) } } // may be needed later, commented because the linter hates unused functions // basically both bazel and go hate colons in filenames // this didm't work by itself because the sorter is reintroducing them // if bad filenames get introduced again we may resurrevt this // func sanitizePathAnnotations(nodes []*yaml.RNode) error { // for _, node := range nodes { // pathNode, err := node.Pipe(yaml.AnnotationGetter{ // Key: kioutil.PathAnnotation, // }) // if err != nil { // return err // } // path := yaml.GetValue(pathNode) // if path == "" { // return nil // } // newPath := strings.ReplaceAll(path, ":", "-") // err = node.PipeE(yaml.AnnotationSetter{ // Key: kioutil.PathAnnotation, // Value: newPath, // }) // if err != nil { // return err // } // } // return nil // } // quoteCPULimits checks if the yaml file has cpu limits recursively, and adds quotes to cpu limits if it has. func quoteCPULimits(node *yaml.Node) { if node.Kind == yaml.ScalarNode { return } for i := range node.Content { if isKey("limits", node.Content, i) { // node.Content[i+1] contains the value of limits field limits := node.Content[i+1] for j := range limits.Content { if isKey("cpu", limits.Content, j) { limits.Content[j+1].Style = yaml.DoubleQuotedStyle } } } else { quoteCPULimits(node.Content[i]) } } } func isKey(key string, nodes []*yaml.Node, idx int) bool { if idx >= len(nodes)-1 { return false } if nodes[idx].Value != key { return false } return true } // Run calls the Normalize function for the provided RNodes. The Run function implements the fn.Function interface. // // NOTE: Due to the kyaml filters.FormatFilter.Filter function, this function normalizes the input array. func (n *Normalizer) Run(input []*yaml.RNode) (output []*yaml.RNode, err error) { err = Normalize(input) if err != nil { return } output = input return } // sortRNodes sorts the provided yaml.RNode slice lexiographically by the kioutil.PathAnnotation value and then by the serialized yaml for identical paths. // The provided nodes should be normalized before they are sorted by this function. This is needed so that concatenated yaml.RNode slices always serialize // in the same order. func sortRNodes(nodes []*yaml.RNode) { // Stable sort so that nodes that shouldn't be lexiographically sorted have their ordering preserved. sort.Stable(&rnodeSorter{n: nodes}) } // rnodeSorter implements sort.Interface to efficiently sort rnodes lexiographically. type rnodeSorter struct { // s is a slice representing serialized nodes s []string // p is a slice representing `kioutil.PathAnnotation` values. p []string // l is a slice representing whether or not a node should be lexiographically sorted l []bool n []*yaml.RNode } var apiVersionPrefixFnsEdgeNcrCom = fmt.Sprintf("%s/", v1alpha1.GroupVersion.Group) // lexiographicallySortAPIVersion returns true if the provided `apiVersion` should be lexiographically sorted. func lexiographicallySortAPIVersion(apiVersion string) bool { return !strings.HasPrefix(apiVersion, apiVersionPrefixFnsEdgeNcrCom) } // Len returns the node count, and initializes the internal `s`, `p`, and `l` slices in the rnodeSorter. func (s *rnodeSorter) Len() int { s.s = make([]string, len(s.n)) // s gets populated in the Less function. s.p = make([]string, len(s.n)) s.l = make([]bool, len(s.n)) for k, n := range s.n { meta, err := n.GetMeta() if err != nil { s.l[k] = true // lexiographically sort nodes without metadata continue } s.p[k] = meta.ObjectMeta.Annotations[kioutil.PathAnnotation] s.l[k] = lexiographicallySortAPIVersion(meta.TypeMeta.APIVersion) } return len(s.n) } // Less orders lexiographically by the kioutil.PathAnnotation and then by the serialized yaml for nodes with identical paths. func (s *rnodeSorter) Less(i, j int) bool { // If the paths are not equal, we can return the lexiographic order of the paths. if s.p[i] != s.p[j] { return s.p[i] < s.p[j] } // Check if we should lexiographically sort these nodes, or preserve their order. if !s.l[i] && !s.l[j] { // Returning false preserves the order when using stable sort. return false } // Serialize the nodes (once) and compare them lexiographically. if s.s[i] == "" { s.s[i], _ = s.n[i].String() } if s.s[j] == "" { s.s[j], _ = s.n[j].String() } return s.s[i] < s.s[j] } func (s *rnodeSorter) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] s.p[i], s.p[j] = s.p[j], s.p[i] s.l[i], s.l[j] = s.l[j], s.l[i] s.n[i], s.n[j] = s.n[j], s.n[i] } // conflictingWhitelistedListSortFieldsError is needed in case we modify yaml.AssociativeSequenceKeys in the future. type conflictingWhitelistedListSortFieldsError struct { // info is a map since we'll probably run into many fields with the same info. info map[string]interface{} } func (err *conflictingWhitelistedListSortFieldsError) Error() string { const msg = "yaml.WhitelistedListSortFields does not support conflicting values for the same field." if len(err.info) == 0 { return msg } // turn the map into a slice for printing. var s = []interface{}{msg} for info := range err.info { s = append(s, info) } return fmt.Sprint(s...) } // IsConflictingWhitelistedListSortFieldsError returns true if the error returned during normalization was // due to the yaml.WhitelistedListSortFields map being unable to accept multiple values for the same key. // // If this error is found, a developer must be alerted and code must be changed. // // NOTE: This error should not occur unless the yaml.AssociativeSequenceKeys is dynamically populated in the // future. That object isn't currently being populated, and it only contains one key: "name". func IsConflictingWhitelistedListSortFieldsError(err error) bool { _, b := err.(*conflictingWhitelistedListSortFieldsError) return b }