Skip to content

Commit 8acd414

Browse files
committed
Add caching of structural schema generation
1 parent c1ff1b2 commit 8acd414

2 files changed

Lines changed: 221 additions & 3 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
Copyright 2025 Red Hat, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package objectpruning
18+
19+
import (
20+
"context"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
25+
apiextensionsv1alpha1 "github.com/openshift/api/apiextensions/v1alpha1"
26+
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/index"
27+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
28+
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/runtime/serializer"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
32+
)
33+
34+
var _ = Describe("validator", func() {
35+
var (
36+
ctx context.Context
37+
testCRD *apiextensionsv1.CustomResourceDefinition
38+
compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement
39+
)
40+
41+
BeforeEach(func() {
42+
ctx = context.Background()
43+
44+
// Create a test CRD with some properties for testing
45+
testCRD = createStrictCRDSchema()
46+
47+
// Create a corresponding compatibility requirement
48+
compatibilityRequirement = createCompatibilityRequirement(testCRD)
49+
})
50+
51+
Describe("getStructuralSchema", func() {
52+
Context("when CompatibilityRequirement exists", func() {
53+
var validator *validator
54+
55+
BeforeEach(func() {
56+
validator = createValidatorWithFakeClient([]client.Object{compatibilityRequirement})
57+
})
58+
59+
It("should return structural schema", func() {
60+
schema, err := validator.getStructuralSchema(ctx, compatibilityRequirement.Name, "v1")
61+
62+
Expect(err).NotTo(HaveOccurred())
63+
Expect(schema).NotTo(BeNil())
64+
})
65+
66+
It("should cache structural schema", func() {
67+
// First call should create and cache the schema
68+
_, err := validator.getStructuralSchema(ctx, compatibilityRequirement.Name, "v1")
69+
Expect(err).NotTo(HaveOccurred())
70+
71+
// Check that cache now contains an entry
72+
validator.structuralSchemaCacheLock.RLock()
73+
cacheSize := len(validator.structuralSchemaCache)
74+
validator.structuralSchemaCacheLock.RUnlock()
75+
76+
Expect(cacheSize).To(Equal(1))
77+
})
78+
79+
It("should use cached schema on subsequent calls", func() {
80+
// First call
81+
schema1, err1 := validator.getStructuralSchema(ctx, compatibilityRequirement.Name, "v1")
82+
Expect(err1).NotTo(HaveOccurred())
83+
84+
// Second call should return the same schema instance
85+
schema2, err2 := validator.getStructuralSchema(ctx, compatibilityRequirement.Name, "v1")
86+
Expect(err2).NotTo(HaveOccurred())
87+
88+
// Should be the exact same object (cached)
89+
// This uses reflect.DeepEqual to compare the two schemas.
90+
Expect(schema1).To(Equal(schema2))
91+
})
92+
})
93+
94+
Context("when CompatibilityRequirement does not exist", func() {
95+
It("should return error", func() {
96+
validator := createValidatorWithFakeClient([]client.Object{}) // No objects
97+
98+
_, err := validator.getStructuralSchema(ctx, "non-existent", "v1")
99+
100+
Expect(err).To(MatchError("failed to get CompatibilityRequirement \"non-existent\": compatibilityrequirements.apiextensions.openshift.io \"non-existent\" not found"))
101+
})
102+
})
103+
104+
Context("when CompatibilityRequirement has invalid CRD YAML", func() {
105+
It("should return error", func() {
106+
brokenCompatibilityRequirement := createCompatibilityRequirement(testCRD)
107+
brokenCompatibilityRequirement.Spec.CompatibilitySchema.CustomResourceDefinition.Data = "invalid: yaml: content: ["
108+
109+
validator := createValidatorWithFakeClient([]client.Object{brokenCompatibilityRequirement})
110+
111+
_, err := validator.getStructuralSchema(ctx, brokenCompatibilityRequirement.Name, "v1")
112+
113+
Expect(err).To(MatchError(ContainSubstring("failed to get structural schema: failed to decode compatibility schema data for CompatibilityRequirement \"\": yaml: mapping values are not allowed in this context")))
114+
})
115+
})
116+
})
117+
})
118+
119+
// Helper function to create a validator with fake client for testing
120+
func createValidatorWithFakeClient(objects []client.Object) *validator {
121+
scheme := runtime.NewScheme()
122+
scheme.AddKnownTypes(apiextensionsv1alpha1.SchemeGroupVersion, &apiextensionsv1alpha1.CompatibilityRequirement{})
123+
scheme.AddKnownTypes(apiextensionsv1.SchemeGroupVersion, &apiextensionsv1.CustomResourceDefinition{})
124+
125+
return &validator{
126+
universalDeserializer: serializer.NewCodecFactory(scheme).UniversalDeserializer(),
127+
client: fake.NewClientBuilder().
128+
WithObjects(objects...).
129+
WithIndex(&apiextensionsv1alpha1.CompatibilityRequirement{},
130+
index.FieldCRDByName,
131+
index.CRDByName).
132+
Build(),
133+
structuralSchemaCache: make(map[structuralSchemaCacheKey]structuralSchemaCacheValue),
134+
}
135+
}

