github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/internal/util/merge/merge3.go (about)

     1  // Copyright 2020 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package merge
    16  
    17  import (
    18  	"path/filepath"
    19  	"strings"
    20  
    21  	"github.com/GoogleContainerTools/kpt/internal/util/attribution"
    22  	kptfilev1 "github.com/GoogleContainerTools/kpt/pkg/api/kptfile/v1"
    23  	"sigs.k8s.io/kustomize/kyaml/kio"
    24  	"sigs.k8s.io/kustomize/kyaml/kio/filters"
    25  	"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
    26  	"sigs.k8s.io/kustomize/kyaml/pathutil"
    27  	"sigs.k8s.io/kustomize/kyaml/resid"
    28  	"sigs.k8s.io/kustomize/kyaml/yaml"
    29  )
    30  
    31  const (
    32  	mergeSourceAnnotation = "config.kubernetes.io/merge-source"
    33  	mergeSourceOriginal   = "original"
    34  	mergeSourceUpdated    = "updated"
    35  	mergeSourceDest       = "dest"
    36  	MergeCommentPrefix    = "kpt-merge:"
    37  )
    38  
    39  // Merge3 performs a 3-way merge on the original, upstream and
    40  // destination packages. It provides support for doing this only for
    41  // the parent package and ignore any subpackages. Whenever the boundaries
    42  // of a package differs between original, upstream and destination, the
    43  // boundaries in destination will be used.
    44  type Merge3 struct {
    45  	OriginalPath       string
    46  	UpdatedPath        string
    47  	DestPath           string
    48  	MatchFilesGlob     []string
    49  	MergeOnPath        bool
    50  	IncludeSubPackages bool
    51  }
    52  
    53  func (m Merge3) Merge() error {
    54  	// If subpackages are not included when doing the merge, first
    55  	// look up the known subpackages in destination so we can make sure
    56  	// those are ignored when reading files from original and updated.
    57  	var relPaths []string
    58  	if !m.IncludeSubPackages {
    59  		var err error
    60  		relPaths, err = m.findExclusions()
    61  		if err != nil {
    62  			return err
    63  		}
    64  	}
    65  
    66  	var inputs []kio.Reader
    67  	dest := &kio.LocalPackageReadWriter{
    68  		PackagePath:        m.DestPath,
    69  		MatchFilesGlob:     m.MatchFilesGlob,
    70  		SetAnnotations:     map[string]string{mergeSourceAnnotation: mergeSourceDest},
    71  		IncludeSubpackages: m.IncludeSubPackages,
    72  		PackageFileName:    kptfilev1.KptFileName,
    73  		PreserveSeqIndent:  true,
    74  		WrapBareSeqNode:    true,
    75  	}
    76  	inputs = append(inputs, dest)
    77  
    78  	// Read the original package
    79  	inputs = append(inputs, PruningLocalPackageReader{
    80  		LocalPackageReader: kio.LocalPackageReader{
    81  			PackagePath:        m.OriginalPath,
    82  			MatchFilesGlob:     m.MatchFilesGlob,
    83  			SetAnnotations:     map[string]string{mergeSourceAnnotation: mergeSourceOriginal},
    84  			IncludeSubpackages: m.IncludeSubPackages,
    85  			PackageFileName:    kptfilev1.KptFileName,
    86  			PreserveSeqIndent:  true,
    87  			WrapBareSeqNode:    true,
    88  		},
    89  		Exclusions: relPaths,
    90  	})
    91  
    92  	// Read the updated package
    93  	inputs = append(inputs, PruningLocalPackageReader{
    94  		LocalPackageReader: kio.LocalPackageReader{
    95  			PackagePath:        m.UpdatedPath,
    96  			MatchFilesGlob:     m.MatchFilesGlob,
    97  			SetAnnotations:     map[string]string{mergeSourceAnnotation: mergeSourceUpdated},
    98  			IncludeSubpackages: m.IncludeSubPackages,
    99  			PackageFileName:    kptfilev1.KptFileName,
   100  			PreserveSeqIndent:  true,
   101  			WrapBareSeqNode:    true,
   102  		},
   103  		Exclusions: relPaths,
   104  	})
   105  
   106  	rmMatcher := ResourceMergeMatcher{MergeOnPath: m.MergeOnPath}
   107  	resourceHandler := resourceHandler{}
   108  	kyamlMerge := filters.Merge3{
   109  		Matcher: &rmMatcher,
   110  		Handler: &resourceHandler,
   111  	}
   112  
   113  	return kio.Pipeline{
   114  		Inputs:  inputs,
   115  		Filters: []kio.Filter{kyamlMerge},
   116  		Outputs: []kio.Writer{dest},
   117  	}.Execute()
   118  }
   119  
   120  func (m Merge3) findExclusions() ([]string, error) {
   121  	var relPaths []string
   122  	paths, err := pathutil.DirsWithFile(m.DestPath, kptfilev1.KptFileName, true)
   123  	if err != nil {
   124  		return relPaths, err
   125  	}
   126  
   127  	for _, p := range paths {
   128  		rel, err := filepath.Rel(m.DestPath, p)
   129  		if err != nil {
   130  			return relPaths, err
   131  		}
   132  		if rel == "." {
   133  			continue
   134  		}
   135  		relPaths = append(relPaths, rel)
   136  	}
   137  	return relPaths, nil
   138  }
   139  
   140  // PruningLocalPackageReader implements the Reader interface. It is similar
   141  // to the LocalPackageReader but allows for exclusion of subdirectories.
   142  type PruningLocalPackageReader struct {
   143  	LocalPackageReader kio.LocalPackageReader
   144  	Exclusions         []string
   145  }
   146  
   147  func (p PruningLocalPackageReader) Read() ([]*yaml.RNode, error) {
   148  	// Delegate reading the resources to the LocalPackageReader.
   149  	nodes, err := p.LocalPackageReader.Read()
   150  	if err != nil {
   151  		return nil, err
   152  	}
   153  
   154  	// Exclude any resources that exist underneath an excluded path.
   155  	var filteredNodes []*yaml.RNode
   156  	for _, node := range nodes {
   157  		if err := kioutil.CopyLegacyAnnotations(node); err != nil {
   158  			return nil, err
   159  		}
   160  		n, err := node.Pipe(yaml.GetAnnotation(kioutil.PathAnnotation))
   161  		if err != nil {
   162  			return nil, err
   163  		}
   164  		path := n.YNode().Value
   165  		if p.isExcluded(path) {
   166  			continue
   167  		}
   168  		filteredNodes = append(filteredNodes, node)
   169  	}
   170  	return filteredNodes, nil
   171  }
   172  
   173  func (p PruningLocalPackageReader) isExcluded(path string) bool {
   174  	for _, e := range p.Exclusions {
   175  		if strings.HasPrefix(path, e) {
   176  			return true
   177  		}
   178  	}
   179  	return false
   180  }
   181  
   182  type ResourceMergeMatcher struct {
   183  	MergeOnPath bool
   184  }
   185  
   186  // IsSameResource determines if 2 resources are same to be merged by matching GKNN+filepath
   187  // Group, Kind are derived from resource metadata directly, Namespace and Name are derived
   188  // from merge comment which is of format "kpt-merge: namespace/name", if the merge comment
   189  // is not present, then it falls back to Namespace and Name on the resource meta
   190  func (rm *ResourceMergeMatcher) IsSameResource(node1, node2 *yaml.RNode) bool {
   191  	if node1 == nil || node2 == nil {
   192  		return false
   193  	}
   194  
   195  	if err := kioutil.CopyLegacyAnnotations(node1); err != nil {
   196  		return false
   197  	}
   198  	if err := kioutil.CopyLegacyAnnotations(node2); err != nil {
   199  		return false
   200  	}
   201  
   202  	meta1, err := node1.GetMeta()
   203  	if err != nil {
   204  		return false
   205  	}
   206  
   207  	meta2, err := node2.GetMeta()
   208  	if err != nil {
   209  		return false
   210  	}
   211  
   212  	if resolveGroup(meta1) != resolveGroup(meta2) {
   213  		return false
   214  	}
   215  
   216  	if meta1.Kind != meta2.Kind {
   217  		return false
   218  	}
   219  
   220  	if resolveName(meta1, metadataComment(node1)) != resolveName(meta2, metadataComment(node2)) {
   221  		return false
   222  	}
   223  
   224  	if resolveNamespace(meta1, metadataComment(node1)) != resolveNamespace(meta2, metadataComment(node2)) {
   225  		return false
   226  	}
   227  
   228  	if rm.MergeOnPath {
   229  		// directories may contain multiple copies of a resource with the same
   230  		// name, namespace, apiVersion and kind -- e.g. kustomize patches, or
   231  		// multiple environments
   232  		// mergeOnPath configures the merge logic to use the path as part of the
   233  		// resource key
   234  		if meta1.Annotations[kioutil.PathAnnotation] != meta2.Annotations[kioutil.PathAnnotation] {
   235  			return false
   236  		}
   237  	}
   238  	return true
   239  }
   240  
   241  // resolveGroup resolves the group of a resource from ResourceMeta
   242  func resolveGroup(meta yaml.ResourceMeta) string {
   243  	group, _ := resid.ParseGroupVersion(meta.APIVersion)
   244  	return group
   245  }
   246  
   247  // resolveNamespace resolves the namespace which should be used for merging resources
   248  // uses namespace from comment on metadata field if present, falls back to resource namespace
   249  func resolveNamespace(meta yaml.ResourceMeta, metadataComment string) string {
   250  	nsName := NsAndNameForMerge(metadataComment)
   251  	if nsName == nil {
   252  		return meta.Namespace
   253  	}
   254  	return nsName[0]
   255  }
   256  
   257  // resolveName resolves the name which should be used for merging resources
   258  // uses name from comment on metadata field if present, falls back to resource name
   259  func resolveName(meta yaml.ResourceMeta, metadataComment string) string {
   260  	nsName := NsAndNameForMerge(metadataComment)
   261  	if nsName == nil {
   262  		return meta.Name
   263  	}
   264  	return nsName[1]
   265  }
   266  
   267  // NsAndNameForMerge returns the namespace and name for merge
   268  // from the line comment on the metadata field
   269  // e.g. metadata: # kpt-merge: default/foo returns [default, foo]
   270  func NsAndNameForMerge(metadataComment string) []string {
   271  	comment := strings.TrimPrefix(metadataComment, "#")
   272  	comment = strings.TrimSpace(comment)
   273  	if !strings.HasPrefix(comment, MergeCommentPrefix) {
   274  		return nil
   275  	}
   276  	comment = strings.TrimPrefix(comment, MergeCommentPrefix)
   277  	nsAndName := strings.SplitN(strings.TrimSpace(comment), "/", 2)
   278  	if len(nsAndName) != 2 {
   279  		return nil
   280  	}
   281  	return nsAndName
   282  }
   283  
   284  // metadataComment returns the line comment on the metadata field of input RNode
   285  func metadataComment(node *yaml.RNode) string {
   286  	mf := node.Field(yaml.MetadataField)
   287  	if mf.IsNilOrEmpty() {
   288  		return ""
   289  	}
   290  	return mf.Key.YNode().LineComment
   291  }
   292  
   293  // resourceHandler is an implementation of the ResourceHandler interface from
   294  // kyaml. It is used to decide how a resource should be handled during the
   295  // 3-way merge. This differs from the default implementation in that if a
   296  // resource is deleted from upstream, it will only be deleted from local if
   297  // there is no diff between origin and local.
   298  type resourceHandler struct {
   299  	keptResources []*yaml.RNode
   300  }
   301  
   302  func (r *resourceHandler) Handle(origin, upstream, local *yaml.RNode) (filters.ResourceMergeStrategy, error) {
   303  	var strategy filters.ResourceMergeStrategy
   304  	switch {
   305  	// Keep the resource if added locally.
   306  	case origin == nil && upstream == nil && local != nil:
   307  		strategy = filters.KeepDest
   308  	// Add the resource if added in upstream.
   309  	case origin == nil && upstream != nil && local == nil:
   310  		strategy = filters.KeepUpdated
   311  	// Do not re-add the resource if deleted from both upstream and local
   312  	case upstream == nil && local == nil:
   313  		strategy = filters.Skip
   314  	// If deleted from upstream, only delete if local fork does not have changes.
   315  	case origin != nil && upstream == nil:
   316  		equal, err := r.equals(origin, local)
   317  		if err != nil {
   318  			return strategy, err
   319  		}
   320  		if equal {
   321  			strategy = filters.Skip
   322  		} else {
   323  			r.keptResources = append(r.keptResources, local)
   324  			strategy = filters.KeepDest
   325  		}
   326  	// Do not re-add if deleted from local.
   327  	case origin != nil && local == nil:
   328  		strategy = filters.Skip
   329  	default:
   330  		strategy = filters.Merge
   331  	}
   332  	return strategy, nil
   333  }
   334  
   335  func (*resourceHandler) equals(r1, r2 *yaml.RNode) (bool, error) {
   336  	// We need to create new copies of the resources since we need to
   337  	// mutate them before comparing them.
   338  	r1Clone, err := yaml.Parse(r1.MustString())
   339  	if err != nil {
   340  		return false, err
   341  	}
   342  	r2Clone, err := yaml.Parse(r2.MustString())
   343  	if err != nil {
   344  		return false, err
   345  	}
   346  
   347  	// The resources include annotations with information used during the merge
   348  	// process. We need to remove those before comparing the resources.
   349  	if err := stripKyamlAnnos(r1Clone); err != nil {
   350  		return false, err
   351  	}
   352  	if err := stripKyamlAnnos(r2Clone); err != nil {
   353  		return false, err
   354  	}
   355  
   356  	return r1Clone.MustString() == r2Clone.MustString(), nil
   357  }
   358  
   359  func stripKyamlAnnos(n *yaml.RNode) error {
   360  	for _, a := range []string{mergeSourceAnnotation, kioutil.PathAnnotation, kioutil.IndexAnnotation,
   361  		kioutil.LegacyPathAnnotation, kioutil.LegacyIndexAnnotation, // nolint:staticcheck
   362  		kioutil.InternalAnnotationsMigrationResourceIDAnnotation, attribution.CNRMMetricsAnnotation} {
   363  		err := n.PipeE(yaml.ClearAnnotation(a))
   364  		if err != nil {
   365  			return err
   366  		}
   367  	}
   368  	return nil
   369  }