Skip to content

Commit f66cf23

Browse files
scotwellsclaude
andcommitted
feat(controller): migrate instance event emission to the supported events API
The InstanceReconciler emitted quota and referenced-data events through the deprecated core/v1 event recorder. Move to the events.k8s.io/v1 recorder so Instance events keep flowing on the supported API surface, with each event now carrying a machine-readable action (the controller operation) alongside its existing reason (the outcome). Operators see the same Warning/Normal events in `kubectl describe instance`, now backed by events.k8s.io. All emit sites funnel through the emitEvent helper, which passes the message as a "%s" argument so backend error text containing a literal '%' is not format-expanded, and truncates the note to the 1024-character server-side limit so oversized notes are not silently rejected. The controller RBAC role is regenerated to grant events.k8s.io instead of the core events group. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9535439 commit f66cf23

6 files changed

Lines changed: 212 additions & 44 deletions

File tree

config/components/controller_rbac/role.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,6 @@ rules:
1717
- patch
1818
- update
1919
- watch
20-
- apiGroups:
21-
- ""
22-
resources:
23-
- events
24-
verbs:
25-
- create
26-
- patch
2720
- apiGroups:
2821
- ""
2922
resources:
@@ -63,6 +56,13 @@ rules:
6356
- get
6457
- patch
6558
- update
59+
- apiGroups:
60+
- events.k8s.io
61+
resources:
62+
- events
63+
verbs:
64+
- create
65+
- patch
6666
- apiGroups:
6767
- networking.datumapis.com
6868
resources:

internal/controller/instance_controller.go

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"k8s.io/apimachinery/pkg/runtime"
2121
"k8s.io/apimachinery/pkg/types"
2222
"k8s.io/client-go/rest"
23-
"k8s.io/client-go/tools/record"
23+
"k8s.io/client-go/tools/events"
2424
ctrl "sigs.k8s.io/controller-runtime"
2525
"sigs.k8s.io/controller-runtime/pkg/client"
2626
"sigs.k8s.io/controller-runtime/pkg/cluster"
@@ -92,6 +92,23 @@ const (
9292
reasonNetworkFailedToCreate = "NetworkFailedToCreate"
9393
)
9494

