feat(application): add WorkloadsReady condition and Events tab

Add two features to improve application observability in the dashboard:

1. WorkloadsReady condition on Application status
   - Query WorkloadMonitor resources to determine if all pods are running
   - Expose WorkloadsReady as a separate condition alongside Ready
   - Ready continues to reflect HelmRelease state only (no override) to
     preserve backward compatibility with tooling and avoid false-negatives
     during normal startup windows when pods are still coming up
   - Handle three states: operational, not operational, unknown (pending)
   - Fail-open on WorkloadMonitor query errors
   - Integrate with Application Watch to emit MODIFIED events on
     WorkloadMonitor changes (with initial-events-end safety)
   - Register cozystack.io/v1alpha1 types in API server scheme

2. Events tab in dashboard
   - Show Kubernetes Events scoped to application namespace
   - Use status.namespace for Tenant applications
   - Include both lastTimestamp and eventTime columns for K8s compat

3. Bug fix: WorkloadMonitor Operational status persistence
   - Operational was written to stale 'monitor' instead of 'fresh'
     inside RetryOnConflict, so it was never persisted to the cluster

Closes #2359
Closes #2360

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
This commit is contained in:
Aleksei Sviridkin 2026-04-09 20:25:20 +03:00
parent c87cccae99
commit 1bae0775be
No known key found for this signature in database
GPG key ID: 7988329FDF395282
11 changed files with 1375 additions and 2 deletions

1
.gitignore vendored
View file

@ -83,3 +83,4 @@ tmp/
# build revision marker (generated by make image-packages)
packages/core/platform/.build-revision
.claude/

View file

