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  }