pkg/controllers/crdcompatibility/objectpruning/webhook.go

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"sync"
2122

2223
apiextensionsv1alpha1 "github.com/openshift/api/apiextensions/v1alpha1"
2324
apiextensionshelpers "k8s.io/apiextensions-apiserver/pkg/apihelpers"
@@ -28,6 +29,7 @@ import (
2829
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2930
"k8s.io/apimachinery/pkg/runtime"
3031
"k8s.io/apimachinery/pkg/runtime/serializer"
32+
"k8s.io/apimachinery/pkg/types"
3133
ctrl "sigs.k8s.io/controller-runtime"
3234
"sigs.k8s.io/controller-runtime/pkg/builder"
3335
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -45,19 +47,40 @@ const (
4547
WebhookPrefix = "/compatibility-requirement-object-mutation/"
4648
)
4749

50+
type structuralSchemaCacheKey struct {
51+
// The name of the CompatibilityRequirement.
52+
compatibilityRequirementName string
53+
// The API version of the schema we are caching.
54+
version string
55+
}
56+
57+
type structuralSchemaCacheValue struct {
58+
schema *structuralschema.Structural
59+
60+
// The UID of the CompatibilityRequirement.
61+
uid types.UID
62+
63+
// The generation of the CompatibilityRequirement.
64+
generation int64
65+
}
66+
4867
// validator implements the admission.Handler to have a custom Handle function which is able to
4968
// validate arbitrary objects against CompatibilityRequirements by leveraging unstructured.
5069
type validator struct {
5170
client client.Reader
5271
decoder admission.Decoder
5372
universalDeserializer runtime.Decoder
73+
74+
structuralSchemaCacheLock sync.RWMutex
75+
structuralSchemaCache map[structuralSchemaCacheKey]structuralSchemaCacheValue
5476
}
5577

5678
// NewValidator returns a partially initialized ObjectValidator.
5779
func NewValidator() *validator {
5880
return &validator{
5981
// This decoder is only used to decode to unstructured and for CompatibilityRequirements.
60-
decoder: admission.NewDecoder(runtime.NewScheme()),
82+
decoder: admission.NewDecoder(runtime.NewScheme()),
83+
structuralSchemaCache: make(map[structuralSchemaCacheKey]structuralSchemaCacheValue),
6184
}
6285
}
6386

@@ -83,7 +106,7 @@ func (v *validator) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts
83106

84107
// handleObjectPruning handles the pruning of an object.
85108
func (v *validator) handleObjectPruning(ctx context.Context, compatibilityRequirementName string, obj *unstructured.Unstructured) error {
86-
schema, err := v.getCopmatibilityRequirementStructuralSchema(ctx, compatibilityRequirementName, obj.GroupVersionKind().Version)
109+
schema, err := v.getStructuralSchema(ctx, compatibilityRequirementName, obj.GroupVersionKind().Version)
87110
if err != nil {
88111
return fmt.Errorf("failed to get schema for CompatibilityRequirement %q: %w", compatibilityRequirementName, err)
89112
}
@@ -93,12 +116,72 @@ func (v *validator) handleObjectPruning(ctx context.Context, compatibilityRequir
93116
return nil
94117
}
95118

96-
func (v *validator) getCopmatibilityRequirementStructuralSchema(ctx context.Context, compatibilityRequirementName string, version string) (*structuralschema.Structural, error) {
119+
func (v *validator) getStructuralSchema(ctx context.Context, compatibilityRequirementName string, version string) (*structuralschema.Structural, error) {
97120
compatibilityRequirement := &apiextensionsv1alpha1.CompatibilityRequirement{}
98121
if err := v.client.Get(ctx, client.ObjectKey{Name: compatibilityRequirementName}, compatibilityRequirement); err != nil {
99122
return nil, fmt.Errorf("failed to get CompatibilityRequirement %q: %w", compatibilityRequirementName, err)
100123
}
101124

125+
cacheKey := getStructuralSchemaCacheKey(compatibilityRequirement, version)
126+
127+
schema, ok := v.getStructuralSchemaFromCache(compatibilityRequirement, cacheKey)
128+
if ok {
129+
return schema, nil
130+
}
131+
132+
v.structuralSchemaCacheLock.Lock()
133+
defer v.structuralSchemaCacheLock.Unlock()
134+
135+
// Check the cache again under the write lock in case another thread populated the cache
136+
// while we were waiting for the write lock.
137+
schemaValue, ok := v.structuralSchemaCache[cacheKey]
138+
if ok && isCacheEntryValid(compatibilityRequirement, schemaValue) {
139+
return schemaValue.schema, nil
140+
}
141+
142+
schema, err := v.getCompatibilityRequirementStructuralSchema(ctx, compatibilityRequirement, version)
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to get structural schema: %w", err)
145+
}
146+
147+
v.storeStructuralSchemaInCache(compatibilityRequirement, cacheKey, schema)
148+
149+
return schema, nil
150+
}
151+
152+
func getStructuralSchemaCacheKey(compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement, version string) structuralSchemaCacheKey {
153+
return structuralSchemaCacheKey{
154+
compatibilityRequirementName: compatibilityRequirement.Name,
155+
version: version,
156+
}
157+
}
158+
159+
func isCacheEntryValid(compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement, schema structuralSchemaCacheValue) bool {
160+
return compatibilityRequirement.Generation == schema.generation && compatibilityRequirement.UID == schema.uid
161+
}
162+
163+
func (v *validator) getStructuralSchemaFromCache(compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement, cacheKey structuralSchemaCacheKey) (*structuralschema.Structural, bool) {
164+
v.structuralSchemaCacheLock.RLock()
165+
defer v.structuralSchemaCacheLock.RUnlock()
166+
167+
schema, ok := v.structuralSchemaCache[cacheKey]
168+
if !ok || !isCacheEntryValid(compatibilityRequirement, schema) {
169+
return nil, false
170+
}
171+
172+
return schema.schema, true
173+
}
174+
175+
func (v *validator) storeStructuralSchemaInCache(compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement, cacheKey structuralSchemaCacheKey, schema *structuralschema.Structural) {
176+
// No locking here as we take the lock when constructing the new schema.
177+
v.structuralSchemaCache[cacheKey] = structuralSchemaCacheValue{
178+
schema: schema,
179+
uid: compatibilityRequirement.UID,
180+
generation: compatibilityRequirement.Generation,
181+
}
182+
}
183+
184+
func (v *validator) getCompatibilityRequirementStructuralSchema(ctx context.Context, compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement, version string) (*structuralschema.Structural, error) {
102185
// Extract the CRD so we can use the schema.
103186
// Use a universal deserializer as it correctly handles YAML and JSON decoding based on the expected key formatting for CRDs.
104187
// N.B. DO NOT switch this to a YAML library - they do not correctly handle the OpenAPIV3Schema casing within the CRD version schema.

0 commit comments

Comments
 (0)