github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/rollout-service/pkg/argo/argo.go (about) 1 /*This file is part of kuberpult. 2 3 Kuberpult is free software: you can redistribute it and/or modify 4 it under the terms of the Expat(MIT) License as published by 5 the Free Software Foundation. 6 7 Kuberpult is distributed in the hope that it will be useful, 8 but WITHOUT ANY WARRANTY; without even the implied warranty of 9 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 MIT License for more details. 11 12 You should have received a copy of the MIT License 13 along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>. 14 15 Copyright 2023 freiheit.com*/ 16 17 package argo 18 19 import ( 20 "context" 21 "fmt" 22 "github.com/google/go-cmp/cmp" 23 "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" 24 "path/filepath" 25 "slices" 26 27 "github.com/argoproj/argo-cd/v2/pkg/apiclient/application" 28 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 29 api "github.com/freiheit-com/kuberpult/pkg/api/v1" 30 "github.com/freiheit-com/kuberpult/pkg/logger" 31 "github.com/freiheit-com/kuberpult/pkg/ptr" 32 "github.com/freiheit-com/kuberpult/pkg/setup" 33 "go.uber.org/zap" 34 "google.golang.org/grpc" 35 "google.golang.org/grpc/codes" 36 "google.golang.org/grpc/status" 37 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 38 ) 39 40 // this is a simpler version of ApplicationServiceClient from the application package 41 type SimplifiedApplicationServiceClient interface { 42 Watch(ctx context.Context, qry *application.ApplicationQuery, opts ...grpc.CallOption) (application.ApplicationService_WatchClient, error) 43 } 44 45 type ArgoAppProcessor struct { 46 trigger chan *api.GetOverviewResponse 47 lastOverview *api.GetOverviewResponse 48 argoApps chan *v1alpha1.ApplicationWatchEvent 49 ApplicationClient application.ApplicationServiceClient 50 ManageArgoAppsEnabled bool 51 ManageArgoAppsFilter []string 52 } 53 54 func New(appClient application.ApplicationServiceClient, manageArgoApplicationEnabled bool, manageArgoApplicationFilter []string) ArgoAppProcessor { 55 return ArgoAppProcessor{ 56 lastOverview: nil, 57 ApplicationClient: appClient, 58 ManageArgoAppsEnabled: manageArgoApplicationEnabled, 59 ManageArgoAppsFilter: manageArgoApplicationFilter, 60 trigger: make(chan *api.GetOverviewResponse), 61 argoApps: make(chan *v1alpha1.ApplicationWatchEvent), 62 } 63 } 64 65 type Key struct { 66 AppName string 67 EnvName string 68 Application *api.Environment_Application 69 Environment *api.Environment 70 } 71 72 func (a *ArgoAppProcessor) Push(ctx context.Context, last *api.GetOverviewResponse) { 73 l := logger.FromContext(ctx).With(zap.String("argo-pushing", "ready")) 74 a.lastOverview = last 75 select { 76 case a.trigger <- a.lastOverview: 77 l.Info("argocd.pushed") 78 default: 79 } 80 } 81 82 func (a *ArgoAppProcessor) Consume(ctx context.Context, hlth *setup.HealthReporter) error { 83 hlth.ReportReady("event-consuming") 84 l := logger.FromContext(ctx).With(zap.String("self-manage", "consuming")) 85 appsKnownToArgo := map[string]map[string]*v1alpha1.Application{} 86 envAppsKnownToArgo := make(map[string]*v1alpha1.Application) 87 for { 88 select { 89 case overview := <-a.trigger: 90 for _, envGroup := range overview.EnvironmentGroups { 91 for _, env := range envGroup.Environments { 92 if ok := appsKnownToArgo[env.Name]; ok != nil { 93 envAppsKnownToArgo = appsKnownToArgo[env.Name] 94 err := a.DeleteArgoApps(ctx, envAppsKnownToArgo, env.Applications) 95 if err != nil { 96 l.Error("deleting applications", zap.Error(err)) 97 continue 98 } 99 } 100 101 for _, app := range env.Applications { 102 a.CreateOrUpdateApp(ctx, overview, app, env, envAppsKnownToArgo) 103 } 104 } 105 } 106 107 case ev := <-a.argoApps: 108 envName, appName := getEnvironmentAndName(ev.Application.Annotations) 109 if appName == "" { 110 continue 111 } 112 if appsKnownToArgo[envName] == nil { 113 appsKnownToArgo[envName] = map[string]*v1alpha1.Application{} 114 } 115 envKnownToArgo := appsKnownToArgo[envName] 116 switch ev.Type { 117 case "ADDED", "MODIFIED": 118 l.Info("created/updated:kuberpult.application:" + ev.Application.Name + ",kuberpult.environment:" + envName) 119 envKnownToArgo[appName] = &ev.Application 120 case "DELETED": 121 l.Info("deleted:kuberpult.application:" + ev.Application.Name + ",kuberpult.environment:" + envName) 122 delete(envKnownToArgo, appName) 123 } 124 appsKnownToArgo[envName] = envKnownToArgo 125 case <-ctx.Done(): 126 return nil 127 } 128 } 129 } 130 131 func (a ArgoAppProcessor) CreateOrUpdateApp(ctx context.Context, overview *api.GetOverviewResponse, app *api.Environment_Application, env *api.Environment, appsKnownToArgo map[string]*v1alpha1.Application) { 132 t := team(overview, app.Name) 133 span, ctx := tracer.StartSpanFromContext(ctx, "Create or Update Applications") 134 defer span.Finish() 135 136 var existingApp *v1alpha1.Application 137 if a.ManageArgoAppsEnabled && len(a.ManageArgoAppsFilter) > 0 && slices.Contains(a.ManageArgoAppsFilter, t) { 138 139 for _, argoApp := range appsKnownToArgo { 140 if argoApp.Annotations["com.freiheit.kuberpult/application"] == app.Name && argoApp.Annotations["com.freiheit.kuberpult/environment"] == env.Name { 141 existingApp = argoApp 142 break 143 } 144 } 145 146 if existingApp == nil { 147 appToCreate := CreateArgoApplication(overview, app, env) 148 appToCreate.ResourceVersion = "" 149 upsert := false 150 validate := false 151 appCreateRequest := &application.ApplicationCreateRequest{ 152 XXX_NoUnkeyedLiteral: struct{}{}, 153 XXX_unrecognized: nil, 154 XXX_sizecache: 0, 155 Application: appToCreate, 156 Upsert: &upsert, 157 Validate: &validate, 158 } 159 _, err := a.ApplicationClient.Create(ctx, appCreateRequest) 160 if err != nil { 161 // We check if the application was created in the meantime 162 if status.Code(err) != codes.InvalidArgument { 163 logger.FromContext(ctx).Error("creating "+appToCreate.Name+",env "+env.Name, zap.Error(err)) 164 } 165 } 166 } else { 167 appToUpdate := CreateArgoApplication(overview, app, env) 168 appUpdateRequest := &application.ApplicationUpdateRequest{ 169 XXX_NoUnkeyedLiteral: struct{}{}, 170 XXX_unrecognized: nil, 171 XXX_sizecache: 0, 172 Validate: ptr.Bool(false), 173 Application: appToUpdate, 174 Project: ptr.FromString(appToUpdate.Spec.Project), 175 } 176 //We have to exclude the unexported type isServerInferred. It is managed by Argo. 177 178 //exhaustruct:ignore 179 emptyAppSpec := v1alpha1.ApplicationSpec{} 180 diff := cmp.Diff(appUpdateRequest.Application.Spec, existingApp.Spec, cmp.AllowUnexported(emptyAppSpec.Destination)) 181 if diff != "" { 182 _, err := a.ApplicationClient.Update(ctx, appUpdateRequest) 183 if err != nil { 184 span.SetTag("argoDiff", diff) 185 logger.FromContext(ctx).Error("updating application: "+appToUpdate.Name+",env "+env.Name, zap.Error(err)) 186 } 187 } 188 } 189 } 190 } 191 192 func (a *ArgoAppProcessor) ConsumeArgo(ctx context.Context, hlth *setup.HealthReporter) error { 193 return hlth.Retry(ctx, func() error { 194 //exhaustruct:ignore 195 watch, err := a.ApplicationClient.Watch(ctx, &application.ApplicationQuery{}) 196 if err != nil { 197 if status.Code(err) == codes.Canceled { 198 // context is cancelled -> we are shutting down 199 return setup.Permanent(nil) 200 } 201 return fmt.Errorf("watching applications: %w", err) 202 } 203 hlth.ReportReady("consuming argo events") 204 for { 205 ev, err := watch.Recv() 206 if err != nil { 207 if status.Code(err) == codes.Canceled { 208 // context is cancelled -> we are shutting down 209 return setup.Permanent(nil) 210 } 211 return err 212 } 213 214 switch ev.Type { 215 case "ADDED", "MODIFIED", "DELETED": 216 a.argoApps <- ev 217 } 218 } 219 }) 220 } 221 222 func calculateFinalizers() []string { 223 return []string{ 224 "resources-finalizer.argocd.argoproj.io", 225 } 226 } 227 228 func (a ArgoAppProcessor) DeleteArgoApps(ctx context.Context, argoApps map[string]*v1alpha1.Application, apps map[string]*api.Environment_Application) error { 229 toDelete := make([]*v1alpha1.Application, 0) 230 for _, argoApp := range argoApps { 231 if apps[argoApp.Annotations["com.freiheit.kuberpult/application"]] == nil { 232 toDelete = append(toDelete, argoApp) 233 } 234 } 235 236 for i := range toDelete { 237 _, err := a.ApplicationClient.Delete(ctx, &application.ApplicationDeleteRequest{ 238 Cascade: nil, 239 PropagationPolicy: nil, 240 AppNamespace: nil, 241 Project: nil, 242 XXX_NoUnkeyedLiteral: struct{}{}, 243 XXX_unrecognized: nil, 244 XXX_sizecache: 0, 245 Name: ptr.FromString(toDelete[i].Name), 246 }) 247 248 if err != nil { 249 return err 250 } 251 } 252 253 return nil 254 } 255 256 func CreateArgoApplication(overview *api.GetOverviewResponse, app *api.Environment_Application, env *api.Environment) *v1alpha1.Application { 257 applicationNs := "" 258 259 annotations := make(map[string]string) 260 labels := make(map[string]string) 261 262 manifestPath := filepath.Join("environments", env.Name, "applications", app.Name, "manifests") 263 264 annotations["com.freiheit.kuberpult/application"] = app.Name 265 annotations["com.freiheit.kuberpult/environment"] = env.Name 266 annotations["com.freiheit.kuberpult/self-managed"] = "true" 267 // This annotation is so that argoCd does not invalidate *everything* in the whole repo when receiving a git webhook. 268 // It has to start with a "/" to be absolute to the git repo. 269 // See https://argo-cd.readthedocs.io/en/stable/operator-manual/high_availability/#webhook-and-manifest-paths-annotation 270 annotations["argocd.argoproj.io/manifest-generate-paths"] = "/" + manifestPath 271 labels["com.freiheit.kuberpult/team"] = team(overview, app.Name) 272 273 if env.Config.Argocd.Destination.Namespace != nil { 274 applicationNs = *env.Config.Argocd.Destination.Namespace 275 } else if env.Config.Argocd.Destination.ApplicationNamespace != nil { 276 applicationNs = *env.Config.Argocd.Destination.ApplicationNamespace 277 } 278 279 applicationDestination := v1alpha1.ApplicationDestination{ 280 Name: env.Config.Argocd.Destination.Name, 281 Namespace: applicationNs, 282 Server: env.Config.Argocd.Destination.Server, 283 } 284 285 ignoreDifferences := make([]v1alpha1.ResourceIgnoreDifferences, len(env.Config.Argocd.IgnoreDifferences)) 286 for index, value := range env.Config.Argocd.IgnoreDifferences { 287 difference := v1alpha1.ResourceIgnoreDifferences{ 288 Group: value.Group, 289 Kind: value.Kind, 290 Name: value.Name, 291 Namespace: value.Namespace, 292 JSONPointers: value.JsonPointers, 293 JQPathExpressions: value.JqPathExpressions, 294 ManagedFieldsManagers: value.ManagedFieldsManagers, 295 } 296 ignoreDifferences[index] = difference 297 } 298 //exhaustruct:ignore 299 ObjectMeta := metav1.ObjectMeta{ 300 Name: fmt.Sprintf("%s-%s", env.Name, app.Name), 301 Annotations: annotations, 302 Labels: labels, 303 Finalizers: calculateFinalizers(), 304 } 305 //exhaustruct:ignore 306 Source := &v1alpha1.ApplicationSource{ 307 RepoURL: overview.ManifestRepoUrl, 308 Path: manifestPath, 309 TargetRevision: overview.Branch, 310 } 311 //exhaustruct:ignore 312 SyncPolicy := &v1alpha1.SyncPolicy{ 313 Automated: &v1alpha1.SyncPolicyAutomated{ 314 Prune: true, 315 SelfHeal: true, 316 // We always allow empty, because it makes it easier to delete apps/environments 317 AllowEmpty: true, 318 }, 319 SyncOptions: env.Config.Argocd.SyncOptions, 320 } 321 //exhaustruct:ignore 322 Spec := v1alpha1.ApplicationSpec{ 323 Source: Source, 324 SyncPolicy: SyncPolicy, 325 Project: env.Name, 326 Destination: applicationDestination, 327 IgnoreDifferences: ignoreDifferences, 328 } 329 //exhaustruct:ignore 330 deployApp := &v1alpha1.Application{ 331 ObjectMeta: ObjectMeta, 332 Spec: Spec, 333 } 334 335 return deployApp 336 } 337 338 func team(overview *api.GetOverviewResponse, app string) string { 339 a := overview.Applications[app] 340 if a == nil { 341 return "" 342 } 343 return a.Team 344 } 345 346 func getEnvironmentAndName(annotations map[string]string) (string, string) { 347 return annotations["com.freiheit.kuberpult/environment"], annotations["com.freiheit.kuberpult/application"] 348 }