Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions pkg/reconciler/apis/apibinding/apibinding_condition_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
Copyright 2026 The kcp Authors.

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 apibinding

import (
"testing"

"github.com/stretchr/testify/require"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2"
conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1"
)

func bindingWithConditions(cluster, name string, conds ...conditionsv1alpha1.Condition) *apisv1alpha2.APIBinding {
return &apisv1alpha2.APIBinding{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Annotations: map[string]string{"kcp.io/cluster": cluster},
},
Status: apisv1alpha2.APIBindingStatus{Conditions: conds},
}
}

func cond(condType conditionsv1alpha1.ConditionType, status corev1.ConditionStatus) conditionsv1alpha1.Condition {
return conditionsv1alpha1.Condition{Type: condType, Status: status}
}

func TestHandleConditionMetricsOnAdd(t *testing.T) {
t.Run("conditions are tracked on add", func(t *testing.T) {
c := newTestController()
b := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
cond(apisv1alpha2.InitialBindingCompleted, corev1.ConditionFalse),
)
c.handleConditionMetricsOnAdd(b)
require.Len(t, c.countedAPIBindingConditions, 1)
for _, snapshot := range c.countedAPIBindingConditions {
require.Equal(t, string(corev1.ConditionTrue), snapshot[string(apisv1alpha2.APIExportValid)])
require.Equal(t, string(corev1.ConditionFalse), snapshot[string(apisv1alpha2.InitialBindingCompleted)])
}
})

t.Run("binding with no conditions stores empty snapshot", func(t *testing.T) {
c := newTestController()
b := bindingWithConditions("root:ws", "test")
c.handleConditionMetricsOnAdd(b)
require.Len(t, c.countedAPIBindingConditions, 1)
for _, snapshot := range c.countedAPIBindingConditions {
require.Empty(t, snapshot)
}
})

t.Run("duplicate add is a no-op", func(t *testing.T) {
c := newTestController()
b := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
c.handleConditionMetricsOnAdd(b)
c.handleConditionMetricsOnAdd(b)
require.Len(t, c.countedAPIBindingConditions, 1)
})
}

func TestHandleConditionMetricsOnUpdate(t *testing.T) {
t.Run("condition status change is tracked", func(t *testing.T) {
c := newTestController()
old := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionFalse),
)
new := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
c.handleConditionMetricsOnAdd(old)
c.handleConditionMetricsOnUpdate(old, new)
for _, snapshot := range c.countedAPIBindingConditions {
require.Equal(t, string(corev1.ConditionTrue), snapshot[string(apisv1alpha2.APIExportValid)])
}
})

t.Run("new condition added on update", func(t *testing.T) {
c := newTestController()
old := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
new := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
cond(apisv1alpha2.InitialBindingCompleted, corev1.ConditionTrue),
)
c.handleConditionMetricsOnAdd(old)
c.handleConditionMetricsOnUpdate(old, new)
for _, snapshot := range c.countedAPIBindingConditions {
require.Len(t, snapshot, 2)
}
})

t.Run("removed condition is cleaned up on update", func(t *testing.T) {
c := newTestController()
old := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
cond(apisv1alpha2.InitialBindingCompleted, corev1.ConditionTrue),
)
new := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
c.handleConditionMetricsOnAdd(old)
c.handleConditionMetricsOnUpdate(old, new)
for _, snapshot := range c.countedAPIBindingConditions {
require.Len(t, snapshot, 1)
require.NotContains(t, snapshot, string(apisv1alpha2.InitialBindingCompleted))
}
})
}

func TestHandleConditionMetricsOnDelete(t *testing.T) {
t.Run("delete removes all condition tracking", func(t *testing.T) {
c := newTestController()
b := bindingWithConditions("root:ws", "test",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
c.handleConditionMetricsOnAdd(b)
require.Len(t, c.countedAPIBindingConditions, 1)
c.handleConditionMetricsOnDelete(b)
require.Empty(t, c.countedAPIBindingConditions)
})

t.Run("delete of unknown binding is a no-op", func(t *testing.T) {
c := newTestController()
b := bindingWithConditions("root:ws", "unknown",
cond(apisv1alpha2.APIExportValid, corev1.ConditionTrue),
)
require.NotPanics(t, func() { c.handleConditionMetricsOnDelete(b) })
})
}
168 changes: 162 additions & 6 deletions pkg/reconciler/apis/apibinding/apibinding_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"encoding/json"
"fmt"
"sync"
"time"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -59,6 +60,7 @@ import (
"github.com/kcp-dev/kcp/pkg/logging"
"github.com/kcp-dev/kcp/pkg/reconciler/committer"
"github.com/kcp-dev/kcp/pkg/reconciler/events"
kcpmetrics "github.com/kcp-dev/kcp/pkg/server/metrics"
"github.com/kcp-dev/kcp/pkg/tombstone"
)

Expand Down Expand Up @@ -195,20 +197,33 @@ func NewController(
commit: committer.NewSSACommitter[*APIBinding, Patcher, *APIBindingSpec, *APIBindingStatus](
kcpClusterClient.ApisV1alpha2().APIBindings(),
ControllerName,
)}
),
countedAPIBindings: make(map[string]string),
countedAPIBindingConditions: make(map[string]map[string]string),
}

logger := logging.WithReconciler(klog.Background(), ControllerName)

