From b743cda9fa871434bc866e2aaae51e3502e1db2c Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Fri, 16 Oct 2026 13:58:30 +0200 Subject: [PATCH] Add lib/healthmonitoring: shared join of health reports and ClusterSummaries Add a small package that joins the HealthCheckReports of one cluster with the ClusterSummaries owning the HealthChecks behind them, and returns the health state per profile, the cluster state, and a flat list of unhealthy resources. Why it lives here: the report carries no reference to a ClusterSummary, so the link can only be made by a join (generated HealthCheck labels/annotations, plus the checks a ClusterProfile lists). ui-backend (cluster list and "Unhealthy resources" view) and sveltosctl (new `show` command) both need that join. It cannot live in libsveltos (it needs the ClusterSummary type and the label constants, and addon-controller imports libsveltos), and both consumers already import addon-controller. A single implementation keeps the cluster badge, the per-profile badge and the detail list from diverging. The package is pure: no client, no locks. Callers build the inputs from their own caches or from Lists (NewSummaryInput, NewReportInput, GeneratedCheckOwner). - Evaluate: per-profile state (NoData, Healthy, worst status), cluster state, resources deduplicated (worst status wins, shared checks list all their profiles), unclaimed reports (no owning profile) keep the HealthCheck name, sorted worst first. - Listed healthChecks are honored for a ClusterProfile only, as in the controller. - status.agentFailureMessage is not considered yet. --- lib/healthmonitoring/healthmonitoring.go | 407 +++++++++++++++++ .../healthmonitoring_suite_test.go | 29 ++ lib/healthmonitoring/healthmonitoring_test.go | 412 ++++++++++++++++++ 3 files changed, 848 insertions(+) create mode 100644 lib/healthmonitoring/healthmonitoring.go create mode 100644 lib/healthmonitoring/healthmonitoring_suite_test.go create mode 100644 lib/healthmonitoring/healthmonitoring_test.go 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")) + }) +})