diff --git a/porch/controllers/main.go b/porch/controllers/main.go index 41e6630674..6cb803452d 100644 --- a/porch/controllers/main.go +++ b/porch/controllers/main.go @@ -47,6 +47,7 @@ import ( rootsyncapi "github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncsets/api/v1alpha1" "github.com/GoogleContainerTools/kpt/porch/controllers/rootsyncsets/pkg/controllers/rootsyncset" "github.com/GoogleContainerTools/kpt/porch/controllers/workloadidentitybindings/pkg/controllers/workloadidentitybinding" + "github.com/GoogleContainerTools/kpt/porch/pkg/controllerrestmapper" //+kubebuilder:scaffold:imports ) @@ -124,6 +125,7 @@ func run(ctx context.Context) error { LeaderElection: false, LeaderElectionID: "porch-operators.config.porch.kpt.dev", LeaderElectionResourceLock: resourcelock.LeasesResourceLock, + MapperProvider: controllerrestmapper.New, } flag.Parse() diff --git a/porch/pkg/controllerrestmapper/caching.go b/porch/pkg/controllerrestmapper/caching.go new file mode 100644 index 0000000000..fda0546bc8 --- /dev/null +++ b/porch/pkg/controllerrestmapper/caching.go @@ -0,0 +1,115 @@ +package controllerrestmapper + +import ( + "fmt" + "strings" + "sync" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// cache is our cache of schema information. +type cache struct { + mutex sync.Mutex + groupVersions map[schema.GroupVersion]*cachedGroupVersion +} + +// newCache is the constructor for a cache. +func newCache() *cache { + return &cache{ + groupVersions: make(map[schema.GroupVersion]*cachedGroupVersion), + } +} + +// cachedGroupVersion caches (all) the resource information for a particular groupversion. +type cachedGroupVersion struct { + gv schema.GroupVersion + mutex sync.Mutex + kinds map[string]cachedGVR +} + +// cachedGVR caches the information for a particular resource. +type cachedGVR struct { + Resource string + Scope meta.RESTScope +} + +// findRESTMapping returns the RESTMapping for the specified GVK, querying discovery if not cached. +func (c *cache) findRESTMapping(discovery discovery.DiscoveryInterface, gv schema.GroupVersion, kind string) (*meta.RESTMapping, error) { + c.mutex.Lock() + cached := c.groupVersions[gv] + if cached == nil { + cached = &cachedGroupVersion{gv: gv} + c.groupVersions[gv] = cached + } + c.mutex.Unlock() + return cached.findRESTMapping(discovery, kind) +} + +// findRESTMapping returns the RESTMapping for the specified GVK, querying discovery if not cached. +func (c *cachedGroupVersion) findRESTMapping(discovery discovery.DiscoveryInterface, kind string) (*meta.RESTMapping, error) { + kinds, err := c.fetch(discovery) + if err != nil { + return nil, err + } + + cached, found := kinds[kind] + if !found { + return nil, nil + } + return &meta.RESTMapping{ + Resource: c.gv.WithResource(cached.Resource), + GroupVersionKind: c.gv.WithKind(kind), + Scope: cached.Scope, + }, nil +} + +// fetch returns the metadata, fetching it if not cached. +func (c *cachedGroupVersion) fetch(discovery discovery.DiscoveryInterface) (map[string]cachedGVR, error) { + log := log.Log + + c.mutex.Lock() + defer c.mutex.Unlock() + + if c.kinds != nil { + return c.kinds, nil + } + + log.Info("discovering server resources for group/version", "gv", c.gv.String()) + resourceList, err := discovery.ServerResourcesForGroupVersion(c.gv.String()) + if err != nil { + // We treat "no match" as an empty result, but any other error percolates back up + if meta.IsNoMatchError(err) || apierrors.IsNotFound(err) { + return nil, nil + } else { + klog.Infof("unexpected error from ServerResourcesForGroupVersion(%v): %w", c.gv, err) + return nil, fmt.Errorf("error from ServerResourcesForGroupVersion(%v): %w", c.gv, err) + } + } + + kinds := make(map[string]cachedGVR) + for i := range resourceList.APIResources { + resource := resourceList.APIResources[i] + + // if we have a slash, then this is a subresource and we shouldn't create mappings for those. + if strings.Contains(resource.Name, "/") { + continue + } + + scope := meta.RESTScopeRoot + if resource.Namespaced { + scope = meta.RESTScopeNamespace + } + kinds[resource.Kind] = cachedGVR{ + Resource: resource.Name, + Scope: scope, + } + } + c.kinds = kinds + return kinds, nil +} diff --git a/porch/pkg/controllerrestmapper/controllerrestmapper.go b/porch/pkg/controllerrestmapper/controllerrestmapper.go new file mode 100644 index 0000000000..d93cb1d12a --- /dev/null +++ b/porch/pkg/controllerrestmapper/controllerrestmapper.go @@ -0,0 +1,80 @@ +package controllerrestmapper + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" +) + +// New is the constructor for a ControllerRESTMapper +func New(cfg *rest.Config) (meta.RESTMapper, error) { + discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, err + } + + return &ControllerRESTMapper{ + uncached: discoveryClient, + cache: newCache(), + }, nil +} + +// ControllerRESTMapper is a meta.RESTMapper that is optimized for controllers. +// It caches results in memory, and minimizes discovery because we don't need shortnames etc in controllers. +// Controllers primarily need to map from GVK -> GVR. +type ControllerRESTMapper struct { + uncached discovery.DiscoveryInterface + cache *cache +} + +var _ meta.RESTMapper = &ControllerRESTMapper{} + +// KindFor takes a partial resource and returns the single match. Returns an error if there are multiple matches +func (m *ControllerRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) { + return schema.GroupVersionKind{}, fmt.Errorf("ControllerRESTMaper does not support KindFor operation") +} + +// KindsFor takes a partial resource and returns the list of potential kinds in priority order +func (m *ControllerRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) { + return nil, fmt.Errorf("ControllerRESTMaper does not support KindsFor operation") +} + +// ResourceFor takes a partial resource and returns the single match. Returns an error if there are multiple matches +func (m *ControllerRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, fmt.Errorf("ControllerRESTMaper does not support ResourceFor operation") +} + +// ResourcesFor takes a partial resource and returns the list of potential resource in priority order +func (m *ControllerRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) { + return nil, fmt.Errorf("ControllerRESTMaper does not support ResourcesFor operation") +} + +// RESTMapping identifies a preferred resource mapping for the provided group kind. +func (m *ControllerRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + for _, version := range versions { + gv := schema.GroupVersion{Group: gk.Group, Version: version} + mapping, err := m.cache.findRESTMapping(m.uncached, gv, gk.Kind) + if err != nil { + return nil, err + } + if mapping != nil { + return mapping, nil + } + } + + return nil, &meta.NoKindMatchError{GroupKind: gk, SearchedVersions: versions} +} + +// RESTMappings returns all resource mappings for the provided group kind if no +// version search is provided. Otherwise identifies a preferred resource mapping for +// the provided version(s). +func (m *ControllerRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) { + return nil, fmt.Errorf("ControllerRESTMaper does not support RESTMappings operation") +} + +func (m *ControllerRESTMapper) ResourceSingularizer(resource string) (singular string, err error) { + return "", fmt.Errorf("ControllerRESTMaper does not support ResourceSingularizer operation") +}