Skip to content

Commit 7b255ac

Browse files
authored
Move to schemaless backend (#295)
* Remove APIResourceSchemas * Address more comments --------- Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt> On-behalf-of: @SAP mangirdas.judeikis@sap.com
1 parent aae3218 commit 7b255ac

78 files changed

Lines changed: 1399 additions & 2993 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/controllers/clusterbinding/clusterbinding_controller.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ func NewClusterBindingReconciler(
7777
}
7878
return exports, nil
7979
},
80-
getAPIResourceSchema: func(ctx context.Context, cache cache.Cache, name string) (*kubebindv1alpha2.APIResourceSchema, error) {
81-
result := &kubebindv1alpha2.APIResourceSchema{}
82-
err := cache.Get(ctx, types.NamespacedName{Name: name}, result)
80+
getBoundSchema: func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) {
81+
result := &kubebindv1alpha2.BoundSchema{}
82+
err := cache.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, result)
8383
if err != nil {
84-
return nil, fmt.Errorf("failed to get APIResourceSchema %q: %w", name, err)
84+
return nil, fmt.Errorf("failed to get BoundSchema %q: %w", name, err)
8585
}
8686
return result, nil
8787
},

backend/controllers/clusterbinding/clusterbinding_reconcile.go

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ package clusterbinding
1919
import (
2020
"context"
2121
"fmt"
22+
"maps"
2223
"reflect"
24+
"slices"
2325
"time"
2426

2527
corev1 "k8s.io/api/core/v1"
@@ -40,11 +42,11 @@ import (
4042
type reconciler struct {
4143
scope kubebindv1alpha2.InformerScope
4244

43-
listServiceExports func(ctx context.Context, cache cache.Cache, ns string) ([]*kubebindv1alpha2.APIServiceExport, error)
44-
getAPIResourceSchema func(ctx context.Context, cache cache.Cache, name string) (*kubebindv1alpha2.APIResourceSchema, error)
45-
getClusterRole func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRole, error)
46-
createClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error
47-
updateClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error
45+
listServiceExports func(ctx context.Context, cache cache.Cache, ns string) ([]*kubebindv1alpha2.APIServiceExport, error)
46+
getBoundSchema func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error)
47+
getClusterRole func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRole, error)
48+
createClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error
49+
updateClusterRole func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRole) error
4850