@ -47,6 +47,7 @@ func (m *Manager) ensureFactory(ctx context.Context, crd *cozyv1alpha1.Applicati
if prefix, ok := vncTabPrefix(kind); ok {
tabs = append(tabs, vncTab(prefix))
}
tabs = append(tabs, eventsTab(kind))
tabs = append(tabs, yamlTab(g, v, plural))
// Use unified factory creation
@ -358,6 +359,37 @@ func secretsTab(kind string) map[string]any {
}
}
// eventsTab shows Kubernetes Events scoped to the application's namespace.
// Events are namespace-scoped because Kubernetes Events don't carry application
// labels and cannot be filtered by label selector. In Cozystack's multi-tenancy
// model, each tenant namespace maps to a single application scope, so namespace
// filtering provides the correct event scope.
// For Tenant applications, events are fetched from status.namespace (the tenant's
// own namespace) instead of the parent namespace where the Tenant object lives.
func eventsTab(kind string) map[string]any {
nsPlaceholder := "{3}"
if kind == "Tenant" {
nsPlaceholder = "{reqsJsonPath[0]['.status.namespace']}"
}
return map[string]any{
"key": "events",
"label": "Events",
"children": []any{
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "events-table",
"fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/" + nsPlaceholder + "/events",
"cluster": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-events",
"pathToItems": []any{"items"},
},
},
},
}
}
func yamlTab(group, version, plural string) map[string]any {
return map[string]any{
"key": "yaml",

View file

@ -0,0 +1,60 @@
package dashboard
import (
"testing"
)
func TestEventsTab_Structure(t *testing.T) {
tab := eventsTab("PostgreSQL")
if tab["key"] != "events" {
t.Errorf("expected key=events, got %v", tab["key"])
}
if tab["label"] != "Events" {
t.Errorf("expected label=Events, got %v", tab["label"])
}
children, ok := tab["children"].([]any)
if !ok || len(children) != 1 {
t.Fatal("expected exactly 1 child in events tab")
}
table, ok := children[0].(map[string]any)
if !ok {
t.Fatal("child is not a map")
}
if table["type"] != "EnrichedTable" {
t.Errorf("expected type=EnrichedTable, got %v", table["type"])
}
data, ok := table["data"].(map[string]any)
if !ok {
t.Fatal("table data is not a map")
}
if data["id"] != "events-table" {
t.Errorf("expected id=events-table, got %v", data["id"])
}
if data["fetchUrl"] != "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/events" {
t.Errorf("unexpected fetchUrl for non-Tenant: %v", data["fetchUrl"])
}
if data["customizationId"] != "factory-details-events" {
t.Errorf("expected customizationId=factory-details-events, got %v", data["customizationId"])
}
pathToItems, ok := data["pathToItems"].([]any)
if !ok || len(pathToItems) != 1 || pathToItems[0] != "items" {
t.Errorf("expected pathToItems=[items], got %v", data["pathToItems"])
}
}
func TestEventsTab_TenantUsesStatusNamespace(t *testing.T) {
tab := eventsTab("Tenant")
children := tab["children"].([]any)
table := children[0].(map[string]any)
data := table["data"].(map[string]any)
expectedURL := "/api/clusters/{2}/k8s/api/v1/namespaces/{reqsJsonPath[0]['.status.namespace']}/events"
if data["fetchUrl"] != expectedURL {
t.Errorf("expected Tenant fetchUrl to use status.namespace, got %v", data["fetchUrl"])
}
}

View file

@ -224,6 +224,17 @@ func CreateAllCustomColumnsOverrides() []*dashboardv1alpha1.CustomColumnsOverrid
createStringColumn("Name", ".name"),
}),
// Factory details events
createCustomColumnsOverride("factory-details-events", []any{
createTimestampColumn("Last Seen", ".lastTimestamp"),
createTimestampColumn("Event Time", ".eventTime"),
createStringColumn("Type", ".type"),
createStringColumn("Reason", ".reason"),
createStringColumn("Object", ".involvedObject.kind"),
createStringColumn("Name", ".involvedObject.name"),
createStringColumn("Message", ".message"),
}),
// Factory status conditions
createCustomColumnsOverride("factory-status-conditions", []any{
createStringColumn("Type", ".type"),

View file

@ -389,9 +389,9 @@ func (r *WorkloadMonitorReconciler) Reconcile(ctx context.Context, req ctrl.Requ
fresh.Status.AvailableReplicas = availableReplicas
// Default to operational = true, but check MinReplicas if set
monitor.Status.Operational = pointer.Bool(true)
fresh.Status.Operational = pointer.Bool(true)
if monitor.Spec.MinReplicas != nil && availableReplicas < *monitor.Spec.MinReplicas {
monitor.Status.Operational = pointer.Bool(false)
fresh.Status.Operational = pointer.Bool(false)
}
return r.Status().Update(ctx, fresh)
})

View file

@ -0,0 +1,180 @@
package controller
import (
"context"
"testing"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
func TestReconcile_OperationalStatusPersisted(t *testing.T) {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = corev1.AddToScheme(scheme)
minReplicas := int32(2)
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "test-monitor",
Namespace: "default",
},
Spec: cozyv1alpha1.WorkloadMonitorSpec{
Selector: map[string]string{"app": "test"},
MinReplicas: &minReplicas,
},
}
// Create one pod that is ready — availableReplicas=1 < minReplicas=2, so Operational should be false
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod-1",
Namespace: "default",
Labels: map[string]string{"app": "test"},
},
Status: corev1.PodStatus{
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(monitor, pod).
WithStatusSubresource(monitor).
Build()
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
_, err := reconciler.Reconcile(context.TODO(), req)
if err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
// Fetch the monitor back from fake client and check Operational is persisted
updated := &cozyv1alpha1.WorkloadMonitor{}
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
}
if updated.Status.Operational == nil {
t.Fatal("Expected Operational to be set, got nil")
}
if *updated.Status.Operational {
t.Error("Expected Operational=false (1 available < 2 minReplicas), got true")
}
if updated.Status.ObservedReplicas != 1 {
t.Errorf("Expected ObservedReplicas=1, got %d", updated.Status.ObservedReplicas)
}
if updated.Status.AvailableReplicas != 1 {
t.Errorf("Expected AvailableReplicas=1, got %d", updated.Status.AvailableReplicas)
}
}
func TestReconcile_OperationalTrue_WhenEnoughReplicas(t *testing.T) {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = corev1.AddToScheme(scheme)
minReplicas := int32(1)
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "test-monitor",
Namespace: "default",
},
Spec: cozyv1alpha1.WorkloadMonitorSpec{
Selector: map[string]string{"app": "test"},
MinReplicas: &minReplicas,
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod-1",
Namespace: "default",
Labels: map[string]string{"app": "test"},
},
Status: corev1.PodStatus{
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(monitor, pod).
WithStatusSubresource(monitor).
Build()
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
_, err := reconciler.Reconcile(context.TODO(), req)
if err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
updated := &cozyv1alpha1.WorkloadMonitor{}
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
}
if updated.Status.Operational == nil {
t.Fatal("Expected Operational to be set, got nil")
}
if !*updated.Status.Operational {
t.Error("Expected Operational=true (1 available >= 1 minReplicas), got false")
}
}
func TestReconcile_OperationalTrue_WhenNoMinReplicas(t *testing.T) {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = corev1.AddToScheme(scheme)
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "test-monitor",
Namespace: "default",
},
Spec: cozyv1alpha1.WorkloadMonitorSpec{
Selector: map[string]string{"app": "test"},
// No MinReplicas — should default to operational=true
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(monitor).
WithStatusSubresource(monitor).
Build()
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
_, err := reconciler.Reconcile(context.TODO(), req)
if err != nil {
t.Fatalf("Reconcile returned error: %v", err)
}
updated := &cozyv1alpha1.WorkloadMonitor{}
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
}
if updated.Status.Operational == nil {
t.Fatal("Expected Operational to be set, got nil")
}
if !*updated.Status.Operational {
t.Error("Expected Operational=true (no MinReplicas constraint), got false")
}
}

