Commit 6308173d authored by Vasilis Katopodis's avatar Vasilis Katopodis
Browse files

refactor: replace pod-watch model with gitops-engine health assessment

parent 1cdc573b
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
tmp/
.air.toml
/service.yml
.access_token
sonarQube
golang-base-module
.scannerwork/
vendor/
CLAUDE.md
+0 −41
Original line number Diff line number Diff line
package business

import (
	"fmt"
	"time"

	corev1 "k8s.io/api/core/v1"
)

type ContainerState struct {
	IsHealthy        bool
	IsStable         bool
	Description      string
	LastHealthChange time.Time
	prevIsHealthy    bool
}

func NewContainerState() *ContainerState {
	return &ContainerState{false, false, "", time.Now(), false}
}

func (c *ContainerState) UpdateState(state corev1.ContainerState) {

	c.IsHealthy = state.Running != nil

	if state.Terminated != nil {
		c.Description = state.Terminated.Reason
	} else if state.Waiting != nil {
		c.Description = state.Waiting.Reason
	} else {
		c.Description = "Running"
	}

	if c.prevIsHealthy != c.IsHealthy {
		c.IsStable = false
		c.LastHealthChange = time.Now()
	}
	c.prevIsHealthy = c.IsHealthy

	fmt.Println("DELETE ME:", c)
}
+30 −101
Original line number Diff line number Diff line
package business

import (
	"fmt"
	"strings"
	"sync"
	"time"

	corev1 "k8s.io/api/core/v1"
	"labs.etsi.org/rep/osl/hypo/code/org.etsi.osl.hypo.core/service.monitor/cmd/datamodels/messages"
	"labs.etsi.org/rep/osl/hypo/code/org.etsi.osl.hypo.core/service.monitor/internal/health"
)

