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 }