Repository: puppetlabs/wash
Pull request #574 (2019-11-07 21:31:31)

Implement the signal action
Signal for core plugins will be implemented separately. The website will also be updated as part of that work.

Supports #548.


Patch:
diff --git a/api/client/client.go b/api/client/client.go
index 58bf0ce..851c309 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -40,6 +40,7 @@ type Client interface {
 	Schema(path string) (*apitypes.EntrySchema, error)
 	Screenview(name string, params analytics.Params) error
 	Delete(path string) (bool, error)
+	Signal(path string, signal string) error
 }
 
 // A domainSocketClient is a wash API client.
@@ -304,3 +305,14 @@ func (c *domainSocketClient) Delete(path string) (bool, error) {
 	err := c.doRequestAndParseJSONBody(http.MethodDelete, "/fs/delete", url.Values{"path": []string{path}}, nil, &deleted)
 	return deleted, err
 }
+
+// Signal sends the given signal to tne entry at "path"
+func (c *domainSocketClient) Signal(path string, signal string) error {
+	payload := apitypes.SignalBody{Signal: signal}
+	jsonBody, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	_, err = c.doRequest(http.MethodPost, "/fs/signal", url.Values{"path": []string{path}}, bytes.NewReader(jsonBody))
+	return err
+}
diff --git a/api/server.go b/api/server.go
index 8e63278..38e701c 100644
--- a/api/server.go
+++ b/api/server.go
@@ -124,6 +124,7 @@ func StartAPI(
 	r.Handle("/fs/exec", execHandler).Methods(http.MethodPost)
 	r.Handle("/fs/schema", schemaHandler).Methods(http.MethodGet)
 	r.Handle("/fs/delete", deleteHandler).Methods(http.MethodDelete)
+	r.Handle("/fs/signal", signalHandler).Methods(http.MethodPost)
 	r.Handle("/cache", cacheHandler).Methods(http.MethodDelete)
 	r.Handle("/history", historyHandler).Methods(http.MethodGet)
 	r.Handle("/history/{index:[0-9]+}", historyEntryHandler).Methods(http.MethodGet)
diff --git a/api/signal.go b/api/signal.go
new file mode 100644
index 0000000..e0431a3
--- /dev/null
+++ b/api/signal.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/puppetlabs/wash/activity"
+	apitypes "github.com/puppetlabs/wash/api/types"
+	"github.com/puppetlabs/wash/plugin"
+)
+
+// swagger:route POST /fs/signal signal
+//
+// Sends a signal to the entry at the specified path.
+//
+//     Consumes:
+//     - application/json
+//
+//     Produces:
+//     - application/json
+//
+//     Schemes: http
+//
+//     Responses:
+//       200:
+//       400: errorResp
+//       404: errorResp
+//       500: errorResp
+var signalHandler handler = func(w http.ResponseWriter, r *http.Request) *errorResponse {
+	ctx := r.Context()
+	entry, path, errResp := getEntryFromRequest(r)
+	if errResp != nil {
+		return errResp
+	}
+
+	if !plugin.SignalAction().IsSupportedOn(entry) {
+		return unsupportedActionResponse(path, plugin.SignalAction())
+	}
+
+	if r.Body == nil {
+		return badActionRequestResponse(path, plugin.SignalAction(), "Please send a JSON request body")
+	}
+
+	var body apitypes.SignalBody
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+		return badActionRequestResponse(path, plugin.SignalAction(), err.Error())
+	}
+
+	if err := plugin.SignalWithAnalytics(ctx, entry.(plugin.Signalable), body.Signal); err != nil {
+		if plugin.IsInvalidInputErr(err) {
+			return badActionRequestResponse(path, plugin.SignalAction(), err.Error())
+		}
+		return erroredActionResponse(path, plugin.SignalAction(), err.Error())
+	}
+
+	activity.Record(ctx, "API: Signal %v %v", path, body.Signal)
+	return nil
+}
diff --git a/api/types/entrySchema.go b/api/types/entrySchema.go
index 8e3e65f..aab2b21 100644
--- a/api/types/entrySchema.go
+++ b/api/types/entrySchema.go
@@ -10,6 +10,9 @@ import (
 	"github.com/puppetlabs/wash/plugin"
 )
 