View file

@ -21,6 +21,7 @@ import (
"fmt"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
@ -79,6 +80,11 @@ func init() {
if err := rbacv1.AddToScheme(mgrScheme); err != nil {
panic(fmt.Errorf("Failed to add RBAC types to scheme: %w", err))
}
// Register Cozystack types for WorkloadMonitor queries.
if err := cozyv1alpha1.AddToScheme(mgrScheme); err != nil {
panic(fmt.Errorf("failed to add Cozystack types to scheme: %w", err))
}
// Add unversioned types.
metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})
@ -168,6 +174,7 @@ func (c completedConfig) New() (*CozyServer, error) {
&corev1.Namespace{},
&corev1.Service{},
&rbacv1.RoleBinding{},
&cozyv1alpha1.WorkloadMonitor{},
); err != nil {
return nil, fmt.Errorf("failed to get informers: %w", err)
}

View file

@ -26,6 +26,7 @@ import (
"sync"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
corev1 "k8s.io/api/core/v1"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
@ -703,9 +704,28 @@ func (r *REST) Watch(ctx context.Context, options *metainternalversion.ListOptio
}
customW.underlying = helmWatcher
// Start watch on WorkloadMonitor to detect pod readiness changes
wmLabelSelector := labels.NewSelector().Add(*appKindReq, *appGroupReq)
wmList := &cozyv1alpha1.WorkloadMonitorList{}
wmWatcher, err := r.w.Watch(ctx, wmList, &client.ListOptions{
Namespace: namespace,
LabelSelector: wmLabelSelector,
})
if err != nil {
klog.Warningf("Failed to set up WorkloadMonitor watch, workload status changes won't trigger events: %v", err)
// Non-fatal: proceed without WorkloadMonitor watch
wmWatcher = nil
}
go func() {
// Capture wmWatcher for cleanup; the variable may be set to nil
// inside the loop when the channel closes, so defer must use this copy.
wmWatcherForCleanup := wmWatcher
defer close(customW.resultChan)
defer customW.underlying.Stop()
if wmWatcherForCleanup != nil {
defer wmWatcherForCleanup.Stop()
}
// Track whether we've sent the initial-events-end bookmark
initialEventsEndSent := !sendInitialEvents // If not sendInitialEvents, consider it already sent
@ -879,6 +899,77 @@ func (r *REST) Watch(ctx context.Context, options *metainternalversion.ListOptio
return
}
case wmEvent, ok := <-wmResultChan(wmWatcher):
if !ok {
klog.V(4).Info("WorkloadMonitor watcher closed")
wmWatcher = nil
continue
}
if wmEvent.Type == watch.Bookmark || wmEvent.Type == watch.Error {
if wmEvent.Type == watch.Error {
klog.V(4).Infof("WorkloadMonitor watch error event: %v", wmEvent.Object)
}
continue
}
// Don't emit WM-triggered events until the initial snapshot is
// complete — the watch-list contract requires all ADDED events
// followed by the initial-events-end bookmark before any live updates.
if !initialEventsEndSent {
continue
}
wm, ok := wmEvent.Object.(*cozyv1alpha1.WorkloadMonitor)
if !ok {
continue
}
// All WM event types (Added/Modified/Deleted) produce a Modified
// Application event because the Application itself is what changed
// from the client's perspective.
wmAppName, hasLabel := wm.Labels[ApplicationNameLabel]
if !hasLabel {
continue
}
// Filter: skip WorkloadMonitor events for applications not matching
// the watch scope (single-resource or field-selector filtered watches)
hrName := r.releaseConfig.Prefix + wmAppName
if filterByName != "" && hrName != filterByName {
continue
}
if resourceName != "" && wmAppName != resourceName {
continue
}
hr := &helmv2.HelmRelease{}
if err := r.c.Get(ctx, client.ObjectKey{Namespace: wm.Namespace, Name: hrName}, hr); err != nil {
klog.V(4).Infof("Cannot find HelmRelease %s/%s for WorkloadMonitor event: %v", wm.Namespace, hrName, err)
continue
}
app, err := r.ConvertHelmReleaseToApplication(ctx, hr)
if err != nil {
klog.V(4).Infof("Error converting HelmRelease for WorkloadMonitor event: %v", err)
continue
}
// Apply label selector filtering (same as HelmRelease event path)
if options.LabelSelector != nil {
sel, err := labels.Parse(options.LabelSelector.String())
if err != nil {
klog.Errorf("Invalid label selector: %v", err)
continue
}
if !sel.Matches(labels.Set(app.Labels)) {
continue
}
}
// Use the WorkloadMonitor's ResourceVersion for the emitted event
// so clients see a monotonically increasing RV and don't skip this update.
app.SetResourceVersion(wm.GetResourceVersion())
lastResourceVersion = wm.GetResourceVersion()
select {
case customW.resultChan <- watch.Event{Type: watch.Modified, Object: &app}:
case <-customW.stopChan:
return
case <-ctx.Done():
return
}
case <-customW.stopChan:
return
case <-ctx.Done():
@ -891,6 +982,15 @@ func (r *REST) Watch(ctx context.Context, options *metainternalversion.ListOptio
return customW, nil
}
// wmResultChan returns the result channel of a WorkloadMonitor watcher, or a nil
// channel (which blocks forever in select) if the watcher is nil.
func wmResultChan(w watch.Interface) <-chan watch.Event {
if w == nil {
return nil
}
return w.ResultChan()
}
// customWatcher wraps the original watcher and filters/converts events
type customWatcher struct {
resultChan chan watch.Event
@ -1150,6 +1250,56 @@ func (r *REST) convertHelmReleaseToApplication(ctx context.Context, hr *helmv2.H
})
}
}
// Enrich conditions with WorkloadMonitor operational status
ws, wsErr := r.getWorkloadsOperational(ctx, hr.Namespace, app.Name)
if wsErr != nil {
// Fail-open: if we can't query WorkloadMonitors (e.g., informer cache not ready),
// don't override Ready. Prefer operational availability over safety.
// The WorkloadsReady=Unknown condition still signals the issue to the user.
klog.Warningf("Failed to check workload monitors for %s/%s: %v", hr.Namespace, app.Name, wsErr)
conditions = append(conditions, metav1.Condition{
Type: "WorkloadsReady",
Status: metav1.ConditionUnknown,
LastTransitionTime: metav1.Now(),
Reason: "Error",
Message: fmt.Sprintf("Failed to check workload status: %v", wsErr),
})
} else if ws.found {
// LastTransitionTime is set to the current time because the Application
// resource is virtual (computed on-the-fly from HelmRelease). There is no
// persistent condition state to track actual transitions. This is consistent
// with how computed/virtual API resources work in Kubernetes.
workloadsCondition := metav1.Condition{
Type: "WorkloadsReady",
LastTransitionTime: metav1.Now(),
Reason: "WorkloadMonitorCheck",
}
switch {
case !ws.operational:
// Concrete failure takes priority over unknown/pending state
workloadsCondition.Status = metav1.ConditionFalse
workloadsCondition.Message = "One or more workloads are not operational"
case ws.unknown:
workloadsCondition.Status = metav1.ConditionUnknown
workloadsCondition.Reason = "Pending"
workloadsCondition.Message = "One or more workloads have not been reconciled yet"
default:
workloadsCondition.Status = metav1.ConditionTrue
workloadsCondition.Message = "All workloads are operational"
}
conditions = append(conditions, workloadsCondition)
// Intentionally do NOT override the Ready condition based on WorkloadsReady.
// Ready continues to reflect HelmRelease state only, which:
// - preserves backward compatibility with existing tooling (kubectl wait,
// GitOps health checks) that expect Ready to match HelmRelease
// - avoids false-negative Ready=False during normal startup windows where
// pods are still coming up but WorkloadMonitor has already reported
// Operational=false due to availableReplicas < MinReplicas
// WorkloadsReady is a separate condition that surfaces workload health
// independently — users and dashboards can observe it for operational visibility.
}
app.SetConditions(conditions)
// Add namespace field for Tenant applications
@ -1166,6 +1316,42 @@ func (r *REST) convertHelmReleaseToApplication(ctx context.Context, hr *helmv2.H
return app, nil
}
// workloadsStatus holds the aggregated operational status of WorkloadMonitors.
type workloadsStatus struct {
operational bool
found bool
unknown bool // true when at least one monitor has nil Operational (not yet reconciled)
}
// getWorkloadsOperational checks WorkloadMonitor resources for an application and returns
// aggregated operational status. If no monitors exist, returns found=false.
func (r *REST) getWorkloadsOperational(ctx context.Context, namespace, appName string) (workloadsStatus, error) {
monitors := &cozyv1alpha1.WorkloadMonitorList{}
if err := r.c.List(ctx, monitors,
client.InNamespace(namespace),
client.MatchingLabels{
appsv1alpha1.ApplicationKindLabel: r.kindName,
appsv1alpha1.ApplicationGroupLabel: r.gvk.Group,
appsv1alpha1.ApplicationNameLabel: appName,
},
); err != nil {
return workloadsStatus{}, err
}
if len(monitors.Items) == 0 {
return workloadsStatus{operational: true, found: false}, nil
}
operational := true
unknown := false
for _, m := range monitors.Items {
if m.Status.Operational == nil {
unknown = true
} else if !*m.Status.Operational {
operational = false
}
}
return workloadsStatus{operational: operational, found: true, unknown: unknown}, nil
}
// convertApplicationToHelmRelease implements the actual conversion logic
func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (*helmv2.HelmRelease, error) {
helmRelease := &helmv2.HelmRelease{

View file

@ -0,0 +1,331 @@
package application
import (
"context"
"testing"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition {
for i := range conditions {
if conditions[i].Type == condType {
return &conditions[i]
}
}
return nil
}
func makeHelmRelease(name, namespace string) *helmv2.HelmRelease {
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{
ApplicationKindLabel: "PostgreSQL",
ApplicationGroupLabel: "apps.cozystack.io",
ApplicationNameLabel: "mydb",
},
},
}
return hr
}
func TestConvertConditions_WorkloadsReadyAdded(t *testing.T) {
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
r := newTestRESTWithSchemes(monitor)
hr := makeHelmRelease("postgresql-mydb", "default")
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Release applied"},
{Type: "Released", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Released"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition to be present")
}
if wc.Status != metav1.ConditionTrue {
t.Errorf("expected WorkloadsReady=True, got %s", wc.Status)
}
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition to be present")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True, got %s", rc.Status)
}
}
func TestConvertConditions_ReadyNotOverriddenWhenWorkloadsNotReady(t *testing.T) {
// Ready must reflect HelmRelease state only. WorkloadsReady is a separate
// signal that users/dashboards can observe independently.
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
r := newTestRESTWithSchemes(monitor)
hr := makeHelmRelease("postgresql-mydb", "default")
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Release applied"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition to be present")
}
if wc.Status != metav1.ConditionFalse {
t.Errorf("expected WorkloadsReady=False, got %s", wc.Status)
}
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition to be present")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True (reflects HelmRelease only), got %s", rc.Status)
}
if rc.Reason != "Succeeded" {
t.Errorf("expected Ready.Reason=Succeeded (unchanged), got %s", rc.Reason)
}
}
func TestConvertConditions_NoOverrideWhenNoMonitors(t *testing.T) {
r := newTestRESTWithSchemes()
hr := makeHelmRelease("postgresql-mydb", "default")
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Release applied"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc != nil {
t.Error("expected no WorkloadsReady condition when no monitors exist")
}
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition to be present")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True unchanged, got %s", rc.Status)
}
}
func TestConvertConditions_ReadyStaysTrue_WhenAllOperational(t *testing.T) {
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
r := newTestRESTWithSchemes(monitor)
hr := makeHelmRelease("postgresql-mydb", "default")
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Release applied"},
{Type: "Released", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "Released"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True when all workloads operational, got %s", rc.Status)
}
if rc.Reason != "Succeeded" {
t.Errorf("expected Ready.Reason=Succeeded (unchanged), got %s", rc.Reason)
}
}
func TestConvertConditions_WorkloadsReadyTimestampIsNonZero(t *testing.T) {
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
r := newTestRESTWithSchemes(monitor)
hr := makeHelmRelease("postgresql-mydb", "default")
// No Ready condition — HR still being reconciled
hr.Status.Conditions = []metav1.Condition{}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition")
}
if wc.LastTransitionTime.IsZero() {
t.Error("expected non-zero LastTransitionTime")
}
}
func TestConvertConditions_WorkloadsReadyUnknownWhenNilOperational(t *testing.T) {
monitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: nil, // Not yet reconciled
},
}
r := newTestRESTWithSchemes(monitor)
hr := makeHelmRelease("postgresql-mydb", "default")
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "ok"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition")
}
if wc.Status != metav1.ConditionUnknown {
t.Errorf("expected WorkloadsReady=Unknown for nil Operational, got %s", wc.Status)
}
// Ready should NOT be overridden for unknown — prefer availability during startup
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True when workloads unknown (pending), got %s", rc.Status)
}
}
func TestConvertConditions_WorkloadsReadyUnknownOnError(t *testing.T) {
// Create a client with only HelmRelease scheme — WorkloadMonitor List will fail
scheme := runtime.NewScheme()
_ = helmv2.AddToScheme(scheme)
// Deliberately NOT registering cozyv1alpha1 so that List returns an error
c := fake.NewClientBuilder().WithScheme(scheme).Build()
r := NewREST(c, nil, &config.Resource{
Application: config.ApplicationConfig{
Kind: "PostgreSQL",
Plural: "postgresqls",
Singular: "postgresql",
},
Release: config.ReleaseConfig{
Prefix: "postgresql-",
},
})
hr := makeHelmRelease("postgresql-mydb", "default")
hr.CreationTimestamp = metav1.Now()
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "ok"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition with Unknown status on error")
}
if wc.Status != metav1.ConditionUnknown {
t.Errorf("expected WorkloadsReady=Unknown, got %s", wc.Status)
}
if wc.Reason != "Error" {
t.Errorf("expected reason=Error, got %s", wc.Reason)
}
// Ready should NOT be overridden on error (fail-open: prefer availability)
rc := findCondition(app.GetConditions(), "Ready")
if rc == nil {
t.Fatal("expected Ready condition")
}
if rc.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True (fail-open on error), got %s", rc.Status)
}
}

