github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/util/helm/diff.go (about) 1 /* 2 Copyright (C) 2022-2023 ApeCloud Co., Ltd 3 4 This file is part of KubeBlocks project 5 6 This program is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Affero General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This program is distributed in the hope that it will be useful 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Affero General Public License for more details. 15 16 You should have received a copy of the GNU Affero General Public License 17 along with this program. If not, see <http://www.gnu.org/licenses/>. 18 */ 19 20 package helm 21 22 import ( 23 "bytes" 24 "encoding/json" 25 "fmt" 26 "io" 27 "reflect" 28 "sort" 29 "strings" 30 31 "github.com/spf13/cast" 32 "golang.org/x/exp/maps" 33 "gopkg.in/yaml.v2" 34 "helm.sh/helm/v3/pkg/release" 35 "helm.sh/helm/v3/pkg/releaseutil" 36 37 "github.com/1aal/kubeblocks/pkg/cli/printer" 38 "github.com/1aal/kubeblocks/pkg/cli/util" 39 ) 40 41 // constants in k8s yaml 42 const k8sCRD = "CustomResourceDefinition" 43 const TYPE = "type" 44 const PROPERTIES = "properties" 45 const ADDITIONALPROPERTIES = "additionalProperties" 46 const REQUIRED = "required" 47 48 // APIPath is the key name to record the API fullpath 49 const APIPath = "KB-API-PATH" 50 51 var ( 52 // four level BlackList to filter useless info between two release, now they are customized for kubeblocks 53 kindBlackList = []string{ 54 "ConfigMapList", 55 } 56 57 nameBlackList = []string{ 58 "grafana", 59 "prometheus", 60 } 61 62 fieldBlackList = []string{ 63 "description", 64 "image", 65 "chartLocationURL", 66 "chartsImage", 67 } 68 69 labelBlackList = []string{ 70 "helm.sh/chart", 71 "app.kubernetes.io/version", 72 } 73 ) 74 75 // MappingResult to store result to diff 76 type MappingResult struct { 77 Name string 78 Kind string 79 Content string 80 } 81 82 type metadata struct { 83 APIVersion string `yaml:"apiVersion"` 84 Kind string `yaml:"kind"` 85 Metadata struct { 86 Name string `yaml:"name"` 87 Labels map[string]string `yaml:"labels"` 88 } 89 } 90 91 const ( 92 object string = "object" 93 array string = "array" 94 ) 95 96 type Mode string 97 98 const ( 99 Modified Mode = "Modified" 100 Added Mode = "Added" 101 Removed Mode = "Removed" 102 ) 103 104 func (m metadata) String() string { 105 apiBase := m.APIVersion 106 sp := strings.Split(apiBase, "/") 107 if len(sp) > 1 { 108 apiBase = strings.Join(sp[:len(sp)-1], "/") 109 } 110 name := m.Metadata.Name 111 return fmt.Sprintf("%s, %s (%s)", name, m.Kind, apiBase) 112 } 113 114 func ParseContent(content string) (*MappingResult, error) { 115 var parsedMetadata metadata 116 117 parseOpenAPIV3Schema := func() (*MappingResult, error) { 118 data := make(map[string]interface{}) 119 if err := yaml.Unmarshal([]byte(content), &data); err != nil { 120 return nil, err 121 } 122 // The content must strictly adhere to Kubernetes' YAML format to ensure correct parsing of the CRD's API Schema 123 openAPIV3Schema := cast.ToStringMap(cast.ToStringMap(cast.ToStringMap(cast.ToSlice(cast.ToStringMap(data["spec"])["versions"])[0])["schema"])["openAPIV3Schema"])[PROPERTIES] 124 normalizedContent, err := yaml.Marshal(openAPIV3Schema) 125 if err != nil { 126 return nil, err 127 } 128 return &MappingResult{ 129 Name: parsedMetadata.String(), 130 Kind: k8sCRD, 131 Content: string(normalizedContent), 132 }, nil 133 } 134 135 if err := yaml.Unmarshal([]byte(content), &parsedMetadata); err != nil { 136 137 return nil, err 138 } 139 if parsedMetadata.APIVersion == "" && parsedMetadata.Kind == "" { 140 return nil, nil 141 } 142 // filter Kind 143 for i := range kindBlackList { 144 if kindBlackList[i] == parsedMetadata.Kind { 145 return nil, nil 146 } 147 } 148 // filter Name 149 for i := range nameBlackList { 150 if strings.Contains(parsedMetadata.Metadata.Name, nameBlackList[i]) { 151 return nil, nil 152 } 153 } 154 if k8sCRD == parsedMetadata.Kind { 155 return parseOpenAPIV3Schema() 156 } 157 var object map[interface{}]interface{} 158 if err := yaml.Unmarshal([]byte(content), &object); err != nil { 159 return nil, err 160 } 161 // filter Label 162 for i := range labelBlackList { 163 deleteLabel(&object, labelBlackList[i]) 164 } 165 // filter Field 166 for i := range fieldBlackList { 167 deleteObjField(&object, fieldBlackList[i]) 168 } 169 normalizedContent, err := yaml.Marshal(object) 170 if err != nil { 171 return nil, err 172 } 173 content = string(normalizedContent) 174 name := parsedMetadata.String() 175 return &MappingResult{ 176 Name: name, 177 Kind: parsedMetadata.Kind, 178 Content: content, 179 }, nil 180 } 181 182 // OutputDiff output the difference between different version for a chart 183 // releaseA corresponds to versionA and releaseB corresponds to versionB. 184 // if detail is true, the detailed lines in YAML will be displayed 185 func OutputDiff(releaseA *release.Release, releaseB *release.Release, versionA, versionB string, out io.Writer, detail bool) error { 186 manifestsMapA, err := buildManifestMapByRelease(releaseA) 187 if err != nil { 188 return err 189 } 190 manifestsMapB, err := buildManifestMapByRelease(releaseB) 191 if err != nil { 192 return err 193 } 194 195 var mayRemoveCRD []string 196 var mayAddCRD []string 197 for _, key := range sortedKeys(manifestsMapA) { 198 manifestA := manifestsMapA[key] 199 if manifestA.Kind != k8sCRD { 200 continue 201 } 202 apiContentsA := make(map[string]any) 203 err := yaml.Unmarshal([]byte(manifestA.Content), &apiContentsA) 204 if err != nil { 205 return err 206 } 207 if manifestB, ok := manifestsMapB[key]; ok { 208 if manifestA.Content == manifestB.Content { 209 continue 210 } 211 apiContentsB := make(map[string]any) 212 err := yaml.Unmarshal([]byte(manifestB.Content), &apiContentsB) 213 if err != nil { 214 return err 215 } 216 outputCRDDiff(apiContentsA, apiContentsB, strings.Split(key, ",")[0], out) 217 } else { 218 mayRemoveCRD = append(mayRemoveCRD, manifestA.Name) 219 } 220 } 221 222 for _, key := range sortedKeys(manifestsMapB) { 223 manifestB := manifestsMapB[key] 224 if manifestB.Kind != k8sCRD { 225 continue 226 } 227 if _, ok := manifestsMapA[key]; !ok { 228 mayAddCRD = append(mayAddCRD, manifestB.Name) 229 } 230 } 231 tblPrinter := printer.NewTablePrinter(out) 232 tblPrinter.SetHeader("CustomResourceDefinition", "MODE") 233 sort.Strings(mayRemoveCRD) 234 sort.Strings(mayAddCRD) 235 236 for i := range mayRemoveCRD { 237 tblPrinter.AddRow(strings.Split(mayRemoveCRD[i], ",")[0], printer.BoldRed(Removed)) 238 } 239 for i := range mayAddCRD { 240 tblPrinter.AddRow(strings.Split(mayAddCRD[i], ",")[0], printer.BoldGreen(Added)) 241 } 242 if tblPrinter.Tbl.Length() != 0 { 243 tblPrinter.Print() 244 printer.PrintBlankLine(out) 245 } 246 // detail will output the yaml files change 247 if !detail { 248 return nil 249 } 250 mayRemove := make([]*MappingResult, 0) 251 mayAdd := make([]*MappingResult, 0) 252 for _, key := range sortedKeys(manifestsMapA) { 253 manifestA := manifestsMapA[key] 254 if manifestB, ok := manifestsMapB[key]; ok { 255 if manifestA.Content == manifestB.Content { 256 continue 257 } 258 diffString, err := util.GetUnifiedDiffString(manifestA.Content, manifestB.Content, fmt.Sprintf("%s %s", manifestA.Name, versionA), fmt.Sprintf("%s %s", manifestB.Name, versionB), 1) 259 if err != nil { 260 return err 261 } 262 util.DisplayDiffWithColor(out, diffString) 263 } else { 264 mayRemove = append(mayRemove, manifestA) 265 } 266 267 } 268 269 // Todo: support find Rename chart.yaml between mayRemove and mayAdd 270 for _, key := range sortedKeys(manifestsMapB) { 271 manifestB := manifestsMapB[key] 272 if _, ok := manifestsMapA[key]; !ok { 273 mayAdd = append(mayAdd, manifestB) 274 } 275 } 276 277 for _, elem := range mayAdd { 278 diffString, err := util.GetUnifiedDiffString("", elem.Content, "", fmt.Sprintf("%s %s", elem.Name, versionB), 1) 279 if err != nil { 280 return err 281 } 282 util.DisplayDiffWithColor(out, diffString) 283 } 284 285 for _, elem := range mayRemove { 286 diffString, err := util.GetUnifiedDiffString(elem.Content, "", fmt.Sprintf("%s %s", elem.Name, versionA), "", 1) 287 if err != nil { 288 return err 289 } 290 util.DisplayDiffWithColor(out, diffString) 291 } 292 return nil 293 } 294 295 // buildManifestMapByRelease parse a helm release manifest, it will get a map which include all k8s resources in 296 // the helm release and the map name is generate by metadata.String() 297 func buildManifestMapByRelease(release *release.Release) (map[string]*MappingResult, error) { 298 if release == nil { 299 return map[string]*MappingResult{}, nil 300 } 301 var manifests bytes.Buffer 302 fmt.Fprintln(&manifests, strings.TrimSpace(release.Manifest)) 303 manifestsKeys := releaseutil.SplitManifests(manifests.String()) 304 manifestsMap := make(map[string]*MappingResult) 305 for _, v := range manifestsKeys { 306 mapResult, err := ParseContent(v) 307 if err != nil { 308 return nil, err 309 } 310 if mapResult == nil { 311 // resources in BlackList 312 continue 313 } 314 manifestsMap[mapResult.Name] = mapResult 315 } 316 return manifestsMap, nil 317 } 318 319 // sortedKeys return sorted keys of manifests 320 func sortedKeys[K any](manifests map[string]K) []string { 321 keys := maps.Keys(manifests) 322 sort.Strings(keys) 323 return keys 324 } 325 326 // deleteObjField delete the field in fieldBlackList recursively 327 func deleteObjField(obj *map[interface{}]interface{}, field string) { 328 ori := *obj 329 _, ok := ori[field] 330 if ok { 331 delete(ori, field) 332 } 333 334 for _, v := range ori { 335 if v == nil { 336 continue 337 } 338 switch reflect.TypeOf(v).Kind() { 339 case reflect.Map: 340 m := v.(map[interface{}]interface{}) 341 deleteObjField(&m, field) 342 case reflect.Slice: 343 s := v.([]interface{}) 344 for i := range s { 345 if m, ok := s[i].(map[interface{}]interface{}); ok { 346 deleteObjField(&m, field) 347 } 348 } 349 } 350 } 351 } 352 353 // deleteLabel delete the label in labelBlackList 354 func deleteLabel(object *map[interface{}]interface{}, s string) { 355 obj := *object 356 if _, ok := obj["metadata"]; !ok { 357 return 358 } 359 if m, ok := obj["metadata"].(map[interface{}]interface{}); ok { 360 label, ok := m["labels"].(map[interface{}]interface{}) 361 if !ok { 362 return 363 } 364 if label[s] != "" { 365 delete(label, s) 366 } 367 } 368 } 369 370 // outputCRDDiff will compare and output the differences between crdA and crdB for the same crd named crdName 371 func outputCRDDiff(crdA, crdB map[string]any, crdName string, out io.Writer) { 372 fmt.Fprintf(out, "%s\n", printer.BoldYellow(crdName)) 373 tblPrinter := printer.NewTablePrinter(out) 374 tblPrinter.SetHeader("API", "IS-REQUIRED", "MODE", "DETAILS") 375 tblPrinter.SortBy(3, 1) 376 getNextLevelAPI := func(curPath, key string) string { 377 if len(curPath) == 0 { 378 return key 379 } 380 return curPath + "." + key 381 } 382 crdA[APIPath] = "" 383 crdB[APIPath] = "" 384 var queueA []map[string]any = []map[string]any{crdA} 385 queueB := make(map[string]map[string]any) 386 requiredA := make(map[string]bool) // to remember requiredAPI 387 requiredB := make(map[string]bool) 388 queueB[""] = crdB 389 390 for len(queueA) > 0 { 391 curA := queueA[0] 392 queueA = queueA[1:] 393 curAPath := curA[APIPath].(string) 394 curB := queueB[curAPath] 395 if curB == nil { 396 // crdA have API but crdB do not have 397 tblPrinter.AddRow(curAPath, requiredA[curAPath], printer.BoldRed(Removed)) 398 continue 399 } 400 delete(queueB, curAPath) 401 // add Content crdB 402 for key, val := range curB { 403 if key == APIPath { 404 continue 405 } 406 contentB := cast.ToStringMap(val) 407 nextLevelAPIKey := getNextLevelAPI(curAPath, key) 408 if slice := cast.ToSlice(contentB[REQUIRED]); slice != nil { 409 for _, key := range slice { 410 requiredB[getNextLevelAPI(nextLevelAPIKey, key.(string))] = true 411 } 412 } 413 switch t, _ := contentB[TYPE].(string); t { 414 case object: 415 queueB[nextLevelAPIKey] = cast.ToStringMap(contentB[PROPERTIES]) 416 case array: 417 itemContent := cast.ToStringMap(contentB["items"]) 418 curPath := getNextLevelAPI(nextLevelAPIKey, "items") 419 if slice := cast.ToSlice(itemContent[REQUIRED]); slice != nil { 420 for _, key := range slice { 421 requiredB[getNextLevelAPI(curPath, key.(string))] = true 422 } 423 } 424 queueB[curPath] = cast.ToStringMap(itemContent[PROPERTIES]) 425 default: 426 queueB[nextLevelAPIKey] = cast.ToStringMap(val) 427 } 428 } 429 430 // check api if equal and add next level api 431 for key, val := range curA { 432 if key == APIPath { 433 continue 434 } 435 contentA := cast.ToStringMap(val) 436 nextLevelAPIKey := getNextLevelAPI(curAPath, key) 437 contentB := cast.ToStringMap(curB[key]) 438 439 delete(contentA, "description") 440 delete(contentB, "description") 441 if slice := cast.ToSlice(contentA[REQUIRED]); slice != nil { 442 for _, key := range slice { 443 requiredA[getNextLevelAPI(nextLevelAPIKey, key.(string))] = true 444 } 445 } 446 // compare contentA and contentB rules by different Type 447 if requiredA[nextLevelAPIKey] != requiredB[nextLevelAPIKey] { 448 tblPrinter.AddRow(nextLevelAPIKey, fmt.Sprintf("%v -> %v", requiredA[nextLevelAPIKey], requiredB[nextLevelAPIKey]), printer.BoldYellow(Modified)) 449 } 450 switch t, _ := contentA[TYPE].(string); t { 451 case object: 452 // compare object , check required 453 nextLevelAPI := cast.ToStringMap(contentA[PROPERTIES]) 454 nextLevelAPI[APIPath] = nextLevelAPIKey 455 queueA = append(queueA, nextLevelAPI) 456 case array: 457 itemContent := cast.ToStringMap(contentA["items"]) 458 curPath := getNextLevelAPI(nextLevelAPIKey, "items") 459 if slice := cast.ToSlice(itemContent[REQUIRED]); slice != nil { 460 for _, key := range slice { 461 requiredA[getNextLevelAPI(curPath, key.(string))] = true 462 } 463 } 464 nextLevelAPI := cast.ToStringMap(itemContent[PROPERTIES]) 465 nextLevelAPI[APIPath] = curPath 466 queueA = append(queueA, nextLevelAPI) 467 default: 468 contentAJson := getAPIInfo(contentA) 469 contentBJson := getAPIInfo(contentB) 470 if contentAJson != contentBJson { 471 switch { 472 case !maps.Equal(contentA, map[string]any{}) && !maps.Equal(contentB, map[string]any{}): 473 tblPrinter.AddRow(nextLevelAPIKey, requiredA[nextLevelAPIKey], printer.BoldYellow(Modified), fmt.Sprintf("%s -> %s", contentAJson, contentBJson)) 474 case !maps.Equal(contentA, map[string]any{}) && maps.Equal(contentB, map[string]any{}): 475 tblPrinter.AddRow(nextLevelAPIKey, requiredA[nextLevelAPIKey], printer.BoldRed(Removed), contentAJson) 476 case maps.Equal(contentA, map[string]any{}) && !maps.Equal(contentB, map[string]any{}): 477 tblPrinter.AddRow(nextLevelAPIKey, requiredB[nextLevelAPIKey], printer.BoldGreen(Added), contentBJson) 478 } 479 } 480 delete(queueB, nextLevelAPIKey) 481 } 482 } 483 } 484 for key := range queueB { 485 tblPrinter.AddRow(key, requiredB[key], printer.BoldGreen(Added)) 486 } 487 if tblPrinter.Tbl.Length() != 0 { 488 tblPrinter.Print() 489 printer.PrintBlankLine(out) 490 } 491 } 492 493 func getAPIInfo(api map[string]any) string { 494 contentAJson, err := json.Marshal(api) 495 if err == nil { 496 return string(contentAJson) 497 } 498 res := "{" 499 for i, key := range sortedKeys(api) { 500 if i > 0 { 501 res += "," 502 } 503 res += fmt.Sprintf("\"%s\":\"%v\"", key, api[key]) 504 } 505 res += "}" 506 return res 507 }