// APIBinding handlers
_, _ = apiBindingInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
c.enqueueAPIBinding(tombstone.Obj[*apisv1alpha2.APIBinding](obj), logger, "")
},
UpdateFunc: func(_, obj interface{}) {
c.enqueueAPIBinding(tombstone.Obj[*apisv1alpha2.APIBinding](obj), logger, "")
binding := tombstone.Obj[*apisv1alpha2.APIBinding](obj)
c.enqueueAPIBinding(binding, logger, "")
c.handlePhaseMetricsOnAdd(binding)
c.handleConditionMetricsOnAdd(binding)
},
UpdateFunc: func(oldObj, obj interface{}) {
binding := tombstone.Obj[*apisv1alpha2.APIBinding](obj)
old := tombstone.Obj[*apisv1alpha2.APIBinding](oldObj)
c.enqueueAPIBinding(binding, logger, "")
c.handlePhaseMetricsOnUpdate(old, binding)
c.handleConditionMetricsOnUpdate(old, binding)
},
DeleteFunc: func(obj interface{}) {
c.enqueueAPIBinding(tombstone.Obj[*apisv1alpha2.APIBinding](obj), logger, "")
binding := tombstone.Obj[*apisv1alpha2.APIBinding](obj)
c.enqueueAPIBinding(binding, logger, "")
c.handlePhaseMetricsOnDelete(binding)
c.handleConditionMetricsOnDelete(binding)
},
})

Expand Down Expand Up @@ -353,6 +368,11 @@ type controller struct {

deletedCRDTracker *lockedStringSet
commit CommitFunc

mu sync.Mutex
countedAPIBindings map[string]string
// countedAPIBindingConditions maps binding key -> (conditionType -> status)
countedAPIBindingConditions map[string]map[string]string
}

// enqueueAPIBinding enqueues an APIBinding .
Expand Down Expand Up @@ -571,3 +591,139 @@ func InstallIndexers(
indexers.APIExportByIdentity: indexers.IndexAPIExportByIdentity,
})
}

func (c *controller) handlePhaseMetricsOnAdd(binding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(binding)
if err != nil {
return
}
phase := string(binding.Status.Phase)

c.mu.Lock()
defer c.mu.Unlock()

if _, exists := c.countedAPIBindings[key]; !exists {
c.countedAPIBindings[key] = phase
if phase != "" {
kcpmetrics.IncrementAPIBindingPhase(phase)
}
}
}

func (c *controller) handlePhaseMetricsOnUpdate(oldBinding, newBinding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(newBinding)
if err != nil {
return
}
oldPhase := string(oldBinding.Status.Phase)
newPhase := string(newBinding.Status.Phase)

c.mu.Lock()
defer c.mu.Unlock()

if oldPhase != newPhase {
if oldPhase != "" {
kcpmetrics.DecrementAPIBindingPhase(oldPhase)
}
if newPhase != "" {
kcpmetrics.IncrementAPIBindingPhase(newPhase)
}
if newPhase == string(apisv1alpha2.APIBindingPhaseBound) {
kcpmetrics.ObserveAPIBindingReadyDuration(newBinding.CreationTimestamp.Time)
}
c.countedAPIBindings[key] = newPhase
}
}

func (c *controller) handlePhaseMetricsOnDelete(binding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(binding)
if err != nil {
return
}

c.mu.Lock()
defer c.mu.Unlock()

if phase, exists := c.countedAPIBindings[key]; exists {
delete(c.countedAPIBindings, key)
if phase != "" {
kcpmetrics.DecrementAPIBindingPhase(phase)
}
}
}

func (c *controller) handleConditionMetricsOnAdd(binding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(binding)
if err != nil {
return
}

snapshot := make(map[string]string, len(binding.Status.Conditions))
for _, cond := range binding.Status.Conditions {
snapshot[string(cond.Type)] = string(cond.Status)
}

c.mu.Lock()
defer c.mu.Unlock()

if _, exists := c.countedAPIBindingConditions[key]; exists {
return
}
c.countedAPIBindingConditions[key] = snapshot
for condType, status := range snapshot {
kcpmetrics.IncrementAPIBindingConditionStatus(condType, status)
}
}

func (c *controller) handleConditionMetricsOnUpdate(oldBinding, newBinding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(newBinding)
if err != nil {
return
}

newSnapshot := make(map[string]string, len(newBinding.Status.Conditions))
for _, cond := range newBinding.Status.Conditions {
newSnapshot[string(cond.Type)] = string(cond.Status)
}

c.mu.Lock()
defer c.mu.Unlock()

oldSnapshot := c.countedAPIBindingConditions[key]

// Decrement removed or changed conditions.
for condType, oldStatus := range oldSnapshot {
newStatus, exists := newSnapshot[condType]
if !exists || newStatus != oldStatus {
kcpmetrics.DecrementAPIBindingConditionStatus(condType, oldStatus)
}
}
// Increment new or changed conditions.
for condType, newStatus := range newSnapshot {
oldStatus, exists := oldSnapshot[condType]
if !exists || oldStatus != newStatus {
kcpmetrics.IncrementAPIBindingConditionStatus(condType, newStatus)
}
}

c.countedAPIBindingConditions[key] = newSnapshot
}

func (c *controller) handleConditionMetricsOnDelete(binding *apisv1alpha2.APIBinding) {
key, err := kcpcache.MetaClusterNamespaceKeyFunc(binding)
if err != nil {
return
}

c.mu.Lock()
defer c.mu.Unlock()

snapshot, exists := c.countedAPIBindingConditions[key]
if !exists {
return
}
for condType, status := range snapshot {
kcpmetrics.DecrementAPIBindingConditionStatus(condType, status)
}
delete(c.countedAPIBindingConditions, key)
}
Loading