Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cmd/controller-manager/app/controllermanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ func startFederatedHorizontalPodAutoscalerController(ctx controllerscontext.Cont
func startCronFederatedHorizontalPodAutoscalerController(ctx controllerscontext.Context) (enabled bool, err error) {
cronFHPAController := cronfederatedhpa.CronFHPAController{
Client: ctx.Mgr.GetClient(),
EventRecorder: ctx.Mgr.GetEventRecorderFor(cronfederatedhpa.ControllerName), //nolint:staticcheck // Note: GetEventRecorderFor is deprecated in controller-runtime v0.23.0 in favor of GetEventRecorder. This changes event API from v1 events to events.k8s.io. We need to migrate carefully, especially considering the impact on users and RBAC permission changes in installation/deployment tools.
EventRecorder: ctx.Mgr.GetEventRecorder(cronfederatedhpa.ControllerName),
RateLimiterOptions: ctx.Opts.RateLimiterOptions,
}
if err = cronFHPAController.SetupWithManager(ctx.Mgr); err != nil {
Expand Down
17 changes: 11 additions & 6 deletions pkg/controllers/cronfederatedhpa/cronfederatedhpa_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/tools/record"
clientgoevents "k8s.io/client-go/tools/events"
"k8s.io/klog/v2"
controllerruntime "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

autoscalingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/autoscaling/v1alpha1"
"github.com/karmada-io/karmada/pkg/events"
"github.com/karmada-io/karmada/pkg/metrics"
"github.com/karmada-io/karmada/pkg/sharedcli/ratelimiterflag"
"github.com/karmada-io/karmada/pkg/util/helper"
Expand All @@ -43,10 +45,13 @@ const (
ControllerName = "cronfederatedhpa-controller"
)

// Check if our CronFHPAController implements necessary interface
var _ reconcile.Reconciler = &CronFHPAController{}

// CronFHPAController is used to operate CronFederatedHPA.
type CronFHPAController struct {
Comment on lines 51 to 52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, any struct that explicitly implements an interface must include a compile-time interface compliance check.

// Check if our CronFHPAController implements necessary interface
var _ controllerruntime.Reconciler = &CronFHPAController{}

// CronFHPAController is used to operate CronFederatedHPA.
References
  1. Any struct that explicitly implements an interface must include a compile-time interface compliance check using the pattern: var _ InterfaceName = &StructName{}. (link)

client.Client // used to operate Cron resources.
EventRecorder record.EventRecorder
EventRecorder clientgoevents.EventRecorder

RateLimiterOptions ratelimiterflag.Options
CronHandler *CronHandler
Expand Down Expand Up @@ -118,7 +123,7 @@ func (c *CronFHPAController) Reconcile(ctx context.Context, req controllerruntim

// SetupWithManager creates a controller and register to controller manager.
func (c *CronFHPAController) SetupWithManager(mgr controllerruntime.Manager) error {
c.CronHandler = NewCronHandler(mgr.GetClient(), mgr.GetEventRecorderFor(ControllerName)) //nolint:staticcheck // Note: GetEventRecorderFor is deprecated in controller-runtime v0.23.0 in favor of GetEventRecorder. This changes event API from v1 events to events.k8s.io. We need to migrate carefully, especially considering the impact on users and RBAC permission changes in installation/deployment tools.
c.CronHandler = NewCronHandler(mgr.GetClient(), mgr.GetEventRecorder(ControllerName))
return controllerruntime.NewControllerManagedBy(mgr).
Named(ControllerName).
For(&autoscalingv1alpha1.CronFederatedHPA{}).
Expand All @@ -139,14 +144,14 @@ func (c *CronFHPAController) processCronRule(ctx context.Context, cronFHPA *auto

if !helper.IsCronFederatedHPARuleSuspend(rule) {
if err := c.CronHandler.CreateCronJobForExecutor(cronFHPA, rule); err != nil {
c.EventRecorder.Event(cronFHPA, corev1.EventTypeWarning, "StartRuleFailed", err.Error())
c.EventRecorder.Eventf(cronFHPA, nil, corev1.EventTypeWarning, events.EventReasonStartCronFederatedHPARuleFailed, events.EventActionStartCronFederatedHPARule, "%v", err)
klog.ErrorS(err, "Fail to start cron for CronFederatedHPA rule", "cronFederatedHPA", cronFHPAKey, "rule", rule.Name)
return err
}
}

if err := c.updateRuleHistory(ctx, cronFHPA, rule); err != nil {
c.EventRecorder.Event(cronFHPA, corev1.EventTypeWarning, "UpdateCronFederatedHPAFailed", err.Error())
c.EventRecorder.Eventf(cronFHPA, nil, corev1.EventTypeWarning, events.EventReasonUpdateCronFederatedHPAFailed, events.EventActionUpdateCronFederatedHPA, "%v", err)
return err
}
return nil
Expand Down Expand Up @@ -207,7 +212,7 @@ func (c *CronFHPAController) removeCronFHPAHistory(ctx context.Context, cronFHPA
return nil
}
if err := c.Client.Status().Update(ctx, cronFHPA); err != nil {
c.EventRecorder.Event(cronFHPA, corev1.EventTypeWarning, "UpdateCronFederatedHPAFailed", err.Error())
c.EventRecorder.Eventf(cronFHPA, nil, corev1.EventTypeWarning, events.EventReasonUpdateCronFederatedHPAFailed, events.EventActionUpdateCronFederatedHPA, "%v", err)
klog.ErrorS(err, "Fail to remove CronFederatedHPA rule history", "namespace", cronFHPA.Namespace, "name", cronFHPA.Name, "rule", ruleName)
return err
}
Expand Down
162 changes: 162 additions & 0 deletions pkg/controllers/cronfederatedhpa/cronfederatedhpa_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
Copyright 2026 The Karmada 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 cronfederatedhpa

import (
"errors"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoevents "k8s.io/client-go/tools/events"
"k8s.io/klog/v2"

autoscalingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/autoscaling/v1alpha1"
"github.com/karmada-io/karmada/pkg/events"
)

// capturingRecorder records every field passed to Eventf, including action
// and related which the upstream events.FakeRecorder discards from its event
// channel. It satisfies clientgoevents.EventRecorderLogger.
type capturingRecorder struct {
regarding runtime.Object
related runtime.Object
eventType string
reason string
action string
note string
called int
}

func (c *capturingRecorder) Eventf(regarding, related runtime.Object, eventtype, reason, action, note string, args ...any) {
c.regarding = regarding
c.related = related
c.eventType = eventtype
c.reason = reason
c.action = action
c.note = fmt.Sprintf(note, args...)
c.called++
}

func (c *capturingRecorder) WithLogger(klog.Logger) clientgoevents.EventRecorderLogger {
return c
}

var _ clientgoevents.EventRecorderLogger = &capturingRecorder{}

func newCronFHPAFixture() *autoscalingv1alpha1.CronFederatedHPA {
return &autoscalingv1alpha1.CronFederatedHPA{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cron-fhpa",
Namespace: "default",
},
Spec: autoscalingv1alpha1.CronFederatedHPASpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: autoscalingv1alpha1.GroupVersion.String(),
Kind: autoscalingv1alpha1.FederatedHPAKind,
Name: "test-fhpa",
},
},
}
}

func TestTargetReferenceFor(t *testing.T) {
cronFHPA := newCronFHPAFixture()

ref := targetReferenceFor(cronFHPA)

assert.Equal(t, autoscalingv1alpha1.GroupVersion.String(), ref.APIVersion)
assert.Equal(t, autoscalingv1alpha1.FederatedHPAKind, ref.Kind)
assert.Equal(t, "test-fhpa", ref.Name)
assert.Equal(t, "default", ref.Namespace)

// The returned reference is itself a runtime.Object — required for use
// as the 'related' argument on the new EventRecorder.Eventf signature.
var _ runtime.Object = ref
}

// TestEventRecorderEmitsAllFields exercises the new events.k8s.io/v1
// Eventf signature with the constants and helpers introduced by this PoC,
// asserting that every field — including action and related, which the
// upstream FakeRecorder drops from its channel — round-trips correctly.
//
// It does not invoke a controller reconcile (that path is covered by the
// existing handler tests); it asserts the contract between the recorder
// and the constants/helpers this migration introduces.
func TestEventRecorderEmitsAllFields(t *testing.T) {
rec := &capturingRecorder{}
cronFHPA := newCronFHPAFixture()

rec.Eventf(cronFHPA, targetReferenceFor(cronFHPA), corev1.EventTypeWarning,
events.EventReasonScaleCronFederatedHPAFailed, events.EventActionScaleCronFederatedHPA,
"failed to scale %s %s/%s: %v", cronFHPA.Spec.ScaleTargetRef.Kind, cronFHPA.Namespace, cronFHPA.Spec.ScaleTargetRef.Name, errors.New("target not found"))

assert.Equal(t, 1, rec.called)
assert.Same(t, cronFHPA, rec.regarding)
assert.Equal(t, corev1.EventTypeWarning, rec.eventType)
assert.Equal(t, "ScaleFailed", rec.reason)
assert.Equal(t, "ScaleCronFederatedHPA", rec.action)
assert.Contains(t, rec.note, "FederatedHPA")
assert.Contains(t, rec.note, "default/test-fhpa")
assert.Contains(t, rec.note, "target not found")

related, ok := rec.related.(*corev1.ObjectReference)
assert.True(t, ok, "related should be *corev1.ObjectReference, got %T", rec.related)
assert.Equal(t, "test-fhpa", related.Name)
assert.Equal(t, autoscalingv1alpha1.FederatedHPAKind, related.Kind)
}

// TestEventConstantsPreserveWireFormat guards against accidental edits to
// the reason strings, which are part of the public surface of these events
// — external tooling may match on them.
func TestEventConstantsPreserveWireFormat(t *testing.T) {
cases := map[string]string{
"StartRuleFailed": events.EventReasonStartCronFederatedHPARuleFailed,
"UpdateCronFederatedHPAFailed": events.EventReasonUpdateCronFederatedHPAFailed,
"ScaleFailed": events.EventReasonScaleCronFederatedHPAFailed,
"UpdateStatusFailed": events.EventReasonUpdateCronFederatedHPAStatusFailed,
}
for want, got := range cases {
assert.Equal(t, want, got, "reason string drifted from existing wire format")
}
}

// TestEventfFormatStringSafety guards against the regression where an error
// message containing format verbs (%v, %s, %d, or even raw % characters from
// URL-encoded paths) is passed as the 'note' format string to Eventf. The
// safe pattern is to pass "%v" as the format and the error as an argument,
// so Sprintf treats the error message as data, not as a format directive.
func TestEventfFormatStringSafety(t *testing.T) {
rec := &capturingRecorder{}
cronFHPA := newCronFHPAFixture()
// An error whose message contains both real format verbs and a raw % —
// the kind that arises from wrapped errors and URL-bearing API failures.
hostileErr := errors.New("decoding %d failed at https://api/foo%20bar: %v")

rec.Eventf(cronFHPA, nil, corev1.EventTypeWarning,
events.EventReasonUpdateCronFederatedHPAFailed, events.EventActionUpdateCronFederatedHPA,
"%v", hostileErr)

// With the safe pattern, the error message round-trips verbatim — the
// %d, %20, and %v inside the error are treated as literal characters.
assert.Equal(t, hostileErr.Error(), rec.note)
assert.NotContains(t, rec.note, "MISSING", "Sprintf saw the error as a format string and substituted MISSING placeholders")
}
6 changes: 3 additions & 3 deletions pkg/controllers/cronfederatedhpa/cronfederatedhpa_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
"github.com/go-co-op/gocron"
autoscalingv2 "k8s.io/api/autoscaling/v2"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/client-go/tools/record"
clientgoevents "k8s.io/client-go/tools/events"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand All @@ -42,7 +42,7 @@ type RuleCron struct {
// CronHandler is the handler for CronFederatedHPA
type CronHandler struct {
client client.Client
eventRecorder record.EventRecorder
eventRecorder clientgoevents.EventRecorder

// cronExecutorMap is [cronFederatedHPA name][rule name]RuleCron
cronExecutorMap map[string]map[string]RuleCron
Expand All @@ -54,7 +54,7 @@ type CronHandler struct {
}

// NewCronHandler creates new cron handler
func NewCronHandler(client client.Client, eventRecorder record.EventRecorder) *CronHandler {
func NewCronHandler(client client.Client, eventRecorder clientgoevents.EventRecorder) *CronHandler {
return &CronHandler{
client: client,
eventRecorder: eventRecorder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
"github.com/stretchr/testify/assert"
autoscalingv2 "k8s.io/api/autoscaling/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/tools/events"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

Expand Down Expand Up @@ -85,7 +85,7 @@ func TestCronFHPAScaleTargetRefUpdates(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

// Empty initialTarget will be skipped
if tt.initialTarget != (autoscalingv2.CrossVersionObjectReference{}) {
Expand All @@ -100,7 +100,7 @@ func TestCronFHPAScaleTargetRefUpdates(t *testing.T) {
}

func TestAddCronExecutorIfNotExist(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPAKey := "default/test-cronhpa"

Expand All @@ -115,7 +115,7 @@ func TestAddCronExecutorIfNotExist(t *testing.T) {
}

func TestRuleCronExecutorExists(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPAKey := "default/test-cronhpa"
ruleName := "test-rule"
Expand All @@ -138,7 +138,7 @@ func TestRuleCronExecutorExists(t *testing.T) {
}

func TestStopRuleExecutor(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPAKey := "default/test-cronhpa"
ruleName := "test-rule"
Expand All @@ -160,7 +160,7 @@ func TestStopRuleExecutor(t *testing.T) {
}

func TestStopCronFHPAExecutor(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPAKey := "default/test-cronhpa"
ruleName1 := "test-rule-1"
Expand Down Expand Up @@ -193,7 +193,7 @@ func TestStopCronFHPAExecutor(t *testing.T) {
}

func TestCreateCronJobForExecutor(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPA := &autoscalingv1alpha1.CronFederatedHPA{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -265,7 +265,7 @@ func TestCreateCronJobForExecutor(t *testing.T) {
}

func TestGetRuleNextExecuteTime(t *testing.T) {
handler := NewCronHandler(fake.NewClientBuilder().Build(), record.NewFakeRecorder(100))
handler := NewCronHandler(fake.NewClientBuilder().Build(), events.NewFakeRecorder(100))

cronFHPA := &autoscalingv1alpha1.CronFederatedHPA{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Loading
Loading