diff --git a/controller/appcontroller.go b/controller/appcontroller.go index 9d500a7af0b59..5865d2055d7ec 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -939,7 +939,7 @@ func (ctrl *ApplicationController) processAppOperationQueueItem() (processNext b Message: err.Error(), }) message := fmt.Sprintf("Unable to delete application resources: %v", err.Error()) - ctrl.auditLogger.LogAppEvent(app, argo.EventInfo{Reason: argo.EventReasonStatusRefreshed, Type: v1.EventTypeWarning}, message, "") + ctrl.logAppEvent(app, argo.EventInfo{Reason: argo.EventReasonStatusRefreshed, Type: v1.EventTypeWarning}, message, context.TODO()) } } return @@ -1442,7 +1442,7 @@ func (ctrl *ApplicationController) setOperationState(app *appv1.Application, sta eventInfo.Type = v1.EventTypeWarning messages = append(messages, "failed:", state.Message) } - ctrl.auditLogger.LogAppEvent(app, eventInfo, strings.Join(messages, " "), "") + ctrl.logAppEvent(app, eventInfo, strings.Join(messages, " "), context.TODO()) ctrl.metricsServer.IncSync(app, state) } } @@ -1774,11 +1774,11 @@ func (ctrl *ApplicationController) persistAppStatus(orig *appv1.Application, new logCtx := getAppLog(orig) if orig.Status.Sync.Status != newStatus.Sync.Status { message := fmt.Sprintf("Updated sync status: %s -> %s", orig.Status.Sync.Status, newStatus.Sync.Status) - ctrl.auditLogger.LogAppEvent(orig, argo.EventInfo{Reason: argo.EventReasonResourceUpdated, Type: v1.EventTypeNormal}, message, "") + ctrl.logAppEvent(orig, argo.EventInfo{Reason: argo.EventReasonResourceUpdated, Type: v1.EventTypeNormal}, message, context.TODO()) } if orig.Status.Health.Status != newStatus.Health.Status { message := fmt.Sprintf("Updated health status: %s -> %s", orig.Status.Health.Status, newStatus.Health.Status) - ctrl.auditLogger.LogAppEvent(orig, argo.EventInfo{Reason: argo.EventReasonResourceUpdated, Type: v1.EventTypeNormal}, message, "") + ctrl.logAppEvent(orig, argo.EventInfo{Reason: argo.EventReasonResourceUpdated, Type: v1.EventTypeNormal}, message, context.TODO()) } var newAnnotations map[string]string if orig.GetAnnotations() != nil { @@ -1936,8 +1936,7 @@ func (ctrl *ApplicationController) autoSync(app *appv1.Application, syncStatus * target = desiredCommitSHA } message := fmt.Sprintf("Initiated automated sync to '%s'", target) - - ctrl.auditLogger.LogAppEvent(app, argo.EventInfo{Reason: argo.EventReasonOperationStarted, Type: v1.EventTypeNormal}, message, "") + ctrl.logAppEvent(app, argo.EventInfo{Reason: argo.EventReasonOperationStarted, Type: v1.EventTypeNormal}, message, context.TODO()) logCtx.Info(message) return nil, setOpTime } @@ -2277,4 +2276,9 @@ func (ctrl *ApplicationController) getAppList(options metav1.ListOptions) (*appv return appList, nil } +func (ctrl *ApplicationController) logAppEvent(a *appv1.Application, eventInfo argo.EventInfo, message string, ctx context.Context) { + eventLabels := argo.GetAppEventLabels(a, applisters.NewAppProjectLister(ctrl.projInformer.GetIndexer()), ctrl.namespace, ctrl.settingsMgr, ctrl.db, ctx) + ctrl.auditLogger.LogAppEvent(a, eventInfo, message, "", eventLabels) +} + type ClusterFilterFunction func(c *appv1.Cluster, distributionFunction sharding.DistributionFunction) bool diff --git a/docs/operator-manual/argocd-cm.yaml b/docs/operator-manual/argocd-cm.yaml index 88daa86c64334..61b8c3faa5d4f 100644 --- a/docs/operator-manual/argocd-cm.yaml +++ b/docs/operator-manual/argocd-cm.yaml @@ -221,6 +221,16 @@ data: # An optional comma-separated list of metadata.labels to observe in the UI. resource.customLabels: tier + # An optional comma-separated list of metadata.labels keys to add to Kubernetes events generated for Applications. + # The keys are compared against the Application and its AppProject. If matched, + # the corresponding labels are added to the generated event. + # In case of a conflict between labels on the Application and AppProject, + # the Application label values are prioritized and added to the event. Supports wildcards. + resource.includeEventLabelKeys: team,env* + # An optional comma-separated list of metadata.labels keys to exclude from Kubernetes events generated for Applications. Supports wildcards. + resource.excludeEventLabelKeys: environment,bu + + resource.compareoptions: | # if ignoreAggregatedRoles set to true then differences caused by aggregated roles in RBAC resources are ignored. ignoreAggregatedRoles: true diff --git a/docs/operator-manual/declarative-setup.md b/docs/operator-manual/declarative-setup.md index 2851ac953082f..324e6dc74620b 100644 --- a/docs/operator-manual/declarative-setup.md +++ b/docs/operator-manual/declarative-setup.md @@ -1132,6 +1132,22 @@ data: Custom Labels configured with `resource.customLabels` (comma separated string) will be displayed in the UI (for any resource that defines them). +## Labels on Application Events + +An optional comma-separated list of `metadata.labels` keys can be configured with `resource.includeEventLabelKeys` to add to Kubernetes events generated for Argo CD Applications. When events are generated for Applications containing the specified labels, the controller adds the matching labels to the event. This establishes an easy link between the event and the application, allowing for filtering using labels. In case of conflict between labels on the Application and AppProject, the Application label values are prioritized and added to the event. + +```yaml + resource.includeEventLabelKeys: team,env* +``` + +To exclude certain labels from events, use the `resource.excludeEventLabelKeys` key, which takes a comma-separated list of `metadata.labels` keys. + +```yaml + resource.excludeEventLabelKeys: environment,bu +``` + +Both `resource.includeEventLabelKeys` and `resource.excludeEventLabelKeys` support wildcards. + ## SSO & RBAC * SSO configuration details: [SSO](./user-management/index.md) diff --git a/server/application/application.go b/server/application/application.go index ec517a30c89fa..055000e882db2 100644 --- a/server/application/application.go +++ b/server/application/application.go @@ -2307,7 +2307,8 @@ func (s *Server) logAppEvent(a *appv1.Application, ctx context.Context, reason s user = "Unknown user" } message := fmt.Sprintf("%s %s", user, action) - s.auditLogger.LogAppEvent(a, eventInfo, message, user) + eventLabels := argo.GetAppEventLabels(a, applisters.NewAppProjectLister(s.projInformer.GetIndexer()), s.ns, s.settingsMgr, s.db, ctx) + s.auditLogger.LogAppEvent(a, eventInfo, message, user, eventLabels) } func (s *Server) logResourceEvent(res *appv1.ResourceNode, ctx context.Context, reason string, action string) { diff --git a/test/e2e/app_k8s_events_test.go b/test/e2e/app_k8s_events_test.go new file mode 100644 index 0000000000000..e306e2ddf0d5a --- /dev/null +++ b/test/e2e/app_k8s_events_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + . "github.com/argoproj/argo-cd/v2/test/e2e/fixture" + . "github.com/argoproj/argo-cd/v2/test/e2e/fixture/app" +) + +// resource.includeEventLabelKeys keys set in argocd-cm +func TestLabelsOnAppK8sEvents(t *testing.T) { + expectedLabels := map[string]string{"app": "test", "environment": "dev"} + + Given(t). + Timeout(60). + Path("two-nice-pods"). + When(). + SetParamInSettingConfigMap("resource.includeEventLabelKeys", "app,team,env*"). + SetParamInSettingConfigMap("resource.excludeEventLabelKeys", "team"). + CreateApp("--label=app=test", "--label=environment=dev", "--label=team=A", "--label=tier=ui"). + Sync(). + Then(). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + And(func(app *Application) { + events, err := KubeClientset.CoreV1().Events(app.Namespace).List(context.Background(), metav1.ListOptions{ + FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.kind=Application", app.Name), + }) + assert.NoError(t, err) + for _, event := range events.Items { + for k, v := range event.Labels { + ev, found := expectedLabels[k] + assert.True(t, found) + assert.Equal(t, ev, v) + } + } + }) +} + +// resource.includeEventLabelKeys keys not set in argocd-cm +func TestNoLabelsOnAppK8sEvents(t *testing.T) { + Given(t). + Timeout(60). + Path("two-nice-pods"). + When(). + CreateApp("--label=app=test", "--label=environment=dev", "--label=team=A", "--label=tier=ui"). + Sync(). + Then(). + Expect(SyncStatusIs(SyncStatusCodeSynced)). + And(func(app *Application) { + events, err := KubeClientset.CoreV1().Events(app.Namespace).List(context.Background(), metav1.ListOptions{ + FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.kind=Application", app.Name), + }) + assert.NoError(t, err) + for _, event := range events.Items { + assert.Nil(t, event.Labels) + } + }) +} diff --git a/util/argo/argo.go b/util/argo/argo.go index f5f7cefe585f3..99c9ca2a98f5a 100644 --- a/util/argo/argo.go +++ b/util/argo/argo.go @@ -27,6 +27,7 @@ import ( applicationsv1 "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" "github.com/argoproj/argo-cd/v2/reposerver/apiclient" "github.com/argoproj/argo-cd/v2/util/db" + "github.com/argoproj/argo-cd/v2/util/glob" "github.com/argoproj/argo-cd/v2/util/io" "github.com/argoproj/argo-cd/v2/util/settings" ) @@ -1104,3 +1105,49 @@ func IsValidContainerName(name string) bool { validationErrors := apimachineryvalidation.NameIsDNSLabel(name, false) return len(validationErrors) == 0 } + +// GetAppEventLabels returns a map of labels to add to a K8s event. +// The Application and its AppProject labels are compared against the `resource.includeEventLabelKeys` key in argocd-cm. +// If matched, the corresponding labels are returned to be added to the generated event. In case of a conflict +// between labels on the Application and AppProject, the Application label values are prioritized and added to the event. +// Furthermore, labels specified in `resource.excludeEventLabelKeys` in argocd-cm are removed from the event labels, if they were included. +func GetAppEventLabels(app *argoappv1.Application, projLister applicationsv1.AppProjectLister, ns string, settingsManager *settings.SettingsManager, db db.ArgoDB, ctx context.Context) map[string]string { + eventLabels := make(map[string]string) + + // Get all app & app-project labels + labels := app.Labels + if labels == nil { + labels = make(map[string]string) + } + proj, err := GetAppProject(app, projLister, ns, settingsManager, db, ctx) + if err == nil { + for k, v := range proj.Labels { + _, found := labels[k] + if !found { + labels[k] = v + } + } + } else { + log.Warn(err) + } + + // Filter out event labels to include + inKeys := settingsManager.GetIncludeEventLabelKeys() + for k, v := range labels { + found := glob.MatchStringInList(inKeys, k, false) + if found { + eventLabels[k] = v + } + } + + // Remove excluded event labels + exKeys := settingsManager.GetExcludeEventLabelKeys() + for k := range eventLabels { + found := glob.MatchStringInList(exKeys, k, false) + if found { + delete(eventLabels, k) + } + } + + return eventLabels +} diff --git a/util/argo/argo_test.go b/util/argo/argo_test.go index f10534b83e246..741ddcffb10b0 100644 --- a/util/argo/argo_test.go +++ b/util/argo/argo_test.go @@ -1575,3 +1575,114 @@ func TestAugmentSyncMsg(t *testing.T) { }) } } + +func TestGetAppEventLabels(t *testing.T) { + tests := []struct { + name string + cmInEventLabelKeys string + cmExEventLabelKeys string + appLabels map[string]string + projLabels map[string]string + expectedEventLabels map[string]string + }{ + { + name: "no label keys in cm - no event labels", + cmInEventLabelKeys: "", + appLabels: map[string]string{"team": "A", "tier": "frontend"}, + projLabels: map[string]string{"environment": "dev"}, + expectedEventLabels: nil, + }, + { + name: "label keys in cm, no labels on app & proj - no event labels", + cmInEventLabelKeys: "team, environment", + appLabels: nil, + projLabels: nil, + expectedEventLabels: nil, + }, + { + name: "labels on app, no labels on proj - event labels matched on app only", + cmInEventLabelKeys: "team, environment", + appLabels: map[string]string{"team": "A", "tier": "frontend"}, + projLabels: nil, + expectedEventLabels: map[string]string{"team": "A"}, + }, + { + name: "no labels on app, labels on proj - event labels matched on proj only", + cmInEventLabelKeys: "team, environment", + appLabels: nil, + projLabels: map[string]string{"environment": "dev"}, + expectedEventLabels: map[string]string{"environment": "dev"}, + }, + { + name: "labels on app & proj with conflicts - event labels matched on both app & proj and app labels prioritized on conflict", + cmInEventLabelKeys: "team, environment", + appLabels: map[string]string{"team": "A", "environment": "stage", "tier": "frontend"}, + projLabels: map[string]string{"environment": "dev"}, + expectedEventLabels: map[string]string{"team": "A", "environment": "stage"}, + }, + { + name: "wildcard support - matched all labels", + cmInEventLabelKeys: "*", + appLabels: map[string]string{"team": "A", "tier": "frontend"}, + projLabels: map[string]string{"environment": "dev"}, + expectedEventLabels: map[string]string{"team": "A", "tier": "frontend", "environment": "dev"}, + }, + { + name: "exclude event labels", + cmInEventLabelKeys: "example.com/team,tier,env*", + cmExEventLabelKeys: "tie*", + appLabels: map[string]string{"example.com/team": "A", "tier": "frontend"}, + projLabels: map[string]string{"environment": "dev"}, + expectedEventLabels: map[string]string{"example.com/team": "A", "environment": "dev"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "argocd-cm", + Namespace: test.FakeArgoCDNamespace, + Labels: map[string]string{ + "app.kubernetes.io/part-of": "argocd", + }, + }, + Data: map[string]string{ + "resource.includeEventLabelKeys": tt.cmInEventLabelKeys, + "resource.excludeEventLabelKeys": tt.cmExEventLabelKeys, + }, + } + + proj := &argoappv1.AppProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: test.FakeArgoCDNamespace, + Labels: tt.projLabels, + }, + } + + var app argoappv1.Application + app.Name = "test-app" + app.Namespace = test.FakeArgoCDNamespace + app.Labels = tt.appLabels + appClientset := appclientset.NewSimpleClientset(proj) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + indexers := cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc} + informer := v1alpha1.NewAppProjectInformer(appClientset, test.FakeArgoCDNamespace, 0, indexers) + go informer.Run(ctx.Done()) + cache.WaitForCacheSync(ctx.Done(), informer.HasSynced) + + kubeClient := fake.NewSimpleClientset(&cm) + settingsMgr := settings.NewSettingsManager(context.Background(), kubeClient, test.FakeArgoCDNamespace) + argoDB := db.NewDB("default", settingsMgr, kubeClient) + + eventLabels := GetAppEventLabels(&app, applisters.NewAppProjectLister(informer.GetIndexer()), test.FakeArgoCDNamespace, settingsMgr, argoDB, ctx) + assert.Equal(t, len(tt.expectedEventLabels), len(eventLabels)) + for ek, ev := range tt.expectedEventLabels { + v, found := eventLabels[ek] + assert.True(t, found) + assert.Equal(t, ev, v) + } + }) + } +} diff --git a/util/argo/audit_logger.go b/util/argo/audit_logger.go index 1645e8d7d65d8..ba0d483922b9c 100644 --- a/util/argo/audit_logger.go +++ b/util/argo/audit_logger.go @@ -45,7 +45,7 @@ const ( EventReasonOperationCompleted = "OperationCompleted" ) -func (l *AuditLogger) logEvent(objMeta ObjectRef, gvk schema.GroupVersionKind, info EventInfo, message string, logFields map[string]string) { +func (l *AuditLogger) logEvent(objMeta ObjectRef, gvk schema.GroupVersionKind, info EventInfo, message string, logFields map[string]string, eventLabels map[string]string) { logCtx := log.WithFields(log.Fields{ "type": info.Type, "reason": info.Reason, @@ -66,6 +66,7 @@ func (l *AuditLogger) logEvent(objMeta ObjectRef, gvk schema.GroupVersionKind, i event := v1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%v.%x", objMeta.Name, t.UnixNano()), + Labels: eventLabels, Annotations: logFields, }, Source: v1.EventSource{ @@ -94,7 +95,7 @@ func (l *AuditLogger) logEvent(objMeta ObjectRef, gvk schema.GroupVersionKind, i } } -func (l *AuditLogger) LogAppEvent(app *v1alpha1.Application, info EventInfo, message, user string) { +func (l *AuditLogger) LogAppEvent(app *v1alpha1.Application, info EventInfo, message, user string, eventLabels map[string]string) { objectMeta := ObjectRef{ Name: app.ObjectMeta.Name, Namespace: app.ObjectMeta.Namespace, @@ -108,7 +109,7 @@ func (l *AuditLogger) LogAppEvent(app *v1alpha1.Application, info EventInfo, mes if user != "" { fields["user"] = user } - l.logEvent(objectMeta, v1alpha1.ApplicationSchemaGroupVersionKind, info, message, fields) + l.logEvent(objectMeta, v1alpha1.ApplicationSchemaGroupVersionKind, info, message, fields, eventLabels) } func (l *AuditLogger) LogAppSetEvent(app *v1alpha1.ApplicationSet, info EventInfo, message, user string) { @@ -122,7 +123,7 @@ func (l *AuditLogger) LogAppSetEvent(app *v1alpha1.ApplicationSet, info EventInf if user != "" { fields["user"] = user } - l.logEvent(objectMeta, v1alpha1.ApplicationSetSchemaGroupVersionKind, info, message, fields) + l.logEvent(objectMeta, v1alpha1.ApplicationSetSchemaGroupVersionKind, info, message, fields, nil) } func (l *AuditLogger) LogResourceEvent(res *v1alpha1.ResourceNode, info EventInfo, message, user string) { @@ -140,7 +141,7 @@ func (l *AuditLogger) LogResourceEvent(res *v1alpha1.ResourceNode, info EventInf Group: res.Group, Version: res.Version, Kind: res.Kind, - }, info, message, fields) + }, info, message, fields, nil) } func (l *AuditLogger) LogAppProjEvent(proj *v1alpha1.AppProject, info EventInfo, message, user string) { @@ -154,7 +155,7 @@ func (l *AuditLogger) LogAppProjEvent(proj *v1alpha1.AppProject, info EventInfo, if user != "" { fields["user"] = user } - l.logEvent(objectMeta, v1alpha1.AppProjectSchemaGroupVersionKind, info, message, nil) + l.logEvent(objectMeta, v1alpha1.AppProjectSchemaGroupVersionKind, info, message, nil, nil) } func NewAuditLogger(ns string, kIf kubernetes.Interface, component string) *AuditLogger { diff --git a/util/argo/audit_logger_test.go b/util/argo/audit_logger_test.go index eef1780d0b4e7..17fcaf366c1d3 100644 --- a/util/argo/audit_logger_test.go +++ b/util/argo/audit_logger_test.go @@ -90,7 +90,7 @@ func TestLogAppEvent(t *testing.T) { } output := captureLogEntries(func() { - logger.LogAppEvent(&app, ei, "This is a test message", "") + logger.LogAppEvent(&app, ei, "This is a test message", "", nil) }) assert.Contains(t, output, "level=info") diff --git a/util/settings/settings.go b/util/settings/settings.go index 7b11274140365..1f036ddeff7c6 100644 --- a/util/settings/settings.go +++ b/util/settings/settings.go @@ -448,6 +448,10 @@ const ( resourceIgnoreResourceUpdatesEnabledKey = "resource.ignoreResourceUpdatesEnabled" // resourceCustomLabelKey is the key to a custom label to show in node info, if present resourceCustomLabelsKey = "resource.customLabels" + // resourceIncludeEventLabelKeys is the key to labels to be added onto Application k8s events if present on an Application or it's AppProject. Supports wildcard. + resourceIncludeEventLabelKeys = "resource.includeEventLabelKeys" + // resourceExcludeEventLabelKeys is the key to labels to be excluded from adding onto Application's k8s events. Supports wildcard. + resourceExcludeEventLabelKeys = "resource.excludeEventLabelKeys" // kustomizeBuildOptionsKey is a string of kustomize build parameters kustomizeBuildOptionsKey = "kustomize.buildOptions" // kustomizeVersionKeyPrefix is a kustomize version key prefix @@ -2221,3 +2225,35 @@ func (mgr *SettingsManager) GetResourceCustomLabels() ([]string, error) { } return []string{}, nil } + +func (mgr *SettingsManager) GetIncludeEventLabelKeys() []string { + labelKeys := []string{} + argoCDCM, err := mgr.getConfigMap() + if err != nil { + log.Error(fmt.Errorf("failed getting configmap: %w", err)) + return labelKeys + } + if value, ok := argoCDCM.Data[resourceIncludeEventLabelKeys]; ok { + if value != "" { + value = strings.ReplaceAll(value, " ", "") + labelKeys = strings.Split(value, ",") + } + } + return labelKeys +} + +func (mgr *SettingsManager) GetExcludeEventLabelKeys() []string { + labelKeys := []string{} + argoCDCM, err := mgr.getConfigMap() + if err != nil { + log.Error(fmt.Errorf("failed getting configmap: %w", err)) + return labelKeys + } + if value, ok := argoCDCM.Data[resourceExcludeEventLabelKeys]; ok { + if value != "" { + value = strings.ReplaceAll(value, " ", "") + labelKeys = strings.Split(value, ",") + } + } + return labelKeys +} diff --git a/util/settings/settings_test.go b/util/settings/settings_test.go index 169e4148b9dec..6c9d901f39e83 100644 --- a/util/settings/settings_test.go +++ b/util/settings/settings_test.go @@ -710,6 +710,46 @@ func TestSettingsManager_GetKustomizeBuildOptions(t *testing.T) { }) } +func TestSettingsManager_GetEventLabelKeys(t *testing.T) { + tests := []struct { + name string + data string + expectedKeys []string + }{ + { + name: "Comma separated data", + data: "app,env, tier, example.com/team-*, *", + expectedKeys: []string{"app", "env", "tier", "example.com/team-*", "*"}, + }, + { + name: "Empty data", + expectedKeys: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, settingsManager := fixtures(map[string]string{}) + if tt.data != "" { + _, settingsManager = fixtures(map[string]string{ + resourceIncludeEventLabelKeys: tt.data, + resourceExcludeEventLabelKeys: tt.data, + }) + } + + inKeys := settingsManager.GetIncludeEventLabelKeys() + assert.Equal(t, len(tt.expectedKeys), len(inKeys)) + + exKeys := settingsManager.GetExcludeEventLabelKeys() + assert.Equal(t, len(tt.expectedKeys), len(exKeys)) + + for i := range tt.expectedKeys { + assert.Equal(t, tt.expectedKeys[i], inKeys[i]) + assert.Equal(t, tt.expectedKeys[i], exKeys[i]) + } + }) + } +} + func TestKustomizeSettings_GetOptions(t *testing.T) { settings := KustomizeSettings{ BuildOptions: "--opt1 val1",