Skip to content

Commit 8fe2939

Browse files
committed
[builder] opt-in metadata.generation tracking on default strategy
Add Server.WithGenerationTracking() that wires CRD-style generation handling into the default strategy: - PrepareForCreate seeds metadata.generation=1. - PrepareForUpdate bumps generation when the object's Spec field differs from the previous spec (apiequality.Semantic.DeepEqual on the Spec value extracted via reflection — universal across kubebuilder/apiserver-runtime types). Status writes go through the separate statusSubresourceStrategy and never reach this path. - Resources without a Spec field are unaffected. Off by default — opt-in so existing apoxy-cloud consumers (project apiserver, infra apiserver, etc.) keep their current behavior until they're independently audited. clrk turns it on at apiserver construction time so revision controllers that key off metadata.generation (DaemonAgentRevisionReconciler) work, and so controller-runtime's GenerationChangedPredicate stops silently dropping events. Tracks APO-508. Per-type PrepareForUpdater hooks continue to fire after the generation bump, so types that augment the default behavior aren't disturbed.
1 parent ae63923 commit 8fe2939

1 file changed

Lines changed: 91 additions & 5 deletions

File tree

pkg/apiserver/server/builder/builder.go

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"strings"
88
"sync"
99

10+
apiequality "k8s.io/apimachinery/pkg/api/equality"
11+
"k8s.io/apimachinery/pkg/api/meta"
1012
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1113
"k8s.io/apimachinery/pkg/conversion"
1214
"k8s.io/apimachinery/pkg/runtime"
@@ -49,6 +51,14 @@ type Server struct {
4951

5052
registeredGroupVersions map[schema.GroupVersion]struct{}
5153
orderedGroupVersions []schema.GroupVersion
54+
55+
// trackGeneration enables CRD-style metadata.generation tracking
56+
// in the default strategy: PrepareForCreate sets generation=1, and
57+
// PrepareForUpdate bumps it when the object's Spec changed (compared
58+
// via apiequality.Semantic.DeepEqual). Off by default for backward
59+
// compatibility with consumers that registered resources before this
60+
// behavior was added.
61+
trackGeneration bool
5262
}
5363

5464
func NewServerBuilder() *Server {
@@ -101,14 +111,30 @@ func (s *Server) WithoutEtcd() *Server {
101111
})
102112
}
103113

114+
// WithGenerationTracking enables CRD-style metadata.generation handling
115+
// on every resource subsequently registered with WithResourceAndStorage:
116+
// PrepareForCreate sets generation=1, and PrepareForUpdate bumps it
117+
// when the object's Spec field has changed (compared via
118+
// apiequality.Semantic.DeepEqual). Off by default — opt-in so existing
119+
// consumers (apoxy-cloud's project apiserver, etc.) keep their current
120+
// behavior until they're independently audited for the change.
121+
//
122+
// Resources without a Spec field are unaffected (no field to compare,
123+
// no generation bump). Per-type PrepareForUpdater hooks continue to
124+
// fire after the generation bump.
125+
func (s *Server) WithGenerationTracking() *Server {
126+
s.trackGeneration = true
127+
return s
128+
}
129+
104130
func (s *Server) WithResourceAndStorage(obj builderresource.Object, fn StoreFn) *Server {
105131
s.apiSchemeBuilder.Register(builderresource.AddToScheme(obj))
106132
s.openapiSchemeBuilder.Register(func(scheme *runtime.Scheme) error {
107133
scheme.AddKnownTypes(obj.GetGroupVersionResource().GroupVersion(), obj.New(), obj.NewList())
108134
return nil
109135
})
110136

111-
sp := newStorageProvider(obj, fn)
137+
sp := newStorageProvider(obj, fn, s.trackGeneration)
112138
s.forGroupVersionResource(obj.GetGroupVersionResource(), sp)
113139
s.withStatusSubresource(obj, sp)
114140
return s
@@ -372,13 +398,14 @@ func (s *statusSubresourceStrategy) PrepareForUpdate(ctx context.Context, obj, o
372398
}
373399
}
374400

