diff --git a/controllers/configauditreport/configauditreport_controller.go b/controllers/configauditreport/configauditreport_controller.go index d2ddd23e..f0b9d4e4 100644 --- a/controllers/configauditreport/configauditreport_controller.go +++ b/controllers/configauditreport/configauditreport_controller.go @@ -23,8 +23,10 @@ import ( aqua "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1" "github.com/go-logr/logr" "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -55,33 +57,22 @@ func (r *ConfigAuditReportReconciler) Reconcile(ctx context.Context, req ctrl.Re _ = log.FromContext(ctx) _ = r.Log.WithValues("configauditreport", req.NamespacedName) - report := &aqua.ConfigAuditReport{} - if err := r.Client.Get(ctx, req.NamespacedName, report); err != nil { - if apierrors.IsNotFound(err) { - // Most likely the report was deleted. - return ctrl.Result{}, nil - } - - // Error reading the object. - r.Log.Error(err, "Unable to read configauditreport") - return ctrl.Result{}, err - } - - // We have fetched the report and have four possibilities: - // - Not deleting + belongs to our shard --> give it our label (to trigger reconciliation by any previous owner) and expose the metric. - // - Not deleting + does not belong to our shard --> remove it from our metrics. Keep the finalizer. - // - Deleting + belongs to our shard --> remove it from our metrics. Remove the finalizer. - // - Deleting + does not belong to our shard --> remove it from our metrics. Keep the finalizer. + deletedSummaries := ConfigAuditSummary.DeletePartialMatch(prometheus.Labels{"report_name": req.NamespacedName.String()}) shouldOwn := r.ShardHelper.ShouldOwn(req.NamespacedName.String()) + if shouldOwn { - if report.DeletionTimestamp.IsZero() && shouldOwn { - // Give the report our finalizer if it doesn't have one. - if !utils.SliceContains(report.GetFinalizers(), ConfigAuditReportFinalizer) { - ctrlutil.AddFinalizer(report, ConfigAuditReportFinalizer) - if err := r.Update(ctx, report); err != nil { - return ctrl.Result{}, err + // Try to get the report. It might not exist anymore, in which case we don't need to do anything. + report := &aqua.ConfigAuditReport{} + if err := r.Client.Get(ctx, req.NamespacedName, report); err != nil { + if apierrors.IsNotFound(err) { + // Most likely the report was deleted. + return ctrl.Result{}, nil } + + // Error reading the object. + r.Log.Error(err, "Unable to read report") + return ctrl.Result{}, err } r.Log.Info(fmt.Sprintf("Reconciled %s || Found (C/H/M/L): %d/%d/%d/%d", @@ -92,32 +83,26 @@ func (r *ConfigAuditReportReconciler) Reconcile(ctx context.Context, req ctrl.Re report.Report.Summary.LowCount, )) - // Publish summary metrics for this report. + // Publish summary and CVE metrics for this report. publishSummaryMetrics(report) + if utils.SliceContains(report.GetFinalizers(), ConfigAuditReportFinalizer) { + // Remove the finalizer if we're the shard owner. + ctrlutil.RemoveFinalizer(report, ConfigAuditReportFinalizer) + if err := r.Update(ctx, report); err != nil { + return ctrl.Result{}, err + } + } + // Add a label to this report so any previous owners will reconcile and drop the metric. report.Labels[controllers.ShardOwnerLabel] = r.ShardHelper.PodIP err := r.Client.Update(ctx, report, &client.UpdateOptions{}) if err != nil { r.Log.Error(err, "unable to add shard owner label") } - } else { - // Unfortunately, we can't yet clear the series based on one label value, - // we have to reconstruct all of the label values to delete the series. - // That's the only reason the finalizer is needed at all. - // So we first clear our metrics for the report, and then remove the finalizer - // if we're the shard which owns this report. - - // Drop the report from our metrics. - r.clearImageMetrics(report) - - if shouldOwn && utils.SliceContains(report.GetFinalizers(), ConfigAuditReportFinalizer) { - // Remove the finalizer if we're the shard owner. - ctrlutil.RemoveFinalizer(report, ConfigAuditReportFinalizer) - if err := r.Update(ctx, report); err != nil { - return ctrl.Result{}, err - } + if deletedSummaries > 0 { + r.Log.Info(fmt.Sprintf("cleared %d summary metrics", deletedSummaries)) } } @@ -136,22 +121,6 @@ func (r *ConfigAuditReportReconciler) SetupWithManager(mgr ctrl.Manager) error { return nil } -func (r *ConfigAuditReportReconciler) clearImageMetrics(report *aqua.ConfigAuditReport) { - // clear summary metrics - summaryValues := valuesForReport(report, metricLabels) - - // Delete the series for each severity. - for severity := range getCountPerSeverity(report) { - v := summaryValues - v["severity"] = severity - - // Delete the metric. - ConfigAuditSummary.Delete( - v, - ) - } -} - func RequeueReportsForPod(c client.Client, log logr.Logger, podIP string) { reportList := &aqua.ConfigAuditReportList{} opts := []client.ListOption{ @@ -219,12 +188,14 @@ func valuesForReport(report *aqua.ConfigAuditReport, labels []string) map[string func reportValueFor(field string, report *aqua.ConfigAuditReport) string { switch field { + case "report_name": + return apitypes.NamespacedName{Name: report.Name, Namespace: report.Namespace}.String() case "resource_name": return report.Name case "resource_namespace": return report.Namespace case "severity": - return "" // this value will be overwritten on publishSummaryMetrics + return "" // this value will be overwritten in publishSummaryMetrics default: // Error? return "" diff --git a/controllers/configauditreport/configauditreport_metrics.go b/controllers/configauditreport/configauditreport_metrics.go index 01d40d39..e7625895 100644 --- a/controllers/configauditreport/configauditreport_metrics.go +++ b/controllers/configauditreport/configauditreport_metrics.go @@ -11,6 +11,7 @@ const ( ) var metricLabels = []string{ + "report_name", "resource_name", "resource_namespace", "severity", diff --git a/controllers/vulnerabilityreport/vulnerabilityreport_controller.go b/controllers/vulnerabilityreport/vulnerabilityreport_controller.go index 1b70b58e..8bfba8ee 100644 --- a/controllers/vulnerabilityreport/vulnerabilityreport_controller.go +++ b/controllers/vulnerabilityreport/vulnerabilityreport_controller.go @@ -27,6 +27,7 @@ import ( "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -64,37 +65,24 @@ func (r *VulnerabilityReportReconciler) Reconcile(ctx context.Context, req ctrl. registerMetricsOnce.Do(r.registerMetrics) - // Once deleting metrics based on partial matches is supported, the logic here will change so we clear the metric for all names not belonging to our shard. - // Then we can only fetch reports for our shard. For now, we'll try to get the report once even if it isn't our shard so that we can later clear the metric. - - report := &aqua.VulnerabilityReport{} - if err := r.Client.Get(ctx, req.NamespacedName, report); err != nil { - if apierrors.IsNotFound(err) { - // Most likely the report was deleted. - return ctrl.Result{}, nil - } - - // Error reading the object. - r.Log.Error(err, "Unable to read report") - return ctrl.Result{}, err - } - - // We have fetched the report and have four possibilities: - // - Not deleting + belongs to our shard --> give it our label (to trigger reconciliation by any previous owner) and expose the metric. - // - Not deleting + does not belong to our shard --> remove it from our metrics. Keep the finalizer. - // - Deleting + belongs to our shard --> remove it from our metrics. Remove the finalizer. - // - Deleting + does not belong to our shard --> remove it from our metrics. Keep the finalizer. + // The report has changed, meaning our metrics are out of date for this report. Clear them. + deletedSummaries := VulnerabilitySummary.DeletePartialMatch(prometheus.Labels{"report_name": req.NamespacedName.String()}) + deletedDetails := VulnerabilityInfo.DeletePartialMatch(prometheus.Labels{"report_name": req.NamespacedName.String()}) shouldOwn := r.ShardHelper.ShouldOwn(req.NamespacedName.String()) + if shouldOwn { - if report.DeletionTimestamp.IsZero() && shouldOwn { - - // Give the report our finalizer if it doesn't have one. - if !utils.SliceContains(report.GetFinalizers(), VulnerabilityReportFinalizer) { - ctrlutil.AddFinalizer(report, VulnerabilityReportFinalizer) - if err := r.Update(ctx, report); err != nil { - return ctrl.Result{}, err + // Try to get the report. It might not exist anymore, in which case we don't need to do anything. + report := &aqua.VulnerabilityReport{} + if err := r.Client.Get(ctx, req.NamespacedName, report); err != nil { + if apierrors.IsNotFound(err) { + // Most likely the report was deleted. + return ctrl.Result{}, nil } + + // Error reading the object. + r.Log.Error(err, "Unable to read report") + return ctrl.Result{}, err } r.Log.Info(fmt.Sprintf("Reconciled %s || Found (C/H/M/L/N/U): %d/%d/%d/%d/%d/%d", @@ -110,29 +98,23 @@ func (r *VulnerabilityReportReconciler) Reconcile(ctx context.Context, req ctrl. // Publish summary and CVE metrics for this report. r.publishImageMetrics(report) + if utils.SliceContains(report.GetFinalizers(), VulnerabilityReportFinalizer) { + // Remove the finalizer if we're the shard owner. + ctrlutil.RemoveFinalizer(report, VulnerabilityReportFinalizer) + if err := r.Update(ctx, report); err != nil { + return ctrl.Result{}, err + } + } + // Add a label to this report so any previous owners will reconcile and drop the metric. report.Labels[controllers.ShardOwnerLabel] = r.ShardHelper.PodIP err := r.Client.Update(ctx, report, &client.UpdateOptions{}) if err != nil { r.Log.Error(err, "unable to add shard owner label") } - } else { - // Unfortunately, we can't yet clear the series based on one label value, - // we have to reconstruct all of the label values to delete the series. - // That's the only reason the finalizer is needed at all. - // So we first clear our metrics for the report, and then remove the finalizer - // if we're the shard which owns this report. - - // Drop the report from our metrics. - r.clearImageMetrics(report) - - if shouldOwn && utils.SliceContains(report.GetFinalizers(), VulnerabilityReportFinalizer) { - // Remove the finalizer if we're the shard owner. - ctrlutil.RemoveFinalizer(report, VulnerabilityReportFinalizer) - if err := r.Update(ctx, report); err != nil { - return ctrl.Result{}, err - } + if deletedSummaries > 0 || deletedDetails > 0 { + r.Log.Info(fmt.Sprintf("cleared %d summary and %d detail metrics", deletedSummaries, deletedDetails)) } } @@ -166,20 +148,8 @@ func (r *VulnerabilityReportReconciler) SetupWithManager(mgr ctrl.Manager) error return nil } -func (r *VulnerabilityReportReconciler) clearImageMetrics(report *aqua.VulnerabilityReport) { - - clearSummaryMetrics(report) - - // If we have custom metrics to delete, do it. - if len(r.TargetLabels) > 0 { - clearCustomMetrics(report, r.TargetLabels) - } -} - func (r *VulnerabilityReportReconciler) publishImageMetrics(report *aqua.VulnerabilityReport) { - publishSummaryMetrics(report) - // If we have custom metrics to expose, do it. if len(r.TargetLabels) > 0 { publishCustomMetrics(report, r.TargetLabels) @@ -229,24 +199,8 @@ func getCountPerSeverity(report *aqua.VulnerabilityReport) map[string]float64 { } } -func clearSummaryMetrics(report *aqua.VulnerabilityReport) { - summaryValues := valuesForReport(report, LabelsForGroup(labelGroupSummary)) - - // Delete the series for each severity. - for severity := range getCountPerSeverity(report) { - v := summaryValues - v["severity"] = severity - - // Expose the metric. - VulnerabilitySummary.Delete( - v, - ) - } -} - func publishSummaryMetrics(report *aqua.VulnerabilityReport) { summaryValues := valuesForReport(report, LabelsForGroup(labelGroupSummary)) - // Add the severity label after the standard labels and expose each severity metric. for severity, count := range getCountPerSeverity(report) { v := summaryValues @@ -259,27 +213,8 @@ func publishSummaryMetrics(report *aqua.VulnerabilityReport) { } } -func clearCustomMetrics(report *aqua.VulnerabilityReport, targetLabels []VulnerabilityLabel) { - reportValues := valuesForReport(report, targetLabels) - - for _, v := range report.Report.Vulnerabilities { - vulnValues := valuesForVulnerability(v, targetLabels) - - // Include the Report-level values. - for label, value := range reportValues { - vulnValues[label] = value - } - - // Delete the metric - VulnerabilityInfo.Delete( - vulnValues, - ) - } -} - func publishCustomMetrics(report *aqua.VulnerabilityReport, targetLabels []VulnerabilityLabel) { reportValues := valuesForReport(report, targetLabels) - for _, v := range report.Report.Vulnerabilities { vulnValues := valuesForVulnerability(v, targetLabels) @@ -324,7 +259,8 @@ func valuesForVulnerability(vuln aqua.Vulnerability, labels []VulnerabilityLabel func reportValueFor(field string, report *aqua.VulnerabilityReport) string { switch field { case "report_name": - return report.Name + // Construct the namespacedname which we'll later be given at reconciliation. + return apitypes.NamespacedName{Name: report.Name, Namespace: report.Namespace}.String() case "image_namespace": return report.Namespace case "image_registry": diff --git a/go.mod b/go.mod index 6cb5a10b..e462e21c 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,19 @@ require ( github.com/cespare/xxhash/v2 v2.1.2 github.com/go-logr/logr v1.2.3 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.12.2 + github.com/prometheus/client_golang v1.12.2-0.20220421062905-4dcf02ec7b3c + // github.com/prometheus/client_golang v1.12.1 k8s.io/api v0.24.0 k8s.io/apimachinery v0.24.0 k8s.io/client-go v0.24.0 sigs.k8s.io/controller-runtime v0.12.1 ) +require ( + github.com/google/go-cmp v0.5.8 + gotest.tools v2.2.0+incompatible +) + require ( cloud.google.com/go/compute v1.6.1 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect @@ -37,7 +43,6 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/gnostic v0.6.9 // indirect - github.com/google/go-cmp v0.5.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/imdario/mergo v0.3.12 // indirect diff --git a/go.sum b/go.sum index 3d24dd32..4da905b9 100644 --- a/go.sum +++ b/go.sum @@ -443,8 +443,8 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34= -github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_golang v1.12.2-0.20220421062905-4dcf02ec7b3c h1:UPm1o0MgQzLM9Vv2RB3xAFgAOi3hIHvpb4fUHSGVxJo= +github.com/prometheus/client_golang v1.12.2-0.20220421062905-4dcf02ec7b3c/go.mod h1:hnQ3yqQt3g4fD/UXIaxxYrafyiYfxYUS9zsKetoOmXQ= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -456,6 +456,7 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/common v0.33.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE= github.com/prometheus/common v0.34.0 h1:RBmGO9d/FVjqHT0yUGQwBJhkwKV+wPCn7KGpvfab0uE= github.com/prometheus/common v0.34.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -1065,6 +1066,8 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index 54cf6ae7..f10b8a7f 100644 --- a/main.go +++ b/main.go @@ -116,6 +116,10 @@ func main() { targetLabels = appendIfNotExists(targetLabels, []vulnerabilityreport.VulnerabilityLabel{label}) } + // If exposing detail metrics, we must always include the report name in order to delete them by name later. + reportNameLabel, _ := vulnerabilityreport.LabelWithName("report_name") + targetLabels = appendIfNotExists(targetLabels, []vulnerabilityreport.VulnerabilityLabel{reportNameLabel}) + return nil }) diff --git a/utils/sharding.go b/utils/sharding.go index a41adc32..a41bf1e8 100644 --- a/utils/sharding.go +++ b/utils/sharding.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "os" "sync" @@ -114,21 +115,13 @@ func BuildPeerInformer(stopper chan struct{}, peerRing *ShardHelper, ringConfig // Set handlers for new/updated endpoints. handlers := cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { - added, kept, _, ok := getEndpointChanges(obj, nil, log) - if !ok { - return - } - peerRing.SetMembersFromLists(added, kept) + updateEndpoints(obj, nil, peerRing, log) }, UpdateFunc: func(oldObj, newObj interface{}) { // In the future, we might need to re-queue objects which belong to deleted peers. // When scaling down, it is possible that metrics will be double reported for up to the reconciliation period. // For now, we'll just set the desired peers. - added, kept, _, ok := getEndpointChanges(newObj, oldObj, log) - if !ok { - return - } - peerRing.SetMembersFromLists(added, kept) + updateEndpoints(newObj, oldObj, peerRing, log) }, // We can add a delete handler here. Not sure yet what it should do. } @@ -137,13 +130,36 @@ func BuildPeerInformer(stopper chan struct{}, peerRing *ShardHelper, ringConfig return informer } -// getEndpointChanges takes a current and optional previous object and returns the added, kept, and removed items, plus a success boolean. -func getEndpointChanges(currentObj interface{}, previousObj interface{}, log logr.Logger) ([]string, []string, []string, bool) { +func updateEndpoints(currentObj interface{}, previousObj interface{}, ring *ShardHelper, log logr.Logger) { current, err := toEndpoint(currentObj, log) if err != nil { log.Error(err, "could not convert obj to Endpoints") - return nil, nil, nil, false + return + } + + var previous *corev1.Endpoints + { + previous = nil + + if previousObj != nil { + previous, err = toEndpoint(currentObj, log) + if err != nil { + log.Error(err, "could not convert obj to Endpoints") + return + } + } + } + + added, kept, _, ok := getEndpointChanges(current, previous, log) + if !ok { + return } + ring.SetMembersFromLists(added, kept) + log.Info(fmt.Sprintf("updated peer list with %d endpoints", len(added)+len(kept))) +} + +// getEndpointChanges takes a current and optional previous object and returns the added, kept, and removed items, plus a success boolean. +func getEndpointChanges(current *corev1.Endpoints, previous *corev1.Endpoints, log logr.Logger) ([]string, []string, []string, bool) { currentEndpoints := []string{} // Stores current endpoints to return directly if we don't have a previous state. currentEndpointsMap := make(map[string]struct{}) // Stores the endpoints as a map for quicker comparisons to previous state. @@ -158,18 +174,12 @@ func getEndpointChanges(currentObj interface{}, previousObj interface{}, log log } } - if previousObj == nil { + if previous == nil { // If there is no previous object, we're only adding new (initial) endpoints. // Just return the current endpoint list. return currentEndpoints, nil, nil, true } - previous, err := toEndpoint(previousObj, log) - if err != nil { - log.Error(err, "could not convert obj to Endpoints") - return nil, nil, nil, false - } - added := []string{} kept := []string{} removed := []string{} diff --git a/utils/sharding_test.go b/utils/sharding_test.go new file mode 100644 index 00000000..5ee18cbb --- /dev/null +++ b/utils/sharding_test.go @@ -0,0 +1,220 @@ +package utils + +import ( + "strconv" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "gotest.tools/assert" + corev1 "k8s.io/api/core/v1" +) + +func Test_getEndpointChanges(t *testing.T) { + testCases := []struct { + name string + current *corev1.Endpoints + previous *corev1.Endpoints + expectedAdded []string + expectedKept []string + expectedRemoved []string + }{ + { + name: "add one new endpoint with no previous state", + current: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "1.2.3.4", + }, + }, + }, + }, + }, + expectedAdded: []string{"1.2.3.4"}, + expectedKept: []string{}, + expectedRemoved: []string{}, + }, + { + name: "add one new endpoint to one previous endpoint", + current: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "1.2.3.4", + }, + { + IP: "5.6.7.8", + }, + }, + }, + }, + }, + previous: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "1.2.3.4", + }, + }, + }, + }, + }, + expectedAdded: []string{"5.6.7.8"}, + expectedKept: []string{"1.2.3.4"}, + expectedRemoved: []string{}, + }, + { + name: "add multiple new endpoints to two previous endpoints", + current: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "8.8.8.8", + }, + { + IP: "8.8.4.4", + }, + { + IP: "1.2.3.4", + }, + { + IP: "5.6.7.8", + }, + }, + }, + }, + }, + previous: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "1.2.3.4", + }, + { + IP: "5.6.7.8", + }, + }, + }, + }, + }, + expectedAdded: []string{"8.8.4.4", "8.8.8.8"}, + expectedKept: []string{"1.2.3.4", "5.6.7.8"}, + expectedRemoved: []string{}, + }, + { + name: "remove multiple endpoints", + current: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "8.8.8.8", + }, + { + IP: "1.2.3.4", + }, + }, + }, + }, + }, + previous: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "8.8.8.8", + }, + { + IP: "8.8.4.4", + }, + { + IP: "1.2.3.4", + }, + { + IP: "5.6.7.8", + }, + }, + }, + }, + }, + expectedAdded: []string{}, + expectedKept: []string{"1.2.3.4", "8.8.8.8"}, + expectedRemoved: []string{"5.6.7.8", "8.8.4.4"}, + }, + { + name: "add and remove endpoints in one udpate", + current: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "8.8.4.4", + }, + { + IP: "1.2.3.4", + }, + { + IP: "5.6.7.8", + }, + }, + }, + }, + }, + previous: &corev1.Endpoints{ + Subsets: []corev1.EndpointSubset{ + { + Addresses: []corev1.EndpointAddress{ + { + IP: "8.8.8.8", + }, + { + IP: "1.2.3.4", + }, + }, + }, + }, + }, + expectedAdded: []string{"5.6.7.8", "8.8.4.4"}, + expectedKept: []string{"1.2.3.4"}, + expectedRemoved: []string{"8.8.8.8"}, + }, + } + + // Logger to pass to helper functions. Wraps testing.T. + log := testr.New(t) + + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + var previous *corev1.Endpoints + { + previous = nil + if tc.previous != nil { + previous = tc.previous + } + } + + // Calculate endpoint updates. + added, kept, removed, ok := getEndpointChanges(tc.current, previous, log) + + t.Logf("case %v: added: %v, kept: %v, removed: %v\n", tc, added, kept, removed) + + if !ok { + t.Fatalf("unable to parse endpoint changes for case %v: added: %s, kept: %s, removed: %s\n", tc, added, kept, removed) + } + + compareStringFunc := func(a, b string) bool { return a < b } + + // Check added, kept, and removed contain the expected items, ignoring order. + assert.Assert(t, cmp.Equal(tc.expectedAdded, added, cmpopts.EquateEmpty(), cmpopts.SortSlices(compareStringFunc)), "test case %v failed.", tc.name) + assert.Assert(t, cmp.Equal(tc.expectedKept, kept, cmpopts.EquateEmpty(), cmpopts.SortSlices(compareStringFunc)), "test case %v failed.", tc.name) + assert.Assert(t, cmp.Equal(tc.expectedRemoved, removed, cmpopts.EquateEmpty(), cmpopts.SortSlices(compareStringFunc)), "test case %v failed.", tc.name) + }) + } +}