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 }