+// SignalSchema represents a signal's schema
+type SignalSchema = plugin.SignalSchema
+
 // EntrySchema describes an entry's schema, which is what's returned by
 // the /fs/schema endpoint.
 //
@@ -202,6 +205,18 @@ func (s *EntrySchema) SetDescription(description string) *EntrySchema {
 	return s
 }
 
+// Signals returns the entry's supported signals
+func (s *EntrySchema) Signals() []SignalSchema {
+	return s.EntrySchema.Signals
+}
+
+// SetSignals sets the entry's supported signals. This should only be called
+// by the tests.
+func (s *EntrySchema) SetSignals(signals []SignalSchema) *EntrySchema {
+	s.EntrySchema.Signals = signals
+	return s
+}
+
 // Singleton returns true if the entry's a singleton, false otherwise.
 func (s *EntrySchema) Singleton() bool {
 	return s.EntrySchema.Singleton
diff --git a/api/types/signal.go b/api/types/signal.go
new file mode 100644
index 0000000..c1674a9
--- /dev/null
+++ b/api/types/signal.go
@@ -0,0 +1,7 @@
+package apitypes
+
+// SignalBody encapsulates the payload for a call to a plugin's Signal function
+type SignalBody struct {
+	// Name of the signal that's to be sent
+	Signal string `json:"signal"`
+}
diff --git a/cmd/docs.go b/cmd/docs.go
index 0f47873..4c5dd7b 100644
--- a/cmd/docs.go
+++ b/cmd/docs.go
@@ -1,6 +1,9 @@
 package cmd
 
 import (
+	"strings"
+
+	apitypes "github.com/puppetlabs/wash/api/types"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
 	"github.com/spf13/cobra"
 )
@@ -31,11 +34,57 @@ func docsMain(cmd *cobra.Command, args []string) exitCode {
 		cmdutil.ErrPrintf("%v: schema unknown\n", path)
 		return exitCode{0}
 	}
+
+	// Print the description
 	description := schema.Description()
 	if len(description) > 0 {
-		cmdutil.Println(description)
+		cmdutil.Println(strings.Trim(description, "\n"))
 	} else {
 		cmdutil.Println("No description provided.")
 	}
+
+	// Print the supported signals/signal groups (if there are any). This part is
+	// printed as
+	//   SUPPORTED SIGNALS:
+	//     * <signal>
+	//         <desc>
+	//     * <signal>
+	//         <desc>
+	//
+	//   SUPPORTED SIGNAL GROUPS:
+	//     * <signal_group>
+	//         <desc>
+	//     * <signal_group>
+	//         <desc>
+	if len(schema.Signals()) > 0 {
+		var supportedSignals []apitypes.SignalSchema
+		var supportedSignalGroups []apitypes.SignalSchema
+		for _, signalSchema := range schema.Signals() {
+			if signalSchema.IsGroup() {
+				supportedSignalGroups = append(supportedSignalGroups, signalSchema)
+			} else {
+				supportedSignals = append(supportedSignals, signalSchema)
+			}
+		}
+		if len(supportedSignals) > 0 {
+			printSignalSet("SUPPORTED SIGNALS", supportedSignals)
+		}
+		if len(supportedSignalGroups) > 0 {
+			printSignalSet("SUPPORTED SIGNAL GROUPS", supportedSignalGroups)
+		}
+	}
+
 	return exitCode{0}
 }