375-
func newStorageProvider(obj builderresource.Object, fn StoreFn) serverapiserver.StorageProvider {
401+
func newStorageProvider(obj builderresource.Object, fn StoreFn, trackGeneration bool) serverapiserver.StorageProvider {
376402
return func(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (registryrest.Storage, error) {
377403
gvr := obj.GetGroupVersionResource()
378404
strategy := &defaultStrategy{
379-
Object: obj,
380-
ObjectTyper: scheme,
381-
TableConvertor: registryrest.NewDefaultTableConvertor(gvr.GroupResource()),
405+
Object: obj,
406+
ObjectTyper: scheme,
407+
TableConvertor: registryrest.NewDefaultTableConvertor(gvr.GroupResource()),
408+
trackGeneration: trackGeneration,
382409
}
383410

384411
singular := gvr.GroupResource()
@@ -417,6 +444,11 @@ type defaultStrategy struct {
417444
Object runtime.Object
418445
runtime.ObjectTyper
419446
TableConvertor registryrest.TableConvertor
447+
// trackGeneration mirrors the Server-level flag. When true,
448+
// PrepareForCreate seeds metadata.generation=1 and PrepareForUpdate
449+
// bumps it on Spec changes — matching upstream CRD strategy
450+
// (k8s.io/apiextensions-apiserver/pkg/registry/customresource).
451+
trackGeneration bool
420452
}
421453

422454
func (d defaultStrategy) GenerateName(base string) string {
@@ -440,6 +472,11 @@ func (d defaultStrategy) NamespaceScoped() bool {
440472
}
441473

442474
func (d defaultStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
475+
if d.trackGeneration {
476+
if accessor, err := meta.Accessor(obj); err == nil {
477+
accessor.SetGeneration(1)
478+
}
479+
}
443480
if v, ok := obj.(resourcestrategy.PrepareForCreater); ok {
444481
v.PrepareForCreate(ctx)
445482
}
@@ -449,11 +486,60 @@ func (d defaultStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.
449486
if v, ok := obj.(builderresource.ObjectWithStatusSubResource); ok {
450487
old.(builderresource.ObjectWithStatusSubResource).GetStatus().CopyTo(v)
451488
}
489+
if d.trackGeneration {
490+
// Compare Spec only — status writes go through statusSubresourceStrategy
491+
// (a separate path) so they don't reach this strategy. Metadata
492+
// changes (labels, annotations, finalizers, owner refs) intentionally
493+
// don't bump generation; matches upstream CRD strategy semantics.
494+
if specChanged(obj, old) {
495+
if newAcc, err := meta.Accessor(obj); err == nil {
496+
if oldAcc, oerr := meta.Accessor(old); oerr == nil {
497+
newAcc.SetGeneration(oldAcc.GetGeneration() + 1)
498+
}
499+
}
500+
}
501+
}
452502
if v, ok := obj.(resourcestrategy.PrepareForUpdater); ok {
453503
v.PrepareForUpdate(ctx, old)
454504
}
455505
}
456506

507+
// specChanged reports whether the Spec field of newObj differs from
508+
// the Spec field of oldObj. Returns false (== "treat as unchanged") for
509+
// objects that don't expose a Spec field — those are pure-status types
510+
// or non-spec resources where generation tracking doesn't apply.
511+
func specChanged(newObj, oldObj runtime.Object) bool {
512+
newSpec, ok := extractSpec(newObj)
513+
if !ok {
514+
return false
515+
}
516+
oldSpec, ok := extractSpec(oldObj)
517+
if !ok {
518+
return false
519+
}
520+
return !apiequality.Semantic.DeepEqual(oldSpec, newSpec)
521+
}
522+
523+
// extractSpec returns obj's "Spec" struct field via reflection. Returns
524+
// (nil, false) when obj is nil, isn't a struct (or pointer-to-struct),
525+
// or has no "Spec" field. The convention `Spec <Kind>Spec` is universal
526+
// across kubebuilder/apiserver-runtime types; we lean on it here so the
527+
// generic strategy doesn't need a type-specific shim per resource.
528+
func extractSpec(obj runtime.Object) (any, bool) {
529+
v := reflect.ValueOf(obj)
530+
if v.Kind() == reflect.Ptr {
531+
v = v.Elem()
532+
}
533+
if !v.IsValid() || v.Kind() != reflect.Struct {
534+
return nil, false
535+
}
536+
f := v.FieldByName("Spec")
537+
if !f.IsValid() {
538+
return nil, false
539+
}
540+
return f.Interface(), true
541+
}
542+
457543
func (d defaultStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
458544
if v, ok := obj.(resourcestrategy.Validater); ok {
459545
return v.Validate(ctx)

0 commit comments

Comments
 (0)