mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-19 05:19:35 +00:00
We will want to put plugin id in a control id, which is sent to an app and then to GUI. When we get a control request from GUI, we will want to extract the plugin ID from the control name. To do it unambiguously we need some separator made of chars that are not allowed in a plugin name. This is to avoid the situation when there are two plugins: "Plugin" and "PluginFoo". "Plugin" exposes a control named "FooControl" and "PluginFoo" exposes a control named "Control". Faking the control names which will be sent to the app would result in two "PluginFooControl". One possible option for plugin ID and control name separator would be "/", but that won't work, since the request sent from GUI to the app to <probe>/<node>/<control> would actually be <probe>/<node>/<plugin>/<control> and as such wouldn't match the URL template in RegisterControlRoutes().
573 lines
14 KiB
Go
573 lines
14 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":"testPlugin","label":"updatedPlugin","interfaces":["reporter"]}]}`
|
|
|
|
r.Report()
|
|
checkLoadedPlugins(t, r.ForEach, []xfer.PluginSpec{
|
|
{
|
|
ID: "testPlugin",
|
|
Label: "updatedPlugin",
|
|
Interfaces: []string{"reporter"},
|
|
Status: "ok",
|
|
},
|
|
})
|
|
}
|
|
|
|
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(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "changedId",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"differentId","label":"changedId","interfaces":["reporter"]}]}`),
|
|
}.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: "changedId",
|
|
Label: "changedId",
|
|
Status: `error: plugin must not change its id (is "differentId", should be "changedId")`,
|
|
},
|
|
{
|
|
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`},
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecksForValidPluginIDs(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(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "P-L-U-G-I-N",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"P-L-U-G-I-N","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
|
|
}.file(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "another-testPlugin",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"another-testPlugin","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
|
|
}.file(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "testPlugin!",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin!","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
|
|
}.file(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "test~plugin",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"test~plugin","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
|
|
}.file(),
|
|
mockPlugin{
|
|
t: t,
|
|
Name: "testPlugin-",
|
|
Handler: stringHandler(http.StatusOK, `{"Plugins":[{"id":"testPlugin-","label":"testPlugin","interfaces":["reporter"],"api_version":"1"}]}`),
|
|
}.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()
|
|
checkLoadedPluginIDs(t, r.ForEach, []string{"P-L-U-G-I-N", "another-testPlugin", "testPlugin"})
|
|
}
|