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  }