+
+func printSignalSet(setName string, signals []apitypes.SignalSchema) {
+	cmdutil.Println()
+	cmdutil.Printf("%v\n", setName)
+	for _, signal := range signals {
+		cmdutil.Printf("* %v\n", signal.Name())
+		lines := strings.Split(strings.Trim(signal.Description(), "\n"), "\n")
+		for _, line := range lines {
+			cmdutil.Printf("    %v\n", line)
+		}
+	}
+}
diff --git a/cmd/root.go b/cmd/root.go
index 1ca341e..5baf9f5 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -164,6 +164,7 @@ then starts your system shell with shortcuts configured for wash subcommands.`,
 	addCommand(rootCmd, streeCommand())
 	addCommand(rootCmd, docsCommand())
 	addCommand(rootCmd, deleteCommand())
+	addCommand(rootCmd, signalCommand())
 
 	return rootCmd
 }
diff --git a/cmd/signal.go b/cmd/signal.go
new file mode 100644
index 0000000..08dbc82
--- /dev/null
+++ b/cmd/signal.go
@@ -0,0 +1,32 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	cmdutil "github.com/puppetlabs/wash/cmd/util"
+)
+
+func signalCommand() *cobra.Command {
+	signalCmd := &cobra.Command{
+		Use:   "signal <signal> <path>",
+		Short: "Sends the specified signal to the entry at the specified path",
+		Args:  cobra.MinimumNArgs(2),
+		RunE:  toRunE(signalMain),
+	}
+
+	return signalCmd
+}
+
+func signalMain(cmd *cobra.Command, args []string) exitCode {
+	signal := args[0]
+	path := args[1]
+
+	conn := cmdutil.NewClient()
+
+	err := conn.Signal(path, signal)
+	if err != nil {
+		cmdutil.ErrPrintf("%v\n", err)
+		return exitCode{1}
+	}
+	return exitCode{0}
+}
diff --git a/plugin/action.go b/plugin/action.go
index c19828b..d8d7c95 100644
--- a/plugin/action.go
+++ b/plugin/action.go
@@ -34,6 +34,7 @@ var readAction = newAction("read", "Readable")
 var streamAction = newAction("stream", "Streamable")
 var execAction = newAction("exec", "Execable")
 var deleteAction = newAction("delete", "Deletable")
+var signalAction = newAction("signal", "Signalable")
 
 // ListAction represents the list action
 func ListAction() Action {
@@ -60,6 +61,11 @@ func DeleteAction() Action {
 	return deleteAction
 }
 
+// SignalAction represents the signal action
+func SignalAction() Action {
+	return signalAction
+}
+
 // Actions returns all of the available Wash actions as a map
 // of <action_name> => <action_object>.
 func Actions() map[string]Action {
diff --git a/plugin/analyticsWrappers.go b/plugin/analyticsWrappers.go
index 5d2b112..63cfdcf 100644
--- a/plugin/analyticsWrappers.go
+++ b/plugin/analyticsWrappers.go
@@ -35,6 +35,13 @@ func ExecWithAnalytics(ctx context.Context, e Execable, cmd string, args []strin
 	return Exec(ctx, e, cmd, args, opts)
 }
 
+// SignalWithAnalytics is a wrapper to plugin.Signal. Use it when you need to report a
+// 'Signal' invocation to analytics. Otherwise, use plugin.Signal.
+func SignalWithAnalytics(ctx context.Context, s Signalable, signal string) error {
+	submitMethodInvocation(ctx, s, "Signal")
+	return Signal(ctx, s, signal)
+}
+
 // DeleteWithAnalytics is a wrapper to plugin.Delete. Use it when you need to report a
 // 'Delete' invocation to analytics. Otherwise, use plugin.Delete.
 func DeleteWithAnalytics(ctx context.Context, d Deletable) (bool, error) {
diff --git a/plugin/entrySchema.go b/plugin/entrySchema.go
index e1d77be..ad9b3de 100644
--- a/plugin/entrySchema.go
+++ b/plugin/entrySchema.go
@@ -66,13 +66,14 @@ func schema(e Entry) (*EntrySchema, error) {
 }
 
 type entrySchema struct {
-	Label               string      `json:"label"`
-	Description         string      `json:"description,omitempty"`
-	Singleton           bool        `json:"singleton"`
-	Actions             []string    `json:"actions"`
-	MetaAttributeSchema *JSONSchema `json:"meta_attribute_schema"`
-	MetadataSchema      *JSONSchema `json:"metadata_schema"`
-	Children            []string    `json:"children"`
+	Label               string         `json:"label"`
+	Description         string         `json:"description,omitempty"`
+	Singleton           bool           `json:"singleton"`
+	Signals             []SignalSchema `json:"signals,omitempty"`
+	Actions             []string       `json:"actions"`
+	MetaAttributeSchema *JSONSchema    `json:"meta_attribute_schema"`
+	MetadataSchema      *JSONSchema    `json:"metadata_schema"`
+	Children            []string       `json:"children"`
 }
 
 // EntrySchema represents an entry's schema. Use plugin.NewEntrySchema
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index 629685d..aa7e979 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -296,6 +296,8 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 	}
 	s := NewEntrySchema(e, "foo")
 	s.graph = graph
+	entrySchemaV, _ := s.graph.Get(TypeID(e))
+	s.entrySchema = entrySchemaV.(entrySchema)
 	return s, nil
 }
 
@@ -375,6 +377,11 @@ func (e *externalPluginEntry) Metadata(ctx context.Context) (JSONObject, error)
 	return metadata, nil
 }
 
+func (e *externalPluginEntry) Signal(ctx context.Context, signal string) error {
+	_, err := e.script.InvokeAndWait(ctx, "signal", e, signal)
+	return err
+}
+
 func (e *externalPluginEntry) Delete(ctx context.Context) (deleted bool, err error) {
 	inv, err := e.script.InvokeAndWait(ctx, "delete", e)
 	if err != nil {
@@ -611,10 +618,13 @@ func unmarshalSchemaGraph(e externalPlugin, stdout []byte) (*linkedhashmap.Map,
 			return fmt.Errorf("the entry's methods must be provided")
 		}
 		isParent := false
+		isSignalable := false
 		for _, method := range node.Methods {
-			if method == "list" {
+			switch method {
+			case "list":
 				isParent = true
-				break
+			case "signal":
+				isSignalable = true
 			}
 		}
 		if !isParent && len(node.Children) > 0 {
@@ -631,6 +641,12 @@ func unmarshalSchemaGraph(e externalPlugin, stdout []byte) (*linkedhashmap.Map,
 			}
 			node.Children = namespacedChildren
 		}
+		if !isSignalable && len(node.Signals) > 0 {
+			return fmt.Errorf("entry has included a list of supported signals even though it is not signalable. Signalable entries must implement signal")
+		}
+		if isSignalable && len(node.Signals) <= 0 {
+			return fmt.Errorf("signalable entries must include their supported signals")
+		}
 		if node.MetaAttributeSchema != nil && node.MetaAttributeSchema.Type.Type != "object" {
 			return fmt.Errorf("invalid value for the meta attribute schema: expected a JSON object schema but got %v", node.MetaAttributeSchema.Type.Type)
 		}
diff --git a/plugin/methodWrappers.go b/plugin/methodWrappers.go
index 079a17f..abc9f5f 100644
--- a/plugin/methodWrappers.go
+++ b/plugin/methodWrappers.go
@@ -8,6 +8,22 @@ import (
 	"time"
 )
 
+// InvalidInputErr indicates that the method invocation received invalid
+// input (e.g. plugin.Signal received an unsupported signal).
+type InvalidInputErr struct {
+	reason string
+}
+
+func (e InvalidInputErr) Error() string {
+	return e.reason
+}
+
+// IsInvalidInputErr returns true if err is an InvalidInputErr error object
+func IsInvalidInputErr(err error) bool {
+	_, ok := err.(InvalidInputErr)
+	return ok
+}
+
 // This file contains all of the plugin.<Method> wrappers. You should
 // invoke plugin.<Method> instead of e.<Method> because plugin.<Method>
 // could contain additional, plugin-agnostic code required to get e.<Method>
@@ -138,6 +154,63 @@ func Stream(ctx context.Context, s Streamable) (io.ReadCloser, error) {
 	return s.Stream(ctx)
 }
 
+// Signal signals the entry with the specified signal
+func Signal(ctx context.Context, s Signalable, signal string) error {
+	// Signals are case-insensitive
+	signal = strings.ToLower(signal)
+
+	// Validate the provided signal if the entry's schema is available
+	schema, err := Schema(s)
+	if err != nil {
+		return fmt.Errorf("failed to retrieve the entry's schema for signal validation: %w", err)
+	}
+	if schema != nil {
+		var validSignals []string
+		var validSignalGroups []string
+		var isValidSignal bool
+		for _, signalSchema := range schema.Signals {
+			if signalSchema.IsGroup() {
+				validSignalGroups = append(validSignalGroups, signalSchema.Name())
+				isValidSignal = signalSchema.Regex().MatchString(signal)
+			} else {
+				validSignals = append(validSignals, signalSchema.Name())
+				isValidSignal = signalSchema.Name() == signal
+			}
+			if isValidSignal {
+				break
+			}
+		}
+		if !isValidSignal {
+			errMsg := fmt.Sprintf("invalid signal %v", signal)
+			if len(validSignals) > 0 {
+				errMsg += fmt.Sprintf(". Valid signals are %v", strings.Join(validSignals, ", "))
+			}
+			if len(validSignalGroups) > 0 {
+				errMsg += fmt.Sprintf(". Valid signal groups are %v", strings.Join(validSignalGroups, ", "))
+			}
+			return InvalidInputErr{errMsg}
+		}
+	}
+
+	// Go ahead and send the signal
+	err = s.Signal(ctx, signal)
+	if err != nil {
+		return err
+	}
+
+	// The signal was successfully sent. Clear the entry's cache and its parent's
+	// cached list result to ensure that fresh data's loaded when needed
+	ClearCacheFor(s.id())
+	parentID, _ := splitID(s.id())
+	listOpName := defaultOpCodeToNameMap[ListOp]
+	entries, _ := cache.Get(listOpName, parentID)
+	if entries != nil {
+		cache.Delete(opKeyRegex(listOpName, parentID))
+	}
+
+	return nil
+}
+
 // Delete deletes the given entry.
 func Delete(ctx context.Context, d Deletable) (deleted bool, err error) {
 	deleted, err = d.Delete(ctx)
@@ -150,24 +223,34 @@ func Delete(ctx context.Context, d Deletable) (deleted bool, err error) {
 	//   * Clearing the entry and its children's cache.
 	//   * Updating the parent's cached list result.
 	ClearCacheFor(d.id())
-	segments := strings.Split(d.id(), "/")
-	parentID := strings.Join(segments[:len(segments)-1], "/")
+	parentID, cname := splitID(d.id())
 	listOpName := defaultOpCodeToNameMap[ListOp]
-	entries, _ := cache.Get(defaultOpCodeToNameMap[ListOp], parentID)
+	entries, _ := cache.Get(listOpName, parentID)
 	if entries == nil {
 		return
 	}
 	if deleted {
 		// The entry was deleted, so delete the entry from the parent's cached list
 		// result.
-		cname := segments[len(segments)-1]
 		entries.(*EntryMap).Delete(cname)
 	} else {
 		// The entry will eventually be deleted. However it's likely that Delete
 		// did update the entry (e.g. on VMs, Delete causes a state transition).
 		// Thus, we clear the parent's cached list result to ensure fresh data.
+		//
+		// Note that this is symmetric with Signal. This makes sense because for
+		// some entries, deleting that entry is equivalent to sending a termination
+		// signal (e.g. EC2 instances).
 		cache.Delete(opKeyRegex(listOpName, parentID))
 	}
 
 	return
 }
+
+// returns (parentID, cname)
+func splitID(entryID string) (string, string) {
+	segments := strings.Split(entryID, "/")
+	parentID := strings.Join(segments[:len(segments)-1], "/")
+	cname := segments[len(segments)-1]
+	return parentID, cname
+}
diff --git a/plugin/signalSchema.go b/plugin/signalSchema.go
new file mode 100644
index 0000000..d6dfdf4
--- /dev/null
+++ b/plugin/signalSchema.go
@@ -0,0 +1,89 @@
+package plugin
+
+import (
+	"encoding/json"
+	"regexp"
+	"strings"
+)
+
+// SignalSchema represents a given signal/signal group's schema
+type SignalSchema struct {
+	signalSchema
+	regex *regexp.Regexp
+}
+
+// Name returns the signal/signal group's name
+func (s *SignalSchema) Name() string {
+	return s.signalSchema.Name
+}
+
+// SetName sets the signal/signal group's name. This should only
+// be called by the tests.
+func (s *SignalSchema) SetName(name string) *SignalSchema {
+	s.signalSchema.Name = name
+	return s
+}
+
+// Description returns the signal/signal group's name
+func (s *SignalSchema) Description() string {
+	return s.signalSchema.Description
+}
+
+// SetDescription sets the signal/signal group's description. This should
+// only be called by the tests.
+func (s *SignalSchema) SetDescription(description string) *SignalSchema {
+	s.signalSchema.Description = description
+	return s
+}
+
+// Regex returns a regex describing an arbitrary signal in the signal
+// group.
+func (s *SignalSchema) Regex() *regexp.Regexp {
+	return s.regex
+}
+
+// SetRegex sets the signal group's regex. This should only be called by the
+// tests.
+func (s *SignalSchema) SetRegex(regex *regexp.Regexp) *SignalSchema {
+	s.regex = regex
+	s.signalSchema.Regex = regex.String()
+	return s
+}
+
+// IsGroup returns true if s is a signal group's schema. This is
+// true if s.Regex() != nil
+func (s *SignalSchema) IsGroup() bool {
+	return s.Regex() != nil
+}
+
+// MarshalJSON marshals the signal schema to JSON. It takes
+// a value receiver so that the entry schema's still marshalled
+// when it's referenced as an interface{} object. See
+// https://stackoverflow.com/a/21394657 for more details.
+func (s SignalSchema) MarshalJSON() ([]byte, error) {
+	return json.Marshal(s.signalSchema)
+}
+
+// UnmarshalJSON unmarshals the signal schema JSON.
+func (s *SignalSchema) UnmarshalJSON(bytes []byte) error {
+	err := json.Unmarshal(bytes, &s.signalSchema)
+	if err != nil {
+		return err
+	}
+	s.signalSchema.Name = strings.ToLower(s.signalSchema.Name)
+	if len(s.signalSchema.Regex) > 0 {
+		// (?i) tells Go that the regex is case-insensitive
+		s.signalSchema.Regex = "(?i)" + s.signalSchema.Regex
+		s.regex, err = regexp.Compile(s.signalSchema.Regex)
+	}
+	return err
+}
+
+// This is to implement JSON Marshal/Unmarshal. The main reason
+// for implementing the Marshaler/Unmarshaler interfaces is to
+// validate the marshalled Regex.
+type signalSchema struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Regex       string `json:"regex,omitempty"`
+}
diff --git a/plugin/types.go b/plugin/types.go
index 20708bf..ae9910c 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -208,3 +208,14 @@ type Deletable interface {
 	Entry
 	Delete(context.Context) (bool, error)
 }
+
+// Signalable is an entry that can be signaled. Signal should return nil if the
+// signal was successfully sent. Otherwise, it should return an error explaining
+// why the signal was not sent.
+//
+// NOTE: The sent signal can be arbitrary. See <SIGNAL TODO: Link docs> for
+// a list of common signals that can be sent.
+type Signalable interface {
+	Entry
+	Signal(context.Context, string) error
+}


Test patch:
diff --git a/cmd/internal/cmdtest/client.go b/cmd/internal/cmdtest/client.go
index 305209f..ad71561 100644
--- a/cmd/internal/cmdtest/client.go
+++ b/cmd/internal/cmdtest/client.go
@@ -79,3 +79,9 @@ func (c *MockClient) Delete(path string) (bool, error) {
 	args := c.Called(path)
 	return args.Get(0).(bool), args.Error(1)
 }
+
+// Signal mocks Client#Signal
+func (c *MockClient) Signal(path string, signal string) error {
+	args := c.Called(path, signal)
+	return args.Error(0)
+}
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index 2ebde12..ef82665 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -326,7 +326,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_PanicsIfNoSchem
 	)
 }
 
-func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchemaGraph() {
+func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchema() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
 		EntryBase: NewEntry("foo"),
@@ -350,6 +350,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchem
 	s, err := entry.schema()
 	if suite.NoError(err) {
 		suite.Equal(entry.schemaGraphs[TypeID(entry)], s.graph)
+		suite.Equal(s.entrySchema.Actions, []string{"schema"})
 		// Make sure that Wash did not shell out to the plugin script
 		mockScript.AssertNotCalled(suite.T(), "InvokeAndWait")
 	}
@@ -466,6 +467,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_SuccessfulIn
 			stdout = strings.ReplaceAll(stdout, "methods", "actions")
 			stdout = strings.ReplaceAll(stdout, "baz.", "fooPlugin::baz.")
 			suite.JSONEq(stdout, string(schemaJSON))
+			suite.Equal(schema.Actions, []string{"list"})
 		}
 	}
 }
@@ -689,6 +691,34 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	}
 }
 
+func (suite *ExternalPluginEntryTestSuite) TestSignal() {
+	mockScript := &mockExternalPluginScript{path: "plugin_script"}
+	entry := &externalPluginEntry{
+		EntryBase: NewEntry("foo"),
+		methods:   map[string]interface{}{"signal": nil},
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	mockInvokeAndWait := func(signal string, stdout []byte, err error) {
+		mockScript.OnInvokeAndWait(ctx, "signal", entry, signal).Return(mockInvocation(stdout), err).Once()
+	}
+
+	// Test that if InvokeAndWait errors, then Signal returns its error
+	mockErr := fmt.Errorf("execution error")
+	mockInvokeAndWait("start", []byte{}, mockErr)
+	err := entry.Signal(ctx, "start")
+	suite.EqualError(mockErr, err.Error())
+
+	// Test that Signal properly signals the entry
+	mockInvokeAndWait("start", []byte{}, nil)
+	err = entry.Signal(ctx, "start")
+	if suite.NoError(err) {
+		mockScript.AssertExpectations(suite.T())
+	}
+}
+
 func (suite *ExternalPluginEntryTestSuite) TestDelete() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
@@ -866,6 +896,46 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfPare
 	suite.Regexp("parent.*entries.*children", err)
 }
 
+func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotSignalableAndSignalsProvided() {
+	entry := &externalPluginEntry{
+		rawTypeID: "foo",
+	}
+	entry.SetTestID("fooPlugin")
+
+	stdout := []byte(`
+{
+	"foo":{
+		"label": "fooLabel",
+		"methods": [],
+		"signals": [
+		  {"name": "foo", "description": "bar"}
+		]
+	}
+}
+`)
+	_, err := unmarshalSchemaGraph(entry, stdout)
+	suite.Regexp("entry.*signals.*not.*signalable", err)
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSignalableAndSignalsNotProvided() {
+	entry := &externalPluginEntry{
+		rawTypeID: "foo",
+	}
+	entry.SetTestID("fooPlugin")
+
+	stdout := []byte(`
+{
+	"foo":{
+		"label": "fooLabel",
+		"methods": ["signal"],
+		"signals": []
+	}
+}
+`)
+	_, err := unmarshalSchemaGraph(entry, stdout)
+	suite.Regexp("signalable.*entries.*signal", err)
+}
+
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMissingChildSchema() {
 	entry := &externalPluginEntry{
 		rawTypeID: "foo",
diff --git a/plugin/methodWrappers_test.go b/plugin/methodWrappers_test.go
index 5574495..e7e1f92 100644
--- a/plugin/methodWrappers_test.go
+++ b/plugin/methodWrappers_test.go
@@ -3,6 +3,7 @@ package plugin
 import (
 	"context"
 	"fmt"
+	"regexp"
 	"testing"
 	"time"
 
@@ -57,7 +58,8 @@ type methodWrappersTestsMockEntry struct {
 }
 
 func (m *methodWrappersTestsMockEntry) Schema() *EntrySchema {
-	return nil
+	args := m.Called()
+	return args.Get(0).(*EntrySchema)
 }
 
 func (m *methodWrappersTestsMockEntry) List(ctx context.Context) ([]Entry, error) {
@@ -75,6 +77,11 @@ func (m *methodWrappersTestsMockEntry) Open(ctx context.Context) (SizedReader, e
 	return args.Get(0).(SizedReader), args.Error(1)
 }
 
+func (m *methodWrappersTestsMockEntry) Signal(ctx context.Context, signal string) error {
+	args := m.Called(ctx, signal)
+	return args.Error(0)
+}
+
 func newMethodWrappersTestsMockEntry(name string) *methodWrappersTestsMockEntry {
 	e := &methodWrappersTestsMockEntry{
 		EntryBase: NewEntry(name),
@@ -101,6 +108,76 @@ func (suite *MethodWrappersTestSuite) TestPrefetched() {
 	suite.True(IsPrefetched(e))
 }
 
+func (suite *MethodWrappersTestSuite) TestSignal_ReturnsSignalError() {
+	ctx := context.Background()
+	e := newMethodWrappersTestsMockEntry("foo")
+
+	expectedErr := fmt.Errorf("an error")
+
+	e.On("Schema").Return((*EntrySchema)(nil))
+	e.On("Signal", ctx, "start").Return(expectedErr)
+
+	err := Signal(ctx, e, "start")
+	suite.Equal(expectedErr, err)
+}
+
+func (suite *MethodWrappersTestSuite) TestSignal_SendsSignalAndUpdatesCache() {
+	ctx := context.Background()
+	e := newMethodWrappersTestsMockEntry("bar")
+	e.SetTestID("/foo/bar")
+
+	e.On("Schema").Return((*EntrySchema)(nil))
+	e.On("Signal", ctx, "start").Return(nil)
+
+	suite.cache.On("Delete", allOpKeysIncludingChildrenRegex(e.id())).Return([]string{})
+	suite.cache.On("Get", "List", "/foo").Return(newEntryMap(), nil)
+	suite.cache.On("Delete", opKeyRegex("List", "/foo")).Return([]string{})
+
+	// Also test case-insensitivity here
+	err := Signal(ctx, e, "START")
+	if suite.NoError(err) {
+		e.AssertExpectations(suite.T())
+		suite.cache.AssertExpectations(suite.T())
+	}
+}
+
+func (suite *MethodWrappersTestSuite) TestSignal_SchemaKnown_ReturnsInvalidInputErrForInvalidSignal() {
+	ctx := context.Background()
+	e := newMethodWrappersTestsMockEntry("foo")
+
+	schema := &EntrySchema{
+		entrySchema: entrySchema{
+			Signals: []SignalSchema{
+				SignalSchema{
+					signalSchema: signalSchema{
+						Name:        "start",
+						Description: "Starts the entry",
+					},
+				},
+				SignalSchema{
+					signalSchema: signalSchema{
+						Name:        "stop",
+						Description: "Stops the entry",
+					},
+				},
+				SignalSchema{
+					signalSchema: signalSchema{
+						Name:        "linux",
+						Description: "Supports one of the Linux signals",
+					},
+					regex: regexp.MustCompile(`\Asig.*`),
+				},
+			},
+		},
+	}
+	e.On("Schema").Return(schema)
+	e.On("Signal", ctx, "start").Return(nil)
+
+	err := Signal(ctx, e, "invalid_signal")
+	suite.True(IsInvalidInputErr(err))
+	suite.Regexp("invalid.*signal.*invalid_signal.*start.*stop.*linux", err)
+}
+
 func (suite *MethodWrappersTestSuite) TestDelete_ReturnsDeleteError() {
 	ctx := context.Background()
 	e := newMethodWrappersTestsMockEntry("foo")