@@ -25,11 +25,13 @@ import (
2525 "context"
2626 "encoding/json"
2727 "fmt"
28+ "sort"
2829 "strings"
2930
3031 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
3132 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3233 "k8s.io/apimachinery/pkg/runtime/schema"
34+ k8sversion "k8s.io/apimachinery/pkg/version"
3335 "k8s.io/client-go/discovery"
3436 "k8s.io/client-go/rest"
3537)
@@ -51,35 +53,51 @@ func isBuiltinGroup(group string) bool {
5153 return builtinGroups [group ] || strings .HasSuffix (group , ".k8s.io" ) || group == "apiregistration.k8s.io"
5254}
5355
56+ // crdVersionInfo holds the per-version data collected during discovery.
57+ type crdVersionInfo struct {
58+ gv schema.GroupVersion
59+ resource metav1.APIResource
60+ hasStatus bool
61+ props * apiextensionsv1.JSONSchemaProps
62+ }
63+
5464// SynthesizeCRDs discovers every served, non-built-in API on the provider and
5565// synthesizes a consumer-installable CRD for each from its /openapi/v3 schema.
66+ // All discovered versions of a group-resource are merged into a single CRD;
67+ // the preferred version (per discovery) is marked as the storage version.
5668func SynthesizeCRDs (ctx context.Context , cfg * rest.Config ) ([]* apiextensionsv1.CustomResourceDefinition , error ) {
5769 dc , err := discovery .NewDiscoveryClientForConfig (cfg )
5870 if err != nil {
5971 return nil , err
6072 }
61- _ , resourceLists , err := dc .ServerGroupsAndResources ()
73+ apiGroups , resourceLists , err := dc .ServerGroupsAndResources ()
6274 if err != nil {
6375 // Partial discovery (an aggregated API may be down) is tolerable.
6476 if len (resourceLists ) == 0 {
6577 return nil , fmt .Errorf ("discovery: %w" , err )
6678 }
6779 }
6880
81+ // Build preferred-version map (group → preferred version name) from discovery.
82+ preferredVersion := make (map [string ]string , len (apiGroups ))
83+ for _ , g := range apiGroups {
84+ preferredVersion [g .Name ] = g .PreferredVersion .Version
85+ }
86+
6987 root := dc .OpenAPIV3 ()
7088 paths , err := root .Paths ()
7189 if err != nil {
7290 return nil , fmt .Errorf ("openapi v3 paths: %w" , err )
7391 }
7492
75- var out []* apiextensionsv1.CustomResourceDefinition
93+ // Collect all versions per group-resource before building CRDs.
94+ versionsForGR := map [schema.GroupResource ][]crdVersionInfo {}
7695 for _ , rl := range resourceLists {
7796 gv , err := schema .ParseGroupVersion (rl .GroupVersion )
7897 if err != nil || isBuiltinGroup (gv .Group ) {
7998 continue
8099 }
81100
82- // Which resources have a status subresource (discovery lists "<r>/status").
83101 hasStatus := map [string ]bool {}
84102 for _ , r := range rl .APIResources {
85103 if base , sub , ok := strings .Cut (r .Name , "/" ); ok && sub == "status" {
@@ -105,12 +123,93 @@ func SynthesizeCRDs(ctx context.Context, cfg *rest.Config) ([]*apiextensionsv1.C
105123 if err != nil {
106124 continue // no usable schema; skip this resource
107125 }
108- out = append (out , buildCRD (gv , r , hasStatus [r .Name ], props ))
126+ gr := schema.GroupResource {Group : gv .Group , Resource : r .Name }
127+ versionsForGR [gr ] = append (versionsForGR [gr ], crdVersionInfo {
128+ gv : gv ,
129+ resource : r ,
130+ hasStatus : hasStatus [r .Name ],
131+ props : props ,
132+ })
109133 }
110134 }
135+
136+ out := make ([]* apiextensionsv1.CustomResourceDefinition , 0 , len (versionsForGR ))
137+ for gr , versions := range versionsForGR {
138+ out = append (out , buildCRD (gr , versions , preferredVersion [gr .Group ]))
139+ }
111140 return out , nil
112141}
113142
143+ func buildCRD (gr schema.GroupResource , versions []crdVersionInfo , preferredVer string ) * apiextensionsv1.CustomResourceDefinition {
144+ // Keep deterministic order so that we don't trigger updates without actually changing the schema.
145+ sort .SliceStable (versions , func (i , j int ) bool {
146+ return k8sversion .CompareKubeAwareVersionStrings (versions [i ].gv .Version , versions [j ].gv .Version ) > 0
147+ })
148+
149+ // Determine the storage version: use the preferred version if it was
150+ // discovered, otherwise fall back to the highest-priority version.
151+ storageVer := preferredVer
152+ storageFound := false
153+ for _ , v := range versions {
154+ if v .gv .Version == storageVer {
155+ storageFound = true
156+ break
157+ }
158+ }
159+ if ! storageFound {
160+ storageVer = versions [0 ].gv .Version
161+ }
162+
163+ // Canonical resource metadata (names, scope) comes from the storage version.
164+ canonical := versions [0 ]
165+ for _ , v := range versions {
166+ if v .gv .Version == storageVer {
167+ canonical = v
168+ break
169+ }
170+ }
171+
172+ scope := apiextensionsv1 .ClusterScoped
173+ if canonical .resource .Namespaced {
174+ scope = apiextensionsv1 .NamespaceScoped
175+ }
176+ singular := canonical .resource .SingularName
177+ if singular == "" {
178+ singular = strings .ToLower (canonical .resource .Kind )
179+ }
180+
181+ crdVersions := make ([]apiextensionsv1.CustomResourceDefinitionVersion , 0 , len (versions ))
182+ for _ , v := range versions {
183+ crdv := apiextensionsv1.CustomResourceDefinitionVersion {
184+ Name : v .gv .Version ,
185+ Served : true ,
186+ Storage : v .gv .Version == storageVer ,
187+ Schema : & apiextensionsv1.CustomResourceValidation {OpenAPIV3Schema : v .props },
188+ }
189+ if v .hasStatus {
190+ crdv .Subresources = & apiextensionsv1.CustomResourceSubresources {
191+ Status : & apiextensionsv1.CustomResourceSubresourceStatus {},
192+ }
193+ }
194+ crdVersions = append (crdVersions , crdv )
195+ }
196+
197+ return & apiextensionsv1.CustomResourceDefinition {
198+ ObjectMeta : metav1.ObjectMeta {Name : gr .Resource + "." + gr .Group },
199+ Spec : apiextensionsv1.CustomResourceDefinitionSpec {
200+ Group : gr .Group ,
201+ Names : apiextensionsv1.CustomResourceDefinitionNames {
202+ Plural : canonical .resource .Name ,
203+ Singular : singular ,
204+ Kind : canonical .resource .Kind ,
205+ ListKind : canonical .resource .Kind + "List" ,
206+ },
207+ Scope : scope ,
208+ Versions : crdVersions ,
209+ },
210+ }
211+ }
212+
114213// schemaForGVK finds the component schema for gvk in an OpenAPI v3 document and
115214// converts it to a structural CRD schema (JSON round-trip + cleanup).
116215func schemaForGVK (doc []byte , gvk schema.GroupVersionKind ) (* apiextensionsv1.JSONSchemaProps , error ) {
@@ -187,37 +286,3 @@ func normalize(p *apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaPr
187286 }
188287 return p
189288}
190-
191- func buildCRD (gv schema.GroupVersion , r metav1.APIResource , hasStatus bool , props * apiextensionsv1.JSONSchemaProps ) * apiextensionsv1.CustomResourceDefinition {
192- scope := apiextensionsv1 .ClusterScoped
193- if r .Namespaced {
194- scope = apiextensionsv1 .NamespaceScoped
195- }
196- singular := r .SingularName
197- if singular == "" {
198- singular = strings .ToLower (r .Kind )
199- }
200- version := apiextensionsv1.CustomResourceDefinitionVersion {
201- Name : gv .Version ,
202- Served : true ,
203- Storage : true ,
204- Schema : & apiextensionsv1.CustomResourceValidation {OpenAPIV3Schema : props },
205- }
206- if hasStatus {
207- version .Subresources = & apiextensionsv1.CustomResourceSubresources {Status : & apiextensionsv1.CustomResourceSubresourceStatus {}}
208- }
209- return & apiextensionsv1.CustomResourceDefinition {
210- ObjectMeta : metav1.ObjectMeta {Name : r .Name + "." + gv .Group },
211- Spec : apiextensionsv1.CustomResourceDefinitionSpec {
212- Group : gv .Group ,
213- Names : apiextensionsv1.CustomResourceDefinitionNames {
214- Plural : r .Name ,
215- Singular : singular ,
216- Kind : r .Kind ,
217- ListKind : r .Kind + "List" ,
218- },
219- Scope : scope ,
220- Versions : []apiextensionsv1.CustomResourceDefinitionVersion {version },
221- },
222- }
223- }
0 commit comments