View file

@ -0,0 +1,223 @@
package application
import (
"context"
"testing"
"time"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestWmResultChan_NilWatcher(t *testing.T) {
ch := wmResultChan(nil)
if ch != nil {
t.Error("expected nil channel for nil watcher")
}
}
func TestWmResultChan_ValidWatcher(t *testing.T) {
fw := watch.NewFake()
ch := wmResultChan(fw)
if ch == nil {
t.Error("expected non-nil channel for valid watcher")
}
fw.Stop()
}
// TestWatchIntegration_WorkloadMonitorTriggersModifiedEvent verifies the
// full path: WM event → label lookup → HelmRelease Get → Application conversion.
func TestWatchIntegration_WorkloadMonitorTriggersModifiedEvent(t *testing.T) {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = helmv2.AddToScheme(scheme)
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "postgresql-mydb",
Namespace: "default",
Labels: map[string]string{
ApplicationKindLabel: "PostgreSQL",
ApplicationGroupLabel: "apps.cozystack.io",
ApplicationNameLabel: "mydb",
},
},
}
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "ok"},
}
wm := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(hr, wm).Build()
r := newTestRESTWithSchemesFromClient(c)
// Simulate the Watch goroutine path: extract app name from WM labels,
// construct HelmRelease name, look up HR, convert to Application
wmAppName := wm.Labels[ApplicationNameLabel]
hrName := r.releaseConfig.Prefix + wmAppName
foundHR := &helmv2.HelmRelease{}
if err := c.Get(context.TODO(), types.NamespacedName{Namespace: "default", Name: hrName}, foundHR); err != nil {
t.Fatalf("failed to get HelmRelease: %v", err)
}
app, err := r.ConvertHelmReleaseToApplication(context.TODO(), foundHR)
if err != nil {
t.Fatalf("failed to convert: %v", err)
}
if app.Name != "mydb" {
t.Errorf("expected app name 'mydb', got %q", app.Name)
}
// Verify WorkloadsReady is False due to non-operational WM
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition")
}
if wc.Status != metav1.ConditionFalse {
t.Errorf("expected WorkloadsReady=False, got %s", wc.Status)
}
}
// TestWatchIntegration_MonitorDeletionDropsWorkloadsReady verifies that when a
// WorkloadMonitor is deleted, the Application's WorkloadsReady condition
// disappears. Ready condition always reflects HelmRelease state regardless.
func TestWatchIntegration_MonitorDeletionDropsWorkloadsReady(t *testing.T) {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = helmv2.AddToScheme(scheme)
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "postgresql-mydb",
Namespace: "default",
Labels: map[string]string{
ApplicationKindLabel: "PostgreSQL",
ApplicationGroupLabel: "apps.cozystack.io",
ApplicationNameLabel: "mydb",
},
},
}
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "ok"},
}
nonOpMonitor := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(hr, nonOpMonitor).Build()
r := newTestRESTWithSchemesFromClient(c)
// Step 1: With non-operational monitor, WorkloadsReady=False, Ready=True
app1, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc1 := findCondition(app1.GetConditions(), "WorkloadsReady")
if wc1 == nil || wc1.Status != metav1.ConditionFalse {
t.Fatalf("expected WorkloadsReady=False with non-operational monitor, got %v", wc1)
}
rc1 := findCondition(app1.GetConditions(), "Ready")
if rc1 == nil || rc1.Status != metav1.ConditionTrue {
t.Fatalf("expected Ready=True (reflects HelmRelease), got %v", rc1)
}
// Step 2: Delete the monitor
if err := c.Delete(context.TODO(), nonOpMonitor); err != nil {
t.Fatalf("failed to delete monitor: %v", err)
}
// Step 3: WorkloadsReady should disappear, Ready stays True
app2, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wc2 := findCondition(app2.GetConditions(), "WorkloadsReady")
if wc2 != nil {
t.Error("expected no WorkloadsReady condition after monitor deletion")
}
rc2 := findCondition(app2.GetConditions(), "Ready")
if rc2 == nil {
t.Fatal("expected Ready condition")
}
if rc2.Status != metav1.ConditionTrue {
t.Errorf("expected Ready=True after monitor deletion, got %s", rc2.Status)
}
}
// TestWatchIntegration_WMWatcherCloseProducesNilChannel verifies that
// after wmWatcher is set to nil, wmResultChan returns nil channel.
func TestWatchIntegration_WMWatcherCloseProducesNilChannel(t *testing.T) {
fw := watch.NewFake()
ch := wmResultChan(fw)
if ch == nil {
t.Fatal("expected non-nil channel before close")
}
fw.Stop()
timeout := time.After(time.Second)
for {
select {
case _, ok := <-ch:
if !ok {
var nilWatcher watch.Interface
nilCh := wmResultChan(nilWatcher)
if nilCh != nil {
t.Error("expected nil channel after watcher set to nil")
}
return
}
case <-timeout:
t.Fatal("timeout waiting for watcher channel to close")
}
}
}
func newTestRESTWithSchemesFromClient(c client.Client) *REST {
return NewREST(c, nil, &config.Resource{
Application: config.ApplicationConfig{
Kind: "PostgreSQL",
Plural: "postgresqls",
Singular: "postgresql",
},
Release: config.ReleaseConfig{
Prefix: "postgresql-",
},
})
}

