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
5464func 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+
104130func (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
422454func (d defaultStrategy ) GenerateName (base string ) string {
@@ -440,6 +472,11 @@ func (d defaultStrategy) NamespaceScoped() bool {
440472}
441473
442474func (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+
457543func (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