Skip to content

Commit ce77504

Browse files
committed
refactor crd-puller to pull all versions (i.e. work on GK basis not GVK anymore)
On-behalf-of: @SAP [email protected]
1 parent 1650e9d commit ce77504

File tree

4 files changed

+141
-81
lines changed

4 files changed

+141
-81
lines changed

cmd/crd-puller/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/crd-puller
2+
*.yaml

cmd/crd-puller/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# CRD Puller
22

33
The `crd-puller` can be used for testing and development in order to export a
4-
CustomResourceDefinition for any Group/Version/Kind (GVK) in a Kubernetes cluster.
4+
CustomResourceDefinition for any Group/Kind (GK) in a Kubernetes cluster.
55

66
The main difference between this and kcp's own `crd-puller` is that this one
7-
works based on GVKs and not resources (i.e. on `apps/v1 Deployment` instead of
7+
works based on GKs and not resources (i.e. on `apps/Deployment` instead of
88
`apps.deployments`). This is more useful since a PublishedResource publishes a
9-
specific Kind and version.
9+
specific Kind and version. Also, this puller pulls all available versions, not
10+
just the preferred version.
1011

1112
## Usage
1213

1314
```shell
1415
export KUBECONFIG=/path/to/kubeconfig
1516

16-
./crd-puller Deployment.v1.apps.k8s.io
17+
./crd-puller Deployment.apps.k8s.io
1718
```

cmd/crd-puller/main.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,10 @@ func main() {
4141
pflag.Parse()
4242

4343
if pflag.NArg() == 0 {
44-
log.Fatal("No argument given. Please specify a GVK in the form 'Kind.version.apigroup.com' to pull.")
44+
log.Fatal("No argument given. Please specify a GroupKind in the form 'Kind.apigroup.com' (case-sensitive) to pull.")
4545
}
4646

47-
gvk, _ := schema.ParseKindArg(pflag.Arg(0))
48-
if gvk == nil {
49-
log.Fatal("Invalid GVK, please use the format 'Kind.version.apigroup.com'.")
50-
}
47+
gk := schema.ParseGroupKind(pflag.Arg(0))
5148

5249
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
5350
loadingRules.ExplicitPath = kubeconfigPath
@@ -67,7 +64,7 @@ func main() {
6764
log.Fatalf("Failed to create discovery client: %v.", err)
6865
}
6966

70-
crd, err := discoveryClient.RetrieveCRD(ctx, *gvk)
67+
crd, err := discoveryClient.RetrieveCRD(ctx, gk)
7168
if err != nil {
7269
log.Fatalf("Failed to pull CRD: %v.", err)
7370
}

internal/discovery/client.go

Lines changed: 131 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package discovery
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"slices"
2324
"strings"
@@ -32,6 +33,7 @@ import (
3233
"k8s.io/apimachinery/pkg/runtime/schema"
3334
utilerrors "k8s.io/apimachinery/pkg/util/errors"
3435
"k8s.io/apimachinery/pkg/util/sets"
36+
"k8s.io/apimachinery/pkg/version"
3537
"k8s.io/apiserver/pkg/endpoints/openapi"
3638
"k8s.io/client-go/discovery"
3739
"k8s.io/client-go/rest"
@@ -61,47 +63,67 @@ func NewClient(config *rest.Config) (*Client, error) {
6163
}, nil
6264
}
6365

64-
func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (*apiextensionsv1.CustomResourceDefinition, error) {
65-
// Most of this code follows the logic in kcp's crd-puller, but is slimmed down
66-
// to extract a specific version, not necessarily the preferred version.
67-
66+
func (c *Client) RetrieveCRD(ctx context.Context, gk schema.GroupKind) (*apiextensionsv1.CustomResourceDefinition, error) {
6867
////////////////////////////////////
69-
// Resolve GVK into GVR, because we need the resource name to construct
68+
// Resolve GK into GR, because we need the resource name to construct
7069
// the full CRD name.
7170

7271
_, resourceLists, err := c.discoveryClient.ServerGroupsAndResources()
7372
if err != nil {
7473
return nil, err
7574
}
7675

76+
// resource is the resource described by gk in any of the found versions
7777
var resource *metav1.APIResource
78-
allResourceNames := sets.New[string]()
78+
79+
availableVersions := sets.New[string]()
80+
subresourcesPerVersion := map[string]sets.Set[string]{}
81+
7982
for _, resList := range resourceLists {
8083
for _, res := range resList.APIResources {
81-
allResourceNames.Insert(res.Name)
84+
// ignore other groups
85+
if res.Group != gk.Group || res.Kind != gk.Kind {
86+
continue
87+
}
8288

83-
// find the requested resource based on the Kind, but ensure that subresources
84-
// are not misinterpreted as the main resource by checking for "/"
85-
if resList.GroupVersion == gvk.GroupVersion().String() && res.Kind == gvk.Kind && !strings.Contains(res.Name, "/") {
89+
// res could describe the main resource or one of its subresources.
90+
name := res.Name
91+
subresource := ""
92+
if strings.Contains(name, "/") {
93+
parts := strings.SplitN(name, "/", 2)
94+
name = parts[0]
95+
subresource = parts[1]
96+
}
97+
98+
if subresource == "" {
8699
resource = &res
100+
} else {
101+
list, ok := subresourcesPerVersion[res.Version]
102+
if !ok {
103+
list = sets.New[string]()
104+
}
105+
list.Insert(subresource)
106+
subresourcesPerVersion[res.Version] = list
87107
}
108+
109+
availableVersions.Insert(res.Version)
88110
}
89111
}
90112

91113
if resource == nil {
92-
return nil, fmt.Errorf("could not find %v in APIs", gvk)
114+
return nil, fmt.Errorf("could not find %v in APIs", gk)
93115
}
94116

95117
////////////////////////////////////
96-
// If possible, retrieve the GVK as its original CRD, which is always preferred
118+
// If possible, retrieve the GK as its original CRD, which is always preferred
97119
// because it's much more precise than what we can retrieve from the OpenAPI.
98120
// If no CRD can be found, fallback to the OpenAPI schema.
99121

100122
crdName := resource.Name
101-
if gvk.Group == "" {
123+
if gk.Group == "" {
102124
crdName += ".core"
103125
} else {
104-
crdName += "." + gvk.Group
126+
crdName += "." + gk.Group
105127
}
106128

107129
crd, err := c.crdClient.CustomResourceDefinitions().Get(ctx, crdName, metav1.GetOptions{})
@@ -110,25 +132,20 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
110132
// of re-creating it later on based on the openapi schema, we take the original
111133
// CRD and just strip it down to what we need.
112134
if err == nil {
113-
// remove all but the requested version
114-
crd.Spec.Versions = slices.DeleteFunc(crd.Spec.Versions, func(ver apiextensionsv1.CustomResourceDefinitionVersion) bool {
115-
return ver.Name != gvk.Version
116-
})
117-
118-
if len(crd.Spec.Versions) == 0 {
119-
return nil, fmt.Errorf("CRD %s does not contain version %s", crdName, gvk.Version)
120-
}
121-
122-
crd.Spec.Versions[0].Served = true
123-
crd.Spec.Versions[0].Storage = true
124-
125135
if apihelpers.IsCRDConditionTrue(crd, apiextensionsv1.NonStructuralSchema) {
126-
crd.Spec.Versions[0].Schema = &apiextensionsv1.CustomResourceValidation{
136+
emptySchema := &apiextensionsv1.CustomResourceValidation{
127137
OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
128138
Type: "object",
129139
XPreserveUnknownFields: ptr.To(true),
130140
},
131141
}
142+
143+
for i, version := range crd.Spec.Versions {
144+
if version.Schema == nil || version.Schema.OpenAPIV3Schema == nil {
145+
version.Schema = emptySchema
146+
crd.Spec.Versions[i] = version
147+
}
148+
}
132149
}
133150

134151
crd.APIVersion = apiextensionsv1.SchemeGroupVersion.Identifier()
@@ -140,6 +157,7 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
140157
Name: oldMeta.Name,
141158
Annotations: filterAnnotations(oldMeta.Annotations),
142159
}
160+
crd.Status.Conditions = []apiextensionsv1.CustomResourceDefinitionCondition{}
143161

144162
// There is only ever one version, so conversion rules do not make sense
145163
// (and even if they did, the conversion webhook from the service cluster
@@ -156,49 +174,34 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
156174
return nil, err
157175
}
158176

177+
////////////////////////////////////
159178
// CRD not found, so fall back to using the OpenAPI schema
179+
160180
openapiSchema, err := c.discoveryClient.OpenAPISchema()
161181
if err != nil {
162182
return nil, err
163183
}
164184

165-
models, err := proto.NewOpenAPIData(openapiSchema)
166-
if err != nil {
167-
return nil, err
168-
}
169-
modelsByGKV, err := openapi.GetModelsByGKV(models)
185+
preferredVersion, err := c.getPreferredVersion(resource)
170186
if err != nil {
171187
return nil, err
172188
}
173189

174-
protoSchema := modelsByGKV[gvk]
175-
if protoSchema == nil {
176-
return nil, fmt.Errorf("no models for %v", gvk)
177-
}
178-
179-
var schemaProps apiextensionsv1.JSONSchemaProps
180-
errs := crdpuller.Convert(protoSchema, &schemaProps)
181-
if len(errs) > 0 {
182-
return nil, utilerrors.NewAggregate(errs)
190+
if preferredVersion == "" {
191+
return nil, errors.New("cannot determine storage version because no preferred version exists in the schema")
183192
}
184193

185-
hasSubResource := func(subResource string) bool {
186-
return allResourceNames.Has(resource.Name + "/" + subResource)
187-
}
188-
189-
var statusSubResource *apiextensionsv1.CustomResourceSubresourceStatus
190-
if hasSubResource("status") {
191-
statusSubResource = &apiextensionsv1.CustomResourceSubresourceStatus{}
194+
models, err := proto.NewOpenAPIData(openapiSchema)
195+
if err != nil {
196+
return nil, err
192197
}
193198

194-
var scaleSubResource *apiextensionsv1.CustomResourceSubresourceScale
195-
if hasSubResource("scale") {
196-
scaleSubResource = &apiextensionsv1.CustomResourceSubresourceScale{
197-
SpecReplicasPath: ".spec.replicas",
198-
StatusReplicasPath: ".status.replicas",
199-
}
199+
modelsByGKV, err := openapi.GetModelsByGKV(models)
200+
if err != nil {
201+
return nil, err
200202
}
201203

204+
// prepare an empty CRD
202205
scope := apiextensionsv1.ClusterScoped
203206
if resource.Namespaced {
204207
scope = apiextensionsv1.NamespaceScoped
@@ -213,22 +216,9 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
213216
Name: crdName,
214217
},
215218
Spec: apiextensionsv1.CustomResourceDefinitionSpec{
216-
Group: gvk.Group,
217-
Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
218-
{
219-
Name: gvk.Version,
220-
Schema: &apiextensionsv1.CustomResourceValidation{
221-
OpenAPIV3Schema: &schemaProps,
222-
},
223-
Subresources: &apiextensionsv1.CustomResourceSubresources{
224-
Status: statusSubResource,
225-
Scale: scaleSubResource,
226-
},
227-
Served: true,
228-
Storage: true,
229-
},
230-
},
231-
Scope: scope,
219+
Group: gk.Group,
220+
Versions: []apiextensionsv1.CustomResourceDefinitionVersion{},
221+
Scope: scope,
232222
Names: apiextensionsv1.CustomResourceDefinitionNames{
233223
Plural: resource.Name,
234224
Kind: resource.Kind,
@@ -239,9 +229,62 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
239229
},
240230
}
241231

232+
// fill-in the schema for each version, making sure that versions are sorted
233+
// according to Kubernetes rules.
234+
sortedVersions := availableVersions.UnsortedList()
235+
slices.SortFunc(sortedVersions, func(a, b string) int {
236+
return version.CompareKubeAwareVersionStrings(a, b)
237+
})
238+
239+
for _, version := range sortedVersions {
240+
subresources := subresourcesPerVersion[version]
241+
gvk := schema.GroupVersionKind{
242+
Group: gk.Group,
243+
Version: version,
244+
Kind: gk.Kind,
245+
}
246+
247+
protoSchema := modelsByGKV[gvk]
248+
if protoSchema == nil {
249+
return nil, fmt.Errorf("no models for %v", gk)
250+
}
251+
252+
var schemaProps apiextensionsv1.JSONSchemaProps
253+
errs := crdpuller.Convert(protoSchema, &schemaProps)
254+
if len(errs) > 0 {
255+
return nil, utilerrors.NewAggregate(errs)
256+
}
257+
258+
var statusSubResource *apiextensionsv1.CustomResourceSubresourceStatus
259+
if subresources.Has("status") {
260+
statusSubResource = &apiextensionsv1.CustomResourceSubresourceStatus{}
261+
}
262+
263+
var scaleSubResource *apiextensionsv1.CustomResourceSubresourceScale
264+
if subresources.Has("scale") {
265+
scaleSubResource = &apiextensionsv1.CustomResourceSubresourceScale{
266+
SpecReplicasPath: ".spec.replicas",
267+
StatusReplicasPath: ".status.replicas",
268+
}
269+
}
270+
271+
out.Spec.Versions = append(out.Spec.Versions, apiextensionsv1.CustomResourceDefinitionVersion{
272+
Name: version,
273+
Schema: &apiextensionsv1.CustomResourceValidation{
274+
OpenAPIV3Schema: &schemaProps,
275+
},
276+
Subresources: &apiextensionsv1.CustomResourceSubresources{
277+
Status: statusSubResource,
278+
Scale: scaleSubResource,
279+
},
280+
Served: true,
281+
Storage: version == preferredVersion,
282+
})
283+
}
284+
242285
apiextensionsv1.SetDefaults_CustomResourceDefinition(out)
243286

244-
if apihelpers.IsProtectedCommunityGroup(gvk.Group) {
287+
if apihelpers.IsProtectedCommunityGroup(gk.Group) {
245288
out.Annotations = map[string]string{
246289
apiextensionsv1.KubeAPIApprovedAnnotation: "https://github.com/kcp-dev/kubernetes/pull/4",
247290
}
@@ -250,6 +293,23 @@ func (c *Client) RetrieveCRD(ctx context.Context, gvk schema.GroupVersionKind) (
250293
return out, nil
251294
}
252295

296+
func (c *Client) getPreferredVersion(resource *metav1.APIResource) (string, error) {
297+
result, err := c.discoveryClient.ServerPreferredResources()
298+
if err != nil {
299+
return "", err
300+
}
301+
302+
for _, resList := range result {
303+
for _, res := range resList.APIResources {
304+
if res.Name == resource.Name && res.Group == resource.Group {
305+
return res.Version, nil
306+
}
307+
}
308+
}
309+
310+
return "", nil
311+
}
312+
253313
func filterAnnotations(ann map[string]string) map[string]string {
254314
allowlist := []string{
255315
apiextensionsv1.KubeAPIApprovedAnnotation,

0 commit comments

Comments
 (0)