95+
// Event action strings name the controller operation an event describes. The
96+
// events.k8s.io API separates the machine-readable action (what the controller
97+
// was doing, UpperCamelCase) from the reason (the outcome), so the reason
98+
// constants carry the failure mode while these name the operation.
99+
const (
100+
eventActionResolvingReferencedData = "ResolvingReferencedData"
101+
eventActionRemovingSchedulingGate = "RemovingSchedulingGate"
102+
eventActionClaimingQuota = "ClaimingQuota"
103+
eventActionReleasingQuota = "ReleasingQuota"
104+
)
105+
106+
// maxEventNoteLen bounds an event note's length. events.k8s.io/v1 rejects notes
107+
// longer than 1024 characters server-side, and the awaiting-propagation note
108+
// joins an unbounded list of missing companions, so the note is truncated
109+
// before emission to keep events from being silently dropped.
110+
const maxEventNoteLen = 1024
111+
95112
// instanceTypeD1Standard2 is the platform instance type name for the
96113
// 1 vCPU / 2 GiB size used as the catalog baseline for quota accounting.
97114
const instanceTypeD1Standard2 = "datumcloud/d1-standard-2"
@@ -190,7 +207,7 @@ type InstanceReconciler struct {
190207
edgeClusterName string
191208
// recorder emits Kubernetes events on the Instance object for quota failure
192209
// modes so operators can diagnose issues via `kubectl describe`.
193-
recorder record.EventRecorder
210+
recorder events.EventRecorder
194211
// projectIDForInstance derives the Milo project ID used for quota
195212
// ResourceClaim management. In Milo mode it returns string(clusterName); in
196213
// single-cell mode it reads the upstream-cluster-name label from the edge
@@ -224,7 +241,7 @@ type InstanceReconciler struct {
224241
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=instances/finalizers,verbs=update
225242
// +kubebuilder:rbac:groups=quota.miloapis.com,resources=resourceclaims,verbs=get;list;watch;create;delete
226243
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
227-
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
244+
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
228245

229246
//nolint:gocyclo // conditions are reconciled, persisted, then returned as errors; the ordered pipeline is inherently branchy
230247
func (r *InstanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (_ ctrl.Result, err error) {
@@ -588,7 +605,8 @@ func (r *InstanceReconciler) reconcileReferencedDataCondition(
588605
computev1alpha.ReferencedDataReasonAwaitingPropagation, msg)
589606
if prevReason != computev1alpha.ReferencedDataReasonAwaitingPropagation {
590607
r.emitEvent(instance, corev1.EventTypeWarning,
591-
computev1alpha.ReferencedDataReasonAwaitingPropagation, msg)
608+
computev1alpha.ReferencedDataReasonAwaitingPropagation,
609+
eventActionResolvingReferencedData, msg)
592610
}
593611
return referencedDataResult{conditionChanged: changed}, 0, nil
594612
}
@@ -902,16 +920,23 @@ func (r *InstanceReconciler) observeGateWaitDuration(instance *computev1alpha.In
902920
func (r *InstanceReconciler) emitReferencedDataClearedEvent(instance *computev1alpha.Instance) {
903921
r.emitEvent(instance, corev1.EventTypeNormal,
904922
computev1alpha.ReferencedDataReasonReady,
923+
eventActionRemovingSchedulingGate,
905924
"All referenced companion ConfigMaps/Secrets are present; ReferencedData gate cleared")
906925
}
907926

908927
// emitEvent emits a Kubernetes event if a recorder is available. Guard against
909928
// a nil recorder so that unit tests that don't wire up a recorder don't panic.
910-
func (r *InstanceReconciler) emitEvent(obj *computev1alpha.Instance, eventType, reason, message string) {
929+
// The pre-built message is passed as the "%s" argument, never as the note format
930+
// string itself, because the events API treats the note as a printf format and
931+
// the quota messages embed backend error text that can contain literal '%'.
932+
func (r *InstanceReconciler) emitEvent(obj *computev1alpha.Instance, eventType, reason, action, message string) {
911933
if r.recorder == nil {
912934
return
913935
}
914-
r.recorder.Event(obj, eventType, reason, message)
936+
if len(message) > maxEventNoteLen {
937+
message = strings.ToValidUTF8(message[:maxEventNoteLen-3], "") + "..."
938+
}
939+
r.recorder.Eventf(obj, nil, eventType, reason, action, "%s", message)
915940
}
916941

917942
// reconcileDeletion handles quota-claim cleanup when an Instance is being
@@ -936,8 +961,9 @@ func (r *InstanceReconciler) reconcileDeletion(ctx context.Context, cl client.Cl
936961
// claim will count against project budget until Milo's TTL/GC removes it.
937962
log.FromContext(ctx).Error(err, "project identity unresolvable during deletion; ResourceClaim may be orphaned — budget leak possible",
938963
"instance", instance.Name, "namespace", instance.Namespace)
939-
r.recorder.Event(instance, corev1.EventTypeWarning,
964+
r.emitEvent(instance, corev1.EventTypeWarning,
940965
"QuotaClaimOrphaned",
966+
eventActionReleasingQuota,
941967
"Skipping ResourceClaim cleanup: project identity could not be resolved; claim may be orphaned in Milo project control plane")
942968
quotametrics.ClaimOrphanedTotal.Inc()
943969
}
@@ -1055,8 +1081,9 @@ func (r *InstanceReconciler) reconcileQuotaCondition(ctx context.Context, cluste
10551081
Message: "ResourceClaim is pending: no AllowanceBucket configured for this project",
10561082
ObservedGeneration: instance.Generation,
10571083
})
1058-
r.recorder.Event(instance, corev1.EventTypeWarning,
1084+
r.emitEvent(instance, corev1.EventTypeWarning,
10591085
computev1alpha.InstanceQuotaGrantedReasonNoBudget,
1086+
eventActionClaimingQuota,
10601087
"ResourceClaim pending: no AllowanceBucket configured for this project")
10611088
quotametrics.EvalFailuresTotal.WithLabelValues(quotametrics.ReasonNoBudget).Inc()
10621089
return changed, claimErr
@@ -1281,8 +1308,8 @@ func (r *InstanceReconciler) reconcileQuotaClaim(ctx context.Context, clusterNam
12811308
// structured condition + warning event + error return, instead of
12821309
// silently parking the instance at PendingEvaluation.
12831310
msg := fmt.Sprintf("Could not resolve project ID: %v", err)
1284-
r.recorder.Event(instance, corev1.EventTypeWarning,
1285-
computev1alpha.InstanceQuotaGrantedReasonProjectIDUnresolvable, msg)
1311+
r.emitEvent(instance, corev1.EventTypeWarning,
1312+
computev1alpha.InstanceQuotaGrantedReasonProjectIDUnresolvable, eventActionClaimingQuota, msg)
12861313
quotametrics.EvalFailuresTotal.WithLabelValues(quotametrics.ReasonProjectIDUnresolvable).Inc()
12871314
return &metav1.Condition{
12881315
Type: computev1alpha.InstanceQuotaGranted,
@@ -1295,8 +1322,8 @@ func (r *InstanceReconciler) reconcileQuotaClaim(ctx context.Context, clusterNam
12951322
projectClient, err := r.quotaClientManager.ClientForProject(ctx, projectID, r.scheme)
12961323
if err != nil {
12971324
msg := fmt.Sprintf("Failed to build quota client for project %q: %v", projectID, err)
1298-
r.recorder.Event(instance, corev1.EventTypeWarning,
1299-
computev1alpha.InstanceQuotaGrantedReasonBackendUnavailable, msg)
1325+
r.emitEvent(instance, corev1.EventTypeWarning,
1326+
computev1alpha.InstanceQuotaGrantedReasonBackendUnavailable, eventActionClaimingQuota, msg)
13001327
quotametrics.EvalFailuresTotal.WithLabelValues(quotametrics.ReasonBackendUnavailable).Inc()
13011328
return &metav1.Condition{
13021329
Type: computev1alpha.InstanceQuotaGranted,
@@ -1309,8 +1336,8 @@ func (r *InstanceReconciler) reconcileQuotaClaim(ctx context.Context, clusterNam
13091336
claimNamespace, err := r.resolveProjectNamespace(ctx, clusterName, instance)
13101337
if err != nil {
13111338
msg := fmt.Sprintf("Could not resolve project namespace: %v", err)
1312-
r.recorder.Event(instance, corev1.EventTypeWarning,
1313-
computev1alpha.InstanceQuotaGrantedReasonProjectIDUnresolvable, msg)
1339+
r.emitEvent(instance, corev1.EventTypeWarning,
1340+
computev1alpha.InstanceQuotaGrantedReasonProjectIDUnresolvable, eventActionClaimingQuota, msg)
13141341
quotametrics.EvalFailuresTotal.WithLabelValues(quotametrics.ReasonProjectIDUnresolvable).Inc()
13151342
return &metav1.Condition{
13161343
Type: computev1alpha.InstanceQuotaGranted,
@@ -1381,8 +1408,8 @@ func (r *InstanceReconciler) reconcileQuotaClaim(ctx context.Context, clusterNam
13811408
}
13821409
// GET itself failed — treat as backend unavailable.
13831410
msg := fmt.Sprintf("Quota backend unreachable getting ResourceClaim: %v", err)
1384-
r.recorder.Event(instance, corev1.EventTypeWarning,
1385-
computev1alpha.InstanceQuotaGrantedReasonBackendUnavailable, msg)
1411+
r.emitEvent(instance, corev1.EventTypeWarning,
1412+
computev1alpha.InstanceQuotaGrantedReasonBackendUnavailable, eventActionClaimingQuota, msg)
13861413
quotametrics.EvalFailuresTotal.WithLabelValues(quotametrics.ReasonBackendUnavailable).Inc()
13871414
return &metav1.Condition{
13881415
Type: computev1alpha.InstanceQuotaGranted,
@@ -1431,7 +1458,7 @@ func (r *InstanceReconciler) classifyCreateError(
14311458
msg = fmt.Sprintf("Quota backend unreachable creating ResourceClaim: %v", err)
14321459
}
14331460

1434-
r.recorder.Event(instance, corev1.EventTypeWarning, reason, msg)
1461+
r.emitEvent(instance, corev1.EventTypeWarning, reason, eventActionClaimingQuota, msg)
14351462
quotametrics.EvalFailuresTotal.WithLabelValues(metricLabel).Inc()
14361463
return &metav1.Condition{
14371464
Type: computev1alpha.InstanceQuotaGranted,
@@ -1854,10 +1881,7 @@ func (r *InstanceReconciler) SetupWithManager(
18541881
) error {
18551882
r.mgr = mgr
18561883
r.scheme = mgr.GetLocalManager().GetScheme()
1857-
//nolint:staticcheck // GetEventRecorder (new events API) has an incompatible Eventf
1858-
// signature (requires related object + action args) that would require migrating
1859-
// all emit sites. GetEventRecorderFor remains correct; migration is deferred.
1860-
r.recorder = mgr.GetLocalManager().GetEventRecorderFor("instance-controller")
1884+
r.recorder = mgr.GetLocalManager().GetEventRecorder("instance-controller")
18611885
r.edgeClusterName = edgeClusterName
18621886
r.projectIDForInstance = projectIDForInstance
18631887
r.projectNamespaceForInstance = projectNamespaceForInstance

internal/controller/instance_controller_test.go

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package controller
33
import (
44
"context"
55
"fmt"
6+
"strings"
67
"testing"
78
"time"
89

@@ -16,7 +17,7 @@ import (
1617
"k8s.io/apimachinery/pkg/runtime"
1718
"k8s.io/apimachinery/pkg/runtime/schema"
1819
"k8s.io/apimachinery/pkg/types"
19-
"k8s.io/client-go/tools/record"
20+
"k8s.io/client-go/tools/events"
2021
"sigs.k8s.io/controller-runtime/pkg/client"
2122
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2223
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
@@ -1231,7 +1232,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
12311232
newReconcilerWithInterceptor := func(
12321233
t *testing.T,
12331234
funcs interceptor.Funcs,
1234-
fakeRecorder *record.FakeRecorder,
1235+
fakeRecorder *capturingEventRecorder,
12351236
) (*InstanceReconciler, client.Client) {
12361237
t.Helper()
12371238
s := newTestScheme(t)
@@ -1279,7 +1280,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
12791280
}
12801281

12811282
t.Run("FM-2: backend unreachable sets QuotaBackendUnavailable", func(t *testing.T) {
1282-
fakeRecorder := record.NewFakeRecorder(10)
1283+
fakeRecorder := newCapturingEventRecorder(10)
12831284
r, projectClient := newReconcilerWithInterceptor(t, interceptor.Funcs{
12841285
Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error {
12851286
return fmt.Errorf("connection refused")
@@ -1306,13 +1307,22 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
13061307
default:
13071308
t.Error("expected a Warning event for backend unavailable, got none")
13081309
}
1310+
1311+
// The event carries the quota-claim action and references the Instance.
1312+
// The apiserver rejects an events.k8s.io event with an empty action, so a
1313+
// silently-empty action would 422-drop in production while passing CI.
1314+
last := fakeRecorder.LastRecorded()
1315+
require.NotNil(t, last)
1316+
assert.Equal(t, eventActionClaimingQuota, last.Action)
1317+
assert.NotEmpty(t, last.Action)
1318+
assert.NotNil(t, last.Regarding)
13091319
})
13101320

13111321
// FM-4/FM-5: 404 on Create maps to NamespaceNotFound when the claim namespace
13121322
// is known (the more common case for project-exists-but-namespace-absent), and
13131323
// to ProjectNotFound when the namespace itself is empty (project CP path missing).
13141324
t.Run("FM-5: 404 on Create with known namespace sets QuotaNamespaceNotFound", func(t *testing.T) {
1315-
fakeRecorder := record.NewFakeRecorder(10)
1325+
fakeRecorder := newCapturingEventRecorder(10)
13161326
notFoundErr := apierrors.NewNotFound(
13171327
schema.GroupResource{Group: testQuotaAPIGroup, Resource: testQuotaResource}, "claim")
13181328
r, projectClient := newReconcilerWithInterceptor(t, interceptor.Funcs{
@@ -1347,7 +1357,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
13471357
})
13481358

13491359
t.Run("FM-6: 403 on Create sets QuotaMisconfigured", func(t *testing.T) {
1350-
fakeRecorder := record.NewFakeRecorder(10)
1360+
fakeRecorder := newCapturingEventRecorder(10)
13511361
forbiddenErr := apierrors.NewForbidden(
13521362
schema.GroupResource{Group: testQuotaAPIGroup, Resource: testQuotaResource}, "claim",
13531363
fmt.Errorf("ResourceRegistration not found"))
@@ -1384,7 +1394,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
13841394

13851395
t.Run("FM-7: claim pending with no budget sets QuotaNoBudget", func(t *testing.T) {
13861396
s := newTestScheme(t)
1387-
fakeRecorder := record.NewFakeRecorder(10)
1397+
fakeRecorder := newCapturingEventRecorder(10)
13881398

13891399
claimName := instanceQuotaClaimNamePrefix + testInstance
13901400
pendingClaim := &quotav1alpha1.ResourceClaim{
@@ -1492,7 +1502,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
14921502
scheme: s,
14931503
quotaClientManager: nil, // explicitly disabled
14941504
edgeClusterName: testEdgeClusterName,
1495-
recorder: record.NewFakeRecorder(10),
1505+
recorder: newCapturingEventRecorder(10),
14961506
projectIDForInstance: func(_ context.Context, cn multicluster.ClusterName, _ *computev1alpha.Instance) (string, error) {
14971507
return string(cn), nil
14981508
},
@@ -1516,7 +1526,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
15161526

15171527
t.Run("observedGeneration guard: stale True condition does not remove gate for new generation", func(t *testing.T) {
15181528
s := newTestScheme(t)
1519-
fakeRecorder := record.NewFakeRecorder(10)
1529+
fakeRecorder := newCapturingEventRecorder(10)
15201530

15211531
// Instance at generation 2 with a stale QuotaGranted=True from generation 1.
15221532
instance := makeInstance()
@@ -1612,7 +1622,7 @@ func TestReconcileQuotaFailureModes(t *testing.T) {
16121622

16131623
t.Run("FM-1: missing identity label sets ProjectIDUnresolvable and errors", func(t *testing.T) {
16141624
s := newTestScheme(t)
1615-
fakeRecorder := record.NewFakeRecorder(10)
1625+
fakeRecorder := newCapturingEventRecorder(10)
16161626

16171627
projectClient := fake.NewClientBuilder().
16181628
WithScheme(s).
@@ -1713,7 +1723,7 @@ func TestReconcileDeletionProjectIdentity(t *testing.T) {
17131723
}
17141724
}
17151725

1716-
newReconciler := func(t *testing.T, projectIDFn InstanceProjectIDFunc, rec record.EventRecorder) (*InstanceReconciler, client.Client, client.Client) {
1726+
newReconciler := func(t *testing.T, projectIDFn InstanceProjectIDFunc, rec events.EventRecorder) (*InstanceReconciler, client.Client, client.Client) {
17171727
t.Helper()
17181728
s := newTestScheme(t)
17191729
projectClient := fake.NewClientBuilder().
@@ -1752,7 +1762,7 @@ func TestReconcileDeletionProjectIdentity(t *testing.T) {
17521762
}
17531763

17541764
t.Run("unresolvable identity: deletion proceeds, claim cleanup skipped", func(t *testing.T) {
1755-
fakeRecorder := record.NewFakeRecorder(10)
1765+
fakeRecorder := newCapturingEventRecorder(10)
17561766
identityErr := fmt.Errorf("edge namespace %q is missing label %q: %w",
17571767
namespace, downstreamclient.UpstreamOwnerClusterNameLabel, errProjectIdentityUnresolvable)
17581768
r, projectClient, quotaClient := newReconciler(t,
@@ -1784,10 +1794,15 @@ func TestReconcileDeletionProjectIdentity(t *testing.T) {
17841794
default:
17851795
t.Error("expected a QuotaClaimOrphaned event, got none")
17861796
}
1797+
1798+
// The deletion-path event names the quota-release action.
1799+
last := fakeRecorder.LastRecorded()
1800+
require.NotNil(t, last)
1801+
assert.Equal(t, eventActionReleasingQuota, last.Action)
17871802
})
17881803

17891804
t.Run("transient resolution failure: reconcile errors and retries", func(t *testing.T) {
1790-
fakeRecorder := record.NewFakeRecorder(10)
1805+
fakeRecorder := newCapturingEventRecorder(10)
17911806
r, projectClient, quotaClient := newReconciler(t,
17921807
func(_ context.Context, _ multicluster.ClusterName, _ *computev1alpha.Instance) (string, error) {
17931808
return "", fmt.Errorf("connection refused")
@@ -2433,7 +2448,7 @@ func TestReconcileQuotaClaim_RequestsIncludeVCPUsAndMemory(t *testing.T) {
24332448
projectIDForInstance: func(_ context.Context, cn multicluster.ClusterName, _ *computev1alpha.Instance) (string, error) {
24342449
return string(cn), nil
24352450
},
2436-
recorder: &record.FakeRecorder{},
2451+
recorder: &capturingEventRecorder{},
24372452
}
24382453
r.finalizers = finalizer.NewFinalizers()
24392454
require.NoError(t, r.finalizers.Register(instanceControllerFinalizer, r))
@@ -2772,3 +2787,43 @@ func TestInstanceBlockingReasonPriority(t *testing.T) {
27722787
})
27732788
}
27742789
}
2790+
2791+
// TestEmitEvent exercises the three load-bearing behaviors of the emitEvent
2792+
// helper directly: the nil-recorder guard, the "%s" indirection that keeps a
2793+
// literal '%' in a message from being format-expanded, and the note truncation
2794+
// that keeps events.k8s.io/v1 from rejecting an oversized note server-side.
2795+
func TestEmitEvent(t *testing.T) {
2796+
obj := &computev1alpha.Instance{
2797+
ObjectMeta: metav1.ObjectMeta{Name: "emit-test-instance", Namespace: "default"},
2798+
}
2799+
2800+
t.Run("nil recorder does not panic", func(t *testing.T) {
2801+
r := &InstanceReconciler{}
2802+
require.NotPanics(t, func() {
2803+
r.emitEvent(obj, corev1.EventTypeWarning, "SomeReason", eventActionClaimingQuota, "msg")
2804+
})
2805+
})
2806+
2807+
t.Run("literal % in message is not format-expanded", func(t *testing.T) {
2808+
rec := newCapturingEventRecorder(1)
2809+
r := &InstanceReconciler{recorder: rec}
2810+
r.emitEvent(obj, corev1.EventTypeWarning, "SomeReason", eventActionClaimingQuota,
2811+
"connection refused: path %2Fapi got 50% errors")
2812+
last := rec.LastRecorded()
2813+
require.NotNil(t, last)
2814+
assert.Contains(t, last.Note, "%2Fapi")
2815+
assert.Contains(t, last.Note, "50%")
2816+
assert.NotContains(t, last.Note, "(MISSING)")
2817+
})
2818+
2819+
t.Run("note truncated at maxEventNoteLen", func(t *testing.T) {
2820+
rec := newCapturingEventRecorder(1)
2821+
r := &InstanceReconciler{recorder: rec}
2822+
r.emitEvent(obj, corev1.EventTypeWarning, "SomeReason", eventActionClaimingQuota,
2823+
strings.Repeat("x", 2000))
2824+
last := rec.LastRecorded()
2825+
require.NotNil(t, last)
2826+
assert.LessOrEqual(t, len(last.Note), maxEventNoteLen)
2827+
assert.True(t, strings.HasSuffix(last.Note, "..."))
2828+
})
2829+
}

0 commit comments

Comments
 (0)