Skip to content

Commit a14d101

Browse files
authored
Synthesize multi-version CRDs from openapi (#600)
On-behalf-of: @SAP robert.vasek@sap.com Signed-off-by: Robert Vasek <rvasek01@gmail.com>
1 parent 3d912b6 commit a14d101

2 files changed

Lines changed: 108 additions & 39 deletions

File tree

engine/connection/reconciler.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,12 +478,16 @@ func (r *Reconciler) discoverAndInstall(ctx context.Context, conn *corev1alpha1.
478478
if _, err := crdpull.Install(ctx, r.Client, crd, conn.Name, conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce); err != nil {
479479
return "", nil, fmt.Errorf("installing synthesized CRD %q: %w", crd.Name, err)
480480
}
481+
versions := make([]string, len(crd.Spec.Versions))
482+
for i, v := range crd.Spec.Versions {
483+
versions[i] = v.Name
484+
}
481485
exported = append(exported, corev1alpha1.ExportedAPI{
482486
Name: crd.Name,
483487
Group: crd.Spec.Group,
484488
Resource: crd.Spec.Names.Plural,
485489
Scope: crd.Spec.Scope,
486-
Versions: []string{crd.Spec.Versions[0].Name},
490+
Versions: versions,
487491
})
488492
}
489493
sort.Slice(exported, func(i, j int) bool { return exported[i].Name < exported[j].Name })

engine/openapi/openapi.go

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5668
func 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).
116215
func 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

Comments
 (0)