Repository: puppetlabs/wash
Pull request #691 (2020-01-16 01:36:05)

Move the external plugin implementation to its own package
Separates the external plugin implementation into a separate package from `plugin` so that it can make use of other packages in Wash that themselves depend on `plugin` (like `volume` and `transport`).

The separation is possible by making `MethodSignature` a public type, and adding a hidden `externalPlugin` interface for "dynamic" plugin functionality ( where an entry's features may not be known at compile-time).

Signed-off-by: Michael Smith <michael.smith@puppet.com>

MikaelSmith: I'd like to re-use transport in particular because SSH libraries in different languages can be quite different to setup, and that's a substantial burden to get right. Also anything we can pull into core Wash will perform better. I think the interface I have in mind will be pretty easy to work with.
MikaelSmith: Ok, I think this is ready again.

Patch:
diff --git a/cmd/server.go b/cmd/server.go
index 28c6003..d83e0dd 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -15,6 +15,7 @@ import (
 	"github.com/puppetlabs/wash/cmd/internal/server"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/plugin/external"
 	"gopkg.in/yaml.v2"
 
 	log "github.com/sirupsen/logrus"
@@ -147,7 +148,7 @@ func serverOptsFor(cmd *cobra.Command) (map[string]plugin.Root, server.Opts, err
 
 	// Check the external plugins. First unmarshal their spec, ensure that
 	// they're valid scripts, then convert them to plugin.Root types.
-	var externalPlugins []plugin.ExternalPluginSpec
+	var externalPlugins []external.PluginSpec
 	if err := viper.UnmarshalKey("external-plugins", &externalPlugins); err != nil {
 		return nil, server.Opts{}, fmt.Errorf("failed to unmarshal the external-plugins key: %v", err)
 	}
diff --git a/cmd/validate.go b/cmd/validate.go
index 8df769e..5c89631 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -15,6 +15,7 @@ import (
 	"github.com/jedib0t/go-pretty/progress"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/plugin/external"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -77,7 +78,7 @@ func validateMain(cmd *cobra.Command, args []string) exitCode {
 	root, ok := plugins[plug]
 	if !ok {
 		// See if it's a script we can run as an external plugin instead
-		spec := plugin.ExternalPluginSpec{Script: plug}
+		spec := external.PluginSpec{Script: plug}
 		root, err = spec.Load()
 		if err != nil {
 			pluginNames := make([]string, 0, len(plugins))
diff --git a/plugin/action.go b/plugin/action.go
index a0c86d5..203df3d 100644
--- a/plugin/action.go
+++ b/plugin/action.go
@@ -1,23 +1,25 @@
 package plugin
 
-type methodSignature int
+// MethodSignature defines what method signature is supported for a method.
+type MethodSignature int
 
+// Defines different method signatures.
 const (
-	unsupportedSignature methodSignature = iota
-	defaultSignature
-	blockReadableSignature
+	UnsupportedSignature MethodSignature = iota
+	DefaultSignature
+	BlockReadableSignature
 )
 
 // Action represents a Wash action.
 type Action struct {
 	Name                         string `json:"name"`
 	Protocol                     string `json:"protocol"`
-	corePluginEntrySignatureFunc func(e Entry) methodSignature
+	corePluginEntrySignatureFunc func(e Entry) MethodSignature
 }
 
 var actions = make(map[string]Action)
 
-func newAction(name string, protocol string, corePluginEntrySignatureFunc func(e Entry) methodSignature) Action {
+func newAction(name string, protocol string, corePluginEntrySignatureFunc func(e Entry) MethodSignature) Action {
 	a := Action{
 		Name:                         name,
 		Protocol:                     protocol,
@@ -30,68 +32,68 @@ func newAction(name string, protocol string, corePluginEntrySignatureFunc func(e
 // IsSupportedOn returns true if the action's supported
 // on the specified entry, false otherwise.
 func (a Action) IsSupportedOn(entry Entry) bool {
-	return a.signature(entry) != unsupportedSignature
+	return a.signature(entry) != UnsupportedSignature
 }
 
-func (a Action) signature(entry Entry) methodSignature {
+func (a Action) signature(entry Entry) MethodSignature {
 	switch t := entry.(type) {
 	case externalPlugin:
-		return t.supportedMethods()[a.Name].signature
+		return t.MethodSignature(a.Name)
 	default:
 		return a.corePluginEntrySignatureFunc(entry)
 	}
 }
 
-var listAction = newAction("list", "Parent", func(e Entry) methodSignature {
+var listAction = newAction("list", "Parent", func(e Entry) MethodSignature {
 	if _, ok := e.(Parent); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var readAction = newAction("read", "Readable", func(e Entry) methodSignature {
+var readAction = newAction("read", "Readable", func(e Entry) MethodSignature {
 	if _, ok := e.(Readable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
 	if _, ok := e.(BlockReadable); ok {
-		return blockReadableSignature
+		return BlockReadableSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var streamAction = newAction("stream", "Streamable", func(e Entry) methodSignature {
+var streamAction = newAction("stream", "Streamable", func(e Entry) MethodSignature {
 	if _, ok := e.(Streamable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var writeAction = newAction("write", "Writable", func(e Entry) methodSignature {
+var writeAction = newAction("write", "Writable", func(e Entry) MethodSignature {
 	if _, ok := e.(Writable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var execAction = newAction("exec", "Execable", func(e Entry) methodSignature {
+var execAction = newAction("exec", "Execable", func(e Entry) MethodSignature {
 	if _, ok := e.(Execable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var deleteAction = newAction("delete", "Deletable", func(e Entry) methodSignature {
+var deleteAction = newAction("delete", "Deletable", func(e Entry) MethodSignature {
 	if _, ok := e.(Deletable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
-var signalAction = newAction("signal", "Signalable", func(e Entry) methodSignature {
+var signalAction = newAction("signal", "Signalable", func(e Entry) MethodSignature {
 	if _, ok := e.(Signalable); ok {
-		return defaultSignature
+		return DefaultSignature
 	}
-	return unsupportedSignature
+	return UnsupportedSignature
 })
 
 // ListAction represents the list action
@@ -153,15 +155,15 @@ func SupportedActionsOf(entry Entry) []string {
 	switch t := entry.(type) {
 	case externalPlugin:
 		for _, action := range actions {
-			signature := t.supportedMethods()[action.Name].signature
-			if signature != unsupportedSignature {
+			signature := t.MethodSignature(action.Name)
+			if signature != UnsupportedSignature {
 				supportedActions = append(supportedActions, action.Name)
 			}
 		}
 	default:
 		for _, action := range actions {
 			signature := action.corePluginEntrySignatureFunc(entry)
-			if signature != unsupportedSignature {
+			if signature != UnsupportedSignature {
 				supportedActions = append(supportedActions, action.Name)
 			}
 		}
diff --git a/plugin/cache.go b/plugin/cache.go
index d7a3f12..b5f58f3 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -229,7 +229,7 @@ func cachedList(ctx context.Context, p Parent) (*EntryMap, error) {
 func cachedRead(ctx context.Context, e Entry) (entryContent, error) {
 	cachedContent, err := cachedDefaultOp(ctx, ReadOp, e, func() (interface{}, error) {
 		switch signature := ReadAction().signature(e); signature {
-		case defaultSignature:
+		case DefaultSignature:
 			// Both external and core plugin entries that have the default Read signature
 			// implement the Readable interface, so we can go ahead and cast directly.
 			r := e.(Readable)
@@ -238,12 +238,12 @@ func cachedRead(ctx context.Context, e Entry) (entryContent, error) {
 				return nil, err
 			}
 			return newEntryContent(rawContent), nil
-		case blockReadableSignature:
+		case BlockReadableSignature:
 			var readFunc blockReadFunc
 			switch t := e.(type) {
 			case externalPlugin:
 				readFunc = func(ctx context.Context, size int64, offset int64) ([]byte, error) {
-					return t.blockRead(ctx, size, offset)
+					return t.BlockRead(ctx, size, offset)
 				}
 			case BlockReadable:
 				readFunc = func(ctx context.Context, size int64, offset int64) ([]byte, error) {
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 419611c..c822656 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -99,6 +99,12 @@ func (e *EntryBase) Name() string {
 	return e.name
 }
 
+// ID returns the entry's ID. It won't panic on an empty string. See ID() for more detail.
+// This exists primarily to support the `external` package.
+func (e *EntryBase) ID() string {
+	return e.id
+}
+
 // String returns a unique identifier for the entry suitable for logging and error messages.
 func (e *EntryBase) String() string {
 	return e.id
@@ -140,6 +146,11 @@ func (e *EntryBase) MarkInaccessible(ctx context.Context, err error) {
 	e.isInaccessible = true
 }
 
+// IsInaccessible returns whether the entry is inaccessible.
+func (e *EntryBase) IsInaccessible() bool {
+	return e.isInaccessible
+}
+
 // Prefetched marks the entry as a prefetched entry. A prefetched entry
 // is an entry that was fetched as part of a batch operation that
 // fetched multiple levels of hierarchy at once. Volume directories and
@@ -170,6 +181,11 @@ func (e *EntryBase) SetTTLOf(op defaultOpCode, ttl time.Duration) *EntryBase {
 	return e
 }
 
+// TTLOf returns the TTL set for the specified op
+func (e *EntryBase) TTLOf(op defaultOpCode) time.Duration {
+	return e.ttl[op]
+}
+
 // DisableCachingFor disables caching for the specified op
 func (e *EntryBase) DisableCachingFor(op defaultOpCode) *EntryBase {
 	e.SetTTLOf(op, -1)
diff --git a/plugin/entrySchema.go b/plugin/entrySchema.go
index 78c6705..7183dfe 100644
--- a/plugin/entrySchema.go
+++ b/plugin/entrySchema.go
@@ -1,6 +1,7 @@
 package plugin
 
 import (
+	"encoding/json"
 	"fmt"
 	"reflect"
 	"strings"
@@ -26,7 +27,16 @@ func TypeID(e Entry) string {
 func schema(e Entry) (*EntrySchema, error) {
 	switch t := e.(type) {
 	case externalPlugin:
-		return t.schema()
+		graph, err := t.SchemaGraph()
+		if err != nil {
+			return nil, err
+		}
+		s := NewEntrySchema(e, "foo")
+		s.graph = graph
+		entrySchemaV, _ := s.graph.Get(TypeID(e))
+		// Nodes in the graph can only set properties on entrySchema, so only copy that.
+		s.entrySchema = entrySchemaV.(EntrySchema).entrySchema
+		return s, nil
 	case *Registry:
 		// The plugin registry includes external plugins, whose schema call can
 		// error. Thus, it needs to be treated differently from the other core
@@ -123,6 +133,13 @@ func NewEntrySchema(e Entry, label string) *EntrySchema {
 // Note that UnmarshalJSON is not implemented since that is not
 // how plugin.EntrySchema objects are meant to be used.
 func (s EntrySchema) MarshalJSON() ([]byte, error) {
+	if s.entry == nil {
+		// Nodes in the external plugin graph don't use NewEntrySchema, they directly set the
+		// undocumented fields of EntrySchema. Since graph and entry won't be set - and this is
+		// part of a graph already - directly serialize entrySchema instead of using the graph.
+		return json.Marshal(s.entrySchema)
+	}
+
 	graph := s.graph
 	if graph == nil {
 		if _, ok := s.entry.(externalPlugin); ok {
diff --git a/plugin/internal/command.go b/plugin/external/command.go
similarity index 95%
rename from plugin/internal/command.go
rename to plugin/external/command.go
index 54b524b..18c28c2 100644
--- a/plugin/internal/command.go
+++ b/plugin/external/command.go
@@ -1,7 +1,4 @@
-// Package internal contains utility classes and helpers that are used
-// by the plugin package. Its purpose is to modularize the plugin package's
-// code without exporting its implementation.
-package internal
+package external
 
 import (
 	"context"
diff --git a/plugin/externalPluginEntry.go b/plugin/external/pluginEntry.go
similarity index 82%
rename from plugin/externalPluginEntry.go
rename to plugin/external/pluginEntry.go
index 0eef203..d58c57e 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/external/pluginEntry.go
@@ -1,4 +1,4 @@
-package plugin
+package external
 
 import (
 	"bytes"
@@ -16,30 +16,9 @@ import (
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
 	"github.com/puppetlabs/wash/activity"
-	"github.com/puppetlabs/wash/plugin/internal"
+	"github.com/puppetlabs/wash/plugin"
 )
 
-type externalPlugin interface {
-	Entry
-	supportedMethods() map[string]methodInfo
-	// Entry#Schema's type-signature only makes sense for core plugins
-	// since core plugin schemas do not require any error-prone API
-	// calls. External plugin schemas can be prefetched (no error)
-	// or obtained by shelling out (error-prone). Since the latter
-	// operation is error prone, the type-signature of external plugin
-	// schemas will include an error object. Since this is something
-	// specific to external plugins, it makes sense to include the
-	// error-prone version of schema here, in the externalPlugin
-	// interface.
-	schema() (*EntrySchema, error)
-	RawTypeID() string
-	// Go doesn't allow overloaded functions, so the external plugin entry type
-	// cannot implement both BlockReadable#Read and Readable#Read. Thus, external
-	// plugins implement the BlockReadable interface via a separate blockRead
-	// method.
-	blockRead(ctx context.Context, size int64, offset int64) ([]byte, error)
-}
-
 type decodedCacheTTLs struct {
 	List     time.Duration `json:"list"`
 	Read     time.Duration `json:"read"`
@@ -48,15 +27,15 @@ type decodedCacheTTLs struct {
 
 // decodedExternalPluginEntry describes a decoded serialized entry.
 type decodedExternalPluginEntry struct {
-	TypeID             string           `json:"type_id"`
-	Name               string           `json:"name"`
-	Methods            []interface{}    `json:"methods"`
-	SlashReplacer      string           `json:"slash_replacer"`
-	CacheTTLs          decodedCacheTTLs `json:"cache_ttls"`
-	InaccessibleReason string           `json:"inaccessible_reason"`
-	Attributes         EntryAttributes  `json:"attributes"`
-	PartialMetadata    JSONObject       `json:"partial_metadata"`
-	State              string           `json:"state"`
+	TypeID             string                 `json:"type_id"`
+	Name               string                 `json:"name"`
+	Methods            []interface{}          `json:"methods"`
+	SlashReplacer      string                 `json:"slash_replacer"`
+	CacheTTLs          decodedCacheTTLs       `json:"cache_ttls"`
+	InaccessibleReason string                 `json:"inaccessible_reason"`
+	Attributes         plugin.EntryAttributes `json:"attributes"`
+	PartialMetadata    plugin.JSONObject      `json:"partial_metadata"`
+	State              string                 `json:"state"`
 }
 
 const entryMethodTypeError = "each method must be a string or tuple [<method>, <result_or_signature>], not %v"
@@ -67,7 +46,7 @@ func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
 		switch data := val.(type) {
 		case string:
 			methods[data] = methodInfo{
-				signature: defaultSignature,
+				signature: plugin.DefaultSignature,
 			}
 		case []interface{}:
 			if len(data) != 2 {
@@ -78,7 +57,7 @@ func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
 				return nil, fmt.Errorf(entryMethodTypeError, data)
 			}
 			info := methodInfo{
-				signature: defaultSignature,
+				signature: plugin.DefaultSignature,
 			}
 			switch name {
 			default:
@@ -88,7 +67,7 @@ func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
 				// The latter implies <block_readable> == false.
 				if blockReadable, ok := data[1].(bool); ok {
 					if blockReadable {
-						info.signature = blockReadableSignature
+						info.signature = plugin.BlockReadableSignature
 					}
 				} else {
 					info.result = data[1]
@@ -102,7 +81,7 @@ func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
 	return methods, nil
 }
 
-func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, schemaKnown, isRoot bool) (*externalPluginEntry, error) {
+func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, schemaKnown, isRoot bool) (*pluginEntry, error) {
 	if len(e.Name) <= 0 {
 		return nil, fmt.Errorf("the entry name must be provided")
 	}
@@ -150,8 +129,8 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 		e.Attributes.SetSize(uint64(len(content)))
 	}
 
-	entry := &externalPluginEntry{
-		EntryBase:   NewEntry(e.Name),
+	entry := &pluginEntry{
+		EntryBase:   plugin.NewEntry(e.Name),
 		methods:     methods,
 		state:       e.State,
 		schemaKnown: schemaKnown,
@@ -181,14 +160,14 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 }
 
 type methodInfo struct {
-	signature methodSignature
+	signature plugin.MethodSignature
 	result    interface{}
 }
 
-// externalPluginEntry represents an external plugin entry
-type externalPluginEntry struct {
-	EntryBase
-	script    externalPluginScript
+// pluginEntry represents an external plugin entry
+type pluginEntry struct {
+	plugin.EntryBase
+	script    pluginScript
 	methods   map[string]methodInfo
 	state     string
 	rawTypeID string
@@ -201,40 +180,47 @@ type externalPluginEntry struct {
 	schemaGraphs map[string]*linkedhashmap.Map
 }
 
-func (e *externalPluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
+func (e *pluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
 	if ttls.List != 0 {
-		e.SetTTLOf(ListOp, ttls.List*time.Second)
+		e.SetTTLOf(plugin.ListOp, ttls.List*time.Second)
 	}
 	if ttls.Read != 0 {
-		e.SetTTLOf(ReadOp, ttls.Read*time.Second)
+		e.SetTTLOf(plugin.ReadOp, ttls.Read*time.Second)
 	}
 	if ttls.Metadata != 0 {
-		e.SetTTLOf(MetadataOp, ttls.Metadata*time.Second)
+		e.SetTTLOf(plugin.MetadataOp, ttls.Metadata*time.Second)
 	}
 }
 
 // implements returns true if the entry implements the given method,
 // false otherwise
-func (e *externalPluginEntry) implements(method string) bool {
+func (e *pluginEntry) implements(method string) bool {
 	_, ok := e.methods[method]
 	return ok
 }
 
-func (e *externalPluginEntry) supportedMethods() map[string]methodInfo {
+func (e *pluginEntry) supportedMethods() map[string]methodInfo {
 	return e.methods
 }
 
-func (e *externalPluginEntry) ChildSchemas() []*EntrySchema {
+func (e *pluginEntry) MethodSignature(name string) plugin.MethodSignature {
+	if info, ok := e.methods[name]; ok {
+		return info.signature
+	}
+	return plugin.UnsupportedSignature
+}
+
+func (e *pluginEntry) ChildSchemas() []*plugin.EntrySchema {
 	// ChildSchema's meant for core plugins.
-	return []*EntrySchema{}
+	return []*plugin.EntrySchema{}
 }
 
-func (e *externalPluginEntry) Schema() *EntrySchema {
+func (e *pluginEntry) Schema() *plugin.EntrySchema {
 	// This version of Schema's only meant for core plugins.
 	return nil
 }
 
-func (e *externalPluginEntry) RawTypeID() string {
+func (e *pluginEntry) RawTypeID() string {
 	return e.rawTypeID
 }
 
@@ -251,17 +237,38 @@ const schemaFormat = `{
 }
 `
 
-func (e *externalPluginEntry) schema() (*EntrySchema, error) {
+func rawTypeID(e *pluginEntry) string {
+	rawTypeID := e.RawTypeID()
+	if rawTypeID == "" {
+		rawTypeID = "unknown"
+	}
+	return rawTypeID
+}
+
+func pluginName(e *pluginEntry) string {
+	trimmedID := strings.Trim(e.ID(), "/")
+	if trimmedID == "" {
+		trimmedID = plugin.CName(e)
+	}
+	segments := strings.SplitN(trimmedID, "/", 2)
+	return segments[0]
+}
+
+func namespace(pluginName string, rawTypeID string) string {
+	return pluginName + "::" + rawTypeID
+}
+
+func (e *pluginEntry) SchemaGraph() (*linkedhashmap.Map, error) {
 	if !e.implements("schema") {
 		return nil, nil
 	}
 	var graph *linkedhashmap.Map
 	if e.schemaGraphs != nil {
-		g, ok := e.schemaGraphs[TypeID(e)]
+		g, ok := e.schemaGraphs[plugin.TypeID(e)]
 		if !ok {
 			msg := fmt.Errorf(
 				"e.Schema(): entry schemas were prefetched, but no schema graph was provided for %v (%v)",
-				ID(e),
+				plugin.ID(e),
 				rawTypeID(e),
 			)
 			panic(msg)
@@ -270,8 +277,8 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 		// As a sanity check, ensure that the methods specified in the entry's schema
 		// match the methods specified in the entry instance. Return an error if there
 		// is a mismatch. Hopefully this should never happen.
-		es, _ := graph.Get(TypeID(e))
-		schemaMethods := es.(entrySchema).Actions
+		es, _ := graph.Get(plugin.TypeID(e))
+		schemaMethods := es.(plugin.EntrySchema).Actions
 		instanceMethods := []string{}
 		for method := range e.supportedMethods() {
 			instanceMethods = append(instanceMethods, method)
@@ -280,7 +287,7 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 		sort.Strings(instanceMethods)
 		mismatchErr := fmt.Errorf(
 			"%v (%v): the schema's supported methods (%v) don't match the instance's supported methods (%v)",
-			ID(e),
+			plugin.ID(e),
 			rawTypeID(e),
 			strings.Join(schemaMethods, ", "),
 			strings.Join(instanceMethods, ", "),
@@ -306,7 +313,7 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 		if err != nil {
 			err := fmt.Errorf(
 				"%v (%v): failed to retrieve the entry's schema: %v",
-				ID(e),
+				plugin.ID(e),
 				rawTypeID(e),
 				err,
 			)
@@ -316,7 +323,7 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 		if err != nil {
 			err := fmt.Errorf(
 				"%v (%v): could not decode schema from stdout: %v\nreceived:\n%v\nexpected something like:\n%v",
-				ID(e),
+				plugin.ID(e),
 				rawTypeID(e),
 				err,
 				strings.TrimSpace(inv.Stdout().String()),
@@ -325,16 +332,12 @@ func (e *externalPluginEntry) schema() (*EntrySchema, error) {
 			return nil, err
 		}
 	}
-	s := NewEntrySchema(e, "foo")
-	s.graph = graph
-	entrySchemaV, _ := s.graph.Get(TypeID(e))
-	s.entrySchema = entrySchemaV.(entrySchema)
-	return s, nil
+	return graph, nil
 }
 
 const listFormat = "[{\"name\":\"entry1\",\"methods\":[\"list\"]},{\"name\":\"entry2\",\"methods\":[\"list\"]}]"
 
-func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
+func (e *pluginEntry) List(ctx context.Context) ([]plugin.Entry, error) {
 	var decodedEntries []decodedExternalPluginEntry
 	if impl := e.methods["list"].result; impl != nil {
 		// Entry statically implements list. Construct new entries based on that rather than invoking the script.
@@ -356,7 +359,7 @@ func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 		}
 	}
 
-	entries := make([]Entry, len(decodedEntries))
+	entries := make([]plugin.Entry, len(decodedEntries))
 	for i, decodedExternalPluginEntry := range decodedEntries {
 		entry, err := decodedExternalPluginEntry.toExternalPluginEntry(ctx, e.schemaKnown, false)
 		if err != nil {
@@ -370,7 +373,7 @@ func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 	return entries, nil
 }
 
-func (e *externalPluginEntry) Read(ctx context.Context) ([]byte, error) {
+func (e *pluginEntry) Read(ctx context.Context) ([]byte, error) {
 	if impl := e.methods["read"].result; impl != nil {
 		if content, ok := impl.(string); ok {
 			return []byte(content), nil
@@ -385,7 +388,7 @@ func (e *externalPluginEntry) Read(ctx context.Context) ([]byte, error) {
 	return inv.Stdout().Bytes(), nil
 }
 
-func (e *externalPluginEntry) blockRead(ctx context.Context, size int64, offset int64) ([]byte, error) {
+func (e *pluginEntry) BlockRead(ctx context.Context, size int64, offset int64) ([]byte, error) {
 	inv, err := e.script.InvokeAndWait(ctx, "read", e, strconv.FormatInt(size, 10), strconv.FormatInt(offset, 10))
 	if err != nil {
 		return nil, err
@@ -393,13 +396,13 @@ func (e *externalPluginEntry) blockRead(ctx context.Context, size int64, offset
 	return inv.Stdout().Bytes(), nil
 }
 
-func (e *externalPluginEntry) Write(ctx context.Context, p []byte) error {
+func (e *pluginEntry) Write(ctx context.Context, p []byte) error {
 	inv := e.script.NewInvocation(ctx, "write", e)
 	inv.SetStdin(bytes.NewReader(p))
 	return inv.RunAndWait(ctx)
 }
 
-func (e *externalPluginEntry) Metadata(ctx context.Context) (JSONObject, error) {
+func (e *pluginEntry) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	if !e.implements("metadata") {
 		// The entry does not override the "Metadata" method so invoke
 		// the default
@@ -409,7 +412,7 @@ func (e *externalPluginEntry) Metadata(ctx context.Context) (JSONObject, error)
 	if err != nil {
 		return nil, err
 	}
-	var metadata JSONObject
+	var metadata plugin.JSONObject
 	if err := json.Unmarshal(inv.Stdout().Bytes(), &metadata); err != nil {
 		return nil, newStdoutDecodeErr(
 			ctx,
@@ -422,12 +425,12 @@ func (e *externalPluginEntry) Metadata(ctx context.Context) (JSONObject, error)
 	return metadata, nil
 }
 
-func (e *externalPluginEntry) Signal(ctx context.Context, signal string) error {
+func (e *pluginEntry) 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) {
+func (e *pluginEntry) Delete(ctx context.Context) (deleted bool, err error) {
 	inv, err := e.script.InvokeAndWait(ctx, "delete", e)
 	if err != nil {
 		return
@@ -445,7 +448,7 @@ func (e *externalPluginEntry) Delete(ctx context.Context) (deleted bool, err err
 	return
 }
 
-func (e *externalPluginEntry) Stream(ctx context.Context) (io.ReadCloser, error) {
+func (e *pluginEntry) Stream(ctx context.Context) (io.ReadCloser, error) {
 	inv := e.script.NewInvocation(ctx, "stream", e)
 	stdoutR, err := inv.StdoutPipe()
 	if err != nil {
@@ -531,10 +534,10 @@ func (e *externalPluginEntry) Stream(ctx context.Context) (io.ReadCloser, error)
 	}
 }
 
-func (e *externalPluginEntry) Exec(ctx context.Context, cmd string, args []string, opts ExecOptions) (ExecCommand, error) {
+func (e *pluginEntry) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecCommand, error) {
 	// Serialize opts to JSON
 	type serializedOptions struct {
-		ExecOptions
+		plugin.ExecOptions
 		Stdin bool `json:"stdin"`
 	}
 	serializedOpts := serializedOptions{
@@ -548,7 +551,7 @@ func (e *externalPluginEntry) Exec(ctx context.Context, cmd string, args []strin
 
 	// Start the command.
 	inv := e.script.NewInvocation(ctx, "exec", e, append([]string{string(optsJSON), cmd}, args...)...)
-	execCmd := NewExecCommand(ctx)
+	execCmd := plugin.NewExecCommand(ctx)
 	inv.SetStdout(execCmd.Stdout())
 	inv.SetStderr(execCmd.Stderr())
 	if opts.Stdin != nil {
@@ -562,7 +565,7 @@ func (e *externalPluginEntry) Exec(ctx context.Context, cmd string, args []strin
 	if err := inv.Start(); err != nil {
 		return nil, err
 	}
-	// internal.Command handles context-cancellation cleanup
+	// Command handles context-cancellation cleanup
 	// for us, so we don't have to use execCmd.SetStopFunc.
 
 	// Asynchronously wait for the command to finish
@@ -580,7 +583,7 @@ func (e *externalPluginEntry) Exec(ctx context.Context, cmd string, args []strin
 }
 
 type stdoutStreamer struct {
-	cmd    internal.Command
+	cmd    Command
 	stdout io.ReadCloser
 }
 
@@ -604,7 +607,7 @@ func newStdoutDecodeErr(ctx context.Context, decodedThing string, reason error,
 	return newInvokeError(fmt.Sprintf("could not decode %v from stdout: %v", decodedThing, reason), inv)
 }
 
-func unmarshalSchemaGraph(e externalPlugin, stdout []byte) (*linkedhashmap.Map, error) {
+func unmarshalSchemaGraph(e *pluginEntry, stdout []byte) (*linkedhashmap.Map, error) {
 	pluginName, rawTypeID := pluginName(e), rawTypeID(e)
 
 	// Since we know e's type ID, it is OK if the serialized schema's keys are
@@ -635,7 +638,7 @@ func unmarshalSchemaGraph(e externalPlugin, stdout []byte) (*linkedhashmap.Map,
 		rawTypeID: true,
 	}
 	type decodedEntrySchema struct {
-		entrySchema
+		plugin.EntrySchema
 		Methods []string `json:"methods"`
 	}
 	graph := linkedhashmap.New()
@@ -701,7 +704,7 @@ func unmarshalSchemaGraph(e externalPlugin, stdout []byte) (*linkedhashmap.Map,
 		// We don't put node itself in because doing so would marshal its "Methods"
 		// field.
 		node.Actions = node.Methods
-		graph.Put(typeID, node.entrySchema)
+		graph.Put(typeID, node.EntrySchema)
 		return nil
 	}
 	if err := putNode(rawTypeID, rawGraph[rawTypeID]); err != nil {
diff --git a/plugin/externalPluginRoot.go b/plugin/external/pluginRoot.go
similarity index 76%
rename from plugin/externalPluginRoot.go
rename to plugin/external/pluginRoot.go
index d63dd76..5434440 100644
--- a/plugin/externalPluginRoot.go
+++ b/plugin/external/pluginRoot.go
@@ -1,4 +1,4 @@
-package plugin
+package external
 
 import (
 	"context"
@@ -8,15 +8,16 @@ import (
 	"time"
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
+	"github.com/puppetlabs/wash/plugin"
 )
 
-// externalPluginRoot represents an external plugin's root.
-type externalPluginRoot struct {
-	*externalPluginEntry
+// pluginRoot represents an external plugin's root.
+type pluginRoot struct {
+	pluginEntry
 }
 
 // Init initializes the external plugin root
-func (r *externalPluginRoot) Init(cfg map[string]interface{}) error {
+func (r *pluginRoot) Init(cfg map[string]interface{}) error {
 	if cfg == nil {
 		cfg = make(map[string]interface{})
 	}
@@ -63,12 +64,12 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 	if err != nil {
 		return err
 	}
-	if !ListAction().IsSupportedOn(entry) {
+	if !plugin.ListAction().IsSupportedOn(entry) {
 		panic(fmt.Sprintf("plugin root for %s must implement 'list'", r.script.Path()))
 	}
 	script := r.script
-	r.externalPluginEntry = entry
-	r.externalPluginEntry.script = script
+	r.pluginEntry = *entry
+	r.pluginEntry.script = script
 
 	// Fill in the schema graph if provided
 	if rawSchema := r.methods["schema"].result; rawSchema != nil {
@@ -76,7 +77,7 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 		if err != nil {
 			panic(fmt.Sprintf("Error remarshaling previously unmarshaled data: %v", err))
 		}
-		graph, err := unmarshalSchemaGraph(r, marshalledSchema)
+		graph, err := unmarshalSchemaGraph(&r.pluginEntry, marshalledSchema)
 		if err != nil {
 			return fmt.Errorf(
 				"could not decode schema from stdout: %v\nreceived:\n%v\nexpected something like:\n%v",
@@ -91,16 +92,16 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 	return nil
 }
 
-func (r *externalPluginRoot) WrappedTypes() SchemaMap {
+func (r *pluginRoot) WrappedTypes() plugin.SchemaMap {
 	// This only makes sense for core plugins because it is a Go-specific
 	// limitation.
 	return nil
 }
 
 // partitionSchemaGraph partitions graph into a map of <type_id> => <schema_graph>
-func (r *externalPluginRoot) partitionSchemaGraph(graph *linkedhashmap.Map) map[string]*linkedhashmap.Map {
-	var populate func(*linkedhashmap.Map, string, entrySchema, map[string]bool)
-	populate = func(g *linkedhashmap.Map, typeID string, node entrySchema, visited map[string]bool) {
+func (r *pluginRoot) partitionSchemaGraph(graph *linkedhashmap.Map) map[string]*linkedhashmap.Map {
+	var populate func(*linkedhashmap.Map, string, plugin.EntrySchema, map[string]bool)
+	populate = func(g *linkedhashmap.Map, typeID string, node plugin.EntrySchema, visited map[string]bool) {
 		if visited[typeID] {
 			return
 		}
@@ -112,14 +113,14 @@ func (r *externalPluginRoot) partitionSchemaGraph(graph *linkedhashmap.Map) map[
 				msg := fmt.Sprintf("plugin.partitionSchemaGraph: expected child %v to be present in the graph", childTypeID)
 				panic(msg)
 			}
-			populate(g, childTypeID, childNode.(entrySchema), visited)
+			populate(g, childTypeID, childNode.(plugin.EntrySchema), visited)
 		}
 	}
 
 	schemaGraphs := make(map[string]*linkedhashmap.Map)
 	graph.Each(func(key interface{}, value interface{}) {
 		g := linkedhashmap.New()
-		populate(g, key.(string), value.(entrySchema), make(map[string]bool))
+		populate(g, key.(string), value.(plugin.EntrySchema), make(map[string]bool))
 		schemaGraphs[key.(string)] = g
 	})
 
diff --git a/plugin/externalPluginScript.go b/plugin/external/pluginScript.go
similarity index 75%
rename from plugin/externalPluginScript.go
rename to plugin/external/pluginScript.go
index a1dea37..217b6ad 100644
--- a/plugin/externalPluginScript.go
+++ b/plugin/external/pluginScript.go
@@ -1,4 +1,4 @@
-package plugin
+package external
 
 import (
 	"bytes"
@@ -8,19 +8,19 @@ import (
 	"strings"
 
 	"github.com/puppetlabs/wash/activity"
-	"github.com/puppetlabs/wash/plugin/internal"
+	"github.com/puppetlabs/wash/plugin"
 )
 
-// externalPluginScript represents an external plugin's script
-type externalPluginScript interface {
+// pluginScript represents an external plugin's script
+type pluginScript interface {
 	Path() string
-	InvokeAndWait(ctx context.Context, method string, entry *externalPluginEntry, args ...string) (invocation, error)
-	NewInvocation(ctx context.Context, method string, entry *externalPluginEntry, args ...string) invocation
+	InvokeAndWait(ctx context.Context, method string, entry *pluginEntry, args ...string) (invocation, error)
+	NewInvocation(ctx context.Context, method string, entry *pluginEntry, args ...string) invocation
 }
 
-// An internal.Command object that stores output in separate stdout and stderr buffers.
+// A Command object that stores output in separate stdout and stderr buffers.
 type invocation interface {
-	internal.Command
+	Command
 	// RunAndWait should run the command, ensuring stdout and stderr are buffered, and return
 	// any errors or non-zero exit codes that result from running the command.
 	RunAndWait(context.Context) error
@@ -29,7 +29,7 @@ type invocation interface {
 }
 
 type invocationImpl struct {
-	internal.Command
+	Command
 	stdout, stderr bytes.Buffer
 }
 
@@ -89,7 +89,7 @@ func (s externalPluginScriptImpl) Path() string {
 func (s externalPluginScriptImpl) InvokeAndWait(
 	ctx context.Context,
 	method string,
-	entry *externalPluginEntry,
+	entry *pluginEntry,
 	args ...string,
 ) (invocation, error) {
 	inv := s.NewInvocation(ctx, method, entry, args...)
@@ -100,19 +100,19 @@ func (s externalPluginScriptImpl) InvokeAndWait(
 func (s externalPluginScriptImpl) NewInvocation(
 	ctx context.Context,
 	method string,
-	entry *externalPluginEntry,
+	entry *pluginEntry,
 	args ...string,
 ) invocation {
 	if method == "init" {
-		return &invocationImpl{Command: internal.NewCommand(ctx, s.Path(), append([]string{"init"}, args...)...)}
+		return &invocationImpl{Command: NewCommand(ctx, s.Path(), append([]string{"init"}, args...)...)}
 	}
 	if entry == nil {
 		msg := fmt.Sprintf("s.NewInvocation called with method '%v' and entry == nil", method)
 		panic(msg)
 	}
-	return &invocationImpl{Command: internal.NewCommand(
+	return &invocationImpl{Command: NewCommand(
 		ctx,
 		s.Path(),
-		append([]string{method, entry.eb().id, entry.state}, args...)...,
+		append([]string{method, plugin.ID(entry), entry.state}, args...)...,
 	)}
 }
diff --git a/plugin/externalPluginSpec.go b/plugin/external/pluginSpec.go
similarity index 70%
rename from plugin/externalPluginSpec.go
rename to plugin/external/pluginSpec.go
index df3efb7..456748b 100644
--- a/plugin/externalPluginSpec.go
+++ b/plugin/external/pluginSpec.go
@@ -1,25 +1,27 @@
-package plugin
+package external
 
 import (
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
+
+	"github.com/puppetlabs/wash/plugin"
 )
 
-// ExternalPluginSpec represents an external plugin's specification.
-type ExternalPluginSpec struct {
+// PluginSpec represents an external plugin's specification.
+type PluginSpec struct {
 	Script string
 }
 
 // Name returns the plugin name, which is the basename of the script with extension removed.
-func (s ExternalPluginSpec) Name() string {
+func (s PluginSpec) Name() string {
 	basename := filepath.Base(s.Script)
 	return strings.TrimSuffix(basename, filepath.Ext(basename))
 }
 
 // Load ensures the external plugin represents an executable artifact and create a plugin Root.
-func (s ExternalPluginSpec) Load() (Root, error) {
+func (s PluginSpec) Load() (plugin.Root, error) {
 	fi, err := os.Stat(s.Script)
 	if err != nil {
 		return nil, err
@@ -29,8 +31,8 @@ func (s ExternalPluginSpec) Load() (Root, error) {
 		return nil, fmt.Errorf("script %v is not executable", s.Script)
 	}
 
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry(s.Name()),
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry(s.Name()),
 		script:    externalPluginScriptImpl{path: s.Script},
 	}}
 	return root, nil
diff --git a/plugin/testdata/external.sh b/plugin/external/testdata/external.sh
similarity index 100%
rename from plugin/testdata/external.sh
rename to plugin/external/testdata/external.sh
diff --git a/plugin/testdata/externalPluginSchema.json b/plugin/external/testdata/externalPluginSchema.json
similarity index 100%
rename from plugin/testdata/externalPluginSchema.json
rename to plugin/external/testdata/externalPluginSchema.json
diff --git a/plugin/testdata/externalPluginSchema_PartitionedSchemaGraph.json b/plugin/external/testdata/externalPluginSchema_PartitionedSchemaGraph.json
similarity index 100%
rename from plugin/testdata/externalPluginSchema_PartitionedSchemaGraph.json
rename to plugin/external/testdata/externalPluginSchema_PartitionedSchemaGraph.json
diff --git a/plugin/testdata/externalPluginSchema_SchemaGraph.json b/plugin/external/testdata/externalPluginSchema_SchemaGraph.json
similarity index 100%
rename from plugin/testdata/externalPluginSchema_SchemaGraph.json
rename to plugin/external/testdata/externalPluginSchema_SchemaGraph.json
diff --git a/plugin/testdata/noexec b/plugin/external/testdata/noexec
similarity index 100%
rename from plugin/testdata/noexec
rename to plugin/external/testdata/noexec
diff --git a/plugin/testdata/notfile/.gitignore b/plugin/external/testdata/notfile/.gitignore
similarity index 100%
rename from plugin/testdata/notfile/.gitignore
rename to plugin/external/testdata/notfile/.gitignore
diff --git a/plugin/methodWrappers.go b/plugin/methodWrappers.go
index 08c29b9..e0e7ebe 100644
--- a/plugin/methodWrappers.go
+++ b/plugin/methodWrappers.go
@@ -148,7 +148,7 @@ func Read(ctx context.Context, e Entry, size int64, offset int64) (data []byte,
 			size = contentSize
 			err = io.EOF
 		}
-	} else if ReadAction().signature(e) == blockReadableSignature {
+	} else if ReadAction().signature(e) == BlockReadableSignature {
 		activity.Warnf(ctx, "size attribute not set for block-readable entry %v", e.eb().id)
 	}
 	content, contentErr := cachedRead(ctx, e)
diff --git a/plugin/types.go b/plugin/types.go
index a6b92cd..859fd51 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -38,6 +38,8 @@ import (
 	"context"
 	"io"
 	"time"
+
+	"github.com/emirpasic/gods/maps/linkedhashmap"
 )
 
 // Entry is the interface for things that are representable by Wash's filesystem. This includes
@@ -235,3 +237,25 @@ type Signalable interface {
 	Entry
 	Signal(context.Context, string) error
 }
+
+// This interface exists to break the circular dependency between plugin and external.
+// The external plugin implementation is in its own module so it can use other modules
+// that implement new features and have dependencies on this module.
+type externalPlugin interface {
+	MethodSignature(string) MethodSignature
+	// Entry#Schema's type-signature only makes sense for core plugins
+	// since core plugin schemas do not require any error-prone API
+	// calls. External plugin schemas can be prefetched (no error)
+	// or obtained by shelling out (error-prone). Since the latter
+	// operation is error prone, the type-signature of external plugin
+	// schemas will include an error object. Since this is something
+	// specific to external plugins, it makes sense to include the
+	// error-prone version of schema here, in the Entry interface.
+	SchemaGraph() (*linkedhashmap.Map, error)
+	RawTypeID() string
+	// Go doesn't allow overloaded functions, so the external plugin entry type
+	// cannot implement both BlockReadable#Read and Readable#Read. Thus, external
+	// plugins implement the BlockReadable interface via a separate BlockRead
+	// method.
+	BlockRead(ctx context.Context, size int64, offset int64) ([]byte, error)
+}


Test patch:
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 18f8bdb..70206eb 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/emirpasic/gods/maps/linkedhashmap"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/suite"
 )
@@ -401,35 +402,63 @@ func (suite *CacheTestSuite) TestCachedRead_ReadableCorePluginEntry_ErroredRead(
 	suite.Equal(expectedErr, err)
 }
 
+type mockExternalPlugin struct {
+	EntryBase
+	mock.Mock
+}
+
+func (e *mockExternalPlugin) Schema() *EntrySchema {
+	return e.Called().Get(0).(*EntrySchema)
+}
+
+func (e *mockExternalPlugin) Read(ctx context.Context) ([]byte, error) {
+	args := e.Called(ctx)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
+func (e *mockExternalPlugin) MethodSignature(name string) MethodSignature {
+	return e.Called(name).Get(0).(MethodSignature)
+}
+
+func (e *mockExternalPlugin) SchemaGraph() (*linkedhashmap.Map, error) {
+	args := e.Called()
+	return args.Get(0).(*linkedhashmap.Map), args.Error(1)
+}
+
+func (e *mockExternalPlugin) RawTypeID() string {
+	return e.Called().String(0)
+}
+
+func (e *mockExternalPlugin) BlockRead(ctx context.Context, size int64, offset int64) ([]byte, error) {
+	args := e.Called(ctx, size, offset)
+	return args.Get(0).([]byte), args.Error(1)
+}
+
 func (suite *CacheTestSuite) TestCachedRead_ReadableExternalPluginEntry_SuccessfulRead() {
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		methods: map[string]methodInfo{
-			"read": methodInfo{signature: defaultSignature, result: "some raw content"},
-		},
-	}
+	entry := &mockExternalPlugin{EntryBase: NewEntry("foo")}
+	ctx := context.Background()
+	entry.On("MethodSignature", "read").Return(DefaultSignature)
+	entry.On("Read", ctx).Return([]byte("some raw content"), nil)
 	entry.DisableDefaultCaching()
 	entry.SetTestID("/foo")
 
 	expectedContent := newEntryContent([]byte("some raw content"))
 
-	actualContent, err := cachedRead(context.Background(), entry)
+	actualContent, err := cachedRead(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(expectedContent, actualContent)
 	}
 }
 
 func (suite *CacheTestSuite) TestCachedRead_ReadableExternalPluginEntry_ErroredRead() {
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		methods: map[string]methodInfo{
-			"read": methodInfo{signature: defaultSignature, result: []byte("invalid content")},
-		},
-	}
+	entry := &mockExternalPlugin{EntryBase: NewEntry("foo")}
+	ctx := context.Background()
+	entry.On("MethodSignature", "read").Return(DefaultSignature)
+	entry.On("Read", ctx).Return([]byte{}, fmt.Errorf("Read failed, not a string"))
 	entry.DisableDefaultCaching()
 	entry.SetTestID("/foo")
 
-	_, err := cachedRead(context.Background(), entry)
+	_, err := cachedRead(ctx, entry)
 	suite.Regexp("Read.*string", err.Error())
 }
 
@@ -468,31 +497,20 @@ func (suite *CacheTestSuite) TestCachedRead_BlockReadableCorePluginEntry() {
 
 func (suite *CacheTestSuite) TestCachedRead_BlockReadableExternalPluginEntry() {
 	expectedRawContent := []byte("some raw content")
-
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		script:    mockScript,
-		methods: map[string]methodInfo{
-			"read": methodInfo{
-				signature: blockReadableSignature,
-			},
-		},
-	}
+	ctx := context.Background()
+	entry := &mockExternalPlugin{EntryBase: NewEntry("foo")}
+	entry.On("MethodSignature", "read").Return(BlockReadableSignature)
+	entry.On("BlockRead", ctx, int64(10), int64(0)).Return(expectedRawContent, nil)
 	entry.DisableDefaultCaching()
 	entry.SetTestID("/foo")
 	entry.Attributes().SetSize(uint64(len(expectedRawContent)))
 
-	ctx := context.Background()
-	mockScript.OnInvokeAndWait(ctx, "read", entry, "10", "0").Return(mockInvocation(expectedRawContent), nil).Once()
-
 	content, err := cachedRead(ctx, entry)
 	suite.Equal(entry.Attributes().Size(), content.size())
 	if suite.NoError(err) {
 		actualRawContent, err := content.read(ctx, 10, 0)
 		if suite.NoError(err) {
 			suite.Equal(expectedRawContent, actualRawContent)
-			mockScript.AssertExpectations(suite.T())
 		}
 	}
 }
diff --git a/plugin/externalPluginEntry_test.go b/plugin/external/pluginEntry_test.go
similarity index 82%
rename from plugin/externalPluginEntry_test.go
rename to plugin/external/pluginEntry_test.go
index e6aec33..b028ae3 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/external/pluginEntry_test.go
@@ -1,9 +1,8 @@
-package plugin
+package external
 
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"path"
@@ -13,34 +12,34 @@ import (
 	"time"
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
-	"github.com/puppetlabs/wash/plugin/internal"
+	"github.com/puppetlabs/wash/plugin"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/suite"
 )
 
-type mockExternalPluginScript struct {
+type mockPluginScript struct {
 	mock.Mock
 	path string
 }
 
-func (m *mockExternalPluginScript) Path() string {
+func (m *mockPluginScript) Path() string {
 	return m.path
 }
 
-func (m *mockExternalPluginScript) InvokeAndWait(
+func (m *mockPluginScript) InvokeAndWait(
 	ctx context.Context,
 	method string,
-	entry *externalPluginEntry,
+	entry *pluginEntry,
 	args ...string,
 ) (invocation, error) {
 	retValues := m.Called(ctx, method, entry, args)
 	return retValues.Get(0).(invocation), retValues.Error(1)
 }
 
-func (m *mockExternalPluginScript) NewInvocation(
+func (m *mockPluginScript) NewInvocation(
 	ctx context.Context,
 	method string,
-	entry *externalPluginEntry,
+	entry *pluginEntry,
 	args ...string,
 ) invocation {
 	retValues := m.Called(ctx, method, entry, args)
@@ -50,10 +49,10 @@ func (m *mockExternalPluginScript) NewInvocation(
 // We make ctx an interface{} so that this method could
 // be used when the caller generates a context using e.g.
 // context.Background()
-func (m *mockExternalPluginScript) OnInvokeAndWait(
+func (m *mockPluginScript) OnInvokeAndWait(
 	ctx interface{},
 	method string,
-	entry *externalPluginEntry,
+	entry *pluginEntry,
 	args ...string,
 ) *mock.Call {
 	return m.On("InvokeAndWait", ctx, method, entry, args)
@@ -76,7 +75,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequired
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(decodedEntry.Name, entry.name)
+		suite.Equal(decodedEntry.Name, plugin.Name(entry))
 		suite.Equal(1, len(entry.methods))
 		suite.Contains(entry.methods, "list")
 		suite.Nil(entry.methods["list"].result)
@@ -91,14 +90,14 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryExtraFie
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(decodedEntry.Name, entry.name)
+		suite.Equal(decodedEntry.Name, entry.Name())
 		suite.Contains(entry.methods, "list")
-		suite.Equal(defaultSignature, entry.methods["list"].signature)
+		suite.Equal(plugin.DefaultSignature, entry.methods["list"].signature)
 		suite.Nil(entry.methods["list"].result)
 		suite.Contains(entry.methods, "stream")
-		suite.Equal(defaultSignature, entry.methods["stream"].signature)
+		suite.Equal(plugin.DefaultSignature, entry.methods["stream"].signature)
 		suite.Nil(entry.methods["stream"].result)
-		suite.False(entry.isPrefetched)
+		suite.False(plugin.IsPrefetched(entry))
 
 		methods := entry.supportedMethods()
 		suite.Equal(2, len(methods))
@@ -115,7 +114,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry_Support
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(decodedEntry.Name, entry.name)
+		suite.Equal(decodedEntry.Name, entry.Name())
 	}
 }
 
@@ -128,13 +127,13 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithMeth
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(decodedEntry.Name, entry.name)
+		suite.Equal(decodedEntry.Name, entry.Name())
 		suite.Contains(entry.methods, "list")
-		suite.Equal(defaultSignature, entry.methods["list"].signature)
+		suite.Equal(plugin.DefaultSignature, entry.methods["list"].signature)
 		suite.NotNil(entry.methods["list"].result)
 		suite.Contains(entry.methods, "read")
 		suite.Nil(entry.methods["read"].result)
-		suite.True(entry.isPrefetched)
+		suite.True(plugin.IsPrefetched(entry))
 	}
 }
 
@@ -146,13 +145,13 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryMethodTu
 
 	type testCase struct {
 		data              interface{}
-		expectedSignature methodSignature
+		expectedSignature plugin.MethodSignature
 		expectedResult    interface{}
 	}
 	testCases := []testCase{
-		testCase{true, blockReadableSignature, nil},
-		testCase{false, defaultSignature, nil},
-		testCase{"foo", defaultSignature, "foo"},
+		testCase{true, plugin.BlockReadableSignature, nil},
+		testCase{false, plugin.DefaultSignature, nil},
+		testCase{"foo", plugin.DefaultSignature, "foo"},
 	}
 	for _, testCase := range testCases {
 		decodedEntry.Methods = []interface{}{[]interface{}{"read", testCase.data}}
@@ -186,14 +185,12 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithCach
 	decodedEntry.CacheTTLs = decodedCacheTTLs{List: 1}
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		expectedTTLs := NewEntry("foo").ttl
-		expectedTTLs[ListOp] = decodedEntry.CacheTTLs.List * time.Second
-		suite.Equal(expectedTTLs, entry.EntryBase.ttl)
+		suite.Equal(decodedEntry.CacheTTLs.List*time.Second, entry.TTLOf(plugin.ListOp))
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSlashReplacer() {
-	decodedEntry := newMockDecodedEntry("name")
+	decodedEntry := newMockDecodedEntry("name/")
 	decodedEntry.SlashReplacer = "a string"
 	suite.Panics(
 		func() { _, _ = decodedEntry.toExternalPluginEntry(context.Background(), false, false) },
@@ -202,7 +199,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSlas
 	decodedEntry.SlashReplacer = ":"
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(':', entry.slashReplacer)
+		suite.Equal("name:", plugin.CName(entry))
 	}
 }
 
@@ -212,18 +209,18 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithAttr
 	decodedEntry.Attributes.SetCtime(t)
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		expectedAttr := EntryAttributes{}
+		expectedAttr := plugin.EntryAttributes{}
 		expectedAttr.SetCtime(t)
-		suite.Equal(expectedAttr, entry.attributes)
+		suite.Equal(expectedAttr, plugin.Attributes(entry))
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithPartialMetadata() {
 	decodedEntry := newMockDecodedEntry("name")
-	decodedEntry.PartialMetadata = JSONObject{}
+	decodedEntry.PartialMetadata = plugin.JSONObject{}
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.Equal(JSONObject{}, entry.partialMetadata())
+		suite.Equal(plugin.JSONObject{}, plugin.PartialMetadata(entry))
 	}
 }
 
@@ -298,7 +295,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), true, false)
 	if suite.NoError(err) {
 		suite.True(entry.schemaKnown)
-		suite.Equal("foo", rawTypeID(entry))
+		suite.Equal("foo", entry.RawTypeID())
 	}
 }
 
@@ -311,7 +308,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithInac
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
-		suite.True(entry.isInaccessible)
+		suite.True(entry.IsInaccessible())
 	}
 }
 
@@ -322,34 +319,34 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 		Metadata: 20,
 	}
 
-	entry := externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	entry := pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 	}
 	entry.setCacheTTLs(decodedTTLs)
 
-	suite.Equal(decodedTTLs.List*time.Second, entry.ttl[ListOp])
-	suite.Equal(decodedTTLs.Read*time.Second, entry.ttl[ReadOp])
-	suite.Equal(decodedTTLs.Metadata*time.Second, entry.ttl[MetadataOp])
+	suite.Equal(decodedTTLs.List*time.Second, entry.TTLOf(plugin.ListOp))
+	suite.Equal(decodedTTLs.Read*time.Second, entry.TTLOf(plugin.ReadOp))
+	suite.Equal(decodedTTLs.Metadata*time.Second, entry.TTLOf(plugin.MetadataOp))
 }
 
 func mockInvocation(stdout []byte) invocation {
-	return &invocationImpl{Command: internal.NewCommand(context.Background(), ""), stdout: *bytes.NewBuffer(stdout)}
+	return &invocationImpl{Command: NewCommand(context.Background(), ""), stdout: *bytes.NewBuffer(stdout)}
 }
 
 // TODO: Add tests for Schema, including when schemaGraph is provided (prefetched)
 // and when it is not provided
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_DoesNotImplementSchema_ReturnsNil() {
-	entry := &externalPluginEntry{}
-	s, err := entry.schema()
+	entry := &pluginEntry{}
+	s, err := entry.SchemaGraph()
 	if suite.NoError(err) {
 		suite.Nil(s)
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_PanicsIfNoSchemaGraphWasProvided() {
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -359,15 +356,15 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_PanicsIfNoSchem
 	entry.SetTestID("/fooPlugin")
 
 	suite.Panics(
-		func() { _, _ = entry.schema() },
+		func() { _, _ = entry.SchemaGraph() },
 		"e.Schema(): entry schemas were prefetched, but no schema graph was provided for /foo (type ID fooTypeID)",
 	)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchema() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -376,28 +373,27 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsTheSchem
 		script:       mockScript,
 	}
 	entry.SetTestID("/fooPlugin")
+	var schema plugin.EntrySchema
+	schema.Actions = []string{"schema"}
 	graph := linkedhashmap.New()
 	graph.Put(
-		TypeID(entry),
-		entrySchema{
-			Actions: []string{"schema"},
-		},
+		plugin.TypeID(entry),
+		schema,
 	)
-	entry.schemaGraphs[TypeID(entry)] = graph
+	entry.schemaGraphs[plugin.TypeID(entry)] = graph
 
-	s, err := entry.schema()
+	graph, err := entry.SchemaGraph()
 	if suite.NoError(err) {
-		suite.Equal(entry.schemaGraphs[TypeID(entry)], s.graph)
-		suite.Equal(s.entrySchema.Actions, []string{"schema"})
+		suite.Equal(entry.schemaGraphs[plugin.TypeID(entry)], graph)
 		// Make sure that Wash did not shell out to the plugin script
 		mockScript.AssertNotCalled(suite.T(), "InvokeAndWait")
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsErrorIfSchemaAndInstanceMethodsDontMatch() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -407,23 +403,23 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_Prefetched_ReturnsErrorIfS
 		script:       mockScript,
 	}
 	entry.SetTestID("/fooPlugin")
+	var schema plugin.EntrySchema
+	schema.Actions = []string{"list", "exec"}
 	graph := linkedhashmap.New()
 	graph.Put(
-		TypeID(entry),
-		entrySchema{
-			Actions: []string{"list", "exec"},
-		},
+		plugin.TypeID(entry),
+		schema,
 	)
-	entry.schemaGraphs[TypeID(entry)] = graph
+	entry.schemaGraphs[plugin.TypeID(entry)] = graph
 
-	_, err := entry.schema()
+	_, err := entry.SchemaGraph()
 	suite.Regexp("schema.*methods.*exec.*list.*instance.*methods.*read.*schema", err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsErrorIfInvocationFails() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -434,14 +430,14 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsError
 
 	invokeErr := fmt.Errorf("invocation failed")
 	mockScript.OnInvokeAndWait(mock.Anything, "schema", entry).Return(mockInvocation([]byte{}), invokeErr).Once()
-	_, err := entry.schema()
+	_, err := entry.SchemaGraph()
 	suite.Regexp("foo.*fooTypeID.*invocation.*failed", err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsErrorIfUnmarshallingFails() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -451,15 +447,15 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_ReturnsError
 	entry.SetTestID("/fooPlugin")
 
 	mockScript.OnInvokeAndWait(mock.Anything, "schema", entry).Return(mockInvocation([]byte("\"foo\"")), nil).Once()
-	_, err := entry.schema()
+	_, err := entry.SchemaGraph()
 	suite.Regexp("/foo.*fooTypeID", err)
 	suite.Regexp(regexp.QuoteMeta(schemaFormat), err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_SuccessfulInvocation_ReturnsSchema() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		rawTypeID: "baz.fooTypeID",
 		methods: map[string]methodInfo{
 			"schema": methodInfo{},
@@ -498,22 +494,21 @@ func (suite *ExternalPluginEntryTestSuite) TestSchema_NotPrefetched_SuccessfulIn
 }
 `
 	mockScript.OnInvokeAndWait(mock.Anything, "schema", entry).Return(mockInvocation([]byte(stdout)), nil).Once()
-	schema, err := entry.schema()
-	if suite.NoError(err) && suite.NotNil(schema) {
-		schemaJSON, err := json.Marshal(schema)
+	graph, err := entry.SchemaGraph()
+	if suite.NoError(err) && suite.NotNil(graph) {
+		schemaJSON, err := graph.ToJSON()
 		if suite.NoError(err) {
 			stdout = strings.ReplaceAll(stdout, "methods", "actions")
 			stdout = strings.ReplaceAll(stdout, "baz.", "fooPlugin::baz.")
 			suite.JSONEq(stdout, string(schemaJSON))
-			suite.Equal(schema.Actions, []string{"list"})
 		}
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestList() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 		schemaGraphs: map[string]*linkedhashmap.Map{
 			"foo": linkedhashmap.New(),
@@ -546,13 +541,13 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	entries, err := entry.List(ctx)
 	if suite.NoError(err) {
-		entryBase := NewEntry("foo")
-		expectedEntries := []Entry{
-			&externalPluginEntry{
+		entryBase := plugin.NewEntry("foo")
+		expectedEntries := []plugin.Entry{
+			&pluginEntry{
 				EntryBase: entryBase,
 				methods: map[string]methodInfo{
 					"list": methodInfo{
-						signature: defaultSignature,
+						signature: plugin.DefaultSignature,
 					},
 				},
 				script:       entry.script,
@@ -566,9 +561,9 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestRead() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -595,9 +590,9 @@ func (suite *ExternalPluginEntryTestSuite) TestRead() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestBlockRead() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -607,16 +602,16 @@ func (suite *ExternalPluginEntryTestSuite) TestBlockRead() {
 		mockScript.OnInvokeAndWait(ctx, "read", entry, "10", "0").Return(mockInvocation(stdout), err).Once()
 	}
 
-	// Test that if InvokeAndWait errors, then blockRead returns its error
+	// Test that if InvokeAndWait errors, then BlockRead returns its error
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
-	_, err := entry.blockRead(ctx, 10, 0)
+	_, err := entry.BlockRead(ctx, 10, 0)
 	suite.EqualError(mockErr, err.Error())
 
-	// Test that blockRead returns the invocation's stdout
+	// Test that BlockRead returns the invocation's stdout
 	stdout := "foo"
 	mockInvokeAndWait([]byte(stdout), nil)
-	content, err := entry.blockRead(ctx, 10, 0)
+	content, err := entry.BlockRead(ctx, 10, 0)
 	if suite.NoError(err) {
 		expectedContent := []byte(stdout)
 		suite.Equal(expectedContent, content)
@@ -624,9 +619,9 @@ func (suite *ExternalPluginEntryTestSuite) TestBlockRead() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestListReadWithMethodResults() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -650,17 +645,17 @@ func (suite *ExternalPluginEntryTestSuite) TestListReadWithMethodResults() {
 	entries, err := entry.List(ctx)
 	if suite.NoError(err) {
 		suite.Equal(1, len(entries))
-		if suite.Equal([]string{"list"}, SupportedActionsOf(entries[0])) {
-			children, err := entries[0].(Parent).List(ctx)
+		if suite.Equal([]string{"list"}, plugin.SupportedActionsOf(entries[0])) {
+			children, err := entries[0].(plugin.Parent).List(ctx)
 			if suite.NoError(err) {
 				suite.Equal(1, len(children))
-				attr := Attributes(children[0])
+				attr := plugin.Attributes(children[0])
 				if suite.True(attr.HasSize()) {
 					suite.Equal(uint64(len(someContent)), attr.Size())
 				}
 
-				if suite.Equal([]string{"read"}, SupportedActionsOf(children[0])) {
-					content, err := children[0].(Readable).Read(ctx)
+				if suite.Equal([]string{"read"}, plugin.SupportedActionsOf(children[0])) {
+					content, err := children[0].(plugin.Readable).Read(ctx)
 					suite.NoError(err)
 					suite.Equal(someContent, string(content))
 				}
@@ -670,7 +665,7 @@ func (suite *ExternalPluginEntryTestSuite) TestListReadWithMethodResults() {
 }
 
 type mockedInvocation struct {
-	internal.Command
+	Command
 	mock.Mock
 }
 
@@ -689,9 +684,9 @@ func (m *mockedInvocation) Stderr() *bytes.Buffer {
 var _ = invocation(&mockedInvocation{})
 
 func (suite *ExternalPluginEntryTestSuite) TestWrite() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -699,7 +694,7 @@ func (suite *ExternalPluginEntryTestSuite) TestWrite() {
 
 	ctx := context.Background()
 	mockRunAndWait := func(err error) {
-		mockInv := &mockedInvocation{Command: internal.NewCommand(ctx, "")}
+		mockInv := &mockedInvocation{Command: NewCommand(ctx, "")}
 		mockScript.On("NewInvocation", ctx, "write", entry, []string(nil)).Return(mockInv).Once()
 		mockInv.On("RunAndWait", ctx).Return(err).Once()
 	}
@@ -717,9 +712,9 @@ func (suite *ExternalPluginEntryTestSuite) TestWrite() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeWithErrors() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -738,27 +733,27 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeWithErrors() {
 	entries, err := entry.List(ctx)
 	if suite.NoError(err) {
 		suite.Equal(1, len(entries))
-		supported := SupportedActionsOf(entries[0])
+		supported := plugin.SupportedActionsOf(entries[0])
 		suite.Contains(supported, "list")
 		suite.Contains(supported, "read")
 
-		_, err = entries[0].(Parent).List(ctx)
+		_, err = entries[0].(plugin.Parent).List(ctx)
 		suite.EqualError(err, `implementation of list must conform to `+
 			`[{"name":"entry1","methods":["list"]},{"name":"entry2","methods":["list"]}], not map[name:bar]`)
 
-		_, err = entries[0].(Readable).Read(ctx)
+		_, err = entries[0].(plugin.Readable).Read(ctx)
 		suite.EqualError(err, "Read method must provide a string, not [1 2]")
 		// TODO: Add a test for block readable here
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}
-	expectedMeta := JSONObject{"foo": "bar"}
+	expectedMeta := plugin.JSONObject{"foo": "bar"}
 	entry.SetPartialMetadata(expectedMeta)
 
 	// If metadata is not implemented, then e.Metadata should return
@@ -772,9 +767,9 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		methods:   map[string]methodInfo{"metadata": methodInfo{}},
 		script:    mockScript,
 	}
@@ -802,15 +797,15 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	metadata, err := entry.Metadata(ctx)
 	if suite.NoError(err) {
-		expectedMetadata := JSONObject{"key": "value"}
+		expectedMetadata := plugin.JSONObject{"key": "value"}
 		suite.Equal(expectedMetadata, metadata)
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestSignal() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		methods:   map[string]methodInfo{"signal": methodInfo{}},
 		script:    mockScript,
 	}
@@ -836,9 +831,9 @@ func (suite *ExternalPluginEntryTestSuite) TestSignal() {
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDelete() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		methods:   map[string]methodInfo{"delete": methodInfo{}},
 		script:    mockScript,
 	}
@@ -875,21 +870,21 @@ func (suite *ExternalPluginEntryTestSuite) TestDelete() {
 // state.
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotAJSONObject() {
-	entry := &externalPluginEntry{}
+	entry := &pluginEntry{}
 	entry.SetTestID("/fooPlugin")
 	_, err := unmarshalSchemaGraph(entry, []byte("[]"))
 	suite.Regexp("non-empty.*object", err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfAnEmptyJSONObject() {
-	entry := &externalPluginEntry{}
+	entry := &pluginEntry{}
 	entry.SetTestID("/fooPlugin")
 	_, err := unmarshalSchemaGraph(entry, []byte("{}"))
 	suite.Regexp("non-empty.*object", err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfTypeIDNotPresent() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("/fooPlugin")
@@ -902,7 +897,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfType
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnMalformedSchema() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("/fooPlugin")
@@ -943,7 +938,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnMalf
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfLabelNotProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("/fooPlugin")
@@ -958,7 +953,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfLabe
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMethodsNotProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -975,7 +970,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMeth
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotParentAndChildrenProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -994,7 +989,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotP
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfParentAndChildrenNotProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1013,7 +1008,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfPare
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotSignalableAndSignalsProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1034,7 +1029,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotS
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSignalableAndSignalsNotProvided() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1053,7 +1048,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSign
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMissingChildSchema() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1072,7 +1067,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMiss
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSchemaIncludesDanglingTypeIDs() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1103,7 +1098,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSche
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnInvalidPartialMetadataSchema() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1124,7 +1119,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnInva
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnInvalidMetadataSchema() {
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "foo",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1150,7 +1145,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ValidInput()
 	// by the "actions" key)
 	stdout := readSchemaFixture(suite.Suite, "externalPluginSchema")
 
-	entry := &externalPluginEntry{
+	entry := &pluginEntry{
 		rawTypeID: "aws.Root",
 	}
 	entry.SetTestID("fooPlugin")
@@ -1159,7 +1154,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ValidInput()
 		// Check that the first key is aws.Root
 		it := actualGraph.Iterator()
 		it.First()
-		suite.Equal(it.Key(), TypeID(entry))
+		suite.Equal(it.Key(), plugin.TypeID(entry))
 
 		// Now check that all of stdout was successfully unmarshalled.
 		stdout = bytes.ReplaceAll(stdout, []byte("methods"), []byte("actions"))
diff --git a/plugin/externalPluginRoot_test.go b/plugin/external/pluginRoot_test.go
similarity index 83%
rename from plugin/externalPluginRoot_test.go
rename to plugin/external/pluginRoot_test.go
index 8e24051..93fc7e6 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/external/pluginRoot_test.go
@@ -1,4 +1,4 @@
-package plugin
+package external
 
 import (
 	"encoding/json"
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
+	"github.com/puppetlabs/wash/plugin"
 
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/suite"
@@ -17,9 +18,9 @@ type ExternalPluginRootTestSuite struct {
 }
 
 func (suite *ExternalPluginRootTestSuite) TestInit() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}}
 
@@ -49,11 +50,11 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	err = root.Init(nil)
 	if suite.NoError(err) {
-		expectedRoot := &externalPluginRoot{
-			externalPluginEntry: &externalPluginEntry{
-				EntryBase: NewEntry("foo"),
+		expectedRoot := &pluginRoot{
+			pluginEntry: pluginEntry{
+				EntryBase: plugin.NewEntry("foo"),
 				methods: map[string]methodInfo{
-					"list": methodInfo{signature: defaultSignature},
+					"list": methodInfo{signature: plugin.DefaultSignature},
 				},
 				script:    root.script,
 				rawTypeID: "foo_type",
@@ -65,9 +66,9 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 }
 
 func (suite *ExternalPluginRootTestSuite) TestInitWithConfig() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}}
 
@@ -82,9 +83,9 @@ func (suite *ExternalPluginRootTestSuite) TestInitWithConfig() {
 }
 
 func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_SetsSchemaKnownVariable() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}}
 
@@ -100,9 +101,9 @@ func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_SetsSchemaKnownVari
 }
 
 func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_PrefetchedSchema_ReturnsErrorIfUnmarshallingSchemaFails() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
 		script:    mockScript,
 	}}
 
@@ -121,9 +122,9 @@ func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_PrefetchedSchema_Re
 }
 
 func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_PrefetchedSchema_PartitionsSchemaGraph() {
-	mockScript := &mockExternalPluginScript{path: "plugin_script"}
-	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("fooPlugin"),
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	root := &pluginRoot{pluginEntry{
+		EntryBase: plugin.NewEntry("fooPlugin"),
 		script:    mockScript,
 	}}
 
@@ -160,7 +161,7 @@ func (suite *ExternalPluginRootTestSuite) TestInitWithSchema_PrefetchedSchema_Pa
 	if err := root.Init(nil); suite.NoError(err) && suite.NotNil(root.schemaGraphs) {
 		// Ensure that the graph of root.schemaGraphs[type_id] has "type_id" as its
 		// first item. We pick an arbitrary type ID here
-		typeID := namespace(root.eb().name, "aws.profile")
+		typeID := namespace(root.Name(), "aws.profile")
 		graph := root.schemaGraphs[typeID]
 		if suite.NotNil(graph) {
 			it := graph.Iterator()
diff --git a/plugin/externalPluginSpec_test.go b/plugin/external/pluginSpec_test.go
similarity index 67%
rename from plugin/externalPluginSpec_test.go
rename to plugin/external/pluginSpec_test.go
index beffc6f..bc66864 100644
--- a/plugin/externalPluginSpec_test.go
+++ b/plugin/external/pluginSpec_test.go
@@ -1,32 +1,33 @@
-package plugin
+package external
 
 import (
 	"testing"
 
+	"github.com/puppetlabs/wash/plugin"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestLoadExternalPlugin(t *testing.T) {
-	spec := ExternalPluginSpec{Script: "testdata/external.sh"}
+	spec := PluginSpec{Script: "testdata/external.sh"}
 	root, err := spec.Load()
 	assert.NoError(t, err)
-	assert.Equal(t, "external", root.eb().name)
+	assert.Equal(t, "external", plugin.Name(root))
 }
 
 func TestLoadExternalPluginNoExec(t *testing.T) {
-	spec := ExternalPluginSpec{Script: "testdata/noexec"}
+	spec := PluginSpec{Script: "testdata/noexec"}
 	_, err := spec.Load()
 	assert.EqualError(t, err, "script testdata/noexec is not executable")
 }
 
 func TestRegisterExternalPluginNoExist(t *testing.T) {
-	spec := ExternalPluginSpec{Script: "testdata/noexist"}
+	spec := PluginSpec{Script: "testdata/noexist"}
 	_, err := spec.Load()
 	assert.EqualError(t, err, "stat testdata/noexist: no such file or directory")
 }
 
 func TestRegisterExternalPluginNotFile(t *testing.T) {
-	spec := ExternalPluginSpec{Script: "testdata/notfile"}
+	spec := PluginSpec{Script: "testdata/notfile"}
 	_, err := spec.Load()
 	assert.EqualError(t, err, "script testdata/notfile is not a file")
 }
diff --git a/plugin/methodWrappers_test.go b/plugin/methodWrappers_test.go
index 32ad47a..42f0030 100644
--- a/plugin/methodWrappers_test.go
+++ b/plugin/methodWrappers_test.go
@@ -114,10 +114,19 @@ func (suite *MethodWrappersTestSuite) TestPrefetched() {
 	suite.True(IsPrefetched(e))
 }
 
+type mockNonReadable struct {
+	EntryBase
+}
+
+// Schema returns a simple schema.
+func (m *mockNonReadable) Schema() *EntrySchema {
+	return nil
+}
+
 func (suite *MethodWrappersTestSuite) TestRead_PanicsOnNonReadableEntry() {
 	// Use an external plugin entry because they're easier to setup
 	panicFunc := func() {
-		entry := &externalPluginEntry{
+		entry := &mockNonReadable{
 			EntryBase: NewEntry("foo"),
 		}
 		_, _ = Read(context.Background(), entry, 10, 0)