type watchDataIndex struct {
	Namespace     string
	PodName       string
	ContainerName string
// statusPriority maps each gitops-engine health status to a severity rank (lower = worse).
var statusPriority = map[health.HealthStatusCode]int{
	health.HealthStatusDegraded:    0,
	health.HealthStatusUnknown:     1,
	health.HealthStatusMissing:     2,
	health.HealthStatusProgressing: 3,
	health.HealthStatusSuspended:   4,
	health.HealthStatusHealthy:     5,
}
type WatchSet struct {
	set   map[watchDataIndex]*ContainerState
	mutex sync.Mutex
}

func NewWatchSet() WatchSet {
	return WatchSet{
		set:   map[watchDataIndex]*ContainerState{},
		mutex: sync.Mutex{},
	}
}

func (wd *WatchSet) UpdateOrCreate(namespace string, podName string, containerName string, state corev1.ContainerState) *ContainerState {

	wd.mutex.Lock()
	defer wd.mutex.Unlock()

	watchDataIndex := watchDataIndex{
		Namespace:     namespace,
		PodName:       podName,
		ContainerName: containerName,
	}

	_, ok := wd.set[watchDataIndex]
	if !ok {
		wd.set[watchDataIndex] = NewContainerState()
	}
	wd.set[watchDataIndex].UpdateState(state)

	return wd.set[watchDataIndex]
}

func (wd *WatchSet) HealthReport() messages.Diagnosis {

	diagnosisReport := messages.Diagnosis{
		Running:     true,
		IsStable:    true,
		Description: "",
// AggregateHealth reduces per-resource health entries to a single release-level Diagnosis.
// The worst status across all resources wins (Degraded > Unknown > Missing > Progressing > Suspended > Healthy).
func AggregateHealth(resources []messages.ResourceHealth) messages.Diagnosis {
	if len(resources) == 0 {
		return messages.Diagnosis{
			Status:    string(health.HealthStatusUnknown),
			Message:   "no resources found for the requested Helm release",
			Resources: resources,
		}
	for key, value := range wd.set {

		// TODO: make it configurable
		value.IsStable = time.Since(value.LastHealthChange) >= 60*time.Second

		if !value.IsHealthy && diagnosisReport.Running {
			diagnosisReport.Running = false
	}

		if !value.IsStable && diagnosisReport.IsStable {
			diagnosisReport.IsStable = false
	worst := health.HealthStatusHealthy
	var msgs []string
	for _, r := range resources {
		code := health.HealthStatusCode(r.Status)
		if pri, ok := statusPriority[code]; ok && pri < statusPriority[worst] {
			worst = code
		}

		if !value.IsHealthy {
			diagnosisReport.Description += fmt.Sprintf(
				"[ns: '%s', pod: '%s', container: '%s']: %s.\n",
				key.Namespace,
				key.PodName,
				key.ContainerName,
				value.Description,
			)
		} else {
			if value.IsStable {
				diagnosisReport.Description += fmt.Sprintf(
					"[ns: '%s', pod: '%s', container: '%s']: Running.\n",
					key.Namespace,
					key.PodName,
					key.ContainerName,
				)
			} else {
				diagnosisReport.Description += fmt.Sprintf(
					"[ns: '%s', pod: '%s', container: '%s']: Running but Unstable. Last change '%s'\n",
					key.Namespace,
					key.PodName,
					key.ContainerName,
					value.LastHealthChange,
				)
		if r.Message != "" {
			msgs = append(msgs, r.Kind+"/"+r.Name+": "+r.Message)
		}
	}

		fmt.Printf("DELETE ME [ns: '%s', pod: '%s', container: '%s']: %s.\n",
			key.Namespace,
			key.PodName,
			key.ContainerName,
			value.Description)
	}
	diagnosisReport.Description = strings.TrimSpace(diagnosisReport.Description)

	fmt.Println("DELETE ME:", diagnosisReport.Description)

	return diagnosisReport
}

func (wd *WatchSet) CleanNamespaceEntries(namespace string) {
	wd.mutex.Lock()
	defer wd.mutex.Unlock()

	for key := range wd.set {
		if key.Namespace == namespace {
			delete(wd.set, key)
		}
	return messages.Diagnosis{
		Status:    string(worst),
		Message:   strings.Join(msgs, "; "),
		Resources: resources,
	}
}
+136 −0
Original line number Diff line number Diff line
package business

import (
	"strings"
	"testing"

	"labs.etsi.org/rep/osl/hypo/code/org.etsi.osl.hypo.core/service.monitor/cmd/datamodels/messages"
	"labs.etsi.org/rep/osl/hypo/code/org.etsi.osl.hypo.core/service.monitor/internal/health"
)

func TestAggregateHealth_NoResources(t *testing.T) {
	result := AggregateHealth(nil)

	if result.Status != string(health.HealthStatusUnknown) {
		t.Errorf("expected %s, got %s", health.HealthStatusUnknown, result.Status)
	}
	if result.Message == "" {
		t.Error("expected non-empty message for empty resource list")
	}
}

func TestAggregateHealth_AllHealthyNoMessages(t *testing.T) {
	resources := []messages.ResourceHealth{
		{Kind: "Deployment", Name: "api", Status: string(health.HealthStatusHealthy), Message: ""},
		{Kind: "StatefulSet", Name: "db", Status: string(health.HealthStatusHealthy), Message: ""},
	}

	result := AggregateHealth(resources)

	if result.Status != string(health.HealthStatusHealthy) {
		t.Errorf("expected %s, got %s", health.HealthStatusHealthy, result.Status)
	}
	if result.Message != "" {
		t.Errorf("expected empty synopsis, got %q", result.Message)
	}
	if len(result.Resources) != 2 {
		t.Errorf("expected 2 resources, got %d", len(result.Resources))
	}
}

func TestAggregateHealth_AllHealthyWithMessages(t *testing.T) {
	resources := []messages.ResourceHealth{
		{Kind: "StatefulSet", Name: "kafka", Status: string(health.HealthStatusHealthy), Message: "rolling update complete"},
		{Kind: "StatefulSet", Name: "db", Status: string(health.HealthStatusHealthy), Message: "partitioned roll out complete"},
	}

	result := AggregateHealth(resources)

	if result.Status != string(health.HealthStatusHealthy) {
		t.Errorf("expected %s, got %s", health.HealthStatusHealthy, result.Status)
	}
	if !strings.Contains(result.Message, "StatefulSet/kafka") {
		t.Errorf("expected synopsis to contain kafka entry, got %q", result.Message)
	}
	if !strings.Contains(result.Message, "StatefulSet/db") {
		t.Errorf("expected synopsis to contain db entry, got %q", result.Message)
	}
}

func TestAggregateHealth_WorstStatusWins(t *testing.T) {
	tests := []struct {
		name           string
		statuses       []health.HealthStatusCode
		expectedWorst  health.HealthStatusCode
	}{
		{
			name:          "degraded beats progressing",
			statuses:      []health.HealthStatusCode{health.HealthStatusProgressing, health.HealthStatusDegraded, health.HealthStatusHealthy},
			expectedWorst: health.HealthStatusDegraded,
		},
		{
			name:          "unknown beats progressing",
			statuses:      []health.HealthStatusCode{health.HealthStatusProgressing, health.HealthStatusUnknown},
			expectedWorst: health.HealthStatusUnknown,
		},
		{
			name:          "progressing beats healthy",
			statuses:      []health.HealthStatusCode{health.HealthStatusHealthy, health.HealthStatusProgressing},
			expectedWorst: health.HealthStatusProgressing,
		},
		{
			name:          "missing beats suspended",
			statuses:      []health.HealthStatusCode{health.HealthStatusSuspended, health.HealthStatusMissing},
			expectedWorst: health.HealthStatusMissing,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var resources []messages.ResourceHealth
			for i, s := range tc.statuses {
				resources = append(resources, messages.ResourceHealth{
					Kind:   "Deployment",
					Name:   string(rune('a' + i)),
					Status: string(s),
				})
			}

			result := AggregateHealth(resources)

			if result.Status != string(tc.expectedWorst) {
				t.Errorf("expected %s, got %s", tc.expectedWorst, result.Status)
			}
		})
	}
}

func TestAggregateHealth_SynopsisOnlyIncludesResourcesWithMessages(t *testing.T) {
	resources := []messages.ResourceHealth{
		{Kind: "Deployment", Name: "api", Status: string(health.HealthStatusHealthy), Message: ""},
		{Kind: "StatefulSet", Name: "kafka", Status: string(health.HealthStatusHealthy), Message: "rolling update complete"},
	}

	result := AggregateHealth(resources)

	if strings.Contains(result.Message, "Deployment/api") {
		t.Errorf("synopsis should not include resources with empty message, got %q", result.Message)
	}
	if !strings.Contains(result.Message, "StatefulSet/kafka") {
		t.Errorf("synopsis should include resources with non-empty message, got %q", result.Message)
	}
}

func TestAggregateHealth_SynopsisEntriesJoinedBySemicolon(t *testing.T) {
	resources := []messages.ResourceHealth{
		{Kind: "StatefulSet", Name: "a", Status: string(health.HealthStatusHealthy), Message: "msg-a"},
		{Kind: "StatefulSet", Name: "b", Status: string(health.HealthStatusHealthy), Message: "msg-b"},
	}

	result := AggregateHealth(resources)

	parts := strings.Split(result.Message, "; ")
	if len(parts) != 2 {
		t.Errorf("expected 2 synopsis parts, got %d: %q", len(parts), result.Message)
	}
}
+10 −11
Original line number Diff line number Diff line
package messages

import "errors"
type ResourceHealth struct {
	Kind      string `json:"kind"`
	Name      string `json:"name"`
	Namespace string `json:"namespace"`
	Status    string `json:"status"`
	Message   string `json:"message"`
}

type Diagnosis struct {
	Running     bool   `json:"running"`
	IsStable    bool   `json:"isStable"`
	Description string `json:"description"`
	Status    string           `json:"status"`
	Message   string           `json:"message"`
	Resources []ResourceHealth `json:"resources"`
}

func (d Diagnosis) Validate() error {
	if len(d.Description) > 5 {
		return errors.New("description should be at least 5 characters long")
	}

	return nil
}

func (d Diagnosis) GetMessageResponseFailure(_ string, err error) any {

	if err != nil {
		return ResponseToDiagnosisRequest{
			Status:         "FAILURE",
@@ -25,12 +26,10 @@ func (d Diagnosis) GetMessageResponseFailure(_ string, err error) any {
			MonitorResults: nil,
		}
	}

	return nil
}

func (d Diagnosis) GetMessageResponseSuccess(_ string, successMessage string) any {

	return ResponseToDiagnosisRequest{
		Status:         "SUCCESS",
		Message:        successMessage,
Loading