github.com/helmwave/helmwave@v0.36.4-0.20240509190856-b35563eba4c6/pkg/plan/diff.go (about)

     1  package plan
     2  
     3  import (
     4  	"context"
     5  	"reflect"
     6  	"slices"
     7  	"strings"
     8  	"sync"
     9  
    10  	"github.com/databus23/helm-diff/v3/diff"
    11  	"github.com/databus23/helm-diff/v3/manifest"
    12  	"github.com/helmwave/helmwave/pkg/helper"
    13  	"github.com/helmwave/helmwave/pkg/parallel"
    14  	"github.com/helmwave/helmwave/pkg/release"
    15  	"github.com/helmwave/helmwave/pkg/release/uniqname"
    16  	structDiff "github.com/r3labs/diff/v3"
    17  	log "github.com/sirupsen/logrus"
    18  	"gopkg.in/yaml.v3"
    19  	"helm.sh/helm/v3/pkg/chart"
    20  	live "helm.sh/helm/v3/pkg/release"
    21  	apiErrors "k8s.io/apimachinery/pkg/api/errors"
    22  	"k8s.io/cli-runtime/pkg/resource"
    23  )
    24  
    25  // SkippedAnnotations is a map with all annotations to be skipped by differ.
    26  var SkippedAnnotations = map[string][]string{
    27  	live.HookAnnotation:               {string(live.HookTest), "test-success", "test-failure"},
    28  	helper.RootAnnoName + "skip-diff": {"true"},
    29  }
    30  
    31  // DiffPlan show diff between 2 plans.
    32  func (p *Plan) DiffPlan(b *Plan, opts *diff.Options) {
    33  	visited := make(map[uniqname.UniqName]bool)
    34  	k := 0
    35  
    36  	log.WithField("suppress", opts.SuppressedKinds).Debug("suppress kinds for diffing")
    37  
    38  	for _, rel := range slices.Concat(p.body.Releases, b.body.Releases) {
    39  		if visited[rel.Uniq()] {
    40  			continue
    41  		}
    42  		visited[rel.Uniq()] = true
    43  
    44  		oldSpecs := parseManifests(b.manifests[rel.Uniq()], rel.Namespace())
    45  		newSpecs := parseManifests(p.manifests[rel.Uniq()], rel.Namespace())
    46  
    47  		change := diff.Manifests(oldSpecs, newSpecs, opts, log.StandardLogger().Out)
    48  		if !change {
    49  			k++
    50  			log.Info("🆚 ❎ ", rel.Uniq(), " no changes")
    51  			p.unchanged = append(p.unchanged, rel)
    52  		}
    53  	}
    54  
    55  	showChangesReport(p.body.Releases, visited, k)
    56  }
    57  
    58  // DiffLive show diff with production releases in k8s-cluster.
    59  func (p *Plan) DiffLive(ctx context.Context, opts *diff.Options, threeWayMerge bool) {
    60  	alive, _, err := p.GetLive(ctx)
    61  	if err != nil {
    62  		log.Fatalf("Something went wrong with getting releases in the kubernetes cluster: %v", err)
    63  	}
    64  
    65  	visited := make(map[uniqname.UniqName]bool, len(p.body.Releases))
    66  	k := 0
    67  
    68  	for _, rel := range p.body.Releases {
    69  		visited[rel.Uniq()] = true
    70  
    71  		if active, ok := alive[rel.Uniq()]; ok {
    72  			newManifest := p.manifests[rel.Uniq()]
    73  			oldManifest := active.Manifest
    74  			if threeWayMerge {
    75  				oldManifest = get3WayMergeManifests(rel, active.Manifest)
    76  			}
    77  			// I don't use manifest.ParseRelease
    78  			// Because Structs are different.
    79  			oldSpecs := parseManifests(oldManifest, rel.Namespace())
    80  			newSpecs := parseManifests(newManifest, rel.Namespace())
    81  
    82  			change := diff.Manifests(oldSpecs, newSpecs, opts, rel.Logger().Logger.Out)
    83  			chartChange := diffCharts(ctx, active.Chart, rel, rel.Logger())
    84  
    85  			if !change && !chartChange {
    86  				k++
    87  				rel.Logger().Info("🆚 ❎ no changes")
    88  				p.unchanged = append(p.unchanged, rel)
    89  			}
    90  		}
    91  	}
    92  
    93  	showChangesReport(p.body.Releases, visited, k)
    94  }
    95  
    96  func get3WayMergeManifests(rel release.Config, oldManifest string) string { //nolint:funlen,gocognit
    97  	cfg := rel.Cfg()
    98  
    99  	err := cfg.KubeClient.IsReachable()
   100  	if err != nil {
   101  		rel.Logger().WithError(err).Warn("failed to connect to k8s to run 3-way merge, skipping")
   102  
   103  		return oldManifest
   104  	}
   105  
   106  	oldResources, err := cfg.KubeClient.Build(strings.NewReader(oldManifest), false)
   107  	if err != nil {
   108  		rel.Logger().WithError(err).Warn("failed to build old resources list for 3-way merge, skipping")
   109  
   110  		return oldManifest
   111  	}
   112  
   113  	updatedManifest := ""
   114  
   115  	err = oldResources.Visit(func(r *resource.Info, err error) error {
   116  		if err != nil {
   117  			return err
   118  		}
   119  
   120  		h := resource.NewHelper(r.Client, r.Mapping)
   121  		currentObject, err := h.Get(r.Namespace, r.Name)
   122  		if err != nil {
   123  			if !apiErrors.IsNotFound(err) {
   124  				return err //nolint:wrapcheck
   125  			}
   126  
   127  			return nil
   128  		}
   129  
   130  		out, err := yaml.Marshal(currentObject)
   131  		if err != nil {
   132  			return err //nolint:wrapcheck
   133  		}
   134  		// currentObject stores everything under 'object' key.
   135  		// We need to get everything from this field and drop some generated parts.
   136  		var ra map[string]any
   137  		_ = yaml.Unmarshal(out, &ra)
   138  		obj := ra["object"].(map[string]any) //nolint:forcetypeassert
   139  		delete(obj, "status")
   140  
   141  		metadata := obj["metadata"].(map[string]any) //nolint:forcetypeassert
   142  		delete(metadata, "creationTimestamp")
   143  		delete(metadata, "generation")
   144  		delete(metadata, "managedFields")
   145  		delete(metadata, "resourceVersion")
   146  		delete(metadata, "uid")
   147  
   148  		if a := metadata["annotations"]; a != nil {
   149  			annotations := a.(map[string]any) //nolint:forcetypeassert
   150  			delete(annotations, "meta.helm.sh/release-name")
   151  			delete(annotations, "meta.helm.sh/release-namespace")
   152  			delete(annotations, "deployment.kubernetes.io/revision")
   153  
   154  			if len(annotations) == 0 {
   155  				delete(metadata, "annotations")
   156  			}
   157  		}
   158  
   159  		out, _ = yaml.Marshal(obj)
   160  		updatedManifest += "\n---\n" + string(out)
   161  
   162  		return nil
   163  	})
   164  	if err != nil {
   165  		rel.Logger().WithError(err).Warn("failed to get latest objects for 3-way merge, skipping")
   166  
   167  		return oldManifest
   168  	}
   169  
   170  	return updatedManifest
   171  }
   172  
   173  //nolint:gocritic // cannot change argument types as it is required by diff library
   174  func diffChartsFilter(path []string, _ reflect.Type, _ reflect.StructField) bool {
   175  	return len(path) >= 1 && path[0] == "Metadata"
   176  }
   177  
   178  func diffCharts(ctx context.Context, oldChart *chart.Chart, rel release.Config, l log.FieldLogger) bool {
   179  	l.Info("getting charts diff")
   180  
   181  	dryRunRelease, err := rel.SyncDryRun(ctx, false)
   182  	if err != nil {
   183  		l.WithError(err).Error("failed to get dry-run release")
   184  
   185  		return false
   186  	}
   187  
   188  	newChart := dryRunRelease.Chart
   189  
   190  	changelog, err := structDiff.Diff(oldChart, newChart, structDiff.Filter(diffChartsFilter))
   191  	if err != nil {
   192  		l.WithError(err).Error("failed to get diff of charts")
   193  
   194  		return false
   195  	}
   196  
   197  	if len(changelog) == 0 {
   198  		return false
   199  	}
   200  
   201  	for i := range changelog {
   202  		change := changelog[i]
   203  		l.WithField("path", strings.Join(change.Path, ".")).Infof("🆚 %q -> %q", change.From, change.To)
   204  	}
   205  
   206  	return true
   207  }
   208  
   209  func parseManifests(m, ns string) map[string]*manifest.MappingResult {
   210  	manifests := manifest.Parse(m, ns, true)
   211  
   212  	type annotationManifest struct {
   213  		Metadata struct {
   214  			Annotations map[string]string
   215  		}
   216  	}
   217  
   218  	for k := range manifests {
   219  		parsed := annotationManifest{}
   220  
   221  		if err := yaml.Unmarshal([]byte(manifests[k].Content), &parsed); err != nil {
   222  			log.WithError(err).WithField("content", manifests[k].Content).Debug("failed to decode manifest")
   223  
   224  			continue
   225  		}
   226  
   227  		for anno := range parsed.Metadata.Annotations {
   228  			if !slices.Contains(SkippedAnnotations[anno], parsed.Metadata.Annotations[anno]) {
   229  				continue
   230  			}
   231  
   232  			log.WithFields(log.Fields{
   233  				"resource":   manifests[k].Name,
   234  				"annotation": anno,
   235  			}).Debug("resource diff is skipped due to annotation")
   236  			delete(manifests, k)
   237  		}
   238  	}
   239  
   240  	return manifests
   241  }
   242  
   243  // showChangesReport help function for reporting helm-diff.
   244  func showChangesReport(releases []release.Config, visited map[uniqname.UniqName]bool, k int) {
   245  	previous := false
   246  	for _, rel := range releases {
   247  		if visited[rel.Uniq()] {
   248  			continue
   249  		}
   250  
   251  		previous = true
   252  		rel.Logger().Warn("🆚 release was found in previous plan but not affected in new")
   253  	}
   254  
   255  	if k == len(releases) && !previous {
   256  		log.Info("🆚 🌝 Plan has no changes")
   257  	}
   258  }
   259  
   260  // GetLive returns maps of releases in a k8s-cluster.
   261  func (p *Plan) GetLive(
   262  	ctx context.Context,
   263  ) (found map[uniqname.UniqName]*live.Release, notFound []uniqname.UniqName, err error) {
   264  	wg := parallel.NewWaitGroup()
   265  	wg.Add(len(p.body.Releases))
   266  
   267  	found = make(map[uniqname.UniqName]*live.Release)
   268  	mu := &sync.Mutex{}
   269  
   270  	for i := range p.body.Releases {
   271  		go func(wg *parallel.WaitGroup, mu *sync.Mutex, rel release.Config) {
   272  			defer wg.Done()
   273  
   274  			r, err := rel.Get(0)
   275  
   276  			mu.Lock()
   277  			defer mu.Unlock()
   278  
   279  			if err != nil {
   280  				log.Warnf("I can't get release from k8s: %v", err)
   281  				//nolint:revive // we are under mutex here
   282  				notFound = append(notFound, rel.Uniq())
   283  			} else {
   284  				//nolint:revive // we are under mutex here
   285  				found[rel.Uniq()] = r
   286  			}
   287  		}(wg, mu, p.body.Releases[i])
   288  	}
   289  
   290  	if err := wg.WaitWithContext(ctx); err != nil {
   291  		return nil, nil, err
   292  	}
   293  
   294  	return found, notFound, nil
   295  }