diff --git a/lib/healthmonitoring/healthmonitoring.go b/lib/healthmonitoring/healthmonitoring.go new file mode 100644 index 00000000..08ae06e9 --- /dev/null +++ b/lib/healthmonitoring/healthmonitoring.go @@ -0,0 +1,407 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package healthmonitoring joins the health reports of one cluster with the ClusterSummaries +// that own the HealthChecks behind them. It has no client and no locks: callers (ui-backend, +// sveltosctl) build the inputs from their own caches or from Lists and pass them in, so the +// cluster list and the per-cluster detail are computed by the same code. +package healthmonitoring + +import ( + "sort" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" +) + +// State is the health state of a profile or of a cluster. +type State string + +const ( + // StateNotEnabled means no profile has health monitoring enabled. + StateNotEnabled State = "NotEnabled" + + // StateNoData means monitoring is enabled but no report has been received yet. + StateNoData State = "NoData" + + StateHealthy State = "Healthy" + StateSuspended State = "Suspended" + StateProgressing State = "Progressing" + StateDegraded State = "Degraded" +) + +// SummaryInput is what the join needs from one ClusterSummary. +type SummaryInput struct { + // Summary is the ClusterSummary. + Summary corev1.ObjectReference + + // Profile is the ClusterProfile or Profile owning the ClusterSummary. + Profile corev1.ObjectReference + + // Enabled is true when the profile has health monitoring enabled. + Enabled bool + + // ListedChecks are the HealthCheck names the profile lists in healthMonitoring.healthChecks. + ListedChecks []string + + // GeneratedCheck is the name of the HealthCheck generated for the ClusterSummary, + // empty if it has not been created yet. + GeneratedCheck string +} + +// ReportInput is the compact copy of one HealthCheckReport of the cluster. +type ReportInput struct { + HealthCheckName string + Resources []ResourceInput +} + +// ResourceInput is the status of one resource in a report. +type ResourceInput struct { + ObjectRef corev1.ObjectReference + Status libsveltosv1beta1.HealthStatus + Message string +} + +// ClusterHealth is the result of Evaluate for one cluster. +type ClusterHealth struct { + State State + + // Profiles has one entry per ClusterSummary with health monitoring enabled. + Profiles []ProfileHealth + + // Resources are the resources not Healthy, one entry per resource, worst status first. + Resources []UnhealthyResource +} + +// ProfileHealth is the health state of one profile on the cluster. +type ProfileHealth struct { + Profile corev1.ObjectReference + Summary corev1.ObjectReference + State State +} + +// UnhealthyResource is a resource reported not Healthy. +type UnhealthyResource struct { + ObjectRef corev1.ObjectReference + Status libsveltosv1beta1.HealthStatus + Message string + + // Profiles are the profiles owning the HealthCheck that reported the resource. + // Empty when no profile owns it. + Profiles []corev1.ObjectReference + + // HealthCheckName is the HealthCheck that reported the resource. Set only when Profiles is empty. + HealthCheckName string +} + +// Evaluate runs the join for ONE cluster: pass only the ClusterSummaries and the reports of that cluster. +// - A summary owns its generated HealthCheck and the ones its profile lists. +// - A report is attributed to every enabled summary owning its HealthCheck. A report +// nobody owns is "unclaimed" and its resources carry the HealthCheck name instead of profiles. +// - A profile has no data until a report of one of its checks exists. A report with +// no unhealthy resources makes it Healthy. +// - The cluster state is the worst state among the profiles and the listed resources. +func Evaluate(summaries []SummaryInput, reports []ReportInput) ClusterHealth { + enabled := enabledSummaries(summaries) + if len(enabled) == 0 { + return ClusterHealth{State: StateNotEnabled} + } + + profiles := make([]ProfileHealth, len(enabled)) + for i := range enabled { + profiles[i] = ProfileHealth{Profile: enabled[i].Profile, Summary: enabled[i].Summary, State: StateNoData} + } + + owners := ownersByCheck(enabled) + collector := newResourceCollector() + for i := range reports { + owning := owners[reports[i].HealthCheckName] + reportState := StateHealthy + for j := range reports[i].Resources { + resource := &reports[i].Resources[j] + reportState = WorstState(reportState, StateOf(resource.Status)) + if resource.Status != libsveltosv1beta1.HealthStatusHealthy { + collector.add(resource, owningProfiles(profiles, owning), reports[i].HealthCheckName) + } + } + for _, index := range owning { + profiles[index].State = WorstState(profiles[index].State, reportState) + } + } + + resources := collector.sorted() + state := StateNoData + for i := range profiles { + state = WorstState(state, profiles[i].State) + } + for i := range resources { + state = WorstState(state, StateOf(resources[i].Status)) + } + + return ClusterHealth{State: state, Profiles: profiles, Resources: resources} +} + +// StateOf converts the status of a resource into a State. +func StateOf(status libsveltosv1beta1.HealthStatus) State { + switch status { + case libsveltosv1beta1.HealthStatusHealthy: + return StateHealthy + case libsveltosv1beta1.HealthStatusSuspended: + return StateSuspended + case libsveltosv1beta1.HealthStatusProgressing: + return StateProgressing + case libsveltosv1beta1.HealthStatusDegraded: + return StateDegraded + } + return StateNoData +} + +// WorstState returns the worse of two states. From best to worst: NotEnabled, NoData, Healthy, +// Suspended, Progressing, Degraded. +func WorstState(a, b State) State { + if severity(b) > severity(a) { + return b + } + return a +} + +var ( + // statesBestToWorst is the order used to compare states. + statesBestToWorst = []State{ + StateNotEnabled, StateNoData, StateHealthy, StateSuspended, StateProgressing, StateDegraded, + } +) + +func severity(state State) int { + for i := range statesBestToWorst { + if statesBestToWorst[i] == state { + return i + } + } + return 0 +} + +// GeneratedCheckOwner returns the ClusterSummary a HealthCheck has been generated for. It +// reads only the metadata, so a metadata only cache is enough. The returned bool is false +// when the object does not carry the labels and annotations of a generated check. +func GeneratedCheckOwner(obj metav1.Object) (*corev1.ObjectReference, bool) { + if _, ok := obj.GetLabels()[configv1beta1.HealthMonitoringClusterSummaryUIDLabel]; !ok { + return nil, false + } + + annotations := obj.GetAnnotations() + namespace := annotations[configv1beta1.HealthMonitoringClusterSummaryNamespaceAnnotation] + name := annotations[configv1beta1.HealthMonitoringClusterSummaryNameAnnotation] + if namespace == "" || name == "" { + return nil, false + } + + return &corev1.ObjectReference{ + APIVersion: configv1beta1.GroupVersion.String(), + Kind: configv1beta1.ClusterSummaryKind, + Namespace: namespace, + Name: name, + }, true +} + +// NewSummaryInput builds the input for a ClusterSummary. generatedCheck is the name of the HealthCheck +// generated for it, empty if unknown. Listed HealthChecks are honored for a ClusterProfile only: a +// Profile gets the default checks only (see HealthMonitoring.HealthChecks). +func NewSummaryInput(clusterSummary *configv1beta1.ClusterSummary, generatedCheck string) (SummaryInput, error) { + profileRef, err := configv1beta1.GetProfileRef(clusterSummary) + if err != nil { + return SummaryInput{}, err + } + if profileRef.Kind == configv1beta1.ProfileKind { + profileRef.Namespace = clusterSummary.Namespace + } + + input := SummaryInput{ + Summary: corev1.ObjectReference{ + APIVersion: configv1beta1.GroupVersion.String(), + Kind: configv1beta1.ClusterSummaryKind, + Namespace: clusterSummary.Namespace, + Name: clusterSummary.Name, + }, + Profile: *profileRef, + GeneratedCheck: generatedCheck, + } + + healthMonitoring := clusterSummary.Spec.ClusterProfileSpec.HealthMonitoring + if healthMonitoring == nil || !healthMonitoring.Enabled { + return input, nil + } + + input.Enabled = true + if profileRef.Kind == configv1beta1.ClusterProfileKind { + input.ListedChecks = append([]string{}, healthMonitoring.HealthChecks...) + } + + return input, nil +} + +// NewReportInput builds the input for a HealthCheckReport. +func NewReportInput(report *libsveltosv1beta1.HealthCheckReport) ReportInput { + resources := make([]ResourceInput, len(report.Spec.ResourceStatuses)) + for i := range report.Spec.ResourceStatuses { + resources[i] = ResourceInput{ + ObjectRef: report.Spec.ResourceStatuses[i].ObjectRef, + Status: report.Spec.ResourceStatuses[i].HealthStatus, + Message: report.Spec.ResourceStatuses[i].Message, + } + } + + return ReportInput{HealthCheckName: report.Spec.HealthCheckName, Resources: resources} +} + +func enabledSummaries(summaries []SummaryInput) []SummaryInput { + enabled := make([]SummaryInput, 0, len(summaries)) + for i := range summaries { + if summaries[i].Enabled { + enabled = append(enabled, summaries[i]) + } + } + return enabled +} + +// ownersByCheck maps a HealthCheck name to the indexes (in summaries) of the summaries owning it. +func ownersByCheck(summaries []SummaryInput) map[string][]int { + owners := make(map[string][]int) + for i := range summaries { + names := summaries[i].ListedChecks + if summaries[i].GeneratedCheck != "" { + names = append([]string{summaries[i].GeneratedCheck}, names...) + } + for _, name := range uniqueNames(names) { + owners[name] = append(owners[name], i) + } + } + return owners +} + +func uniqueNames(names []string) []string { + seen := make(map[string]bool, len(names)) + unique := make([]string, 0, len(names)) + for _, name := range names { + if !seen[name] { + seen[name] = true + unique = append(unique, name) + } + } + return unique +} + +func owningProfiles(profiles []ProfileHealth, indexes []int) []corev1.ObjectReference { + refs := make([]corev1.ObjectReference, 0, len(indexes)) + for _, index := range indexes { + refs = append(refs, profiles[index].Profile) + } + return refs +} + +// resourceKey identifies a resource: the same resource reported by several checks is listed once. +type resourceKey struct { + apiVersion string + kind string + namespace string + name string +} + +type resourceCollector struct { + byKey map[resourceKey]*UnhealthyResource + order []resourceKey +} + +func newResourceCollector() *resourceCollector { + return &resourceCollector{byKey: make(map[resourceKey]*UnhealthyResource)} +} + +// add records an unhealthy resource. If the resource is already known, the profiles are merged +// and the worst status, with its message, is kept. +func (c *resourceCollector) add(resource *ResourceInput, profiles []corev1.ObjectReference, healthCheckName string) { + key := resourceKey{ + apiVersion: resource.ObjectRef.APIVersion, + kind: resource.ObjectRef.Kind, + namespace: resource.ObjectRef.Namespace, + name: resource.ObjectRef.Name, + } + + existing, ok := c.byKey[key] + if !ok { + entry := &UnhealthyResource{ + ObjectRef: resource.ObjectRef, + Status: resource.Status, + Message: resource.Message, + Profiles: profiles, + } + if len(profiles) == 0 { + entry.HealthCheckName = healthCheckName + } + c.byKey[key] = entry + c.order = append(c.order, key) + return + } + + if severity(StateOf(resource.Status)) > severity(StateOf(existing.Status)) { + existing.Status = resource.Status + existing.Message = resource.Message + } + for i := range profiles { + if !containsRef(existing.Profiles, &profiles[i]) { + existing.Profiles = append(existing.Profiles, profiles[i]) + } + } + if len(existing.Profiles) > 0 { + existing.HealthCheckName = "" + } +} + +// sorted returns the collected resources, worst status first, then by kind, namespace and name. +func (c *resourceCollector) sorted() []UnhealthyResource { + resources := make([]UnhealthyResource, 0, len(c.order)) + for _, key := range c.order { + resources = append(resources, *c.byKey[key]) + } + + sort.SliceStable(resources, func(i, j int) bool { + si, sj := severity(StateOf(resources[i].Status)), severity(StateOf(resources[j].Status)) + if si != sj { + return si > sj + } + a, b := resources[i].ObjectRef, resources[j].ObjectRef + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if a.Namespace != b.Namespace { + return a.Namespace < b.Namespace + } + return a.Name < b.Name + }) + + return resources +} + +func containsRef(refs []corev1.ObjectReference, ref *corev1.ObjectReference) bool { + for i := range refs { + if refs[i].Kind == ref.Kind && refs[i].Namespace == ref.Namespace && refs[i].Name == ref.Name { + return true + } + } + return false +} diff --git a/lib/healthmonitoring/healthmonitoring_suite_test.go b/lib/healthmonitoring/healthmonitoring_suite_test.go new file mode 100644 index 00000000..1c9a8e2d --- /dev/null +++ b/lib/healthmonitoring/healthmonitoring_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package healthmonitoring_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHealthMonitoring(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "HealthMonitoring Suite") +} diff --git a/lib/healthmonitoring/healthmonitoring_test.go b/lib/healthmonitoring/healthmonitoring_test.go new file mode 100644 index 00000000..d6085b36 --- /dev/null +++ b/lib/healthmonitoring/healthmonitoring_test.go @@ -0,0 +1,412 @@ +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package healthmonitoring_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/lib/healthmonitoring" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" +) + +const ( + testNamespace = "ns" + generatedCheckName = "hm-1" + listedCheck = "mine" + testUID = "uid" + summaryName = "summary" + resourceNamespace = "d" +) + +func summaryInput(name, profileName string, enabled bool, + generatedCheck string, listedChecks ...string) healthmonitoring.SummaryInput { + + return healthmonitoring.SummaryInput{ + Summary: corev1.ObjectReference{Kind: configv1beta1.ClusterSummaryKind, Namespace: testNamespace, Name: name}, + Profile: corev1.ObjectReference{Kind: configv1beta1.ClusterProfileKind, Name: profileName}, + Enabled: enabled, + GeneratedCheck: generatedCheck, + ListedChecks: listedChecks, + } +} + +func resourceInput(kind, name string, status libsveltosv1beta1.HealthStatus, + message string) healthmonitoring.ResourceInput { + + return healthmonitoring.ResourceInput{ + ObjectRef: corev1.ObjectReference{APIVersion: "apps/v1", Kind: kind, Namespace: resourceNamespace, Name: name}, + Status: status, + Message: message, + } +} + +var _ = Describe("Evaluate", func() { + It("returns NotEnabled when no summary has monitoring enabled", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", false, generatedCheckName), + } + reports := []healthmonitoring.ReportInput{{HealthCheckName: generatedCheckName}} + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.State).To(Equal(healthmonitoring.StateNotEnabled)) + Expect(result.Profiles).To(BeEmpty()) + Expect(result.Resources).To(BeEmpty()) + + Expect(healthmonitoring.Evaluate(nil, nil).State).To(Equal(healthmonitoring.StateNotEnabled)) + }) + + It("returns NoData for a profile and the cluster when no report exists", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + } + + result := healthmonitoring.Evaluate(summaries, nil) + Expect(result.State).To(Equal(healthmonitoring.StateNoData)) + Expect(result.Profiles).To(HaveLen(1)) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateNoData)) + Expect(result.Profiles[0].Profile.Name).To(Equal("p1")) + }) + + It("does not use a report of a check the summary does not own as data", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, ""), + } + reports := []healthmonitoring.ReportInput{{HealthCheckName: generatedCheckName}} + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateNoData)) + Expect(result.State).To(Equal(healthmonitoring.StateNoData)) + }) + + It("returns Healthy for a profile whose report has no unhealthy resources", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + } + reports := []healthmonitoring.ReportInput{{HealthCheckName: generatedCheckName}} + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.State).To(Equal(healthmonitoring.StateHealthy)) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateHealthy)) + Expect(result.Resources).To(BeEmpty()) + }) + + It("attributes resources of the generated check and of a listed check to the profile", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName, listedCheck), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName, Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusDegraded, "no replicas"), + }}, + {HealthCheckName: listedCheck, Resources: []healthmonitoring.ResourceInput{ + resourceInput("StatefulSet", "db", libsveltosv1beta1.HealthStatusProgressing, "rolling"), + }}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.State).To(Equal(healthmonitoring.StateDegraded)) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateDegraded)) + Expect(result.Resources).To(HaveLen(2)) + for i := range result.Resources { + Expect(result.Resources[i].Profiles).To(HaveLen(1)) + Expect(result.Resources[i].Profiles[0].Name).To(Equal("p1")) + Expect(result.Resources[i].HealthCheckName).To(BeEmpty()) + } + }) + + It("lists a resource of a shared check once, under every profile listing the check", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName, "shared"), + summaryInput("s2", "p2", true, "hm-2", "shared"), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: "shared", Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusDegraded, "bad"), + }}, + {HealthCheckName: generatedCheckName}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Resources).To(HaveLen(1)) + Expect(result.Resources[0].Profiles).To(HaveLen(2)) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateDegraded)) + Expect(result.Profiles[1].State).To(Equal(healthmonitoring.StateDegraded)) + }) + + It("lists the resources of a report no profile owns as unclaimed and counts them in the cluster state", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName}, + {HealthCheckName: "user-check", Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusSuspended, "paused"), + }}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Profiles[0].State).To(Equal(healthmonitoring.StateHealthy)) + Expect(result.Resources).To(HaveLen(1)) + Expect(result.Resources[0].Profiles).To(BeEmpty()) + Expect(result.Resources[0].HealthCheckName).To(Equal("user-check")) + Expect(result.State).To(Equal(healthmonitoring.StateSuspended)) + }) + + It("ignores the reports of a summary with monitoring disabled", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + summaryInput("s2", "p2", false, "hm-2"), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Profiles).To(HaveLen(1)) + Expect(result.Profiles[0].Profile.Name).To(Equal("p1")) + }) + + It("keeps the worst status of a resource reported by two checks", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName, listedCheck), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName, Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusProgressing, "rolling"), + }}, + {HealthCheckName: listedCheck, Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusDegraded, "crashing"), + }}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Resources).To(HaveLen(1)) + Expect(result.Resources[0].Status).To(Equal(libsveltosv1beta1.HealthStatusDegraded)) + Expect(result.Resources[0].Message).To(Equal("crashing")) + Expect(result.Resources[0].Profiles).To(HaveLen(1)) + }) + + It("does not list Healthy resources", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName, Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "web", libsveltosv1beta1.HealthStatusHealthy, ""), + }}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.State).To(Equal(healthmonitoring.StateHealthy)) + Expect(result.Resources).To(BeEmpty()) + }) + + It("sorts the resources worst status first, then by kind, namespace and name", func() { + summaries := []healthmonitoring.SummaryInput{ + summaryInput("s1", "p1", true, generatedCheckName), + } + reports := []healthmonitoring.ReportInput{ + {HealthCheckName: generatedCheckName, Resources: []healthmonitoring.ResourceInput{ + resourceInput("Deployment", "b", libsveltosv1beta1.HealthStatusProgressing, ""), + resourceInput("StatefulSet", "a", libsveltosv1beta1.HealthStatusDegraded, ""), + resourceInput("Deployment", "a", libsveltosv1beta1.HealthStatusProgressing, ""), + resourceInput("Deployment", "c", libsveltosv1beta1.HealthStatusDegraded, ""), + }}, + } + + result := healthmonitoring.Evaluate(summaries, reports) + names := make([]string, 0, len(result.Resources)) + for i := range result.Resources { + names = append(names, result.Resources[i].ObjectRef.Kind+"/"+result.Resources[i].ObjectRef.Name) + } + Expect(names).To(Equal([]string{"Deployment/c", "StatefulSet/a", "Deployment/a", "Deployment/b"})) + }) + + It("evaluates 100 summaries and reports", func() { + summaries := make([]healthmonitoring.SummaryInput, 0, 100) + reports := make([]healthmonitoring.ReportInput, 0, 100) + for i := 0; i < 100; i++ { + check := "hm-" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + summaries = append(summaries, summaryInput(check, check, true, check)) + reports = append(reports, healthmonitoring.ReportInput{HealthCheckName: check}) + } + + result := healthmonitoring.Evaluate(summaries, reports) + Expect(result.Profiles).To(HaveLen(100)) + Expect(result.State).To(Equal(healthmonitoring.StateHealthy)) + }) +}) + +var _ = Describe("WorstState", func() { + It("orders the states from NotEnabled to Degraded", func() { + ordered := []healthmonitoring.State{ + healthmonitoring.StateNotEnabled, healthmonitoring.StateNoData, healthmonitoring.StateHealthy, + healthmonitoring.StateSuspended, healthmonitoring.StateProgressing, healthmonitoring.StateDegraded, + } + for i := range ordered { + for j := range ordered { + worst := ordered[i] + if j > i { + worst = ordered[j] + } + Expect(healthmonitoring.WorstState(ordered[i], ordered[j])).To(Equal(worst)) + } + } + }) +}) + +var _ = Describe("StateOf", func() { + It("maps every health status", func() { + Expect(healthmonitoring.StateOf(libsveltosv1beta1.HealthStatusHealthy)).To(Equal(healthmonitoring.StateHealthy)) + Expect(healthmonitoring.StateOf(libsveltosv1beta1.HealthStatusSuspended)).To(Equal(healthmonitoring.StateSuspended)) + Expect(healthmonitoring.StateOf(libsveltosv1beta1.HealthStatusProgressing)).To(Equal(healthmonitoring.StateProgressing)) + Expect(healthmonitoring.StateOf(libsveltosv1beta1.HealthStatusDegraded)).To(Equal(healthmonitoring.StateDegraded)) + }) +}) + +var _ = Describe("GeneratedCheckOwner", func() { + It("returns the ClusterSummary a generated HealthCheck belongs to", func() { + healthCheck := &libsveltosv1beta1.HealthCheck{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hm-abc", + Labels: map[string]string{configv1beta1.HealthMonitoringClusterSummaryUIDLabel: testUID}, + Annotations: map[string]string{ + configv1beta1.HealthMonitoringClusterSummaryNamespaceAnnotation: testNamespace, + configv1beta1.HealthMonitoringClusterSummaryNameAnnotation: summaryName, + }, + }, + } + + ref, ok := healthmonitoring.GeneratedCheckOwner(healthCheck) + Expect(ok).To(BeTrue()) + Expect(ref.Kind).To(Equal(configv1beta1.ClusterSummaryKind)) + Expect(ref.Namespace).To(Equal(testNamespace)) + Expect(ref.Name).To(Equal(summaryName)) + }) + + It("returns false without the UID label or without the annotations", func() { + withoutLabel := &libsveltosv1beta1.HealthCheck{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + configv1beta1.HealthMonitoringClusterSummaryNamespaceAnnotation: testNamespace, + configv1beta1.HealthMonitoringClusterSummaryNameAnnotation: summaryName, + }, + }, + } + _, ok := healthmonitoring.GeneratedCheckOwner(withoutLabel) + Expect(ok).To(BeFalse()) + + withoutAnnotations := &libsveltosv1beta1.HealthCheck{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{configv1beta1.HealthMonitoringClusterSummaryUIDLabel: testUID}, + }, + } + _, ok = healthmonitoring.GeneratedCheckOwner(withoutAnnotations) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("NewSummaryInput", func() { + newSummary := func(profileKind string, healthMonitoring *configv1beta1.HealthMonitoring) *configv1beta1.ClusterSummary { + return &configv1beta1.ClusterSummary{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: summaryName, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: configv1beta1.GroupVersion.String(), Kind: profileKind, Name: "profile"}, + }, + }, + Spec: configv1beta1.ClusterSummarySpec{ + ClusterProfileSpec: configv1beta1.Spec{HealthMonitoring: healthMonitoring}, + }, + } + } + + It("keeps the listed checks of a ClusterProfile", func() { + summary := newSummary(configv1beta1.ClusterProfileKind, + &configv1beta1.HealthMonitoring{Enabled: true, HealthChecks: []string{"a", "b"}}) + + input, err := healthmonitoring.NewSummaryInput(summary, generatedCheckName) + Expect(err).To(BeNil()) + Expect(input.Enabled).To(BeTrue()) + Expect(input.ListedChecks).To(Equal([]string{"a", "b"})) + Expect(input.GeneratedCheck).To(Equal(generatedCheckName)) + Expect(input.Summary.Namespace).To(Equal(testNamespace)) + Expect(input.Summary.Name).To(Equal(summaryName)) + Expect(input.Profile.Kind).To(Equal(configv1beta1.ClusterProfileKind)) + Expect(input.Profile.Name).To(Equal("profile")) + }) + + It("ignores the listed checks of a Profile and gives it the namespace of the ClusterSummary", func() { + summary := newSummary(configv1beta1.ProfileKind, + &configv1beta1.HealthMonitoring{Enabled: true, HealthChecks: []string{"a"}}) + + input, err := healthmonitoring.NewSummaryInput(summary, "") + Expect(err).To(BeNil()) + Expect(input.Enabled).To(BeTrue()) + Expect(input.ListedChecks).To(BeEmpty()) + Expect(input.Profile.Kind).To(Equal(configv1beta1.ProfileKind)) + Expect(input.Profile.Namespace).To(Equal(testNamespace)) + }) + + It("is not enabled when health monitoring is unset or disabled", func() { + input, err := healthmonitoring.NewSummaryInput(newSummary(configv1beta1.ClusterProfileKind, nil), "") + Expect(err).To(BeNil()) + Expect(input.Enabled).To(BeFalse()) + + input, err = healthmonitoring.NewSummaryInput(newSummary(configv1beta1.ClusterProfileKind, + &configv1beta1.HealthMonitoring{Enabled: false}), "") + Expect(err).To(BeNil()) + Expect(input.Enabled).To(BeFalse()) + }) + + It("returns an error when the ClusterSummary has no profile owner", func() { + summary := &configv1beta1.ClusterSummary{ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: summaryName}} + _, err := healthmonitoring.NewSummaryInput(summary, "") + Expect(err).ToNot(BeNil()) + }) +}) + +var _ = Describe("NewReportInput", func() { + It("copies the health check name and the resource statuses", func() { + report := &libsveltosv1beta1.HealthCheckReport{ + Spec: libsveltosv1beta1.HealthCheckReportSpec{ + HealthCheckName: generatedCheckName, + ResourceStatuses: []libsveltosv1beta1.ResourceStatus{ + { + ObjectRef: corev1.ObjectReference{Kind: "Deployment", Namespace: "d", Name: "web"}, + HealthStatus: libsveltosv1beta1.HealthStatusDegraded, + Message: "bad", + Resource: []byte("big"), + }, + }, + }, + } + + input := healthmonitoring.NewReportInput(report) + Expect(input.HealthCheckName).To(Equal(generatedCheckName)) + Expect(input.Resources).To(HaveLen(1)) + Expect(input.Resources[0].ObjectRef.Name).To(Equal("web")) + Expect(input.Resources[0].Status).To(Equal(libsveltosv1beta1.HealthStatusDegraded)) + Expect(input.Resources[0].Message).To(Equal("bad")) + }) +})