diff --git a/plugin/discovery/meta.go b/plugin/discovery/meta.go new file mode 100644 index 000000000..16c7e8712 --- /dev/null +++ b/plugin/discovery/meta.go @@ -0,0 +1,28 @@ +package discovery + +import ( + "github.com/blang/semver" +) + +// PluginMeta is metadata about a plugin, useful for launching the plugin +// and for understanding which plugins are available. +type PluginMeta struct { + // Name is the name of the plugin, e.g. as inferred from the plugin + // binary's filename, or by explicit configuration. + Name string + + // Version is the semver version of the plugin, expressed as a string + // that might not be semver-valid. (Call VersionObj to attempt to + // parse it and thus detect if it is invalid.) + Version string + + // Path is the absolute path of the executable that can be launched + // to provide the RPC server for this plugin. + Path string +} + +// VersionObj returns the semver version of the plugin as an object, or +// an error if the version string is not semver-syntax-compliant. +func (m PluginMeta) VersionObj() (semver.Version, error) { + return semver.Make(m.Version) +} diff --git a/plugin/discovery/meta_set.go b/plugin/discovery/meta_set.go new file mode 100644 index 000000000..72c0019b3 --- /dev/null +++ b/plugin/discovery/meta_set.go @@ -0,0 +1,151 @@ +package discovery + +import ( + "github.com/blang/semver" +) + +// A PluginMetaSet is a set of PluginMeta objects meeting a certain criteria. +// +// Methods on this type allow filtering of the set to produce subsets that +// meet more restrictive criteria. +type PluginMetaSet map[PluginMeta]struct{} + +// Add inserts the given PluginMeta into the receiving set. This is a no-op +// if the given meta is already present. +func (s PluginMetaSet) Add(p PluginMeta) { + s[p] = struct{}{} +} + +// Remove removes the given PluginMeta from the receiving set. This is a no-op +// if the given meta is not already present. +func (s PluginMetaSet) Remove(p PluginMeta) { + delete(s, p) +} + +// Has returns true if the given meta is in the receiving set, or false +// otherwise. +func (s PluginMetaSet) Has(p PluginMeta) bool { + _, ok := s[p] + return ok +} + +// Count returns the number of metas in the set +func (s PluginMetaSet) Count() int { + return len(s) +} + +// ValidateVersions returns two new PluginMetaSets, separating those with +// versions that have syntax-valid semver versions from those that don't. +// +// Eliminating invalid versions from consideration (and possibly warning about +// them) is usually the first step of working with a meta set after discovery +// has completed. +func (s PluginMetaSet) ValidateVersions() (valid, invalid PluginMetaSet) { + valid = make(PluginMetaSet) + invalid = make(PluginMetaSet) + for p := range s { + if _, err := p.VersionObj(); err == nil { + valid.Add(p) + } else { + invalid.Add(p) + } + } + return +} + +// WithName returns the subset of metas that have the given name. +func (s PluginMetaSet) WithName(name string) PluginMetaSet { + ns := make(PluginMetaSet) + for p := range s { + if p.Name == name { + ns.Add(p) + } + } + return ns +} + +// ByName groups the metas in the set by their Names, returning a map. +func (s PluginMetaSet) ByName() map[string]PluginMetaSet { + ret := make(map[string]PluginMetaSet) + for p := range s { + if _, ok := ret[p.Name]; !ok { + ret[p.Name] = make(PluginMetaSet) + } + ret[p.Name].Add(p) + } + return ret +} + +// Newest returns the one item from the set that has the newest Version value. +// +// The result is meaningful only if the set is already filtered such that +// all of the metas have the same Name. +// +// If there isn't at least one meta in the set then this function will panic. +// Use Count() to ensure that there is at least one value before calling. +// +// If any of the metas have invalid version strings then this function will +// panic. Use ValidateVersions() first to filter out metas with invalid +// versions. +// +// If two metas have the same Version then one is arbitrarily chosen. This +// situation should be avoided by pre-filtering the set. +func (s PluginMetaSet) Newest() PluginMeta { + if len(s) == 0 { + panic("can't call NewestStable on empty PluginMetaSet") + } + + var first = true + var winner PluginMeta + var winnerVersion semver.Version + for p := range s { + version, err := p.VersionObj() + if err != nil { + panic(err) + } + + if first == true || version.GT(winnerVersion) { + winner = p + winnerVersion = version + first = false + } + } + + return winner +} + +// ConstrainVersions takes a map of version constraints by name and attempts to +// return a map from name to a set of metas that have the matching +// name and an appropriate version. +// +// If any of the given constraints match *no* plugins then its PluginMetaSet +// in the returned map will be nil. +// +// All viable metas are returned, so the caller can apply any desired filtering +// to reduce down to a single option. For example, calling Newest() to obtain +// the highest available version. +// +// If any of the metas in the set have invalid version strings then this +// function will panic. Use ValidateVersions() first to filter out metas with +// invalid versions. +func (s PluginMetaSet) ConstrainVersions(reqd map[string]semver.Range) map[string]PluginMetaSet { + ret := make(map[string]PluginMetaSet) + for p := range s { + name := p.Name + constraint, ok := reqd[name] + if !ok { + continue + } + if _, ok := ret[p.Name]; !ok { + ret[p.Name] = make(PluginMetaSet) + } + version, err := p.VersionObj() + if err != nil { + panic(err) + } + if constraint(version) { + ret[p.Name].Add(p) + } + } + return ret +} diff --git a/plugin/discovery/meta_set_test.go b/plugin/discovery/meta_set_test.go new file mode 100644 index 000000000..15fc7bae5 --- /dev/null +++ b/plugin/discovery/meta_set_test.go @@ -0,0 +1,345 @@ +package discovery + +import ( + "fmt" + "strings" + "testing" + + "github.com/blang/semver" +) + +func TestPluginMetaSetManipulation(t *testing.T) { + metas := []PluginMeta{ + { + Name: "foo", + Version: "1.0.0", + Path: "test-foo", + }, + { + Name: "bar", + Version: "2.0.0", + Path: "test-bar", + }, + { + Name: "baz", + Version: "2.0.0", + Path: "test-bar", + }, + } + s := make(PluginMetaSet) + + if count := s.Count(); count != 0 { + t.Fatalf("set has Count %d before any items added", count) + } + + // Can we add metas? + for _, p := range metas { + s.Add(p) + if !s.Has(p) { + t.Fatalf("%q not in set after adding it", p.Name) + } + } + + if got, want := s.Count(), len(metas); got != want { + t.Fatalf("set has Count %d after all items added; want %d", got, want) + } + + // Can we still retrieve earlier ones after we added later ones? + for _, p := range metas { + if !s.Has(p) { + t.Fatalf("%q not in set after all adds", p.Name) + } + } + + // Can we remove metas? + for _, p := range metas { + s.Remove(p) + if s.Has(p) { + t.Fatalf("%q still in set after removing it", p.Name) + } + } + + if count := s.Count(); count != 0 { + t.Fatalf("set has Count %d after all items removed", count) + } +} + +func TestPluginMetaSetValidateVersions(t *testing.T) { + metas := []PluginMeta{ + { + Name: "foo", + Version: "1.0.0", + Path: "test-foo", + }, + { + Name: "bar", + Version: "0.0.1", + Path: "test-bar", + }, + { + Name: "baz", + Version: "bananas", + Path: "test-bar", + }, + } + s := make(PluginMetaSet) + + for _, p := range metas { + s.Add(p) + } + + valid, invalid := s.ValidateVersions() + if count := valid.Count(); count != 2 { + t.Errorf("valid set has %d metas; want 2", count) + } + if count := invalid.Count(); count != 1 { + t.Errorf("valid set has %d metas; want 1", count) + } + + if !valid.Has(metas[0]) { + t.Errorf("'foo' not in valid set") + } + if !valid.Has(metas[1]) { + t.Errorf("'bar' not in valid set") + } + if !invalid.Has(metas[2]) { + t.Errorf("'baz' not in invalid set") + } + + if invalid.Has(metas[0]) { + t.Errorf("'foo' in invalid set") + } + if invalid.Has(metas[1]) { + t.Errorf("'bar' in invalid set") + } + if valid.Has(metas[2]) { + t.Errorf("'baz' in valid set") + } + +} + +func TestPluginMetaSetWithName(t *testing.T) { + tests := []struct { + metas []PluginMeta + name string + wantCount int + }{ + { + []PluginMeta{}, + "foo", + 0, + }, + { + []PluginMeta{ + { + Name: "foo", + Version: "0.0.1", + Path: "foo", + }, + }, + "foo", + 1, + }, + { + []PluginMeta{ + { + Name: "foo", + Version: "0.0.1", + Path: "foo", + }, + }, + "bar", + 0, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("Test%02d", i), func(t *testing.T) { + s := make(PluginMetaSet) + for _, p := range test.metas { + s.Add(p) + } + filtered := s.WithName(test.name) + if gotCount := filtered.Count(); gotCount != test.wantCount { + t.Errorf("got count %d in %#v; want %d", gotCount, filtered, test.wantCount) + } + }) + } +} + +func TestPluginMetaSetByName(t *testing.T) { + metas := []PluginMeta{ + { + Name: "foo", + Version: "1.0.0", + Path: "test-foo", + }, + { + Name: "foo", + Version: "2.0.0", + Path: "test-foo-2", + }, + { + Name: "bar", + Version: "0.0.1", + Path: "test-bar", + }, + { + Name: "baz", + Version: "1.2.0", + Path: "test-bar", + }, + } + s := make(PluginMetaSet) + + for _, p := range metas { + s.Add(p) + } + + byName := s.ByName() + if got, want := len(byName), 3; got != want { + t.Errorf("%d keys in ByName map; want %d", got, want) + } + if got, want := len(byName["foo"]), 2; got != want { + t.Errorf("%d metas for 'foo'; want %d", got, want) + } + if got, want := len(byName["bar"]), 1; got != want { + t.Errorf("%d metas for 'bar'; want %d", got, want) + } + if got, want := len(byName["baz"]), 1; got != want { + t.Errorf("%d metas for 'baz'; want %d", got, want) + } + + if !byName["foo"].Has(metas[0]) { + t.Errorf("%#v missing from 'foo' set", metas[0]) + } + if !byName["foo"].Has(metas[1]) { + t.Errorf("%#v missing from 'foo' set", metas[1]) + } + if !byName["bar"].Has(metas[2]) { + t.Errorf("%#v missing from 'bar' set", metas[2]) + } + if !byName["baz"].Has(metas[3]) { + t.Errorf("%#v missing from 'baz' set", metas[3]) + } +} + +func TestPluginMetaSetNewest(t *testing.T) { + tests := []struct { + versions []string + want string + }{ + { + []string{ + "0.0.1", + }, + "0.0.1", + }, + { + []string{ + "0.0.1", + "0.0.2", + }, + "0.0.2", + }, + { + []string{ + "1.0.0", + "1.0.0-beta1", + }, + "1.0.0", + }, + { + []string{ + "0.0.1", + "1.0.0", + }, + "1.0.0", + }, + } + + for _, test := range tests { + t.Run(strings.Join(test.versions, "|"), func(t *testing.T) { + s := make(PluginMetaSet) + for _, version := range test.versions { + s.Add(PluginMeta{ + Name: "foo", + Version: version, + Path: "foo-V" + version, + }) + } + + newest := s.Newest() + if newest.Version != test.want { + t.Errorf("version is %q; want %q", newest.Version, test.want) + } + }) + } +} + +func TestPluginMetaSetConstrainVersions(t *testing.T) { + metas := []PluginMeta{ + { + Name: "foo", + Version: "1.0.0", + Path: "test-foo", + }, + { + Name: "foo", + Version: "2.0.0", + Path: "test-foo-2", + }, + { + Name: "foo", + Version: "3.0.0", + Path: "test-foo-2", + }, + { + Name: "bar", + Version: "0.0.5", + Path: "test-bar", + }, + { + Name: "baz", + Version: "0.0.1", + Path: "test-bar", + }, + } + s := make(PluginMetaSet) + + for _, p := range metas { + s.Add(p) + } + + byName := s.ConstrainVersions(map[string]semver.Range{ + "foo": semver.MustParseRange(">=2.0.0"), + "bar": semver.MustParseRange(">=0.0.0"), + "baz": semver.MustParseRange(">=1.0.0"), + "fun": semver.MustParseRange(">5.0.0"), + }) + if got, want := len(byName), 3; got != want { + t.Errorf("%d keys in map; want %d", got, want) + } + + if got, want := len(byName["foo"]), 2; got != want { + t.Errorf("%d metas for 'foo'; want %d", got, want) + } + if got, want := len(byName["bar"]), 1; got != want { + t.Errorf("%d metas for 'bar'; want %d", got, want) + } + if got, want := len(byName["baz"]), 0; got != want { + t.Errorf("%d metas for 'baz'; want %d", got, want) + } + // "fun" is not in the map at all, because we have no metas for that name + + if !byName["foo"].Has(metas[1]) { + t.Errorf("%#v missing from 'foo' set", metas[1]) + } + if !byName["foo"].Has(metas[2]) { + t.Errorf("%#v missing from 'foo' set", metas[2]) + } + if !byName["bar"].Has(metas[3]) { + t.Errorf("%#v missing from 'bar' set", metas[3]) + } + +}