@@ -44,7 +44,7 @@ type reconciler struct {
4444 clusterScopedIsolation kubebindv1alpha2.Isolation
4545 schemaSource string
4646
47- getBoundSchema func (ctx context.Context , cache cache. Cache , namespace , name string ) (* kubebindv1alpha2.BoundSchema , error )
47+ getBoundSchema func (ctx context.Context , cl client. Client , namespace , name string ) (* kubebindv1alpha2.BoundSchema , error )
4848 createBoundSchema func (ctx context.Context , cl client.Client , schema * kubebindv1alpha2.BoundSchema ) error
4949
5050 getServiceExport func (ctx context.Context , cache cache.Cache , ns , name string ) (* kubebindv1alpha2.APIServiceExport , error )
@@ -53,14 +53,21 @@ type reconciler struct {
5353}
5454
5555func (r * reconciler ) reconcile (ctx context.Context , cl client.Client , cache cache.Cache , req * kubebindv1alpha2.APIServiceExportRequest ) error {
56+ // We must ensure schemas are created in form of boundSchemas first for the validation.
57+ // Worst case scenario if validation fails, we will reuse schemas for same consumer once issues are fixed.
5658 if err := r .ensureBoundSchemas (ctx , cl , cache , req ); err != nil {
5759 conditions .SetSummary (req )
58- return err
60+ return fmt .Errorf ("failed to ensure bound schemas: %w" , err )
61+ }
62+
63+ if err := r .validate (ctx , cl , req ); err != nil {
64+ conditions .SetSummary (req )
65+ return fmt .Errorf ("failed to validate APIServiceExportRequest: %w" , err )
5966 }
6067
6168 if err := r .ensureExports (ctx , cl , cache , req ); err != nil {
6269 conditions .SetSummary (req )
63- return err
70+ return fmt . Errorf ( "failed to ensure exports: %w" , err )
6471 }
6572
6673 // TODO(mjudeikis): we could potentially add finallizer to APIServiceExport above or "adopt" boundschemas
@@ -72,104 +79,77 @@ func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cach
7279 return nil
7380}
7481
75- func (r * reconciler ) ensureBoundSchemas (ctx context.Context , cl client.Client , cache cache.Cache , req * kubebindv1alpha2.APIServiceExportRequest ) error {
76- // Ensure all bound schemas exist
77- for _ , res := range req .Spec .Resources {
78- parts := strings .SplitN (r .schemaSource , "." , 3 )
79- if len (parts ) != 3 { // We check this in validation, but just in case.
80- return fmt .Errorf ("malformed schema source: %q" , r .schemaSource )
81- }
82+ // getExportedSchemas will list all schemas, exported by current backend.
83+ // Important: getExportedSchemas is using client.Client to list resources, not cache.
84+ // This is due to fact we use dynamic client and unstructured.Unstructured to get schemas and it
85+ // does not quite work with dynamic cache informers:
86+ // failed to get informer for *unstructured.UnstructuredList apis.kcp.io/v1alpha1, Kind=APIResourceSchemaList: failed to find newly started informer for apis.kcp.io/v1alpha1, Kind=APIResourceSchema"}.
87+ func (r * reconciler ) getExportedSchemas (ctx context.Context , cl client.Client ) (kubebindv1alpha2.ExportedSchemas , error ) {
88+ parts := strings .SplitN (r .schemaSource , "." , 3 )
89+ if len (parts ) != 3 { // We check this in validation, but just in case.
90+ return nil , fmt .Errorf ("malformed schema source: %q" , r .schemaSource )
91+ }
8292
83- gvk := schema.GroupVersionKind {
84- Kind : parts [0 ],
85- Version : parts [1 ],
86- Group : parts [2 ],
87- }
93+ gvk := schema.GroupVersionKind {
94+ Kind : parts [0 ],
95+ Version : parts [1 ],
96+ Group : parts [2 ],
97+ }
8898
89- // Ensure we have the List kind
90- listGVK := gvk
91- if ! strings .HasSuffix (listGVK .Kind , "List" ) {
92- listGVK .Kind += "List"
93- }
99+ // Ensure we have the List kind
100+ listGVK := gvk
101+ if ! strings .HasSuffix (listGVK .Kind , "List" ) {
102+ listGVK .Kind += "List"
103+ }
94104
95- list := & unstructured.UnstructuredList {}
96- list .SetGroupVersionKind (listGVK )
105+ list := & unstructured.UnstructuredList {}
106+ list .SetGroupVersionKind (listGVK )
97107
98- // TODO(mjudeikis): This is hardcoded here and in handlers.go for now.
99- labelSelector := labels.Set {
100- resources .ExportedCRDsLabel : "true" ,
101- }
108+ // TODO(mjudeikis): This is hardcoded here and in handlers.go for now.
109+ labelSelector := labels.Set {
110+ resources .ExportedCRDsLabel : "true" ,
111+ }
102112
103- listOpts := []client.ListOption {}
104- listOpts = append (listOpts , client.MatchingLabelsSelector {Selector : labelSelector .AsSelector ()})
113+ listOpts := []client.ListOption {}
114+ listOpts = append (listOpts , client.MatchingLabelsSelector {Selector : labelSelector .AsSelector ()})
105115
106- if err := cl .List (ctx , list , listOpts ... ); err != nil {
107- return err
108- }
116+ if err := cl .List (ctx , list , listOpts ... ); err != nil {
117+ return nil , err
118+ }
109119
110- for _ , item := range list .Items {
111- var schemaFailed bool
112- obj := item .UnstructuredContent ()
113- group , ok , err := unstructured .NestedString (obj , "spec" , "group" )
114- if ! ok || err != nil || group == "" {
115- klog .FromContext (ctx ).Error (err , "Skipping invalid schema: missing group" , "ns" , item .GetNamespace (), "name" , item .GetName ())
116- schemaFailed = true
117- }
118- plural , ok , err := unstructured .NestedString (obj , "spec" , "names" , "plural" )
119- if ! ok || err != nil || plural == "" {
120- klog .FromContext (ctx ).Error (err , "Skipping invalid schema: missing names.plural" , "ns" , item .GetNamespace (), "name" , item .GetName ())
121- schemaFailed = true
122- }
120+ boundSchemas := make (kubebindv1alpha2.ExportedSchemas , len (list .Items ))
121+ for _ , item := range list .Items {
122+ boundSchema , err := helpers .UnstructuredToBoundSchema (item )
123+ if err != nil {
124+ return nil , err
125+ }
126+ boundSchemas [boundSchema .ResourceGroupName ()] = boundSchema
127+ }
123128
124- scope , ok , err := unstructured .NestedString (obj , "spec" , "scope" )
125- if ! ok || err != nil || scope == "" {
126- klog .FromContext (ctx ).Error (err , "Skipping invalid schema: missing scope" , "ns" , item .GetNamespace (), "name" , item .GetName ())
127- schemaFailed = true
128- }
129+ return boundSchemas , nil
130+ }
129131
130- if schemaFailed {
131- conditions .MarkFalse (
132- req ,
133- kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady ,
134- "APIServiceExportRequestInvalid" ,
135- conditionsapi .ConditionSeverityError ,
136- "APIServiceExportRequest %s is invalid: resource %s/%s has invalid schema" ,
137- req .Name , group , plural ,
138- )
139- req .Status .Phase = kubebindv1alpha2 .APIServiceExportRequestPhaseFailed
140- return fmt .Errorf ("resource %s/%s is invalid" , group , plural )
141- }
132+ func (r * reconciler ) ensureBoundSchemas (ctx context.Context , cl client.Client , cache cache.Cache , req * kubebindv1alpha2.APIServiceExportRequest ) error {
133+ exportedSchemas , err := r .getExportedSchemas (ctx , cl )
134+ if err != nil {
135+ return err
136+ }
142137
143- if group == res .Group && plural == res .Resource {
144- // Important: This checks if the resource are correctly scoped. If consumer is namespaced, we can't allow this.
145- // We terminate early to prevent triggering other controllers.
146- if r .informerScope .String () != scope && r .informerScope != kubebindv1alpha2 .ClusterScope {
147- conditions .MarkFalse (
148- req ,
149- kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady ,
150- "APIServiceExportRequestInvalid" ,
151- conditionsapi .ConditionSeverityError ,
152- "APIServiceExportRequest %s is invalid: resource %s/%s has scope %q which is incompatible with backend informer scope %q" ,
153- req .Name , group , plural , scope , r .informerScope ,
154- )
155- req .Status .Phase = kubebindv1alpha2 .APIServiceExportRequestPhaseFailed
156- req .Status .TerminalMessage = conditions .GetMessage (req , kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady )
157- // We can't proceed with this request.
158- return fmt .Errorf ("resource %s/%s has scope %q which is incompatible with backend informer scope %q" , group , plural , scope , r .informerScope )
159- }
138+ // Ensure all bound schemas exist
139+ for _ , res := range req .Spec .Resources {
140+ if len (res .Versions ) == 0 {
141+ continue
142+ }
160143
161- // https://github.com/kube-bind/kube-bind/issues/297 to fix.
162- boundSchema , err := helpers .UnstructuredToBoundSchema (item )
163- if err != nil {
164- return err
165- }
144+ for _ , boundSchema := range exportedSchemas {
145+ if boundSchema .Spec .Group == res .Group && boundSchema .Spec .Names .Plural == res .Resource {
166146 boundSchema .Name = res .ResourceGroupName ()
167147 boundSchema .Namespace = req .Namespace
168148 boundSchema .Spec .InformerScope = r .informerScope
169149 boundSchema .ResourceVersion = ""
170150
171- obj , err := r .getBoundSchema (ctx , cache , boundSchema .Namespace , boundSchema .Name )
172- if err != nil && ! apierrors .IsNotFound (err ) {
151+ obj , err := r .getBoundSchema (ctx , cl , boundSchema .Namespace , boundSchema .Name )
152+ if err != nil && ! apierrors .IsNotFound (err ) && ! strings . Contains ( err . Error (), "no matches for kind" ) {
173153 return err
174154 }
175155
@@ -196,7 +176,7 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
196176 if req .Status .Phase == kubebindv1alpha2 .APIServiceExportRequestPhasePending {
197177 for _ , res := range req .Spec .Resources {
198178 name := res .ResourceGroupName ()
199- boundSchema , err := r .getBoundSchema (ctx , cache , req .Namespace , name )
179+ boundSchema , err := r .getBoundSchema (ctx , cl , req .Namespace , name )
200180 if err != nil {
201181 if apierrors .IsNotFound (err ) {
202182 conditions .MarkFalse (
@@ -258,6 +238,7 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
258238 Versions : res .Versions ,
259239 })
260240 }
241+ export .Spec .PermissionClaims = req .Spec .PermissionClaims
261242
262243 logger .V (1 ).Info ("Creating APIServiceExport" , "name" , export .Name , "namespace" , export .Namespace )
263244 if err := r .createServiceExport (ctx , cl , export ); err != nil {
@@ -283,3 +264,106 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
283264
284265 return nil
285266}
267+
268+ // Validate validates if the APIServiceExportRequest is in a valid state.
269+ // Currently it validates if all requested schemas are of the same scope and
270+ // if claimable apis are allowed and valid.
271+ //
272+ // TODO: Move this to validatingAdmissionWebhook as this is not really part of reconciliation.
273+ // https://github.com/kube-bind/kube-bind/issues/325
274+ func (r * reconciler ) validate (ctx context.Context , cl client.Client , req * kubebindv1alpha2.APIServiceExportRequest ) error {
275+ exportedSchemas , err := r .getExportedSchemas (ctx , cl )
276+ if err != nil {
277+ return err
278+ }
279+
280+ if len (exportedSchemas ) == 0 {
281+ conditions .MarkFalse (
282+ req ,
283+ kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady ,
284+ "SchemaNotFound" ,
285+ conditionsapi .ConditionSeverityError ,
286+ "Schema not found" ,
287+ )
288+ return fmt .Errorf ("no exported schemas found" )
289+ }
290+
291+ first := apiextensionsv1 .ResourceScope ("" )
292+ for _ , res := range req .Spec .Resources {
293+ boundSchema , ok := exportedSchemas [res .ResourceGroupName ()]
294+ if ! ok {
295+ conditions .MarkFalse (
296+ req ,
297+ kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady ,
298+ "SchemaNotFound" ,
299+ conditionsapi .ConditionSeverityError ,
300+ "Schema %s not found" ,
301+ res .ResourceGroupName (),
302+ )
303+ return fmt .Errorf ("schema %s not found" , res .ResourceGroupName ())
304+ }
305+ if first == apiextensionsv1 .ResourceScope ("" ) {
306+ first = boundSchema .Spec .Scope
307+ continue
308+ }
309+ if boundSchema .Spec .Scope != first {
310+ conditions .MarkFalse (req ,
311+ kubebindv1alpha2 .APIServiceExportRequestConditionExportsReady ,
312+ "DifferentScopes" ,
313+ conditionsapi .ConditionSeverityError ,
314+ "Different scopes found: %v" ,
315+ boundSchema .Spec .Scope ,
316+ )
317+ return fmt .Errorf ("different scopes found for claimed resources: %v" , boundSchema .Name )
318+ }
319+ }
320+
321+ // Add validation if claimable apis are valid here
322+ for _ , claim := range req .Spec .PermissionClaims {
323+ if ! isClaimableAPI (claim ) {
324+ conditions .MarkFalse (
325+ req ,
326+ kubebindv1alpha2 .APIServiceExportConditionPermissionClaim ,
327+ "InvalidPermissionClaim" ,
328+ conditionsapi .ConditionSeverityError ,
329+ "Resource %s is not a valid claimable API" ,
330+ claim .GroupResource .String (),
331+ )
332+ req .Status .Phase = kubebindv1alpha2 .APIServiceExportRequestPhaseFailed
333+ req .Status .TerminalMessage = conditions .GetMessage (req , kubebindv1alpha2 .APIServiceExportConditionPermissionClaim )
334+ return fmt .Errorf ("resource %s is not a valid claimable API" , claim .GroupResource .String ())
335+ }
336+ }
337+
338+ // Add validation for duplicate group/resource combinations
339+ seenGroupResources := make (map [string ]bool )
340+ for _ , claim := range req .Spec .PermissionClaims {
341+ key := claim .Group + "/" + claim .Resource
342+ if seenGroupResources [key ] {
343+ conditions .MarkFalse (
344+ req ,
345+ kubebindv1alpha2 .APIServiceExportConditionPermissionClaim ,
346+ "DuplicatePermissionClaim" ,
347+ conditionsapi .ConditionSeverityError ,
348+ "Duplicate permission claim found for group/resource %s" ,
349+ claim .GroupResource .String (),
350+ )
351+ req .Status .Phase = kubebindv1alpha2 .APIServiceExportRequestPhaseFailed
352+ req .Status .TerminalMessage = conditions .GetMessage (req , kubebindv1alpha2 .APIServiceExportConditionPermissionClaim )
353+ return fmt .Errorf ("duplicate permission claim found for group/resource %s" , claim .GroupResource .String ())
354+ }
355+ seenGroupResources [key ] = true
356+ }
357+
358+ return nil
359+ }
360+
361+ // isClaimableAPI checks if a permission claim is for a claimable API.
362+ func isClaimableAPI (claim kubebindv1alpha2.PermissionClaim ) bool {
363+ for _ , api := range kubebindv1alpha2 .ClaimableAPIs {
364+ if claim .Group == api .GroupVersionResource .Group && claim .Resource == api .Names .Plural {
365+ return true
366+ }
367+ }
368+ return false
369+ }
0 commit comments