plugin/discovery: helpers for wrangling plugin versions

With forthcoming support for versioned plugins we need to be able to
answer questions like what versions of plugins are currently installed,
what's the newest version of a given plugin available, etc.

PluginMetaSet gives us a building block for this sort of plugin version
wrangling.
This commit is contained in:
Martin Atkins 2017-04-11 10:56:04 -07:00
parent f69f7e623b
commit 39deb1ff6b
3 changed files with 524 additions and 0 deletions

28
plugin/discovery/meta.go Normal file
View File

@ -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)
}

View File

@ -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
}

View File

@ -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])
}
}