Files
weave-scope/probe/plugins/registry_internal_test.go

504 lines
12 KiB
Go

package plugins
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"path/filepath"
"sort"
"syscall"
"testing"
"time"
"github.com/paypal/ionet"
fs_hook "github.com/weaveworks/scope/common/fs"
"github.com/weaveworks/scope/common/xfer"
"github.com/weaveworks/scope/test"
"github.com/weaveworks/scope/test/fs"
"github.com/weaveworks/scope/test/reflect"
)
func stubTransport(fn func(socket string, timeout time.Duration) (http.RoundTripper, error)) {
transport = fn
}
func restoreTransport() { transport = makeUnixRoundTripper }
type readWriteCloseRoundTripper struct {
io.ReadWriteCloser
}
func (rwc readWriteCloseRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
conn := &closeableConn{
Conn: &ionet.Conn{R: rwc, W: rwc},
Closer: rwc,
}
client := httputil.NewClientConn(conn, nil)
defer client.Close()
return client.Do(req)
}
// closeableConn gives us an overrideable Close, where ionet.Conn does not.
type closeableConn struct {
net.Conn
io.Closer
}
func (c *closeableConn) Close() error {
c.Conn.Close()
return c.Closer.Close()
}
type mockPlugin struct {
t *testing.T
Name string
Handler http.Handler
}
func (p mockPlugin) dir() string {
return "/plugins"
}
func (p mockPlugin) path() string {
return filepath.Join(p.dir(), p.base())
}
func (p mockPlugin) base() string {
return p.Name + ".sock"
}
func (p mockPlugin) file() fs.File {
incomingR, incomingW := io.Pipe()
outgoingR, outgoingW := io.Pipe()
// TODO: This is a terrible hack of a little http server. Really, we should
// implement some sort of fs.File -> net.Listener bridge and run an net/http
// server on that.
go func() {
for {
conn := httputil.NewServerConn(&ionet.Conn{R: incomingR, W: outgoingW}, nil)
req, err := conn.Read()
if err == io.EOF {
outgoingW.Close()
return
} else if err != nil {
p.t.Fatal(err)
}
resp := httptest.NewRecorder()
p.Handler.ServeHTTP(resp, req)
fmt.Fprintf(outgoingW, "HTTP/1.1 %d %s\nContent-Length: %d\n\n%s", resp.Code, http.StatusText(resp.Code), resp.Body.Len(), resp.Body.String())
}
}()
return fs.File{
FName: p.base(),
FWriter: incomingW,
FReader: outgoingR,
FStat: syscall.Stat_t{Mode: syscall.S_IFSOCK},
}
}
type chanWriter chan []byte
func (w chanWriter) Write(p []byte) (int, error) {
w <- p
return len(p), nil
}
func (w chanWriter) Close() error {
close(w)
return nil
}
func setup(t *testing.T, sockets ...fs.Entry) fs.Entry {
mockFS := fs.Dir("", fs.Dir("plugins", sockets...))
fs_hook.Mock(
mockFS)
stubTransport(func(socket string, timeout time.Duration) (http.RoundTripper, error) {
f, err := mockFS.Open(socket)
return readWriteCloseRoundTripper{f}, err
})
return mockFS
}
func restore(t *testing.T) {
fs_hook.Restore()
restoreTransport()
}
type iterator func(func(*Plugin))
func checkLoadedPlugins(t *testing.T, forEach iterator, expected []xfer.PluginSpec) {
var plugins []xfer.PluginSpec
forEach(func(p *Plugin) {
plugins = append(plugins, p.PluginSpec)
})
sort.Sort(xfer.PluginSpecsByID(plugins))
if !reflect.DeepEqual(plugins, expected) {
t.Fatalf(test.Diff(expected, plugins))
}
}
func checkLoadedPluginIDs(t *testing.T, forEach iterator, expectedIDs []string) {
var pluginIDs []string
forEach(func(p *Plugin) {
pluginIDs = append(pluginIDs, p.ID)
})
sort.Strings(pluginIDs)
if len(pluginIDs) != len(expectedIDs) {
t.Fatalf("Expected plugins %q, got: %q", expectedIDs, pluginIDs)
}
for i, id := range pluginIDs {
if id != expectedIDs[i] {
t.Fatalf("Expected plugins %q, got: %q", expectedIDs, pluginIDs)
}
}
}
// stringHandler returns an http.Handler which just prints the given string
func stringHandler(status int, j string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/report" {
http.NotFound(w, r)
return
}
w.WriteHeader(status)
fmt.Fprint(w, j)
})
}
func TestRegistryLoadsExistingPlugins(t *testing.T) {
setup(
t,
mockPlugin{
t: t,
Name: "testPlugin",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
}.file(),
)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "1", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"testPlugin"})
}
func TestRegistryLoadsExistingPluginsEvenWhenOneFails(t *testing.T) {
setup(
t,
// TODO: This first one needs to fail
fs.Dir("fail",
mockPlugin{
t: t,
Name: "aFailure",
Handler: stringHandler(http.StatusInternalServerError, `Internal Server Error`),
}.file(),
),
mockPlugin{
t: t,
Name: "testPlugin",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
}.file(),
)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "1", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPlugins(t, r.ForEach, []xfer.PluginSpec{
{
ID: "aFailure",
Label: "aFailure",
Status: "error: plugin returned non-200 status code: 500 Internal Server Error",
},
{
ID: "testPlugin",
Label: "testPlugin",
Interfaces: []string{"reporter"},
APIVersion: "1",
Status: "ok",
},
})
}
func TestRegistryDiscoversNewPlugins(t *testing.T) {
mockFS := setup(t)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{})
// Add the new plugin
plugin := mockPlugin{
t: t,
Name: "testPlugin",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin","label":"testPlugin","interfaces":["reporter"]}]}`),
}
mockFS.Add(plugin.dir(), plugin.file())
if err := r.scan(); err != nil {
t.Fatal(err)
}
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"testPlugin"})
}
func TestRegistryRemovesPlugins(t *testing.T) {
plugin := mockPlugin{
t: t,
Name: "testPlugin",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin","label":"testPlugin","interfaces":["reporter"]}]}`),
}
mockFS := setup(t, plugin.file())
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"testPlugin"})
// Remove the plugin
mockFS.Remove(plugin.path())
if err := r.scan(); err != nil {
t.Fatal(err)
}
checkLoadedPluginIDs(t, r.ForEach, []string{})
}
func TestRegistryUpdatesPluginsWhenTheyChange(t *testing.T) {
resp := `{"Plugins":[{"id":"testPlugin","label":"testPlugin","interfaces":["reporter"]}]}`
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, resp)
})
plugin := mockPlugin{
t: t,
Name: "testPlugin",
Handler: handler,
}
setup(t, plugin.file())
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"testPlugin"})
// Update the plugin. Just change what the handler will respond with.
resp = `{"Plugins":[{"id":"updatedPlugin","label":"updatedPlugin","interfaces":["reporter"]}]}`
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"updatedPlugin"})
}
func TestRegistryReturnsPluginsByInterface(t *testing.T) {
setup(
t,
mockPlugin{
t: t,
Name: "plugin1",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"plugin1","label":"plugin1","interfaces":["reporter"]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "plugin2",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"plugin2","label":"plugin2","interfaces":["other"]}]}`),
}.file(),
)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPluginIDs(t, r.ForEach, []string{"plugin1", "plugin2"})
checkLoadedPluginIDs(t, func(fn func(*Plugin)) { r.Implementers("reporter", fn) }, []string{"plugin1"})
checkLoadedPluginIDs(t, func(fn func(*Plugin)) { r.Implementers("other", fn) }, []string{"plugin2"})
}
func TestRegistryHandlesConflictingPlugins(t *testing.T) {
setup(
t,
mockPlugin{
t: t,
Name: "plugin1",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"plugin1","label":"plugin1","interfaces":["reporter"]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "plugin1",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"plugin1","label":"plugin2","interfaces":["reporter","other"]}]}`),
}.file(),
)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
// Should just have the second one (we just log conflicts)
checkLoadedPluginIDs(t, r.ForEach, []string{"plugin1"})
checkLoadedPluginIDs(t, func(fn func(*Plugin)) { r.Implementers("other", fn) }, []string{"plugin1"})
}
func TestRegistryRejectsErroneousPluginResponses(t *testing.T) {
setup(
t,
mockPlugin{
t: t,
Name: "okPlugin",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"okPlugin","label":"okPlugin","interfaces":["reporter"]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "non200ResponseCode",
Handler: stringHandler(http.StatusInternalServerError, `{"Plugins":[{"id":"non200ResponseCode","label":"non200ResponseCode","interfaces":["reporter"]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "noLabel",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"noLabel","interfaces":["reporter"]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "noInterface",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"noInterface","label":"noInterface","interfaces":[]}]}`),
}.file(),
mockPlugin{
t: t,
Name: "wrongApiVersion",
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"wrongApiVersion","label":"wrongApiVersion","interfaces":["reporter"],"api_version":"foo"}]}`),
}.file(),
mockPlugin{
t: t,
Name: "nonJSONResponseBody",
Handler: stringHandler(http.StatusOK, `notJSON`),
}.file(),
)
defer restore(t)
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPlugins(t, r.ForEach, []xfer.PluginSpec{
{
ID: "noInterface",
Label: "noInterface",
Interfaces: []string{},
Status: `error: spec must implement the "reporter" interface`,
},
{
ID: "noLabel",
Interfaces: []string{"reporter"},
Status: `error: spec must contain a label`,
},
{
ID: "non200ResponseCode",
Label: "non200ResponseCode",
Status: "error: plugin returned non-200 status code: 500 Internal Server Error",
},
{
ID: "nonJSONResponseBody",
Label: "nonJSONResponseBody",
Status: "error: decoding error: [pos 4]: json: expecting ull: got otJ",
},
{
ID: "okPlugin",
Label: "okPlugin",
Interfaces: []string{"reporter"},
Status: `ok`,
},
{
ID: "wrongApiVersion",
Label: "wrongApiVersion",
Interfaces: []string{"reporter"},
APIVersion: "foo",
Status: `error: incorrect API version: expected "", got "foo"`,
},
})
}
func TestRegistryRejectsPluginResponsesWhichAreTooLarge(t *testing.T) {
description := ""
for i := 0; i < 129; i++ {
description += "a"
}
response := fmt.Sprintf(
`{
"Plugins": [
{
"id": "foo",
"label": "foo",
"description": %q,
"interfaces": ["reporter"]
}
]
}`,
description,
)
setup(t, mockPlugin{t: t, Name: "foo", Handler: stringHandler(http.StatusOK, response)}.file())
oldMaxResponseBytes := maxResponseBytes
maxResponseBytes = 128
defer func() {
maxResponseBytes = oldMaxResponseBytes
restore(t)
}()
root := "/plugins"
r, err := NewRegistry(root, "", nil)
if err != nil {
t.Fatal(err)
}
defer r.Close()
r.Report()
checkLoadedPlugins(t, r.ForEach, []xfer.PluginSpec{
{ID: "foo", Label: "foo", Status: `error: response must be shorter than 50MB`},
})
}