View file

@ -0,0 +1,342 @@
package application
import (
"context"
"testing"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
)
func newTestRESTWithSchemes(objs ...runtime.Object) *REST {
scheme := runtime.NewScheme()
_ = cozyv1alpha1.AddToScheme(scheme)
_ = helmv2.AddToScheme(scheme)
builder := fake.NewClientBuilder().WithScheme(scheme)
for _, obj := range objs {
builder = builder.WithRuntimeObjects(obj)
}
c := builder.Build()
return NewREST(c, nil, &config.Resource{
Application: config.ApplicationConfig{
Kind: "PostgreSQL",
Plural: "postgresqls",
Singular: "postgresql",
},
Release: config.ReleaseConfig{
Prefix: "postgresql-",
},
})
}
func TestGetWorkloadsOperational_NoMonitors(t *testing.T) {
r := newTestRESTWithSchemes()
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.found {
t.Error("expected found=false when no monitors exist")
}
if !ws.operational {
t.Error("expected operational=true when no monitors exist")
}
}
func TestGetWorkloadsOperational_AllOperational(t *testing.T) {
m1 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
m2 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-2",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
r := newTestRESTWithSchemes(m1, m2)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ws.found {
t.Error("expected found=true")
}
if !ws.operational {
t.Error("expected operational=true when all monitors are operational")
}
}
func TestGetWorkloadsOperational_SomeNotOperational(t *testing.T) {
m1 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
m2 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-2",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
r := newTestRESTWithSchemes(m1, m2)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ws.found {
t.Error("expected found=true")
}
if ws.operational {
t.Error("expected operational=false when at least one monitor is not operational")
}
}
func TestGetWorkloadsOperational_OperationalNil(t *testing.T) {
m := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: nil, // Not yet reconciled
},
}
r := newTestRESTWithSchemes(m)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ws.found {
t.Error("expected found=true")
}
if !ws.unknown {
t.Error("expected unknown=true when Operational is nil")
}
}
func TestGetWorkloadsOperational_MixedNilAndOperational(t *testing.T) {
m1 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(true),
},
}
m2 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-2",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: nil,
},
}
r := newTestRESTWithSchemes(m1, m2)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ws.unknown {
t.Error("expected unknown=true when at least one monitor has nil Operational")
}
}
func TestGetWorkloadsOperational_MixedFailedAndPending(t *testing.T) {
m1 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false), // Confirmed failure
},
}
m2 := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-2",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: nil, // Not yet reconciled
},
}
r := newTestRESTWithSchemes(m1, m2)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.operational {
t.Error("expected operational=false when at least one monitor is explicitly failed")
}
if !ws.unknown {
t.Error("expected unknown=true when at least one monitor has nil Operational")
}
}
func TestConvertConditions_MixedFailedAndPendingShowsFalse(t *testing.T) {
mFailed := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-failed",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
mPending := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-pending",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "mydb",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: nil,
},
}
r := newTestRESTWithSchemes(mFailed, mPending)
hr := &helmv2.HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Name: "postgresql-mydb",
Namespace: "default",
Labels: map[string]string{
ApplicationKindLabel: "PostgreSQL",
ApplicationGroupLabel: "apps.cozystack.io",
ApplicationNameLabel: "mydb",
},
},
}
hr.Status.Conditions = []metav1.Condition{
{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Succeeded", Message: "ok"},
}
app, err := r.convertHelmReleaseToApplication(context.TODO(), hr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Concrete failure should take priority over unknown
wc := findCondition(app.GetConditions(), "WorkloadsReady")
if wc == nil {
t.Fatal("expected WorkloadsReady condition")
}
if wc.Status != metav1.ConditionFalse {
t.Errorf("expected WorkloadsReady=False (failure takes priority over unknown), got %s", wc.Status)
}
}
func TestGetWorkloadsOperational_DifferentApp_NotFound(t *testing.T) {
m := &cozyv1alpha1.WorkloadMonitor{
ObjectMeta: metav1.ObjectMeta{
Name: "mon-1",
Namespace: "default",
Labels: map[string]string{
appsv1alpha1.ApplicationKindLabel: "PostgreSQL",
appsv1alpha1.ApplicationGroupLabel: "apps.cozystack.io",
appsv1alpha1.ApplicationNameLabel: "other-db",
},
},
Status: cozyv1alpha1.WorkloadMonitorStatus{
Operational: ptr.To(false),
},
}
r := newTestRESTWithSchemes(m)
ws, err := r.getWorkloadsOperational(context.TODO(), "default", "mydb")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws.found {
t.Error("expected found=false for different app name")
}
if !ws.operational {
t.Error("expected operational=true when no matching monitors found")
}
}