4951
getClusterRoleBinding func(ctx context.Context, cache cache.Cache, name string) (*rbacv1.ClusterRoleBinding, error)
5052
createClusterRoleBinding func(ctx context.Context, client client.Client, binding *rbacv1.ClusterRoleBinding) error
@@ -148,24 +150,35 @@ func (r *reconciler) ensureRBACClusterRole(ctx context.Context, client client.Cl
148150
},
149151
},
150152
},
151-
}
153+
Rules: []rbacv1.PolicyRule{
154+
// Always need to be able to get/list/watch the BoundSchemas
155+
// to be able to figure out what to bind.
156+
{
157+
APIGroups: []string{kubebindv1alpha2.GroupName},
158+
Resources: []string{"boundschemas"},
159+
Verbs: []string{"get", "list", "watch"},
160+
},
161+
}}
152162
for _, export := range exports {
163+
// Collect unique GroupResources and sort for stable rule ordering.
164+
grSet := map[string]kubebindv1alpha2.GroupResource{}
153165
for _, res := range export.Spec.Resources {
154-
schema, err := r.getAPIResourceSchema(ctx, cache, res.Name)
166+
key := res.ResourceGroupName()
167+
grSet[key] = kubebindv1alpha2.GroupResource{Group: res.Group, Resource: res.Resource}
168+
}
169+
keys := slices.Collect(maps.Keys(grSet))
170+
slices.Sort(keys)
171+
for _, k := range keys {
172+
// k is already normalized (e.g., "pods.core" for empty group).
173+
schema, err := r.getBoundSchema(ctx, cache, clusterBinding.Namespace, k)
155174
if err != nil {
156-
return fmt.Errorf("failed to get APIResourceSchema %w", err)
175+
return fmt.Errorf("failed to get BoundSchema %q: %w", k, err)
157176
}
158-
159177
expected.Rules = append(expected.Rules,
160178
rbacv1.PolicyRule{
161-
APIGroups: []string{schema.Spec.APIResourceSchemaCRDSpec.Group},
162-
Resources: []string{schema.Spec.APIResourceSchemaCRDSpec.Names.Plural},
163-
Verbs: []string{"get", "list", "watch", "update", "patch", "delete", "create"},
164-
},
165-
rbacv1.PolicyRule{
166-
APIGroups: []string{kubebindv1alpha2.GroupName},
167-
Resources: []string{"apiresourceschemas"},
168-
Verbs: []string{"get", "list", "watch"},
179+
APIGroups: []string{schema.Spec.Group},
180+
Resources: []string{schema.Spec.Names.Plural},
181+
Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"},
169182
},
170183
)
171184
}
@@ -192,7 +205,6 @@ func (r *reconciler) ensureRBACClusterRoleBinding(ctx context.Context, client cl
192205
if err != nil && !errors.IsNotFound(err) {
193206
return fmt.Errorf("failed to get ClusterRoleBinding %s: %w", name, err)
194207
}
195-
196208
if r.scope != kubebindv1alpha2.ClusterScope {
197209
if err := r.deleteClusterRoleBinding(ctx, client, name); err != nil && !errors.IsNotFound(err) {
198210
return fmt.Errorf("failed to delete ClusterRoleBinding %s: %w", name, err)

backend/controllers/serviceexport/serviceexport_controller.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,18 @@ func NewAPIServiceExportReconciler(
5858
mgr mcmanager.Manager,
5959
opts controller.TypedOptions[mcreconcile.Request],
6060
) (*APIServiceExportReconciler, error) {
61-
if err := mgr.GetFieldIndexer().IndexField(ctx, &kubebindv1alpha2.APIServiceExport{}, indexers.ServiceExportByAPIResourceSchema,
62-
indexers.IndexServiceExportByAPIResourceSchema); err != nil {
63-
return nil, fmt.Errorf("failed to setup ServiceExportByAPIResourceSchema indexer: %w", err)
61+
if err := mgr.GetFieldIndexer().IndexField(ctx, &kubebindv1alpha2.APIServiceExport{}, indexers.ServiceExportByBoundSchema,
62+
indexers.IndexServiceExportByBoundSchema); err != nil {
63+
return nil, fmt.Errorf("failed to setup ServiceExportByBoundSchema indexer: %w", err)
6464
}
6565

6666
r := &APIServiceExportReconciler{
6767
manager: mgr,
6868
opts: opts,
6969
reconciler: reconciler{
70-
getAPIResourceSchema: func(ctx context.Context, cache cache.Cache, name string) (*kubebindv1alpha2.APIResourceSchema, error) {
71-
var schema kubebindv1alpha2.APIResourceSchema
72-
key := types.NamespacedName{Name: name}
70+
getBoundSchema: func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) {
71+
var schema kubebindv1alpha2.BoundSchema
72+
key := types.NamespacedName{Namespace: namespace, Name: name}
7373
if err := cache.Get(ctx, key, &schema); err != nil {
7474
return nil, err
7575
}
@@ -92,6 +92,8 @@ func NewAPIServiceExportReconciler(
9292
//+kubebuilder:rbac:groups=kubebind.k8s.io,resources=apiserviceexports,verbs=get;list;watch;create;update;patch;delete
9393
//+kubebuilder:rbac:groups=kubebind.k8s.io,resources=apiserviceexports/status,verbs=get;update;patch
9494
//+kubebuilder:rbac:groups=kubebind.k8s.io,resources=apiserviceexports/finalizers,verbs=update
95+
//+kubebuilder:rbac:groups=kubebind.k8s.io,resources=boundschemas,verbs=get;list;watch
96+
//+kubebuilder:rbac:groups=kubebind.k8s.io,resources=boundschemas/status,verbs=get;update;patch
9597

9698
// Reconcile is part of the main kubernetes reconciliation loop which aims to
9799
// move the current state of the cluster closer to the desired state.
@@ -128,10 +130,18 @@ func (r *APIServiceExportReconciler) Reconcile(ctx context.Context, req mcreconc
128130
return ctrl.Result{}, err
129131
}
130132

131-
// Update status if it has changed
132-
if !equality.Semantic.DeepEqual(original, apiServiceExport) {
133-
err := client.Status().Update(ctx, apiServiceExport)
134-
if err != nil {
133+
// Update annotations changed (hash), we need to propagate it and requeue for status changes.
134+
// This is why we compare annotations only as we don't expect any changes to spec.
135+
// Status changes are handled below.
136+
if !equality.Semantic.DeepEqual(original.Annotations, apiServiceExport.Annotations) {
137+
if err := client.Update(ctx, apiServiceExport); err != nil {
138+
return ctrl.Result{}, fmt.Errorf("failed to update APIServiceExport: %w", err)
139+
}
140+
logger.Info("APIServiceExport hash updated", "namespace", apiServiceExport.Namespace, "name", apiServiceExport.Name)
141+
return ctrl.Result{Requeue: true}, nil
142+
}
143+
if !equality.Semantic.DeepEqual(original.Status, apiServiceExport.Status) {
144+
if err := client.Status().Update(ctx, apiServiceExport); err != nil {
135145
return ctrl.Result{}, fmt.Errorf("failed to update APIServiceExport status: %w", err)
136146
}
137147
logger.Info("APIServiceExport status updated", "namespace", apiServiceExport.Namespace, "name", apiServiceExport.Name)
@@ -141,14 +151,14 @@ func (r *APIServiceExportReconciler) Reconcile(ctx context.Context, req mcreconc
141151
}
142152

143153
// getAPIResourceSchemaMapper returns a mapper function that uses the manager to find related APIServiceExports.
144-
func getAPIResourceSchemaMapper(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
154+
func getBoundSchemaMapper(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
145155
return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request {
146-
apiResourceSchema := obj.(*kubebindv1alpha2.APIResourceSchema)
147-
apiResourceSchemaKey := apiResourceSchema.Name
156+
boundSchema := obj.(*kubebindv1alpha2.BoundSchema)
157+
boundSchemaKey := boundSchema.Spec.Names.Plural + "." + boundSchema.Spec.Group
148158
c := cl.GetClient()
149159

150160
var exports kubebindv1alpha2.APIServiceExportList
151-
if err := c.List(ctx, &exports, client.MatchingFields{indexers.ServiceExportByAPIResourceSchema: apiResourceSchemaKey}); err != nil {
161+
if err := c.List(ctx, &exports, client.MatchingFields{indexers.ServiceExportByBoundSchema: boundSchemaKey}); err != nil {
152162
return []mcreconcile.Request{}
153163
}
154164

@@ -171,8 +181,8 @@ func (r *APIServiceExportReconciler) SetupWithManager(mgr mcmanager.Manager) err
171181
return mcbuilder.ControllerManagedBy(mgr).
172182
For(&kubebindv1alpha2.APIServiceExport{}).
173183
Watches(
174-
&kubebindv1alpha2.APIResourceSchema{},
175-
getAPIResourceSchemaMapper,
184+
&kubebindv1alpha2.BoundSchema{},
185+
getBoundSchemaMapper,
176186
).
177187
WithOptions(r.opts).
178188
Named(controllerName).

backend/controllers/serviceexport/serviceexport_reconcile.go

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ package serviceexport
1818

1919
import (
2020
"context"
21-
"crypto/sha256"
22-
"encoding/hex"
23-
"slices"
24-
"sort"
2521

2622
"k8s.io/apimachinery/pkg/api/errors"
2723
utilerrors "k8s.io/apimachinery/pkg/util/errors"
@@ -30,75 +26,65 @@ import (
3026
"sigs.k8s.io/controller-runtime/pkg/client"
3127

3228
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
33-
kubebindhelpers "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2/helpers"
29+
"github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2/helpers"
30+
conditionsapi "github.com/kube-bind/kube-bind/sdk/apis/third_party/conditions/apis/conditions/v1alpha1"
3431
"github.com/kube-bind/kube-bind/sdk/apis/third_party/conditions/util/conditions"
3532
)
3633

3734
type reconciler struct {
38-
getAPIResourceSchema func(ctx context.Context, cache cache.Cache, name string) (*kubebindv1alpha2.APIResourceSchema, error)
39-
deleteServiceExport func(ctx context.Context, client client.Client, namespace, name string) error
35+
getBoundSchema func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error)
36+
deleteServiceExport func(ctx context.Context, client client.Client, namespace, name string) error
4037
}
4138

4239
func (r *reconciler) reconcile(ctx context.Context, cache cache.Cache, export *kubebindv1alpha2.APIServiceExport) error {
4340
var errs []error
4441

45-
if specChanged, err := r.ensureSchema(ctx, cache, export); err != nil {
42+
if err := r.ensureSchema(ctx, cache, export); err != nil {
4643
errs = append(errs, err)
47-
} else if specChanged {
48-
// TODO: This should be separate controller for apiresourceschemas.
49-
// This is wrong place now.
50-
// r.requeue(export)
51-
return nil
5244
}
5345

5446
return utilerrors.NewAggregate(errs)
5547
}
5648

57-
func (r *reconciler) ensureSchema(ctx context.Context, cache cache.Cache, export *kubebindv1alpha2.APIServiceExport) (specChanged bool, err error) {
49+
func (r *reconciler) ensureSchema(ctx context.Context, cache cache.Cache, export *kubebindv1alpha2.APIServiceExport) error {
5850
logger := klog.FromContext(ctx)
59-
leafHashes := make([]string, 0, len(export.Spec.Resources))
60-
for _, resourceRef := range export.Spec.Resources {
61-
if resourceRef.Type != "APIResourceSchema" {
62-
logger.V(1).Info("Skipping unsupported resource type", "type", resourceRef.Type)
63-
continue
64-
}
51+
schemas := make([]*kubebindv1alpha2.BoundSchema, 0, len(export.Spec.Resources))
6552

66-
schema, err := r.getAPIResourceSchema(ctx, cache, resourceRef.Name)
53+
for _, res := range export.Spec.Resources {
54+
name := res.ResourceGroupName()
55+
schema, err := r.getBoundSchema(ctx, cache, export.Namespace, name)
6756
if err != nil {
6857
if errors.IsNotFound(err) {
69-
continue
58+
conditions.MarkFalse(
59+
export,
60+
kubebindv1alpha2.APIServiceExportConditionProviderInSync,
61+
"BoundSchemaMissing",
62+
conditionsapi.ConditionSeverityError,
63+
"BoundSchema %q is not available: %v", name, err)
64+
return nil
7065
}
71-
return false, err
66+
return err
7267
}
7368

74-
hash := kubebindhelpers.APIResourceSchemaCRDSpecHash(&schema.Spec.APIResourceSchemaCRDSpec)
75-
leafHashes = append(leafHashes, hash)
69+
schemas = append(schemas, schema)
7670
}
7771

78-
hashOfHashes := hashOfHashes(leafHashes)
72+
hash, err := helpers.BoundSchemasSpecHash(schemas)
73+
if err != nil {
74+
return err
75+
}
7976

80-
if export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey] != hashOfHashes {
77+
if export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey] != hash {
8178
// both exist, update APIServiceExport
82-
logger.V(1).Info("Updating APIServiceExport. Hash mismatch", "hash", hashOfHashes, "expected", export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey])
79+
logger.V(1).Info("Updating APIServiceExport. Hash mismatch", "hash", hash, "expected", export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey])
8380
if export.Annotations == nil {
8481
export.Annotations = map[string]string{}
8582
}
86-
export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey] = hashOfHashes
87-
return true, nil
83+
export.Annotations[kubebindv1alpha2.SourceSpecHashAnnotationKey] = hash
84+
return nil
8885
}
8986

9087
conditions.MarkTrue(export, kubebindv1alpha2.APIServiceExportConditionProviderInSync)
9188

92-
return false, nil
93-
}
94-
95-
func hashOfHashes(hashes []string) string {
96-
hexHashes := slices.Clone(hashes)
97-
sort.Strings(hexHashes)
98-
99-
rootHasher := sha256.New()
100-
for _, h := range hexHashes {
101-
rootHasher.Write([]byte(h))
102-
}
103-
return hex.EncodeToString(rootHasher.Sum(nil))
89+
return nil
10490
}

backend/controllers/serviceexportrequest/serviceexportrequest_controller.go

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func NewAPIServiceExportRequestReconciler(
6161
opts controller.TypedOptions[mcreconcile.Request],
6262
scope kubebindv1alpha2.InformerScope,
6363
isolation kubebindv1alpha2.Isolation,
64+
schemaSource string,
6465
) (*APIServiceExportRequestReconciler, error) {
6566
// Set up field indexers for APIServiceExportRequests
6667
if err := mgr.GetFieldIndexer().IndexField(ctx, &kubebindv1alpha2.APIServiceExportRequest{}, indexers.ServiceExportRequestByServiceExport,
@@ -81,9 +82,10 @@ func NewAPIServiceExportRequestReconciler(
8182
reconciler: reconciler{
8283
informerScope: scope,
8384
clusterScopedIsolation: isolation,
84-
getAPIResourceSchema: func(ctx context.Context, cache cache.Cache, name string) (*kubebindv1alpha2.APIResourceSchema, error) {
85-
var schema kubebindv1alpha2.APIResourceSchema
86-
key := types.NamespacedName{Name: name}
85+
schemaSource: schemaSource,
86+
getBoundSchema: func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) {
87+
var schema kubebindv1alpha2.BoundSchema
88+
key := types.NamespacedName{Namespace: namespace, Name: name}
8789
if err := cache.Get(ctx, key, &schema); err != nil {
8890
return nil, err
8991
}
@@ -100,11 +102,8 @@ func NewAPIServiceExportRequestReconciler(
100102
createServiceExport: func(ctx context.Context, cl client.Client, resource *kubebindv1alpha2.APIServiceExport) error {
101103
return cl.Create(ctx, resource)
102104
},
103-
createAPIResourceSchema: func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.APIResourceSchema) (*kubebindv1alpha2.APIResourceSchema, error) {
104-
if err := cl.Create(ctx, schema); err != nil {
105-
return nil, err
106-
}
107-
return schema, nil
105+
createBoundSchema: func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error {
106+
return cl.Create(ctx, schema)
108107
},
109108
deleteServiceExportRequest: func(ctx context.Context, cl client.Client, ns, name string) error {
110109
return cl.Delete(ctx, &kubebindv1alpha2.APIServiceExportRequest{
@@ -150,15 +149,15 @@ func getServiceExportRequestMapper(clusterName string, cl cluster.Cluster) handl
150149
})
151150
}
152151

153-
// getAPIResourceSchemaMapper creates a mapping function for APIResourceSchema changes.
154-
func getAPIResourceSchemaMapper(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
152+
// getBoundSchemaMapper creates a mapping function for BoundSchema changes.
153+
func getBoundSchemaMapper(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
155154
return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request {
156-
apiResourceSchema := obj.(*kubebindv1alpha2.APIResourceSchema)
157-
apiResourceSchemaKey := apiResourceSchema.Name
155+
boundSchema := obj.(*kubebindv1alpha2.BoundSchema)
156+
boundSchemaKey := boundSchema.Spec.Names.Plural + "." + boundSchema.Spec.Group
158157
c := cl.GetClient()
159158

160159
var requests kubebindv1alpha2.APIServiceExportRequestList
161-
if err := c.List(ctx, &requests, client.MatchingFields{indexers.ServiceExportRequestByGroupResource: apiResourceSchemaKey}); err != nil {
160+
if err := c.List(ctx, &requests, client.MatchingFields{indexers.ServiceExportRequestByGroupResource: boundSchemaKey}); err != nil {
162161
return []mcreconcile.Request{}
163162
}
164163

@@ -216,13 +215,20 @@ func (r *APIServiceExportRequestReconciler) Reconcile(ctx context.Context, req m
216215
// Run the reconciliation logic
217216
if err := r.reconciler.reconcile(ctx, client, cache, apiServiceExportRequest); err != nil {
218217
logger.Error(err, "Failed to reconcile APIServiceExportRequest")
218+
if !reflect.DeepEqual(original.Status.Phase, apiServiceExportRequest.Status.Phase) {
219+
if err := client.Status().Update(ctx, apiServiceExportRequest); err != nil {
220+
logger.Error(err, "Failed to update APIServiceExportRequest status")
221+
return ctrl.Result{}, fmt.Errorf("failed to update APIServiceExportRequest status: %w", err)
222+
}
223+
logger.Info("APIServiceExportRequest status updated", "namespace", apiServiceExportRequest.Namespace, "name", apiServiceExportRequest.Name)
224+
}
225+
219226
return ctrl.Result{}, err
220227
}
221228

222229
// Update status if it has changed
223230
if !reflect.DeepEqual(original.Status, apiServiceExportRequest.Status) {
224-
err := client.Status().Update(ctx, apiServiceExportRequest)
225-
if err != nil {
231+
if err := client.Status().Update(ctx, apiServiceExportRequest); err != nil {
226232
logger.Error(err, "Failed to update APIServiceExportRequest status")
227233
return ctrl.Result{}, fmt.Errorf("failed to update APIServiceExportRequest status: %w", err)
228234
}
@@ -241,8 +247,8 @@ func (r *APIServiceExportRequestReconciler) SetupWithManager(mgr mcmanager.Manag
241247
getServiceExportRequestMapper,
242248
).
243249
Watches(
244-
&kubebindv1alpha2.APIResourceSchema{},
245-
getAPIResourceSchemaMapper,
250+
&kubebindv1alpha2.BoundSchema{},
251+
getBoundSchemaMapper,
246252
).
247253
WithOptions(r.opts).
248254
Named(controllerName).

0 commit comments

Comments
 (0)