From 6c732168fc377d14c9e6f08447c0ec24acc6c281 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Tue, 1 Aug 2023 16:46:22 -0500 Subject: [PATCH] registry: implement a generic registry for plugins This change adds a generic registry mechanism that is typesafe for users and enforces consistent naming. Signed-off-by: Hank Donnay --- toolkit/registry/interfaces.go | 31 +++++ toolkit/registry/plugins.go | 213 +++++++++++++++++++++++++++++++ toolkit/registry/plugins_test.go | 62 +++++++++ 3 files changed, 306 insertions(+) create mode 100644 toolkit/registry/interfaces.go create mode 100644 toolkit/registry/plugins.go create mode 100644 toolkit/registry/plugins_test.go diff --git a/toolkit/registry/interfaces.go b/toolkit/registry/interfaces.go new file mode 100644 index 000000000..2216bb56e --- /dev/null +++ b/toolkit/registry/interfaces.go @@ -0,0 +1,31 @@ +package registry + +import ( + "context" + "net" + "net/http" +) + +// These are some common interfaces for plugins to optionally implement. +// +// Implementers can use blank assignments to check the interface is satisfied at +// compile time, and users can do a guarded type assertion to implement +// progressive enhancement. +type ( + // CanHTTP is implemented by plugins that expect access to an http client. + // + // The passed Client can be stored by the callee, but the Client should be + // assumed to be shared. That is, it is unsafe to modify the Client and may + // panic the program. + CanHTTP interface { + HTTPClient(context.Context, *http.Client) error + } + // CanDial is implemented by plugins that expect general network access. + // + // The passed Dialer can be stored by the callee, but the Dialer should be + // assumed to be shared. That is, it is unsafe to modify the Dialer and may + // panic the program. + CanDial interface { + NetDialer(context.Context, *net.Dialer) error + } +) diff --git a/toolkit/registry/plugins.go b/toolkit/registry/plugins.go new file mode 100644 index 000000000..06e132602 --- /dev/null +++ b/toolkit/registry/plugins.go @@ -0,0 +1,213 @@ +// Package registry is the central registry for all pluggable components in +// Claircore. +// +// Code referring to a pluggable component should use the name across API +// boundaries instead of passing instances of the objects. +package registry + +import ( + "context" + "fmt" + "path" + "reflect" + "sort" + "strings" + "sync" + + "github.com/quay/claircore/toolkit/urn" +) + +// Registry is the global registry. +var registry = struct { + sync.RWMutex + Lookup map[reflect.Type]interface{} +}{ + Lookup: make(map[reflect.Type]interface{}), +} + +// TypedReg is the per-type registry. +type typedReg[T any] struct { + sync.RWMutex + Lookup map[string]*Description[T] + AsPassed map[string]string +} + +// GetNames returns the names for which the passed function reports true. +func (r *typedReg[T]) getNames(f func(*Description[T]) bool) []string { + r.RLock() + defer r.RUnlock() + ret := make([]string, 0, len(r.Lookup)) + for n, d := range r.Lookup { + if f(d) { + ret = append(ret, r.AsPassed[n]) + } + } + sort.Strings(ret) + return ret +} + +// GetReg does the type assertion from the global registry to the per-type +// registry. The returned cleanup function should be called unconditionally. The +// returned *typedReg may be nil if the "create" argument is false. +func getReg[T any](create bool) (*typedReg[T], func()) { + var t T + key := reflect.TypeOf(t) + registry.RLock() + v, ok := registry.Lookup[key] + if !ok { + registry.RUnlock() + if !create { + return nil, func() {} + } + registry.Lock() + v2, ok := registry.Lookup[key] + if ok { + v = v2 + } else { + v = &typedReg[T]{ + Lookup: make(map[string]*Description[T]), + AsPassed: make(map[string]string), + } + registry.Lookup[key] = v + } + registry.Unlock() + registry.RLock() + } + reg := v.(*typedReg[T]) // Don't check the assertion, panic on purpose. + return reg, registry.RUnlock +} + +// Default reports the names of the plugins that are default-enabled for the +// given type parameter. +func Default[T any]() []string { + tr, unlock := getReg[T](false) + defer unlock() + if tr == nil { + return nil + } + return tr.getNames(func(d *Description[T]) bool { return d.Default }) +} + +// All reports the names of the plugins that are registered for the given type +// parameter. +func All[T any]() []string { + tr, unlock := getReg[T](false) + defer unlock() + if tr == nil { + return nil + } + return tr.getNames(func(_ *Description[T]) bool { return true }) +} + +// Description is a description of all the information and hooks to construct a +// plugin of type T. +type Description[T any] struct { + // JSON Schema to validate a configuration against. + // See https://json-schema.org/ for information on the format. + ConfigSchema string + // New is a constructor for the given type. + // + // The passed function will unmarshal a configuration into the provided + // value. JSON is the default format, unless a Capability flag indicates + // otherwise. + New func(context.Context, func(any) error) (T, error) + // Capabilities flags. + // + // Meanings are set per-type. + Capabilities uint + // Default signals that the plugin should be enabled by default. + Default bool +} + +// Register registers the provided description with the provided name in the +// type-specific registry indicated by the type parameter. +// +// Register may report errors if the name is already in use, or if the provided +// name is not valid. +func Register[T any](name string, desc Description[T]) error { + u, err := urn.Parse(name) + if err != nil { + return fmt.Errorf("registry: bad name: %w", err) + } + n, err := u.Name() + if err != nil { + return fmt.Errorf("registry: bad name: %w", err) + } + if err := checkname[T](&n); err != nil { + return fmt.Errorf("registry: bad name: %w", err) + } + key := u.Normalized() + + tr, unlock := getReg[T](true) + defer unlock() + tr.Lock() + defer tr.Unlock() + if _, exists := tr.Lookup[key]; exists { + return fmt.Errorf("registry: name already registered: %q", name) + } + tr.Lookup[key] = &desc + tr.AsPassed[key] = name + return nil +} + +// GetDescription returns Descriptions identified by the names in the registry +// indicated by the type parameter. +// +// An error will be reported if an unknown name is provided or if the type +// parameter has no names registered for it. +// +// The returned slice of Descriptions will have the same arity and order as the +// input names. That is, given `var names []string` and `var out []Description`, +// the i-th string is the i-th Description and vice-versa. +func GetDescription[T any](names ...string) ([]Description[T], error) { + keys := make([]string, len(names)) + for i, n := range names { + u, err := urn.Parse(n) + if err != nil { + return nil, fmt.Errorf("registry: bad name at parameter %d: %w", i, err) + } + n, err := u.Name() + if err != nil { + return nil, fmt.Errorf("registry: bad name at parameter %d: %w", i, err) + } + if err := checkname[T](&n); err != nil { + return nil, fmt.Errorf("registry: bad name at parameter %d: %w", i, err) + } + keys[i] = u.Normalized() + } + tr, unlock := getReg[T](false) + defer unlock() + if tr == nil { + var t T + return nil, fmt.Errorf("registry: unknown type: %T", t) + } + tr.RLock() + defer tr.RUnlock() + ret := make([]Description[T], 0, len(names)) + for _, k := range keys { + d, ok := tr.Lookup[k] + if !ok { + var t T + return nil, fmt.Errorf("registry: type %T: unknown name: %q", t, k) + } + ret = append(ret, *d) + } + return ret, nil +} + +// Checkname makes sure the passed name is congruent with the expected type. +func checkname[T any](n *urn.Name) error { + var t *T + typ := reflect.TypeOf(t).Elem() + tk := strings.ToLower(typ.Name()) + ts := strings.ToLower(path.Base(typ.PkgPath())) + switch { + case n.System != ts: + return fmt.Errorf("expected %q for system component, got %q", ts, n.System) + case n.Kind != tk: + return fmt.Errorf("expected %q for kind component, got %q", tk, n.Kind) + default: + // OK + } + return nil +} diff --git a/toolkit/registry/plugins_test.go b/toolkit/registry/plugins_test.go new file mode 100644 index 000000000..c7c222d44 --- /dev/null +++ b/toolkit/registry/plugins_test.go @@ -0,0 +1,62 @@ +package registry + +import ( + "context" + "fmt" +) + +type MyPlugin interface { + Example() +} + +func Example() { + // MyPlugin is an exported interface type. + desc := Description[MyPlugin]{ + ConfigSchema: `{}`, + New: func(_ context.Context, _ func(_ any) error) (MyPlugin, error) { + return nil, nil + }, + } + err := Register[MyPlugin](`urn:claircore:registry:myplugin:example`, desc) + if err != nil { + fmt.Println("error:", err) + } else { + fmt.Println("OK") + } + for _, n := range All[MyPlugin]() { + fmt.Println("all:", n) + } + for _, n := range Default[MyPlugin]() { + fmt.Println("default:", n) + } + // Output: + // OK + // all: urn:claircore:registry:myplugin:example +} + +func Example_failure() { + // MyPlugin is an exported interface type. + desc := Description[MyPlugin]{ + ConfigSchema: `{}`, + New: func(_ context.Context, _ func(_ any) error) (MyPlugin, error) { + return nil, nil + }, + } + var err error + err = Register[MyPlugin](`urn:claircore:wrongpackage:myplugin:example`, desc) + if err != nil { + fmt.Println("error:", err) + } else { + fmt.Println("OK") + } + err = Register[MyPlugin](`urn:claircore:registry:wrongname:example`, desc) + if err != nil { + fmt.Println("error:", err) + } else { + fmt.Println("OK") + } + + // Output: + // error: registry: bad name: expected "registry" for system component, got "wrongpackage" + // error: registry: bad name: expected "myplugin" for kind component, got "wrongname" +}