Repository: puppetlabs/wash
Pull request #347 (2019-06-21 00:33:35)

Refactor the EntrySchema interface
Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org). This can be as simple as using `git commit -s` on each commit.


Patch:
diff --git a/.travis.yml b/.travis.yml
index cab20f6..60cf306 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,9 +16,6 @@ jobs:
     before_script:
     - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.15.0
     script: golangci-lint run -v
-  - name: Test website updated
-    install: curl -sfL https://github.com/gohugoio/hugo/releases/download/v0.55.6/hugo_0.55.6_Linux-64bit.tar.gz | tar -xzC $(go env GOPATH)/bin
-    script: rm -r docs && hugo -s website && test -z "$(git status --porcelain)"
   - stage: deploy
     name: Deploy to GitHub Releases
     if: tag IS present
diff --git a/api/fs/core.go b/api/fs/core.go
index 83d951d..bf7daf4 100644
--- a/api/fs/core.go
+++ b/api/fs/core.go
@@ -16,22 +16,21 @@ type fsnode struct {
 	path string
 }
 
-func (n *fsnode) build(finfo os.FileInfo, path string) {
-	n.path = path
+func newFSNode(ctx context.Context, finfo os.FileInfo, path string) *fsnode {
+	// TODO: finfo.Sys() contains more detailed file attributes,
+	// but it's platform-specific. We should eventually use it for
+	// a more complete implementation of apifs.
+	n := &fsnode{
+		EntryBase: plugin.NewEntry(finfo.Name()),
+		path:      path,
+	}
 	n.
-		SetName(finfo.Name()).
+		DisableDefaultCaching().
 		Attributes().
 		SetMtime(finfo.ModTime()).
 		SetMode(finfo.Mode()).
 		SetSize(uint64(finfo.Size())).
 		SetMeta(plugin.ToJSONObject(newFileInfo(finfo)))
-}
-
-func fsnodeBase() *fsnode {
-	n := &fsnode{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	n.DisableDefaultCaching()
 	return n
 }
 
diff --git a/api/fs/dir.go b/api/fs/dir.go
index 47c5c5d..6055234 100644
--- a/api/fs/dir.go
+++ b/api/fs/dir.go
@@ -12,22 +12,10 @@ type dir struct {
 	*fsnode
 }
 
-func dirBase() *dir {
-	d := &dir{
-		fsnode: fsnodeBase(),
-	}
-	d.SetLabel("dir")
-	return d
-}
-
 func newDir(ctx context.Context, finfo os.FileInfo, path string) *dir {
-	d := dirBase()
-	d.build(finfo, path)
-	return d
-}
-
-func (d *dir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(dirBase(), fileBase())
+	return &dir{
+		newFSNode(ctx, finfo, path),
+	}
 }
 
 func (d *dir) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -46,3 +34,14 @@ func (d *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	}
 	return entries, nil
 }
+
+func (d *dir) ChildSchemas() []*plugin.EntrySchema {
+	return []*plugin.EntrySchema{
+		d.Schema(),
+		(&file{}).Schema(),
+	}
+}
+
+func (d *dir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(d, "dir")
+}
diff --git a/api/fs/file.go b/api/fs/file.go
index 437404d..0962332 100644
--- a/api/fs/file.go
+++ b/api/fs/file.go
@@ -13,18 +13,10 @@ type file struct {
 	*fsnode
 }
 
-func fileBase() *file {
-	f := &file{
-		fsnode: fsnodeBase(),
-	}
-	f.SetLabel("file")
-	return f
-}
-
 func newFile(ctx context.Context, finfo os.FileInfo, path string) *file {
-	f := fileBase()
-	f.build(finfo, path)
-	return f
+	return &file{
+		newFSNode(ctx, finfo, path),
+	}
 }
 
 func (f *file) Open(ctx context.Context) (plugin.SizedReader, error) {
@@ -34,3 +26,7 @@ func (f *file) Open(ctx context.Context) (plugin.SizedReader, error) {
 	}
 	return bytes.NewReader(content), nil
 }
+
+func (f *file) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(f, "file")
+}
diff --git a/api/helpers.go b/api/helpers.go
index b80bec7..ff0c765 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -16,13 +16,16 @@ import (
 )
 
 func toAPIEntry(e plugin.Entry) apitypes.Entry {
-	return apitypes.Entry{
-		TypeID:     plugin.TypeID(e),
+	apiEntry := apitypes.Entry{
 		Name:       plugin.Name(e),
 		CName:      plugin.CName(e),
 		Actions:    plugin.SupportedActionsOf(e),
 		Attributes: plugin.Attributes(e),
 	}
+	if s := e.Schema(); s != nil {
+		apiEntry.TypeID = s.TypeID()
+	}
+	return apiEntry
 }
 
 func findEntry(ctx context.Context, root plugin.Entry, segments []string) (plugin.Entry, *errorResponse) {
diff --git a/api/schema.go b/api/schema.go
index 0f00b1d..8186e65 100644
--- a/api/schema.go
+++ b/api/schema.go
@@ -4,8 +4,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
-
-	"github.com/puppetlabs/wash/plugin"
 )
 
 var schemaHandler handler = func(w http.ResponseWriter, r *http.Request) *errorResponse {
@@ -14,7 +12,9 @@ var schemaHandler handler = func(w http.ResponseWriter, r *http.Request) *errorR
 		return errResp
 	}
 	jsonEncoder := json.NewEncoder(w)
-	if err := jsonEncoder.Encode(plugin.Schema(entry)); err != nil {
+	s := entry.Schema()
+	s.FillChildren()
+	if err := jsonEncoder.Encode(s); err != nil {
 		return unknownErrorResponse(fmt.Errorf("Could not marshal schema for %v: %v", path, err))
 	}
 	return nil
diff --git a/cmd/internal/find/primary/action.go b/cmd/internal/find/primary/action.go
index 1e4c7e3..8f4127f 100644
--- a/cmd/internal/find/primary/action.go
+++ b/cmd/internal/find/primary/action.go
@@ -35,7 +35,7 @@ var Action = Parser.add(&Primary{
 			return e.Supports(action)
 		})
 		p.SchemaP = func(s *types.EntrySchema) bool {
-			for _, a := range s.Actions {
+			for _, a := range s.Actions() {
 				if action.Name == a {
 					return true
 				}
diff --git a/cmd/internal/find/types/entrySchema.go b/cmd/internal/find/types/entrySchema.go
index 5a72636..d443fe4 100644
--- a/cmd/internal/find/types/entrySchema.go
+++ b/cmd/internal/find/types/entrySchema.go
@@ -23,25 +23,25 @@ func NewEntrySchema(s *apitypes.EntrySchema) *EntrySchema {
 
 	var gatherNodes func(s *apitypes.EntrySchema)
 	gatherNodes = func(s *apitypes.EntrySchema) {
-		if len(s.TypeID) == 0 {
+		if len(s.TypeID()) == 0 {
 			panic("gatherNodes called with an empty type ID!")
 		}
-		node := nodes[s.TypeID]
+		node := nodes[s.TypeID()]
 		if node == nil {
 			node = &EntrySchema{
 				EntrySchema: s,
 				Children:    make(map[string]*EntrySchema),
 			}
-			nodes[s.TypeID] = node
+			nodes[s.TypeID()] = node
 		}
 		if len(node.Children) > 0 {
 			// We already gathered everything we need to know about this node
 			return
 		}
-		for _, child := range s.Children {
+		for _, child := range s.Children() {
 			// The node's children will be filled in after all the nodes have been
 			// gathered.
-			node.Children[child.TypeID] = nil
+			node.Children[child.TypeID()] = nil
 			gatherNodes(child)
 		}
 	}
@@ -52,27 +52,27 @@ func NewEntrySchema(s *apitypes.EntrySchema) *EntrySchema {
 			node.Children[childTypeID] = nodes[childTypeID]
 		}
 	}
-	return nodes[s.TypeID]
+	return nodes[s.TypeID()]
 }
 
 // Prune prunes s to contain only nodes that satisfy p. It modifies s'
 // state so it is not an idempotent operation.
 func Prune(s *EntrySchema, p EntrySchemaPredicate) *EntrySchema {
 	keep := evaluateNodesToKeep(s, p)
-	if !keep[s.TypeID] {
+	if !keep[s.TypeID()] {
 		return nil
 	}
 
 	visited := make(map[string]bool)
 	var prune func(s *EntrySchema)
 	prune = func(s *EntrySchema) {
-		if visited[s.TypeID] {
+		if visited[s.TypeID()] {
 			return
 		}
-		visited[s.TypeID] = true
+		visited[s.TypeID()] = true
 		for _, child := range s.Children {
-			if !keep[child.TypeID] {
-				delete(s.Children, child.TypeID)
+			if !keep[child.TypeID()] {
+				delete(s.Children, child.TypeID())
 			} else {
 				prune(child)
 			}
@@ -126,14 +126,14 @@ func evaluateNodesToKeep(s *EntrySchema, p EntrySchemaPredicate) map[string]bool
 	// children.
 	var applyPredicate func(s *EntrySchema)
 	applyPredicate = func(s *EntrySchema) {
-		if _, ok := visited[s.TypeID]; ok {
+		if _, ok := visited[s.TypeID()]; ok {
 			return
 		}
-		result[s.TypeID] = p(s)
-		if !result[s.TypeID] && len(s.Children) > 0 {
-			delete(result, s.TypeID)
+		result[s.TypeID()] = p(s)
+		if !result[s.TypeID()] && len(s.Children) > 0 {
+			delete(result, s.TypeID())
 		}
-		visited[s.TypeID] = true
+		visited[s.TypeID()] = true
 		for _, child := range s.Children {
 			applyPredicate(child)
 		}
@@ -143,14 +143,14 @@ func evaluateNodesToKeep(s *EntrySchema, p EntrySchemaPredicate) map[string]bool
 	// Now we iteratively update "result".
 	var updateResult func(s *EntrySchema)
 	updateResult = func(s *EntrySchema) {
-		if _, ok := visited[s.TypeID]; ok {
+		if _, ok := visited[s.TypeID()]; ok {
 			return
 		}
-		visited[s.TypeID] = true
+		visited[s.TypeID()] = true
 		for _, child := range s.Children {
 			updateResult(child)
-			if result[child.TypeID] {
-				result[s.TypeID] = true
+			if result[child.TypeID()] {
+				result[s.TypeID()] = true
 			}
 		}
 	}
@@ -174,15 +174,15 @@ func (s *EntrySchema) toMap() map[string][]string {
 	mp := make(map[string][]string)
 	var visit func(s *EntrySchema)
 	visit = func(s *EntrySchema) {
-		if _, ok := mp[s.TypeID]; ok {
+		if _, ok := mp[s.TypeID()]; ok {
 			return
 		}
-		mp[s.TypeID] = []string{}
+		mp[s.TypeID()] = []string{}
 		for _, child := range s.Children {
-			mp[s.TypeID] = append(mp[s.TypeID], child.TypeID)
+			mp[s.TypeID()] = append(mp[s.TypeID()], child.TypeID())
 			visit(child)
 		}
-		sort.Strings(mp[s.TypeID])
+		sort.Strings(mp[s.TypeID()])
 	}
 	visit(s)
 	return mp
diff --git a/cmd/stree.go b/cmd/stree.go
index 95c332d..861cbcb 100644
--- a/cmd/stree.go
+++ b/cmd/stree.go
@@ -50,12 +50,12 @@ func streeMain(cmd *cobra.Command, args []string) exitCode {
 }
 
 func fill(stree treeprint.Tree, schema *apitypes.EntrySchema) treeprint.Tree {
-	value := schema.Label
-	if !schema.Singleton {
+	value := schema.Label()
+	if !schema.Singleton() {
 		value = fmt.Sprintf("[%v]", value)
 	}
 	stree.SetValue(value)
-	for _, child := range schema.Children {
+	for _, child := range schema.Children() {
 		// treeprint.Tree has no "AddBranch()" method, so we need to
 		// set a stub value. Note that the value will be reset to the
 		// correct value in the recursive call, so this is OK.
diff --git a/docs/docs/index.html b/docs/docs/index.html
index c4bf61a..a335c1e 100644
--- a/docs/docs/index.html
+++ b/docs/docs/index.html
@@ -70,8 +70,7 @@
 <li><a href="#plugin-concepts">Plugin Concepts</a>
 
 <ul>
-<li><a href="#plugin-debugging">Plugin Debugging</a></li>
-<li><a href="#attributes/metadata">Attributes/Metadata</a></li>
+<li><a href="#attributes-metadata">Attributes/Metadata</a></li>
 <li><a href="/wash/docs/external_plugins">➠External plugins</a></li>
 <li><a href="/wash/docs/core_plugins">➠Core Plugins</a></li>
 <li><a href="/wash/docs/api">➠Server API</a></li>
@@ -237,64 +236,8 @@ to display running processes on all listed nodes. Errors on paths that don&rsquo
 <li><a href="/wash/docs/api">➠Server API</a></li>
 </ul>
 
-<p>NOTE: We recommend that you read the <a href="#attributes/metadata">Attributes/Metadata</a> section before reading the plugin tutorials to take full advantage of Wash&rsquo;s capabilities, especially that of <code>wash find</code>&rsquo;s.</p>
+<p>NOTE: We recommend that you read the <code>Attributes/Metadata</code> section before reading the plugin tutorials to take full advantage of Wash&rsquo;s capabilities, especially that of <code>wash find</code>&rsquo;s.</p>
 
-<h3 id="plugin-debugging">Plugin Debugging</h3>
-
-<p>Plugin-related activity is currently logged at <code>debug</code> level. You can control logging with the <code>loglevel</code> and <code>logfile</code> options to <code>wash</code> or <code>wash server</code>. So when developing a plugin, it&rsquo;s useful to start your shell with</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">wash --loglevel debug --logfile &lt;file&gt;</pre></td></tr></table>
-</div>
-</div>
-<p>then <code>tail -f &lt;file&gt;</code> in another terminal to see what Wash is doing.</p>
-
-<p>For external plugins, those logs will include the commands used for all invocations of your plugin script and the responses. For example</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">level=debug msg=&#34;Invoking /washreads/goodreads list /goodreads \\{\\\&#34;userid\\\&#34;:\\\&#34;12345678\\\&#34;}&#34;
-level=debug msg=&#34;stdout: [{\&#34;name\&#34;:\&#34;read\&#34;,\&#34;methods\&#34;:[\&#34;list\&#34;],\&#34;attributes\&#34;:{\&#34;meta\&#34;:{\&#34;id\&#34;:\&#34;53094184\&#34;,\&#34;book_count\&#34;:\&#34;494\&#34;,\&#34;exclusive_flag\&#34;:\&#34;true\&#34;,\&#34;description\&#34;:\&#34;\&#34;,\&#34;sort\&#34;:\&#34;\&#34;,\&#34;order\&#34;:\&#34;\&#34;,\&#34;per_page\&#34;:\&#34;\&#34;,\&#34;display_fields\&#34;:\&#34;\&#34;,\&#34;featured\&#34;:\&#34;true\&#34;,\&#34;recommend_for\&#34;:\&#34;false\&#34;,\&#34;sticky\&#34;:\&#34;\&#34;}},\&#34;state\&#34;:\&#34;{\\\&#34;type\\\&#34;:\\\&#34;shelf\\\&#34;,\\\&#34;name\\\&#34;:\\\&#34;read\\\&#34;,\\\&#34;userid\\\&#34;:\\\&#34;16580428\\\&#34;,\\\&#34;count\\\&#34;:494}\&#34;},...]\n&#34;
-level=debug msg=&#34;stderr: something&#39;s happening&#34;</pre></td></tr></table>
-</div>
-</div>
-<p>Activity related to a specific operation are always available in the <code>history</code> entry for that operation. Given a history entry like</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">$ whistory
-1  2019-06-20 13:47  wash whistory
-2  2019-06-20 13:47  ls -pG goodreads</pre></td></tr></table>
-</div>
-</div>
-<p>we can view activity related to that command with</p>
-<div class="highlight"><div style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">
-<table style="border-spacing:0;padding:0;margin:0;border:0;width:auto;overflow:auto;display:block;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
-</span><span style="margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
-</span></pre></td>
-<td style="vertical-align:top;padding:0;margin:0;border:0;">
-<pre style="background-color:#fff;-moz-tab-size:4;-o-tab-size:4;tab-size:4">$ whistory 2
-Jun 20 13:47:09.212 FUSE: List /goodreads
-Jun 20 13:47:09.212 Invoking /Users/michaelsmith/puppetlabs/washreads/goodreads list /goodreads \{\&#34;userid\&#34;:\&#34;16580428\&#34;}
-Jun 20 13:47:09.886 stdout: [{&#34;name&#34;:&#34;read&#34;,&#34;methods&#34;:[&#34;list&#34;],&#34;attributes&#34;:{&#34;meta&#34;:{&#34;id&#34;:&#34;53094184&#34;,&#34;book_count&#34;:&#34;494&#34;,&#34;exclusive_flag&#34;:&#34;true&#34;,&#34;description&#34;:&#34;&#34;,&#34;sort&#34;:&#34;&#34;,&#34;order&#34;:&#34;&#34;,&#34;per_page&#34;:&#34;&#34;,&#34;display_fields&#34;:&#34;&#34;,&#34;featured&#34;:&#34;true&#34;,&#34;recommend_for&#34;:&#34;false&#34;,&#34;sticky&#34;:&#34;&#34;}},&#34;state&#34;:&#34;{\&#34;type\&#34;:\&#34;shelf\&#34;,\&#34;name\&#34;:\&#34;read\&#34;,\&#34;userid\&#34;:\&#34;16580428\&#34;,\&#34;count\&#34;:494}&#34;},...]
-Jun 20 13:47:09.886 FUSE: Listed in /goodreads: [{Inode:0 Type:dir Name:read} {Inode:0 Type:dir Name:currently-reading} {Inode:0 Type:dir Name:to-read} {Inode:0 Type:dir Name:fantasy} {Inode:0 Type:dir Name:science-fiction}]</pre></td></tr></table>
-</div>
-</div>
 <h3 id="attributes-metadata">Attributes/Metadata</h3>
 
 <p>All entries have metadata, which is a JSON object containing a complete description of the entry. For example, a Docker container&rsquo;s metadata includes its labels, its state, its start time, the image it was built from, its mounted volumes, etc. <a href="#wash-find"><code>wash find</code></a> can filter on this metadata. In our example, you can use <code>find docker/containers -daystart -fullmeta -m .state .startedAt -{1d} -a .status running</code> to see a list of all running containers that started today (try it out!). Thus, metadata filtering is powerful. However, it also requires the user to query an entry&rsquo;s metadata to construct the filter. Creating a filter on the same property that&rsquo;s shared by many different kinds of entries is repetitive, error-prone, and an obvious candidate for usability improvement. For example, metadata filtering gets annoying when you are trying to filter on an EC2 instance&rsquo;s/Docker container&rsquo;s/Kubernetes pod&rsquo;s state due to the structural differences in their metadata (e.g. an EC2 instance&rsquo;s state is contained in the <code>.state.name</code> key, while a Kubernetes pod&rsquo;s state is contained in the <code>.status.phase</code> key). Metadata filtering is also slow. It requires O(N) API requests, where N is the number of visited entries.</p>
diff --git a/plugin/aws/ec2Dir.go b/plugin/aws/ec2Dir.go
index 241560b..1539131 100644
--- a/plugin/aws/ec2Dir.go
+++ b/plugin/aws/ec2Dir.go
@@ -17,19 +17,11 @@ type ec2Dir struct {
 	entries []plugin.Entry
 }
 
-func ec2DirBase() *ec2Dir {
+func newEC2Dir(session *session.Session) *ec2Dir {
 	ec2Dir := &ec2Dir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("ec2"),
 	}
-	ec2Dir.
-		SetName("ec2").
-		IsSingleton().
-		DisableDefaultCaching()
-	return ec2Dir
-}
-
-func newEC2Dir(session *session.Session) *ec2Dir {
-	ec2Dir := ec2DirBase()
+	ec2Dir.DisableDefaultCaching()
 	ec2Dir.session = session
 	ec2Dir.client = ec2Client.New(session)
 
@@ -40,8 +32,14 @@ func newEC2Dir(session *session.Session) *ec2Dir {
 	return ec2Dir
 }
 
+func (e *ec2Dir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(e, "ec2").IsSingleton()
+}
+
 func (e *ec2Dir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(ec2InstancesDirBase())
+	return []*plugin.EntrySchema{
+		(&ec2InstancesDir{}).Schema(),
+	}
 }
 
 func (e *ec2Dir) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 9adc71a..53a0fc5 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -40,17 +40,6 @@ const (
 	EC2InstanceStopped           = 80
 )
 
-func ec2InstanceBase() *ec2Instance {
-	ec2Instance := &ec2Instance{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	ec2Instance.
-		SetLabel("instance").
-		SetTTLOf(plugin.ListOp, 30*time.Second).
-		DisableCachingFor(plugin.MetadataOp)
-	return ec2Instance
-}
-
 func newEC2Instance(ctx context.Context, inst *ec2Client.Instance, session *session.Session, client *ec2Client.EC2) *ec2Instance {
 	id := awsSDK.StringValue(inst.InstanceId)
 	name := id
@@ -64,12 +53,14 @@ func newEC2Instance(ctx context.Context, inst *ec2Client.Instance, session *sess
 			break
 		}
 	}
-	ec2Instance := ec2InstanceBase()
+	ec2Instance := &ec2Instance{
+		EntryBase: plugin.NewEntry(name),
+	}
 	ec2Instance.id = id
 	ec2Instance.session = session
 	ec2Instance.client = client
 	ec2Instance.
-		SetName(name).
+		SetTTLOf(plugin.ListOp, 30*time.Second).
 		SetAttributes(getAttributes(inst))
 
 	return ec2Instance
@@ -110,12 +101,16 @@ func getAttributes(inst *ec2Client.Instance) plugin.EntryAttributes {
 	return attr
 }
 
+func (inst *ec2Instance) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(inst, "instance")
+}
+
 func (inst *ec2Instance) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(
-		ec2InstanceConsoleOutputBase(),
-		ec2InstanceMetadataJSONBase(),
-		volume.FSBase("fs"),
-	)
+	return []*plugin.EntrySchema{
+		(&ec2InstanceConsoleOutput{}).Schema(),
+		(&ec2InstanceMetadataJSON{}).Schema(),
+		(&volume.FS{}).Schema().SetLabel("fs"),
+	}
 }
 
 func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/aws/ec2InstanceConsoleOutput.go b/plugin/aws/ec2InstanceConsoleOutput.go
index c2bb47e..b6efc43 100644
--- a/plugin/aws/ec2InstanceConsoleOutput.go
+++ b/plugin/aws/ec2InstanceConsoleOutput.go
@@ -19,23 +19,15 @@ type ec2InstanceConsoleOutput struct {
 	latest bool
 }
 
-func ec2InstanceConsoleOutputBase() *ec2InstanceConsoleOutput {
-	cl := &ec2InstanceConsoleOutput{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	cl.SetLabel("console.out").DisableDefaultCaching()
-	return cl
-}
-
 func newEC2InstanceConsoleOutput(ctx context.Context, inst *ec2Instance, latest bool) (*ec2InstanceConsoleOutput, error) {
-	cl := ec2InstanceConsoleOutputBase()
+	cl := &ec2InstanceConsoleOutput{}
 	cl.inst = inst
 	cl.latest = latest
 
 	if cl.latest {
-		cl.SetName("console-latest.out")
+		cl.EntryBase = plugin.NewEntry("console-latest.out")
 	} else {
-		cl.SetName("console.out")
+		cl.EntryBase = plugin.NewEntry("console.out")
 	}
 
 	output, err := cl.cachedConsoleOutput(ctx)
@@ -90,6 +82,10 @@ func (cl *ec2InstanceConsoleOutput) cachedConsoleOutput(ctx context.Context) (co
 	return output.(consoleOutput), nil
 }
 
+func (cl *ec2InstanceConsoleOutput) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(cl, "console.out")
+}
+
 func (cl *ec2InstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
 	output, err := cl.cachedConsoleOutput(ctx)
 	if err != nil {
diff --git a/plugin/aws/ec2InstanceMetadataJSON.go b/plugin/aws/ec2InstanceMetadataJSON.go
index e361ede..0a1435a 100644
--- a/plugin/aws/ec2InstanceMetadataJSON.go
+++ b/plugin/aws/ec2InstanceMetadataJSON.go
@@ -15,19 +15,11 @@ type ec2InstanceMetadataJSON struct {
 	inst *ec2Instance
 }
 
-func ec2InstanceMetadataJSONBase() *ec2InstanceMetadataJSON {
+func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2InstanceMetadataJSON, error) {
 	im := &ec2InstanceMetadataJSON{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("metadata.json"),
 	}
-	im.
-		SetName("metadata.json").
-		IsSingleton().
-		DisableDefaultCaching()
-	return im
-}
-
-func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2InstanceMetadataJSON, error) {
-	im := ec2InstanceMetadataJSONBase()
+	im.DisableDefaultCaching()
 	im.inst = inst
 
 	content, err := im.Open(ctx)
@@ -39,6 +31,10 @@ func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2Ins
 	return im, nil
 }
 
+func (im *ec2InstanceMetadataJSON) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(im, "metadata.json").IsSingleton()
+}
+
 func (im *ec2InstanceMetadataJSON) Open(ctx context.Context) (plugin.SizedReader, error) {
 	meta, err := plugin.CachedMetadata(ctx, im.inst)
 	if err != nil {
diff --git a/plugin/aws/ec2InstancesDir.go b/plugin/aws/ec2InstancesDir.go
index b7f762c..1ef3b29 100644
--- a/plugin/aws/ec2InstancesDir.go
+++ b/plugin/aws/ec2InstancesDir.go
@@ -21,23 +21,23 @@ type ec2InstancesDir struct {
 	client  *ec2Client.EC2
 }
 
-func ec2InstancesDirBase() *ec2InstancesDir {
+func newEC2InstancesDir(session *session.Session, client *ec2Client.EC2) *ec2InstancesDir {
 	ec2InstancesDir := &ec2InstancesDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("instances"),
 	}
-	ec2InstancesDir.SetName("instances").IsSingleton()
-	return ec2InstancesDir
-}
-
-func newEC2InstancesDir(session *session.Session, client *ec2Client.EC2) *ec2InstancesDir {
-	ec2InstancesDir := ec2InstancesDirBase()
 	ec2InstancesDir.session = session
 	ec2InstancesDir.client = client
 	return ec2InstancesDir
 }
 
+func (is *ec2InstancesDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(is, "instances").IsSingleton()
+}
+
 func (is *ec2InstancesDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(ec2InstanceBase())
+	return []*plugin.EntrySchema{
+		(&ec2Instance{}).Schema(),
+	}
 }
 
 func (is *ec2InstancesDir) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/aws/profile.go b/plugin/aws/profile.go
index 0e996cc..8528b8b 100644
--- a/plugin/aws/profile.go
+++ b/plugin/aws/profile.go
@@ -18,17 +18,11 @@ type profile struct {
 	resourcesDir []plugin.Entry
 }
 
-func profileBase() *profile {
+func newProfile(ctx context.Context, name string) (*profile, error) {
 	profile := &profile{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	profile.SetLabel("profile").DisableDefaultCaching()
-	return profile
-}
-
-func newProfile(ctx context.Context, name string) (*profile, error) {
-	profile := profileBase()
-	profile.SetName(name)
+	profile.DisableDefaultCaching()
 
 	activity.Record(ctx, "Creating a new AWS session for the %v profile", name)
 
@@ -68,8 +62,14 @@ func newProfile(ctx context.Context, name string) (*profile, error) {
 	return profile, nil
 }
 
+func (p *profile) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(p, "profile")
+}
+
 func (p *profile) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(resourcesDirBase())
+	return []*plugin.EntrySchema{
+		(&resourcesDir{}).Schema(),
+	}
 }
 
 // List lists the resources directory
diff --git a/plugin/aws/resourcesDir.go b/plugin/aws/resourcesDir.go
index 3f04aef..8926f64 100644
--- a/plugin/aws/resourcesDir.go
+++ b/plugin/aws/resourcesDir.go
@@ -14,19 +14,11 @@ type resourcesDir struct {
 	resources []plugin.Entry
 }
 
-func resourcesDirBase() *resourcesDir {
+func newResourcesDir(session *session.Session) *resourcesDir {
 	resourcesDir := &resourcesDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("resources"),
 	}
-	resourcesDir.
-		SetName("resources").
-		IsSingleton().
-		DisableDefaultCaching()
-	return resourcesDir
-}
-
-func newResourcesDir(session *session.Session) *resourcesDir {
-	resourcesDir := resourcesDirBase()
+	resourcesDir.DisableDefaultCaching()
 	resourcesDir.session = session
 
 	resourcesDir.resources = []plugin.Entry{
@@ -37,8 +29,15 @@ func newResourcesDir(session *session.Session) *resourcesDir {
 	return resourcesDir
 }
 
+func (r *resourcesDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(r, "resources").IsSingleton()
+}
+
 func (r *resourcesDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(s3DirBase(), ec2DirBase())
+	return []*plugin.EntrySchema{
+		(&s3Dir{}).Schema(),
+		(&ec2Dir{}).Schema(),
+	}
 }
 
 // List lists the available AWS resources
diff --git a/plugin/aws/root.go b/plugin/aws/root.go
index 424f7d9..f74d998 100644
--- a/plugin/aws/root.go
+++ b/plugin/aws/root.go
@@ -62,8 +62,7 @@ func exists(path string) error {
 
 // Init for root
 func (r *Root) Init(cfg map[string]interface{}) error {
-	r.EntryBase = plugin.NewEntryBase()
-	r.SetName("aws")
+	r.EntryBase = plugin.NewEntry("aws")
 	r.SetTTLOf(plugin.ListOp, 1*time.Minute)
 
 	if profsI, ok := cfg["profiles"]; ok {
@@ -88,7 +87,14 @@ func (r *Root) Init(cfg map[string]interface{}) error {
 
 // ChildSchemas returns the root's child schema
 func (r *Root) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(profileBase())
+	return []*plugin.EntrySchema{
+		(&profile{}).Schema(),
+	}
+}
+
+// Schema returns the root's schema
+func (r *Root) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(r, "aws").IsSingleton()
 }
 
 // List the available AWS profiles
diff --git a/plugin/aws/s3Bucket.go b/plugin/aws/s3Bucket.go
index 4c0f357..71c8ec8 100644
--- a/plugin/aws/s3Bucket.go
+++ b/plugin/aws/s3Bucket.go
@@ -151,21 +151,14 @@ type s3Bucket struct {
 	session *session.Session
 }
 
-func s3BucketBase() *s3Bucket {
+func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Bucket {
 	bucket := &s3Bucket{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	bucket.SetLabel("bucket")
-	return bucket
-}
-
-func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Bucket {
-	bucket := s3BucketBase()
 	bucket.ctime = ctime
 	bucket.client = s3Client.New(session)
 	bucket.session = session
 	bucket.
-		SetName(name).
 		Attributes().
 		SetCtime(bucket.ctime).
 		SetMtime(bucket.ctime).
@@ -174,8 +167,12 @@ func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Buck
 	return bucket
 }
 
+func (b *s3Bucket) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(b, "bucket")
+}
+
 func (b *s3Bucket) ChildSchemas() []*plugin.EntrySchema {
-	return s3ObjectPrefixBase().ChildSchemas()
+	return (&s3ObjectPrefix{}).ChildSchemas()
 }
 
 func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/aws/s3Dir.go b/plugin/aws/s3Dir.go
index 9fd1ed7..951787b 100644
--- a/plugin/aws/s3Dir.go
+++ b/plugin/aws/s3Dir.go
@@ -18,23 +18,23 @@ type s3Dir struct {
 	client  *s3Client.S3
 }
 
-func s3DirBase() *s3Dir {
+func newS3Dir(session *session.Session) *s3Dir {
 	s3Dir := &s3Dir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("s3"),
 	}
-	s3Dir.SetName("s3").IsSingleton()
-	return s3Dir
-}
-
-func newS3Dir(session *session.Session) *s3Dir {
-	s3Dir := s3DirBase()
 	s3Dir.session = session
 	s3Dir.client = s3Client.New(session)
 	return s3Dir
 }
 
+func (s *s3Dir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(s, "s3").IsSingleton()
+}
+
 func (s *s3Dir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(s3BucketBase())
+	return []*plugin.EntrySchema{
+		(&s3Bucket{}).Schema(),
+	}
 }
 
 // List lists the buckets.
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index 6e1ca06..c7cd76c 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -22,22 +22,14 @@ type s3Object struct {
 	client *s3Client.S3
 }
 
-func s3ObjectBase() *s3Object {
+func newS3Object(o *s3Client.Object, name string, bucket string, key string, client *s3Client.S3) *s3Object {
 	s3Obj := &s3Object{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	s3Obj.
-		SetLabel("object").
-		DisableCachingFor(plugin.MetadataOp)
-	return s3Obj
-}
-
-func newS3Object(o *s3Client.Object, name string, bucket string, key string, client *s3Client.S3) *s3Object {
-	s3Obj := s3ObjectBase()
+	s3Obj.DisableCachingFor(plugin.MetadataOp)
 	s3Obj.bucket = bucket
 	s3Obj.key = key
 	s3Obj.client = client
-	s3Obj.SetName(name)
 
 	// S3 objects do not have a "creation time"; they're treated as atomic
 	// blobs that get replaced whenever the user uploads new data. Thus, we
@@ -75,6 +67,10 @@ func (o *s3Object) cachedHeadObject(ctx context.Context) (*s3Client.HeadObjectOu
 	return resp.(*s3Client.HeadObjectOutput), nil
 }
 
+func (o *s3Object) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(o, "object")
+}
+
 func (o *s3Object) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	metadata, err := o.cachedHeadObject(ctx)
 	if err != nil {
diff --git a/plugin/aws/s3ObjectPrefix.go b/plugin/aws/s3ObjectPrefix.go
index 7744f43..a33a728 100644
--- a/plugin/aws/s3ObjectPrefix.go
+++ b/plugin/aws/s3ObjectPrefix.go
@@ -19,25 +19,25 @@ type s3ObjectPrefix struct {
 	client *s3Client.S3
 }
 
-func s3ObjectPrefixBase() *s3ObjectPrefix {
+func newS3ObjectPrefix(name string, bucket string, prefix string, client *s3Client.S3) *s3ObjectPrefix {
 	objPrefix := &s3ObjectPrefix{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	objPrefix.SetLabel("prefix")
-	return objPrefix
-}
-
-func newS3ObjectPrefix(name string, bucket string, prefix string, client *s3Client.S3) *s3ObjectPrefix {
-	objPrefix := s3ObjectPrefixBase()
 	objPrefix.bucket = bucket
 	objPrefix.prefix = prefix
 	objPrefix.client = client
-	objPrefix.SetName(name)
 	return objPrefix
 }
 
+func (d *s3ObjectPrefix) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(d, "prefix")
+}
+
 func (d *s3ObjectPrefix) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(s3ObjectPrefixBase(), s3ObjectBase())
+	return []*plugin.EntrySchema{
+		(&s3ObjectPrefix{}).Schema(),
+		(&s3Object{}).Schema(),
+	}
 }
 
 // List lists all S3 objects and S3 object prefixes that are
diff --git a/plugin/docker/container-log.go b/plugin/docker/container-log.go
index e4f4e3a..c6f13dc 100644
--- a/plugin/docker/container-log.go
+++ b/plugin/docker/container-log.go
@@ -18,16 +18,10 @@ type containerLogFile struct {
 	client        *client.Client
 }
 
-func containerLogFileBase() *containerLogFile {
+func newContainerLogFile(container *container) *containerLogFile {
 	clf := &containerLogFile{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("log"),
 	}
-	clf.SetName("log").IsSingleton()
-	return clf
-}
-
-func newContainerLogFile(container *container) *containerLogFile {
-	clf := containerLogFileBase()
 	clf.containerName = container.id
 	clf.client = container.client
 	return clf
@@ -43,6 +37,10 @@ func (clf *containerLogFile) isTty(ctx context.Context) bool {
 	return true
 }
 
+func (clf *containerLogFile) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(clf, "log").IsSingleton()
+}
+
 func (clf *containerLogFile) Open(ctx context.Context) (plugin.SizedReader, error) {
 	opts := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}
 	rdr, err := clf.client.ContainerLogs(ctx, clf.containerName, opts)
diff --git a/plugin/docker/container-metadata.go b/plugin/docker/container-metadata.go
index 9619023..9d27056 100644
--- a/plugin/docker/container-metadata.go
+++ b/plugin/docker/container-metadata.go
@@ -13,21 +13,17 @@ type containerMetadata struct {
 	container *container
 }
 
-func containerMetadataBase() *containerMetadata {
+func newContainerMetadata(container *container) *containerMetadata {
 	cm := &containerMetadata{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("metadata.json"),
 	}
-	cm.
-		SetName("metadata.json").
-		IsSingleton().
-		DisableDefaultCaching()
+	cm.DisableDefaultCaching()
+	cm.container = container
 	return cm
 }
 
-func newContainerMetadata(container *container) *containerMetadata {
-	cm := containerMetadataBase()
-	cm.container = container
-	return cm
+func (cm *containerMetadata) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(cm, "metadata.json").IsSingleton()
 }
 
 func (cm *containerMetadata) Open(ctx context.Context) (plugin.SizedReader, error) {
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index 4fe470e..e68b735 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -21,19 +21,7 @@ type container struct {
 	client *client.Client
 }
 
-func containerBase() *container {
-	cont := &container{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	cont.SetLabel("container")
-	return cont
-}
-
 func newContainer(inst types.Container, client *client.Client) *container {
-	cont := containerBase()
-	cont.id = inst.ID
-	cont.client = client
-
 	name := inst.ID
 	if len(inst.Names) > 0 {
 		// The docker API prefixes all names with '/', so remove that.
@@ -43,7 +31,11 @@ func newContainer(inst types.Container, client *client.Client) *container {
 		// it appears to always be a single name, so take the first as the canonical name.
 		name = strings.TrimPrefix(inst.Names[0], "/")
 	}
-	cont.SetName(name)
+	cont := &container{
+		EntryBase: plugin.NewEntry(name),
+	}
+	cont.id = inst.ID
+	cont.client = client
 
 	startTime := time.Unix(inst.Created, 0)
 	cont.
@@ -66,12 +58,16 @@ func (c *container) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	return plugin.ToJSONObject(raw), nil
 }
 
+func (c *container) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(c, "container")
+}
+
 func (c *container) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(
-		containerLogFileBase(),
-		containerMetadataBase(),
-		vol.FSBase("fs"),
-	)
+	return []*plugin.EntrySchema{
+		(&containerLogFile{}).Schema(),
+		(&containerMetadata{}).Schema(),
+		(&vol.FS{}).Schema().SetLabel("fs"),
+	}
 }
 
 func (c *container) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/docker/containersDir.go b/plugin/docker/containersDir.go
index 1c75a05..aa6dc28 100644
--- a/plugin/docker/containersDir.go
+++ b/plugin/docker/containersDir.go
@@ -14,22 +14,22 @@ type containersDir struct {
 	client *client.Client
 }
 
-func containersDirBase() *containersDir {
+func newContainersDir(client *client.Client) *containersDir {
 	containersDir := &containersDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("containers"),
 	}
-	containersDir.SetName("containers").IsSingleton()
+	containersDir.client = client
 	return containersDir
 }
 
-func newContainersDir(client *client.Client) *containersDir {
-	containersDir := containersDirBase()
-	containersDir.client = client
-	return containersDir
+func (cs *containersDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(cs, "containers").IsSingleton()
 }
 
 func (cs *containersDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(containerBase())
+	return []*plugin.EntrySchema{
+		(&container{}).Schema(),
+	}
 }
 
 // List
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index 15ff82a..495fb38 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -26,8 +26,7 @@ func (r *Root) Init(map[string]interface{}) error {
 		return err
 	}
 
-	r.EntryBase = plugin.NewEntryBase()
-	r.SetName("docker")
+	r.EntryBase = plugin.NewEntry("docker")
 	r.DisableDefaultCaching()
 	r.resources = []plugin.Entry{
 		newContainersDir(dockerCli),
@@ -37,9 +36,17 @@ func (r *Root) Init(map[string]interface{}) error {
 	return nil
 }
 
+// Schema returns the root's schema
+func (r *Root) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(r, "docker").IsSingleton()
+}
+
 // ChildSchemas returns the root's child schema
 func (r *Root) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(containersDirBase(), volumesDirBase())
+	return []*plugin.EntrySchema{
+		(&containersDir{}).Schema(),
+		(&volumesDir{}).Schema(),
+	}
 }
 
 // List lists the types of resources the Docker plugin exposes.
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index 7719601..dad59d5 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -26,24 +26,17 @@ type volume struct {
 
 const mountpoint = "/mnt"
 
-func volumeBase() *volume {
-	vol := &volume{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	vol.SetLabel("volume")
-	return vol
-}
-
 func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 	startTime, err := time.Parse(time.RFC3339, v.CreatedAt)
 	if err != nil {
 		return nil, err
 	}
 
-	vol := volumeBase()
+	vol := &volume{
+		EntryBase: plugin.NewEntry(v.Name),
+	}
 	vol.client = c
 	vol.
-		SetName(v.Name).
 		Attributes().
 		SetCtime(startTime).
 		SetMtime(startTime).
@@ -53,6 +46,10 @@ func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 	return vol, nil
 }
 
+func (v *volume) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(v, "volume")
+}
+
 func (v *volume) ChildSchemas() []*plugin.EntrySchema {
 	return vol.ChildSchemas()
 }
diff --git a/plugin/docker/volumesDir.go b/plugin/docker/volumesDir.go
index 4ea7eea..1072a45 100644
--- a/plugin/docker/volumesDir.go
+++ b/plugin/docker/volumesDir.go
@@ -14,22 +14,22 @@ type volumesDir struct {
 	client *client.Client
 }
 
-func volumesDirBase() *volumesDir {
+func newVolumesDir(client *client.Client) *volumesDir {
 	volumesDir := &volumesDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("volumes"),
 	}
-	volumesDir.SetName("volumes").IsSingleton()
+	volumesDir.client = client
 	return volumesDir
 }
 
-func newVolumesDir(client *client.Client) *volumesDir {
-	volumesDir := volumesDirBase()
-	volumesDir.client = client
-	return volumesDir
+func (vs *volumesDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(vs, "volumes").IsSingleton()
 }
 
 func (vs *volumesDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(volumeBase())
+	return []*plugin.EntrySchema{
+		(&volume{}).Schema(),
+	}
 }
 
 // List
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 6f4376c..7097479 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -21,12 +21,12 @@ var defaultOpCodeToNameMap = [3]string{"List", "Open", "Metadata"}
 
 /*
 EntryBase implements Entry, making it easy to create new entries.
-You should use plugin.NewEntryBaseBase to create new EntryBase objects.
+You should use plugin.NewEntry to create new EntryBase objects.
 
 Each of the setters supports the builder pattern, which enables you
 to do something like
 
-	e := plugin.NewEntryBaseBase()
+	e := plugin.NewEntry("foo")
 	e.
 		DisableCachingFor(plugin.ListOp).
 		Attributes().
@@ -39,15 +39,18 @@ type EntryBase struct {
 	attr               EntryAttributes
 	slashReplacerCh    rune
 	// washID represents the entry's wash ID. It is set in CachedList.
-	washID             string
-	ttl                [3]time.Duration
-	label              string
-	isSingleton        bool
+	washID string
+	ttl    [3]time.Duration
 }
 
-// NewEntryBase creates a new EntryBase object
-func NewEntryBase() EntryBase {
+// NewEntry creates a new entry
+func NewEntry(name string) EntryBase {
+	if name == "" {
+		panic("plugin.NewEntry: received an empty name")
+	}
+
 	e := EntryBase{
+		entryName:          name,
 		slashReplacerCh: '#',
 	}
 	for op := range e.ttl {
@@ -92,29 +95,16 @@ func (e *EntryBase) getTTLOf(op defaultOpCode) time.Duration {
 	return e.ttl[op]
 }
 
-func (e *EntryBase) entryBase() *EntryBase {
-	return e
-}
-
 // OTHER METHODS USED TO FACILITATE PLUGIN DEVELOPMENT
 // AND TESTING
 
 // Name returns the entry's name as it was passed into
-// e.SetName. You should use e.Name() when making the
-// appropriate API calls within your plugin.
+// plugin.NewEntry. You should use e.Name() when making
+// the appropriate API calls within your plugin.
 func (e *EntryBase) Name() string {
 	return e.name()
 }
 
-// SetName sets the entry's name.
-func (e *EntryBase) SetName(name string) *EntryBase {
-	if name == "" {
-		panic("e.SetName: received an empty name")
-	}
-	e.entryName = name
-	return e
-}
-
 // Attributes returns a pointer to the entry's attributes. Use it
 // to individually set the entry's attributes
 func (e *EntryBase) Attributes() *EntryAttributes {
@@ -143,44 +133,6 @@ func (e *EntryBase) SetSlashReplacer(char rune) *EntryBase {
 	return e
 }
 
-/*
-SetLabel sets the entry's label, which is a shortened version
-of the class/struct name. It is useful when documenting your
-plugin's hierarchy.
-
-NOTE: If your entry's a singleton, then Wash will default to using the
-entry's cname as its label.
-
-TODO: Give an example of why this is important once the stree command's
-implemented.
-*/
-func (e *EntryBase) SetLabel(label string) *EntryBase {
-	if len(label) == 0 {
-		panic("e.SetLabel called with an empty label")
-	}
-	e.label = label
-	return e
-}
-
-/*
-IsSingleton indicates that the given entry's a singleton, meaning there
-will only ever be one instance of the entry. It is useful when documenting
-your plugin's hierarchy.
-
-NOTE: If the entry's short type was not set, then IsSingleton sets it to
-the entry's cname.
-
-TODO: Give an example of why this is important once the stree command's
-implemented.
-*/
-func (e *EntryBase) IsSingleton() *EntryBase {
-	e.isSingleton = true
-	if len(e.label) == 0 {
-		e.SetLabel(CName(e))
-	}
-	return e
-}
-
 // SetTTLOf sets the specified op's TTL
 func (e *EntryBase) SetTTLOf(op defaultOpCode, ttl time.Duration) *EntryBase {
 	e.ttl[op] = ttl
diff --git a/plugin/entrySchema.go b/plugin/entrySchema.go
new file mode 100644
index 0000000..235422f
--- /dev/null
+++ b/plugin/entrySchema.go
@@ -0,0 +1,207 @@
+package plugin
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"strings"
+)
+
+type entrySchema struct {
+	TypeID    string         `json:"type_id"`
+	Label     string         `json:"label"`
+	Singleton bool           `json:"singleton"`
+	Actions   []string       `json:"actions"`
+	Children  []*EntrySchema `json:"children"`
+	entry     Entry
+}
+
+// EntrySchema represents an entry's schema. Use plugin.NewEntrySchema
+// to create instances of these objects.
+//
+// EntrySchema's a useful way to document your plugin's hierarchy. Users
+// can view your hierarchy via the stree command. For example, if you
+// invoke `stree docker` in a Wash shell (try it!), you should see something
+// like
+//
+// docker
+// ├── containers
+// │   └── [container]
+// │       ├── log
+// │       ├── metadata.json
+// │       └── fs
+// │           ├── [dir]
+// │           │   ├── [dir]
+// │           │   └── [file]
+// │           └── [file]
+// └── volumes
+//     └── [volume]
+//         ├── [dir]
+//         │   ├── [dir]
+//         │   └── [file]
+// 		└── [file]
+//
+// (Your output may differ depending on the state of the Wash project, but it
+// should be similarly structured).
+//
+// Every node must have a label. The "[]" are printed for non-singleton nodes;
+// they imply multiple instances of this thing. For example, "[container]" means
+// that there will be multiple "container" instances under the "containers" directory
+// ("container" is the label that was passed into NewEntrySchema). Similarly, "containers"
+// means that there will be only one "containers" directory (i.e. that "containers" is a
+// singleton). You can use EntrySchema#IsSingleton() to mark your entry as a singleton.
+//
+// TODO: Talk about how metadata schema's used to optimize `wash find` once that
+// is added.
+type EntrySchema struct {
+	// This pattern's a nice way of making JSON marshalling/unmarshalling
+	// easy without having to export these fields via the godocs. The latter
+	// is good because plugin authors should use the builders when setting them
+	// (so that we maintain a consistent API for e.g. metadata schemas).
+	//
+	// This pattern was obtained from https://stackoverflow.com/a/11129474
+	entrySchema
+}
+
+// NewEntrySchema returns a new EntrySchema object with the specified label.
+//
+// NOTE: If your entry's a singleton, then the label should match the entry's
+// name, i.e. the name that's passed into plugin.NewEntry.
+func NewEntrySchema(e Entry, label string) *EntrySchema {
+	s := &EntrySchema{
+		entrySchema: entrySchema{
+			TypeID:  strings.TrimPrefix(reflect.TypeOf(e).String(), "*"),
+			Actions: SupportedActionsOf(e),
+			// Store the entry so that when marshalling, we can enumerate
+			// its child schemas.
+			entry: e,
+		},
+	}
+	s.SetLabel(label)
+	return s
+}
+
+// MarshalJSON marshals the entry's 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 EntrySchema) MarshalJSON() ([]byte, error) {
+	return json.Marshal(s.entrySchema)
+}
+
+// UnmarshalJSON unmarshals the entry's schema from JSON.
+func (s *EntrySchema) UnmarshalJSON(data []byte) error {
+	var es entrySchema
+	if err := json.Unmarshal(data, &es); err != nil {
+		return err
+	}
+	s.entrySchema = es
+	return nil
+}
+
+// TypeID returns the entry's type ID. This should be unique.
+func (s *EntrySchema) TypeID() string {
+	return s.entrySchema.TypeID
+}
+
+// SetTypeID sets the entry's type ID. Only the tests
+// can call this method.
+func (s *EntrySchema) SetTypeID(typeID string) *EntrySchema {
+	if notRunningTests() {
+		panic("s.SetTypeID can only be called by the tests")
+	}
+	s.entrySchema.TypeID = typeID
+	return s
+}
+
+// Label returns the entry's label
+func (s *EntrySchema) Label() string {
+	return s.entrySchema.Label
+}
+
+/*
+SetLabel overrides the entry's label. You should only override the
+label if you are extending a helper type or are using that helper
+type as part of your entry's child schema. See docker.Container#ChildSchema
+for an example of how this is used.
+*/
+func (s *EntrySchema) SetLabel(label string) *EntrySchema {
+	if len(label) == 0 {
+		panic("s.SetLabel called with an empty label")
+	}
+	s.entrySchema.Label = label
+	return s
+}
+
+// Singleton returns true if the entry's a singleton, false otherwise.
+func (s *EntrySchema) Singleton() bool {
+	return s.entrySchema.Singleton
+}
+
+// IsSingleton marks the entry as a singleton entry.
+func (s *EntrySchema) IsSingleton() *EntrySchema {
+	s.entrySchema.Singleton = true
+	return s
+}
+
+// Actions returns the entry's supported actions
+func (s *EntrySchema) Actions() []string {
+	return s.entrySchema.Actions
+}
+
+// SetActions sets the entry's supported actions. Only
+// the tests can call this method.
+func (s *EntrySchema) SetActions(actions []string) *EntrySchema {
+	if notRunningTests() {
+		panic("s.SetActions can only be called by the tests")
+	}
+	s.entrySchema.Actions = actions
+	return s
+}
+
+// Children returns the entry's child schemas
+func (s *EntrySchema) Children() []*EntrySchema {
+	return s.entrySchema.Children
+}
+
+// SetChildren sets the entry's children. Only the tests
+// can call this method.
+func (s *EntrySchema) SetChildren(children []*EntrySchema) *EntrySchema {
+	if notRunningTests() {
+		panic("s.SetChildren can only be called by the tests")
+	}
+	s.entrySchema.Children = children
+	return s
+}
+
+// FillChildren fills s' children.
+func (s *EntrySchema) FillChildren() {
+	s.fillChildren(make(map[string]bool))
+}
+
+func (s *EntrySchema) fillChildren(visited map[string]bool) {
+	if s.entrySchema.Children != nil {
+		return
+	}
+	if !ListAction().IsSupportedOn(s.entry) {
+		return
+	}
+	if visited[s.TypeID()] {
+		// This means that s' children can have s' type, which is
+		// true if s is e.g. a volume directory.
+		return
+	}
+	children := s.entry.(Parent).ChildSchemas()
+	visited[s.TypeID()] = true
+	for _, child := range children {
+		if child == nil {
+			msg := fmt.Sprintf("s.fillChildren: found a nil child schema for %v", s.TypeID())
+			panic(msg)
+		}
+		s.entrySchema.Children = append(s.entrySchema.Children, child)
+		child.fillChildren(visited)
+	}
+	// Delete "s" from visited so that siblings or ancestors that
+	// also use "s" won't be affected.
+	delete(visited, s.TypeID())
+}
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index bb31b97..0775e54 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -38,11 +38,10 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*externalPluginEntr
 		return nil, fmt.Errorf("the entry's methods must be provided")
 	}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry(e.Name),
 		methods:   e.Methods,
 		state:     e.State,
 	}
-	entry.SetName(e.Name)
 	entry.SetAttributes(e.Attributes)
 	entry.setCacheTTLs(e.CacheTTLs)
 	if e.SlashReplacer != "" {
@@ -92,6 +91,11 @@ func (e *externalPluginEntry) ChildSchemas() []*EntrySchema {
 	return []*EntrySchema{}
 }
 
+func (e *externalPluginEntry) Schema() *EntrySchema {
+	// TODO: Support external plugin schemas.
+	return nil
+}
+
 func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 	stdout, err := e.script.InvokeAndWait(ctx, "list", e)
 	if err != nil {
diff --git a/plugin/externalPluginScript.go b/plugin/externalPluginScript.go
index 0328c76..dcaf7e5 100644
--- a/plugin/externalPluginScript.go
+++ b/plugin/externalPluginScript.go
@@ -42,14 +42,13 @@ func (s externalPluginScriptImpl) InvokeAndWait(
 	if exitCode < 0 {
 		return nil, err
 	}
-	stderr := stderrBuf.String()
+	stderr := stderrBuf.Bytes()
 	if exitCode == 0 {
-		activity.Record(ctx, "stdout: %v", stdoutBuf.String())
 		if len(stderr) != 0 {
-			activity.Record(ctx, "stderr: %v", stderr)
+			activity.Record(ctx, "stderr: %v", string(stderr))
 		}
 	} else {
-		return nil, fmt.Errorf("script returned a non-zero exit code of %v. stderr output: %v", exitCode, stderr)
+		return nil, fmt.Errorf("script returned a non-zero exit code of %v. stderr output: %v", exitCode, string(stderr))
 	}
 	return stdoutBuf.Bytes(), nil
 }
diff --git a/plugin/externalPluginSpec.go b/plugin/externalPluginSpec.go
index 5c5752f..df3efb7 100644
--- a/plugin/externalPluginSpec.go
+++ b/plugin/externalPluginSpec.go
@@ -30,9 +30,8 @@ func (s ExternalPluginSpec) Load() (Root, error) {
 	}
 
 	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry(s.Name()),
 		script:    externalPluginScriptImpl{path: s.Script},
 	}}
-	root.SetName(s.Name())
 	return root, nil
 }
diff --git a/plugin/kubernetes/context.go b/plugin/kubernetes/context.go
index 54cf9ca..e8a8530 100644
--- a/plugin/kubernetes/context.go
+++ b/plugin/kubernetes/context.go
@@ -17,25 +17,24 @@ type k8context struct {
 	defaultns string
 }
 
-func k8ContextBase() *k8context {
+func newK8Context(name string, client *k8s.Clientset, config *rest.Config, defaultns string) *k8context {
 	context := &k8context{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	context.SetLabel("context")
-	return context
-}
-
-func newK8Context(name string, client *k8s.Clientset, config *rest.Config, defaultns string) *k8context {
-	context := k8ContextBase()
-	context.SetName(name)
 	context.client = client
 	context.config = config
 	context.defaultns = defaultns
 	return context
 }
 
+func (c *k8context) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(c, "context")
+}
+
 func (c *k8context) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(namespaceBase())
+	return []*plugin.EntrySchema{
+		(&namespace{}).Schema(),
+	}
 }
 
 func (c *k8context) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/kubernetes/namespace.go b/plugin/kubernetes/namespace.go
index 9e98dc3..fb0130f 100644
--- a/plugin/kubernetes/namespace.go
+++ b/plugin/kubernetes/namespace.go
@@ -2,7 +2,7 @@ package kubernetes
 
 import (
 	"context"
-	
+
 	"github.com/puppetlabs/wash/plugin"
 	corev1 "k8s.io/api/core/v1"
 	k8s "k8s.io/client-go/kubernetes"
@@ -11,24 +11,17 @@ import (
 
 type namespace struct {
 	plugin.EntryBase
-	client        *k8s.Clientset
-	config        *rest.Config
-	resources     []plugin.Entry
+	client    *k8s.Clientset
+	config    *rest.Config
+	resources []plugin.Entry
 }
 
-func namespaceBase() *namespace {
+func newNamespace(name string, meta *corev1.Namespace, c *k8s.Clientset, cfg *rest.Config) *namespace {
 	ns := &namespace{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(name),
 	}
-	ns.SetLabel("namespace")
-	return ns
-}
-
-func newNamespace(name string, meta *corev1.Namespace, c *k8s.Clientset, cfg *rest.Config) *namespace {
-	ns := namespaceBase()
 	ns.client = c
 	ns.config = cfg
-	ns.SetName(name)
 	ns.resources = []plugin.Entry{
 		newPodsDir(ns),
 		newPVCSDir(ns),
@@ -38,10 +31,17 @@ func newNamespace(name string, meta *corev1.Namespace, c *k8s.Clientset, cfg *re
 	return ns
 }
 
+func (n *namespace) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(n, "namespace")
+}
+
 func (n *namespace) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(podsDirBase(), pvcsDirBase())
+	return []*plugin.EntrySchema{
+		(&podsDir{}).Schema(),
+		(&pvcsDir{}).Schema(),
+	}
 }
 
 func (n *namespace) List(ctx context.Context) ([]plugin.Entry, error) {
 	return n.resources, nil
-}
\ No newline at end of file
+}
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index e747ce1..6447fcd 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -25,16 +25,11 @@ type pod struct {
 	ns     string
 }
 
-func podBase() *pod {
+func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns string, p *corev1.Pod) (*pod, error) {
 	pd := &pod{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(p.Name),
 	}
-	pd.SetLabel("pod").DisableDefaultCaching()
-	return pd
-}
-
-func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns string, p *corev1.Pod) (*pod, error) {
-	pd := podBase()
+	pd.DisableDefaultCaching()
 	pd.client = client
 	pd.config = config
 	pd.ns = ns
@@ -46,7 +41,6 @@ func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns
 
 	meta := pdInfo.toMeta()
 	pd.
-		SetName(p.Name).
 		Attributes().
 		SetCtime(p.CreationTimestamp.Time).
 		SetAtime(p.CreationTimestamp.Time).
@@ -56,6 +50,10 @@ func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns
 	return pd, nil
 }
 
+func (p *pod) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(p, "pod")
+}
+
 type podInfoResult struct {
 	pd         *corev1.Pod
 	logContent []byte
@@ -165,8 +163,8 @@ func (p *pod) Exec(ctx context.Context, cmd string, args []string, opts plugin.E
 		streamOpts := remotecommand.StreamOptions{
 			Stdout: execCmd.Stdout(),
 			Stderr: execCmd.Stderr(),
-			Stdin: stdin,
-			Tty: opts.Tty,
+			Stdin:  stdin,
+			Tty:    opts.Tty,
 		}
 		err = executor.Stream(streamOpts)
 		activity.Record(ctx, "Exec on %v complete: %v", p.Name(), err)
diff --git a/plugin/kubernetes/podsDir.go b/plugin/kubernetes/podsDir.go
index 5d8f2c1..c2c7405 100644
--- a/plugin/kubernetes/podsDir.go
+++ b/plugin/kubernetes/podsDir.go
@@ -16,24 +16,24 @@ type podsDir struct {
 	ns     string
 }
 
-func podsDirBase() *podsDir {
+func newPodsDir(ns *namespace) *podsDir {
 	pds := &podsDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("pods"),
 	}
-	pds.SetName("pods").IsSingleton()
-	return pds
-}
-
-func newPodsDir(ns *namespace) *podsDir {
-	pds := podsDirBase()
 	pds.client = ns.client
 	pds.config = ns.config
 	pds.ns = ns.Name()
 	return pds
 }
 
+func (ps *podsDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(ps, "pods").IsSingleton()
+}
+
 func (ps *podsDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(podBase())
+	return []*plugin.EntrySchema{
+		(&pod{}).Schema(),
+	}
 }
 
 func (ps *podsDir) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index e4d8873..1f26ebb 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -28,21 +28,14 @@ const mountpoint = "/mnt"
 
 var errPodTerminated = errors.New("Pod terminated unexpectedly")
 
-func pvcBase() *pvc {
+func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface, p *corev1.PersistentVolumeClaim) *pvc {
 	vol := &pvc{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry(p.Name),
 	}
-	vol.SetLabel("persistentvolumeclaim")
-	return vol
-}
-
-func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface, p *corev1.PersistentVolumeClaim) *pvc {
-	vol := pvcBase()
 	vol.pvci = pi
 	vol.podi = pd
 
 	vol.
-		SetName(p.Name).
 		Attributes().
 		SetCtime(p.CreationTimestamp.Time).
 		SetMtime(p.CreationTimestamp.Time).
@@ -52,6 +45,10 @@ func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface,
 	return vol
 }
 
+func (v *pvc) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(v, "persistentvolumeclaim")
+}
+
 func (v *pvc) ChildSchemas() []*plugin.EntrySchema {
 	return volume.ChildSchemas()
 }
diff --git a/plugin/kubernetes/pvcsDir.go b/plugin/kubernetes/pvcsDir.go
index 0098b21..7ae61f7 100644
--- a/plugin/kubernetes/pvcsDir.go
+++ b/plugin/kubernetes/pvcsDir.go
@@ -14,23 +14,23 @@ type pvcsDir struct {
 	ns     string
 }
 
-func pvcsDirBase() *pvcsDir {
+func newPVCSDir(ns *namespace) *pvcsDir {
 	pv := &pvcsDir{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("persistentvolumeclaims"),
 	}
-	pv.SetName("persistentvolumeclaims").IsSingleton()
-	return pv
-}
-
-func newPVCSDir(ns *namespace) *pvcsDir {
-	pv := pvcsDirBase()
 	pv.client = ns.client
 	pv.ns = ns.Name()
 	return pv
 }
 
+func (pv *pvcsDir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(pv, "persistentvolumeclaims").IsSingleton()
+}
+
 func (pv *pvcsDir) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(pvcBase())
+	return []*plugin.EntrySchema{
+		(&pvc{}).Schema(),
+	}
 }
 
 func (pv *pvcsDir) List(ctx context.Context) ([]plugin.Entry, error) {
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index 57b69f2..35f8cce 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -48,8 +48,7 @@ func (r *Root) Init(map[string]interface{}) error {
 		return err
 	}
 
-	r.EntryBase = plugin.NewEntryBase()
-	r.SetName("kubernetes")
+	r.EntryBase = plugin.NewEntry("kubernetes")
 	r.DisableDefaultCaching()
 
 	contexts := make([]plugin.Entry, 0)
@@ -66,9 +65,16 @@ func (r *Root) Init(map[string]interface{}) error {
 	return nil
 }
 
+// Schema returns the root's schema
+func (r *Root) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(r, "kubernetes").IsSingleton()
+}
+
 // ChildSchemas returns the root's child schemas
 func (r *Root) ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(k8ContextBase())
+	return []*plugin.EntrySchema{
+		(&k8context{}).Schema(),
+	}
 }
 
 // List returns available contexts.
diff --git a/plugin/registry.go b/plugin/registry.go
index f3f5f9e..d7dde73 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -16,11 +16,9 @@ type Registry struct {
 // NewRegistry creates a new plugin registry object
 func NewRegistry() *Registry {
 	r := &Registry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("/"),
 		plugins:   make(map[string]Root),
 	}
-	r.SetName("/")
-	r.SetLabel("mountpoint")
 	r.setID("/")
 	r.DisableDefaultCaching()
 
@@ -52,7 +50,6 @@ func (r *Registry) RegisterPlugin(root Root, config map[string]interface{}) erro
 		panic(msg)
 	}
 
-	root.entryBase().IsSingleton()
 	r.plugins[root.name()] = root
 	r.pluginRoots = append(r.pluginRoots, root)
 	return nil
@@ -60,7 +57,29 @@ func (r *Registry) RegisterPlugin(root Root, config map[string]interface{}) erro
 
 // ChildSchemas returns the child schemas of the plugin registry
 func (r *Registry) ChildSchemas() []*EntrySchema {
-	return ChildSchemas(r.pluginRoots...)
+	var childSchemas []*EntrySchema
+	for _, root := range r.pluginRoots {
+		s := root.Schema()
+		if s == nil {
+			// s doesn't have a schema, which means it's an external plugin.
+			//
+			// TODO: This makes it possible for core plugins to return nil
+			// schemas, which shouldn't happen. Find a way to rectify this once
+			// external plugin schemas are supported.
+			continue
+		}
+		s.IsSingleton()
+		if len(s.Label()) == 0 {
+			s.SetLabel(CName(root))
+		}
+		childSchemas = append(childSchemas, root.Schema())
+	}
+	return childSchemas
+}
+
+// Schema returns the plugin registry's schema
+func (r *Registry) Schema() *EntrySchema {
+	return NewEntrySchema(r, "mountpoint").IsSingleton()
 }
 
 // List all of Wash's loaded plugins
diff --git a/plugin/schema.go b/plugin/schema.go
deleted file mode 100644
index ab9a1e5..0000000
--- a/plugin/schema.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package plugin
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-)
-
-// EntrySchema represents an entry's schema.
-type EntrySchema struct {
-	TypeID    string         `json:"type_id"`
-	Label     string         `json:"label"`
-	Singleton bool           `json:"singleton"`
-	Actions   []string       `json:"actions"`
-	Children  []*EntrySchema `json:"children"`
-	entry     Entry
-}
-
-// ChildSchemas is a helper that's used to implement Parent#ChildSchemas.
-//
-// NOTE: "Entry" should be "EntryBase". The reason it isn't is because
-// "EntryBase" doesn't implement Parent, so there is no way for Wash to
-// get a Parent child's child schemas. We could move ChildSchemas over to
-// EntryBase, but doing so removes the existing compile-time check on whether
-// a Parent provided their child schemas.
-func ChildSchemas(childBases ...Entry) []*EntrySchema {
-	var schemas []*EntrySchema
-	for _, childBase := range childBases {
-		schemas = append(schemas, schema(childBase, false))
-	}
-	return schemas
-}
-
-// TypeID returns the entry's type ID
-func TypeID(e Entry) string {
-	// TODO: Handle external plugin type IDs.
-	return strings.TrimPrefix(reflect.TypeOf(e).String(), "*")
-}
-
-// Schema returns the entry's schema. Plugin authors should use plugin.ChildSchemas
-// when implementing Parent#ChildSchemas. Using Schema to do this can cause infinite
-// recursion if e's children have the same type as e, which can happen if e's e.g.
-// a volume directory.
-func Schema(e Entry) *EntrySchema {
-	return schema(e, true)
-}
-
-// Common helper for Schema and ChildSchema
-func schema(e Entry, includeChildren bool) *EntrySchema {
-	// TODO: Handle external plugin schemas
-	switch e.(type) {
-	case *externalPluginRoot:
-		return nil
-	case *externalPluginEntry:
-		return nil
-	}
-
-	s := &EntrySchema{
-		TypeID:    TypeID(e),
-		Label:     e.entryBase().label,
-		Singleton: e.entryBase().isSingleton,
-		Actions:   SupportedActionsOf(e),
-		entry:     e,
-	}
-	if len(s.Label) == 0 {
-		msg := fmt.Sprintf("Schema for type %v has an empty label. Use EntryBase#SetLabel to set the label.", s.TypeID)
-		panic(msg)
-	}
-	if includeChildren {
-		s.fillChildren(make(map[string]bool))
-	}
-	return s
-}
-
-func (s *EntrySchema) fillChildren(visited map[string]bool) {
-	if !ListAction().IsSupportedOn(s.entry) {
-		return
-	}
-	if visited[s.TypeID] {
-		// This means that s' children can have s' type, which is
-		// true if s is e.g. a volume directory.
-		return
-	}
-	children := s.entry.(Parent).ChildSchemas()
-	visited[s.TypeID] = true
-	for _, child := range children {
-		if child == nil {
-			continue
-		}
-		s.Children = append(s.Children, child)
-		child.fillChildren(visited)
-	}
-	// Delete "s" from visited so that siblings or ancestors that
-	// also use "s" won't be affected.
-	delete(visited, s.TypeID)
-}
diff --git a/plugin/types.go b/plugin/types.go
index 9078672..fe43e32 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -40,17 +40,35 @@ import (
 // details on when to override it.
 type Entry interface {
 	Metadata(ctx context.Context) (JSONObject, error)
+	Schema() *EntrySchema
 	name() string
 	attributes() EntryAttributes
 	slashReplacer() rune
 	id() string
 	setID(id string)
 	getTTLOf(op defaultOpCode) time.Duration
-	entryBase() *EntryBase
 }
 
-// Parent is an entry with children. It will be represented as a directory in the Wash
-// filesystem.
+/*
+Parent is an entry with children. It will be represented as a directory in the Wash filesystem.
+All parents must implement ChildSchemas, which is useful for documenting your plugin's hierarchy
+via the stree command, and for optimizing `wash find`'s traversal by eliminating non-satisfying
+paths. Your implementation of ChildSchemas should be something like
+	return []*plugin.EntrySchema{
+		(&childObj1{}).Schema(),
+		(&childObj2{}).Schema(),
+		...
+		(&childObjN{}).Schema(),
+	}
+
+For example,
+	return []*plugin.EntrySchema{
+		(&containerLogFile{}).Schema(),
+		(&containerMetadata{}).Schema(),
+		(&vol.FS{}).Schema().SetLabel("fs"),
+	}
+is the implementation of ChildSchemas for a Docker container.
+*/
 type Parent interface {
 	Entry
 	// TODO: "nil" schemas mean that the schema's unknown. This condition's necessary for
diff --git a/volume/core.go b/volume/core.go
index 433e725..54c13fe 100644
--- a/volume/core.go
+++ b/volume/core.go
@@ -40,7 +40,10 @@ type DirMap = map[string]Dir
 
 // ChildSchemas returns a volume's child schema
 func ChildSchemas() []*plugin.EntrySchema {
-	return plugin.ChildSchemas(dirBase(), fileBase())
+	return []*plugin.EntrySchema{
+		(&dir{}).Schema(),
+		(&file{}).Schema(),
+	}
 }
 
 // RootPath is the root of the filesystem described by a DirMap returned from VolumeList.
diff --git a/volume/dir.go b/volume/dir.go
index 4bb2694..1cd0e04 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -21,24 +21,17 @@ type dir struct {
 	path        string
 }
 
-func dirBase() *dir {
-	vd := &dir{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	vd.SetLabel("dir")
-	return vd
-}
-
 // newDir creates a dir populated from dirs.
 func newDir(name string, attr plugin.EntryAttributes, impl Interface, subtreeRoot *dir, path string) *dir {
-	vd := dirBase()
+	vd := &dir{
+		EntryBase: plugin.NewEntry(name),
+	}
 	vd.impl = impl
 	vd.subtreeRoot = subtreeRoot
 	if vd.subtreeRoot == nil {
 		vd.subtreeRoot = vd
 	}
 	vd.path = path
-	vd.SetName(name)
 	vd.SetAttributes(attr)
 	vd.SetTTLOf(plugin.OpenOp, 60*time.Second)
 	// Caching handled in List based on 'impl'.
@@ -51,6 +44,10 @@ func (v *dir) ChildSchemas() []*plugin.EntrySchema {
 	return ChildSchemas()
 }
 
+func (v *dir) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(v, "dir")
+}
+
 // List lists the children of the directory.
 func (v *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	// Use subtree root if specified. If it's the base path, then use impl instead because we started
diff --git a/volume/file.go b/volume/file.go
index 945275b..22a8061 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -15,26 +15,23 @@ type file struct {
 	path string
 }
 
-func fileBase() *file {
-	vf := &file{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	vf.SetLabel("file")
-	return vf
-}
-
 // newFile creates a VolumeFile.
 func newFile(name string, attr plugin.EntryAttributes, impl Interface, path string) *file {
-	vf := fileBase()
+	vf := &file{
+		EntryBase: plugin.NewEntry(name),
+	}
 	vf.impl = impl
 	vf.path = path
-	vf.SetName(name)
 	vf.SetAttributes(attr)
 	vf.SetTTLOf(plugin.OpenOp, 60*time.Second)
 
 	return vf
 }
 
+func (v *file) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(v, "file")
+}
+
 // Open returns the content of the file as a SizedReader.
 func (v *file) Open(ctx context.Context) (plugin.SizedReader, error) {
 	return v.impl.VolumeOpen(ctx, v.path)
diff --git a/volume/fs.go b/volume/fs.go
index 658ff1a..9080bb0 100644
--- a/volume/fs.go
+++ b/volume/fs.go
@@ -17,23 +17,13 @@ type FS struct {
 	maxdepth int
 }
 
-// FSBase returns a base FS entry
-func FSBase(name string) *FS {
-	fs := &FS{
-		EntryBase: plugin.NewEntryBase(),
-	}
-	fs.
-		SetName(name).
-		IsSingleton().
-		// Caching handled in List.
-		DisableCachingFor(plugin.ListOp)
-	return fs
-}
-
 // NewFS creates a new FS entry with the given name, using the supplied executor to satisfy volume
 // operations.
 func NewFS(name string, executor plugin.Execable, maxdepth int) *FS {
-	fs := FSBase(name)
+	fs := &FS{
+		EntryBase: plugin.NewEntry(name),
+	}
+	fs.DisableCachingFor(plugin.ListOp)
 	fs.executor = executor
 	fs.maxdepth = maxdepth
 	return fs
@@ -44,6 +34,12 @@ func (d *FS) ChildSchemas() []*plugin.EntrySchema {
 	return ChildSchemas()
 }
 
+// Schema returns the FS entry's schema. You can override its label with
+// EntrySchema#SetLabel
+func (d *FS) Schema() *plugin.EntrySchema {
+	return plugin.NewEntrySchema(d, "fs")
+}
+
 // List creates a hierarchy of the filesystem of an Execable resource (the executor).
 func (d *FS) List(ctx context.Context) ([]plugin.Entry, error) {
 	return List(ctx, d)
diff --git a/website/content/docs/_index.md b/website/content/docs/_index.md
index 91db403..3e71f62 100644
--- a/website/content/docs/_index.md
+++ b/website/content/docs/_index.md
@@ -20,8 +20,7 @@ title= "Wash Documentation"
   * [Docker](#docker)
   * [Kubernetes](#kubernetes)
 * [Plugin Concepts](#plugin-concepts)
-  * [Plugin Debugging](#plugin-debugging)
-  * [Attributes/Metadata](#attributes/metadata)
+  * [Attributes/Metadata](#attributes-metadata)
   * [➠External plugins]
   * [➠Core Plugins]
   * [➠Server API]
@@ -159,40 +158,7 @@ For more on implementing plugins, see:
 [➠Core plugins]: /wash/docs/core_plugins
 [➠Server API]: /wash/docs/api
 
-NOTE: We recommend that you read the [Attributes/Metadata](#attributes/metadata) section before reading the plugin tutorials to take full advantage of Wash's capabilities, especially that of `wash find`'s.
-
-### Plugin Debugging
-
-Plugin-related activity is currently logged at `debug` level. You can control logging with the `loglevel` and `logfile` options to `wash` or `wash server`. So when developing a plugin, it's useful to start your shell with
-
-```
-wash --loglevel debug --logfile <file>
-```
-
-then `tail -f <file>` in another terminal to see what Wash is doing.
-
-For external plugins, those logs will include the commands used for all invocations of your plugin script and the responses. For example
-
-```
-level=debug msg="Invoking /washreads/goodreads list /goodreads \\{\\\"userid\\\":\\\"12345678\\\"}"
-level=debug msg="stdout: [{\"name\":\"read\",\"methods\":[\"list\"],\"attributes\":{\"meta\":{\"id\":\"53094184\",\"book_count\":\"494\",\"exclusive_flag\":\"true\",\"description\":\"\",\"sort\":\"\",\"order\":\"\",\"per_page\":\"\",\"display_fields\":\"\",\"featured\":\"true\",\"recommend_for\":\"false\",\"sticky\":\"\"}},\"state\":\"{\\\"type\\\":\\\"shelf\\\",\\\"name\\\":\\\"read\\\",\\\"userid\\\":\\\"16580428\\\",\\\"count\\\":494}\"},...]\n"
-level=debug msg="stderr: something's happening"
-```
-
-Activity related to a specific operation are always available in the `history` entry for that operation. Given a history entry like
-```
-$ whistory
-1  2019-06-20 13:47  wash whistory
-2  2019-06-20 13:47  ls -pG goodreads
-```
-we can view activity related to that command with
-```
-$ whistory 2
-Jun 20 13:47:09.212 FUSE: List /goodreads
-Jun 20 13:47:09.212 Invoking /Users/michaelsmith/puppetlabs/washreads/goodreads list /goodreads \{\"userid\":\"16580428\"}
-Jun 20 13:47:09.886 stdout: [{"name":"read","methods":["list"],"attributes":{"meta":{"id":"53094184","book_count":"494","exclusive_flag":"true","description":"","sort":"","order":"","per_page":"","display_fields":"","featured":"true","recommend_for":"false","sticky":""}},"state":"{\"type\":\"shelf\",\"name\":\"read\",\"userid\":\"16580428\",\"count\":494}"},...]
-Jun 20 13:47:09.886 FUSE: Listed in /goodreads: [{Inode:0 Type:dir Name:read} {Inode:0 Type:dir Name:currently-reading} {Inode:0 Type:dir Name:to-read} {Inode:0 Type:dir Name:fantasy} {Inode:0 Type:dir Name:science-fiction}]
-```
+NOTE: We recommend that you read the `Attributes/Metadata` section before reading the plugin tutorials to take full advantage of Wash's capabilities, especially that of `wash find`'s.
 
 ### Attributes/Metadata
 
@@ -202,4 +168,4 @@ To make `wash find`'s filtering less tedious and better performing, entries can
 
 NOTE: _All_ attributes are optional, so set the ones that you think make sense. For example, if the `mode` or `size` attributes don't make sense for your entry, then feel free to ignore them. However, we recommend that you try to set the `meta` attribute when you can to take advantage of metadata filtering.
 
-NOTE: We plan on adding more attributes depending on user feedback (e.g. like `state` and `labels`). Thus if you find yourself metadata-filtering on a common property across a bunch of different entries, then please feel free to file an issue so we can consider adding that property as an attribute (and as a corresponding `wash find` primary).
\ No newline at end of file
+NOTE: We plan on adding more attributes depending on user feedback (e.g. like `state` and `labels`). Thus if you find yourself metadata-filtering on a common property across a bunch of different entries, then please feel free to file an issue so we can consider adding that property as an attribute (and as a corresponding `wash find` primary).


Test patch:
diff --git a/api/cache_test.go b/api/cache_test.go
index b847d9a..65049e0 100644
--- a/api/cache_test.go
+++ b/api/cache_test.go
@@ -152,9 +152,9 @@ type mockedParent struct {
 
 func newMockedParent() *mockedParent {
 	p := &mockedParent{
-		EntryBase: plugin.NewEntryBase(),
+		EntryBase: plugin.NewEntry("mockParent"),
 	}
-	p.SetName("mockParent")
+
 	return p
 }
 
@@ -166,3 +166,7 @@ func (p *mockedParent) List(ctx context.Context) ([]plugin.Entry, error) {
 func (p *mockedParent) ChildSchemas() []*plugin.EntrySchema {
 	return nil
 }
+
+func (p *mockedParent) Schema() *plugin.EntrySchema {
+	return nil
+}
diff --git a/api/helpers_test.go b/api/helpers_test.go
index 183a3d5..dccc26f 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -24,6 +24,24 @@ func (g *mockParent) ChildSchemas() []*plugin.EntrySchema {
 	return nil
 }
 
+func (g *mockParent) Schema() *plugin.EntrySchema {
+	return nil
+}
+
+type mockEntry struct {
+	plugin.EntryBase
+}
+
+func newMockEntry(name string) *mockEntry {
+	return &mockEntry{
+		EntryBase: plugin.NewEntry(name),
+	}
+}
+
+func (e *mockEntry) Schema() *plugin.EntrySchema {
+	return nil
+}
+
 type HelpersTestSuite struct {
 	suite.Suite
 }
@@ -56,10 +74,8 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 		}
 	}
 
-	foo := plugin.NewEntryBase()
-	foo.SetName("foo/bar")
-	parent := &mockParent{plugin.NewEntryBase(), []plugin.Entry{&foo}}
-	parent.SetName("root")
+	foo := newMockEntry("foo/bar")
+	parent := &mockParent{plugin.NewEntry("root"), []plugin.Entry{foo}}
 	parent.SetTestID("/root")
 	parent.DisableDefaultCaching()
 	for _, c := range []testcase{
@@ -70,10 +86,8 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 		runTestCase(parent, c)
 	}
 
-	baz := plugin.NewEntryBase()
-	baz.SetName("baz")
-	nestedParent := &mockParent{plugin.NewEntryBase(), []plugin.Entry{&baz}}
-	nestedParent.SetName("bar")
+	baz := newMockEntry("baz")
+	nestedParent := &mockParent{plugin.NewEntry("bar"), []plugin.Entry{baz}}
 	nestedParent.DisableDefaultCaching()
 	parent.entries = append(parent.entries, nestedParent)
 	for _, c := range []testcase{
@@ -85,9 +99,8 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 	}
 
 	// Finally, test the duplicate cname error response
-	duplicateFoo := plugin.NewEntryBase()
-	duplicateFoo.SetName("foo#bar")
-	parent.entries = append(parent.entries, &duplicateFoo)
+	duplicateFoo := newMockEntry("foo#bar")
+	parent.entries = append(parent.entries, duplicateFoo)
 	expectedErr := plugin.DuplicateCNameErr{
 		ParentID:                 plugin.ID(parent),
 		FirstChildName:           foo.Name(),
@@ -116,12 +129,12 @@ func (m *mockRoot) List(ctx context.Context) ([]plugin.Entry, error) {
 	return args.Get(0).([]plugin.Entry), args.Error(1)
 }
 
+func (m *mockRoot) Schema() *plugin.EntrySchema {
+	return nil
+}
+
 func (m *mockRoot) ChildSchemas() []*plugin.EntrySchema {
-	return []*plugin.EntrySchema{
-		&plugin.EntrySchema{
-			TypeID: "mockEntry",
-		},
-	}
+	return nil
 }
 
 func getRequest(ctx context.Context, path string) *http.Request {
@@ -130,8 +143,7 @@ func getRequest(ctx context.Context, path string) *http.Request {
 
 func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	reg := plugin.NewRegistry()
-	plug := &mockRoot{EntryBase: plugin.NewEntryBase()}
-	plug.SetName("mine")
+	plug := &mockRoot{EntryBase: plugin.NewEntry("mine")}
 	plug.SetTestID("/mine")
 	suite.NoError(reg.RegisterPlugin(plug, map[string]interface{}{}))
 	ctx := context.WithValue(context.Background(), pluginRegistryKey, reg)
@@ -165,10 +177,9 @@ func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	_, _, err = getEntryFromRequest(getRequest(ctx, mountpoint+"/yours"))
 	suite.Error(err)
 
-	file := plugin.NewEntryBase()
-	file.SetName("a file")
+	file := newMockEntry("a file")
 	file.SetTestID("/mine/a file")
-	plug.On("List", mock.Anything).Return([]plugin.Entry{&file}, nil)
+	plug.On("List", mock.Anything).Return([]plugin.Entry{file}, nil)
 
 	entry, path, err = getEntryFromRequest(getRequest(ctx, mountpoint+"/mine/a file"))
 	if suite.Nil(err) {
@@ -177,7 +188,7 @@ func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	}
 	plug.AssertExpectations(suite.T())
 
-	plug.On("List", mock.Anything).Return([]plugin.Entry{&file}, nil)
+	plug.On("List", mock.Anything).Return([]plugin.Entry{file}, nil)
 	_, _, err = getEntryFromRequest(getRequest(ctx, mountpoint+"/mine/a dir"))
 	suite.Error(err)
 	plug.AssertExpectations(suite.T())
diff --git a/cmd/internal/find/primary/action_test.go b/cmd/internal/find/primary/action_test.go
index 446f60a..2553558 100644
--- a/cmd/internal/find/primary/action_test.go
+++ b/cmd/internal/find/primary/action_test.go
@@ -41,7 +41,7 @@ func TestActionPrimary(t *testing.T) {
 		s := &types.EntrySchema{
 			EntrySchema: &apitypes.EntrySchema{},
 		}
-		s.Actions = v.([]string)
+		s.SetActions(v.([]string))
 		return s
 	}
 	suite.Run(t, s)
diff --git a/cmd/internal/find/types/entrySchema_test.go b/cmd/internal/find/types/entrySchema_test.go
index b99827f..dbcd785 100644
--- a/cmd/internal/find/types/entrySchema_test.go
+++ b/cmd/internal/find/types/entrySchema_test.go
@@ -72,7 +72,7 @@ func (suite *EntrySchemaTestSuite) TestPrune_Complex() {
 func (suite *EntrySchemaTestSuite) TestPrune_RealWorld_AWS() {
 	s := suite.readFixture("real_world_aws")
 	p := func(s *EntrySchema) bool {
-		for _, action := range s.Actions {
+		for _, action := range s.Actions() {
 			if action == "exec" {
 				return true
 			}
@@ -180,7 +180,7 @@ func (suite *EntrySchemaTestSuite) readFixture(name string) *EntrySchema {
 func (suite *EntrySchemaTestSuite) makeSchemaP(trueValues ...string) EntrySchemaPredicate {
 	return func(s *EntrySchema) bool {
 		for _, typeID := range trueValues {
-			if s.TypeID == typeID {
+			if s.TypeID() == typeID {
 				return true
 			}
 		}
diff --git a/cmd/internal/find/walker_test.go b/cmd/internal/find/walker_test.go
index 5ba29ec..a5e889c 100644
--- a/cmd/internal/find/walker_test.go
+++ b/cmd/internal/find/walker_test.go
@@ -71,15 +71,10 @@ func (s *WalkerTestSuite) TestWalk_HappyCase() {
 func (s *WalkerTestSuite) TestWalk_WithSchema_HappyCase() {
 	// Set-up the mocks
 	fileSchema := func(typeID string) *apitypes.EntrySchema {
-		return &apitypes.EntrySchema{
-			TypeID: typeID,
-		}
+		return (&apitypes.EntrySchema{}).SetTypeID(typeID)
 	}
 	dirSchema := func(typeID string, children ...*apitypes.EntrySchema) *apitypes.EntrySchema {
-		return &apitypes.EntrySchema{
-			TypeID: typeID,
-			Children: children,
-		}
+		return (&apitypes.EntrySchema{}).SetTypeID(typeID).SetChildren(children)
 	}
 	schema := dirSchema(
 		".",
@@ -108,7 +103,7 @@ func (s *WalkerTestSuite) TestWalk_WithSchema_HappyCase() {
 
 	s.walker.p.SchemaP = func(s *types.EntrySchema) bool {
 		// Print only "foo" or "file"s. Ignore everything else.
-		return s.TypeID == "foo" || s.TypeID == "file"
+		return s.TypeID() == "foo" || s.TypeID() == "file"
 	}
 
 	s.True(s.walker.Walk("."))
@@ -326,8 +321,8 @@ func (s *WalkerTestSuite) toAbsPath(path string) string {
 
 func (s *WalkerTestSuite) toEntry(path string, isParent bool, typeID string) apitypes.Entry {
 	e := apitypes.Entry{
-		Path:  s.toAbsPath(path),
-		CName: filepath.Base(path),
+		Path:   s.toAbsPath(path),
+		CName:  filepath.Base(path),
 		TypeID: typeID,
 	}
 	if isParent {
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 9d7a0e5..ceb8acb 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -123,9 +123,8 @@ type cacheTestsMockEntry struct {
 
 func newCacheTestsMockEntry(name string) *cacheTestsMockEntry {
 	e := &cacheTestsMockEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry(name),
 	}
-	e.SetName(name)
 	return e
 }
 
@@ -138,6 +137,10 @@ func (e *cacheTestsMockEntry) ChildSchemas() []*EntrySchema {
 	return nil
 }
 
+func (e *cacheTestsMockEntry) Schema() *EntrySchema {
+	return nil
+}
+
 func (e *cacheTestsMockEntry) Open(ctx context.Context) (SizedReader, error) {
 	args := e.Called(ctx)
 	return args.Get(0).(SizedReader), args.Error(1)
@@ -192,12 +195,12 @@ func (suite *CacheTestSuite) TestCachedOp() {
 
 func (suite *CacheTestSuite) TestDuplicateCNameErr() {
 	err := DuplicateCNameErr{
-		ParentID:                        "/my_plugin/foo",
-		FirstChildName:                  "foo/bar/",
-		FirstChildSlashReplacer:         '#',
-		SecondChildName:                 "foo#bar/",
-		SecondChildSlashReplacer:        '#',
-		CName:                           "foo#bar#",
+		ParentID:                 "/my_plugin/foo",
+		FirstChildName:           "foo/bar/",
+		FirstChildSlashReplacer:  '#',
+		SecondChildName:          "foo#bar/",
+		SecondChildSlashReplacer: '#',
+		CName:                    "foo#bar#",
 	}
 
 	suite.Regexp("listing /my_plugin/foo", err.Error())
@@ -288,12 +291,12 @@ func (suite *CacheTestSuite) TestCachedListCNameErrors() {
 	_, err := CachedList(ctx, entry)
 	if suite.Error(err) {
 		expectedErr := DuplicateCNameErr{
-			ParentID:                        "/my_plugin/foo",
-			FirstChildName:                  "foo/bar/",
-			FirstChildSlashReplacer:         '#',
-			SecondChildName:                 "foo#bar/",
-			SecondChildSlashReplacer:        '#',
-			CName:                           "foo#bar#",
+			ParentID:                 "/my_plugin/foo",
+			FirstChildName:           "foo/bar/",
+			FirstChildSlashReplacer:  '#',
+			SecondChildName:          "foo#bar/",
+			SecondChildSlashReplacer: '#',
+			CName:                    "foo#bar#",
 		}
 
 		suite.Equal(expectedErr, err)
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
index 063eb70..c8549b6 100644
--- a/plugin/entryBase_test.go
+++ b/plugin/entryBase_test.go
@@ -24,17 +24,20 @@ func (suite *EntryBaseTestSuite) assertOpTTL(e EntryBase, op defaultOpCode, opNa
 	)
 }
 
-func (suite *EntryBaseTestSuite) TestNewEntryBase() {
+func (suite *EntryBaseTestSuite) TestNewEntry() {
+	suite.Panics(
+		func() { NewEntry("") },
+		"plugin.NewEntry: received an empty name",
+	)
+
 	initialAttr := EntryAttributes{}
 	initialAttr.SetCtime(time.Now())
-	e := NewEntryBase()
+	e := NewEntry("foo")
 
 	e.SetAttributes(initialAttr)
 	suite.Equal(initialAttr, e.attr)
 
-	e.SetName("foo")
 	suite.Equal("foo", e.Name())
-	
 	suite.assertOpTTL(e, ListOp, "List", 15*time.Second)
 	suite.assertOpTTL(e, OpenOp, "Open", 15*time.Second)
 	suite.assertOpTTL(e, MetadataOp, "Metadata", 15*time.Second)
@@ -54,7 +57,7 @@ func (suite *EntryBaseTestSuite) TestNewEntryBase() {
 }
 
 func (suite *EntryBaseTestSuite) TestMetadata() {
-	e := NewEntryBase()
+	e := NewEntry("foo")
 
 	meta, err := e.Metadata(context.Background())
 	if suite.NoError(err) {
@@ -64,7 +67,7 @@ func (suite *EntryBaseTestSuite) TestMetadata() {
 }
 
 func (suite *EntryBaseTestSuite) TestSetSlashReplacer() {
-	e := NewEntryBase()
+	e := NewEntry("foo/bar")
 
 	suite.Panics(
 		func() { e.SetSlashReplacer('/') },
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index cfd4536..60bd4ed 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -99,7 +99,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithCach
 	decodedEntry.CacheTTLs = decodedCacheTTLs{List: 1}
 	entry, err := decodedEntry.toExternalPluginEntry()
 	if suite.NoError(err) {
-		expectedTTLs := NewEntryBase().ttl
+		expectedTTLs := NewEntry("foo").ttl
 		expectedTTLs[ListOp] = decodedEntry.CacheTTLs.List * time.Second
 		suite.Equal(expectedTTLs, entry.EntryBase.ttl)
 	}
@@ -139,7 +139,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 	}
 
 	entry := externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 	}
 	entry.setCacheTTLs(decodedTTLs)
 
@@ -157,7 +157,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -186,8 +186,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	entries, err := entry.List(ctx)
 	if suite.NoError(err) {
-		entryBase := NewEntryBase()
-		entryBase.SetName("foo")
+		entryBase := NewEntry("foo")
 		expectedEntries := []Entry{
 			&externalPluginEntry{
 				EntryBase:        entryBase,
@@ -203,7 +202,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -232,7 +231,7 @@ func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		script:    mockScript,
 	}
 	expectedMeta := JSONObject{"foo": "bar"}
@@ -251,7 +250,7 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		methods:   []string{"metadata"},
 		script:    mockScript,
 	}
diff --git a/plugin/externalPluginRoot_test.go b/plugin/externalPluginRoot_test.go
index bd92b9a..455b12f 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/externalPluginRoot_test.go
@@ -16,10 +16,9 @@ type ExternalPluginRootTestSuite struct {
 func (suite *ExternalPluginRootTestSuite) TestInit() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		script:    mockScript,
 	}}
-	root.SetName("foo")
 
 	mockInvokeAndWait := func(stdout []byte, err error) {
 		mockScript.OnInvokeAndWait(
@@ -49,12 +48,11 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 	if suite.NoError(err) {
 		expectedRoot := &externalPluginRoot{
 			externalPluginEntry: &externalPluginEntry{
-				EntryBase: NewEntryBase(),
+				EntryBase: NewEntry("foo"),
 				methods:   []string{"list"},
 				script:    root.script,
 			},
 		}
-		expectedRoot.SetName("foo")
 
 		suite.Equal(expectedRoot, root)
 	}
@@ -63,10 +61,9 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 func (suite *ExternalPluginRootTestSuite) TestInitWithConfig() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry("foo"),
 		script:    mockScript,
 	}}
-	root.SetName("foo")
 
 	mockScript.OnInvokeAndWait(
 		mock.Anything,
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 3934a9d..7a2b3e9 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -22,30 +22,29 @@ func (suite *HelpersTestSuite) TearDownSuite() {
 }
 
 func (suite *HelpersTestSuite) TestName() {
-	e := NewEntryBase()
-	e.SetName("foo")
-	suite.Equal(Name(&e), "foo")
+	e := newHelpersTestsMockEntry("foo")
+	suite.Equal(Name(e), "foo")
 }
 
 func (suite *HelpersTestSuite) TestCName() {
-	e := NewEntryBase()
-	e.SetName("foo/bar/baz")
-	suite.Equal("foo#bar#baz", CName(&e))
+	e := newHelpersTestsMockEntry("foo/bar/baz")
+	suite.Equal("foo#bar#baz", CName(e))
 
 	e.SetSlashReplacer(':')
-	suite.Equal("foo:bar:baz", CName(&e))
+	suite.Equal("foo:bar:baz", CName(e))
 }
 
 func (suite *HelpersTestSuite) TestID() {
-	e := NewEntryBase()
-	e.SetName("foo/bar")
+	e := newHelpersTestsMockEntry("foo/bar")
 
+	e.SetTestID("")
 	suite.Panics(
-		func() { ID(&e) },
+		func() { ID(e) },
 		"plugin.ID: entry foo (cname foo#bar) has no ID",
 	)
-	e.setID("/foo/bar")
-	suite.Equal(ID(&e), "/foo/bar")
+
+	e.SetTestID("/foo/bar")
+	suite.Equal(ID(e), "/foo/bar")
 }
 
 type helpersTestsMockEntry struct {
@@ -53,11 +52,14 @@ type helpersTestsMockEntry struct {
 	mock.Mock
 }
 
-func newHelpersTestsMockEntry() *helpersTestsMockEntry {
+func (e *helpersTestsMockEntry) Schema() *EntrySchema {
+	return nil
+}
+
+func newHelpersTestsMockEntry(name string) *helpersTestsMockEntry {
 	e := &helpersTestsMockEntry{
-		EntryBase: NewEntryBase(),
+		EntryBase: NewEntry(name),
 	}
-	e.SetName("mockEntry")
 	e.SetTestID("id")
 	e.DisableDefaultCaching()
 
@@ -65,7 +67,7 @@ func newHelpersTestsMockEntry() *helpersTestsMockEntry {
 }
 
 func (suite *HelpersTestSuite) TestAttributes() {
-	e := newHelpersTestsMockEntry()
+	e := newHelpersTestsMockEntry("mockEntry")
 	e.attr = EntryAttributes{}
 	e.attr.SetCtime(time.Now())
 	suite.Equal(e.attr, Attributes(e))
diff --git a/plugin/registry_test.go b/plugin/registry_test.go
index e1fcad6..5873b1f 100644
--- a/plugin/registry_test.go
+++ b/plugin/registry_test.go
@@ -34,11 +34,11 @@ func (m *mockRoot) List(ctx context.Context) ([]Entry, error) {
 }
 
 func (m *mockRoot) ChildSchemas() []*EntrySchema {
-	return []*EntrySchema{
-		&EntrySchema{
-			TypeID: "entry",
-		},
-	}
+	return []*EntrySchema{}
+}
+
+func (m *mockRoot) Schema() *EntrySchema {
+	return nil
 }
 
 func (suite *RegistryTestSuite) TestPluginNameRegex() {
@@ -57,8 +57,7 @@ func (suite *RegistryTestSuite) TestPluginNameRegex() {
 
 func (suite *RegistryTestSuite) TestRegisterPlugin() {
 	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntryBase()}
-	m.SetName("mine")
+	m := &mockRoot{EntryBase: NewEntry("mine")}
 	cfg := map[string]interface{}{}
 	m.On("Init", cfg).Return(nil)
 
@@ -69,8 +68,7 @@ func (suite *RegistryTestSuite) TestRegisterPlugin() {
 
 func (suite *RegistryTestSuite) TestRegisterPluginWithConfig() {
 	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntryBase()}
-	m.SetName("mine")
+	m := &mockRoot{EntryBase: NewEntry("mine")}
 	cfg := map[string]interface{}{"key": "value"}
 	m.On("Init", cfg).Return(nil)
 
@@ -81,8 +79,7 @@ func (suite *RegistryTestSuite) TestRegisterPluginWithConfig() {
 
 func (suite *RegistryTestSuite) TestRegisterPluginInitError() {
 	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntryBase()}
-	m.SetName("mine")
+	m := &mockRoot{EntryBase: NewEntry("mine")}
 	m.On("Init", map[string]interface{}(nil)).Return(errors.New("failed"))
 
 	suite.EqualError(reg.RegisterPlugin(m, nil), "failed")
@@ -93,8 +90,7 @@ func (suite *RegistryTestSuite) TestRegisterPluginInitError() {
 func (suite *RegistryTestSuite) TestRegisterPluginInvalidPluginName() {
 	panicFunc := func() {
 		reg := NewRegistry()
-		m := &mockRoot{EntryBase: NewEntryBase()}
-		m.SetName("b@dname")
+		m := &mockRoot{EntryBase: NewEntry("b@dname")}
 		_ = reg.RegisterPlugin(m, map[string]interface{}{})
 	}
 
@@ -107,8 +103,7 @@ func (suite *RegistryTestSuite) TestRegisterPluginInvalidPluginName() {
 func (suite *RegistryTestSuite) TestRegisterPluginRegisteredPlugin() {
 	panicFunc := func() {
 		reg := NewRegistry()
-		m1 := &mockRoot{EntryBase: NewEntryBase()}
-		m1.SetName("mine")
+		m1 := &mockRoot{EntryBase: NewEntry("mine")}
 		_ = reg.RegisterPlugin(m1, map[string]interface{}{})
 		_ = reg.RegisterPlugin(m1, map[string]interface{}{})
 	}
diff --git a/volume/dir_test.go b/volume/dir_test.go
index 161377c..ab8106d 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -29,13 +29,16 @@ func (m *mockDirEntry) VolumeStream(context.Context, string) (io.ReadCloser, err
 	return nil, nil
 }
 
+func (m *mockDirEntry) Schema() *plugin.EntrySchema {
+	return nil
+}
+
 func TestVolumeDir(t *testing.T) {
 	dmap, err := StatParseAll(strings.NewReader(fixture), mountpoint, mountpoint, mountDepth)
 	assert.Nil(t, err)
 
 	plugin.SetTestCache(datastore.NewMemCache())
-	entry := mockDirEntry{EntryBase: plugin.NewEntryBase(), dmap: dmap}
-	entry.SetName("mine")
+	entry := mockDirEntry{EntryBase: plugin.NewEntry("mine"), dmap: dmap}
 	entry.SetTestID("/mine")
 	entryDir := newDir("dummy", plugin.EntryAttributes{}, &entry, nil, RootPath)
 
diff --git a/volume/file_test.go b/volume/file_test.go
index 6c7dd4b..55b43cb 100644
--- a/volume/file_test.go
+++ b/volume/file_test.go
@@ -37,13 +37,16 @@ func (m *mockFileEntry) VolumeStream(context.Context, string) (io.ReadCloser, er
 	return ioutil.NopCloser(strings.NewReader(m.content)), nil
 }
 
+func (m *mockFileEntry) Schema() *plugin.EntrySchema {
+	return nil
+}
+
 func TestVolumeFile(t *testing.T) {
 	now := time.Now()
 	initialAttr := plugin.EntryAttributes{}
 	initialAttr.SetCtime(now)
 
-	impl := &mockFileEntry{EntryBase: plugin.NewEntryBase(), content: "hello"}
-	impl.SetName("parent")
+	impl := &mockFileEntry{EntryBase: plugin.NewEntry("parent"), content: "hello"}
 	vf := newFile("mine", initialAttr, impl, "my path")
 
 	attr := plugin.Attributes(vf)
@@ -72,8 +75,7 @@ func TestVolumeFile(t *testing.T) {
 }
 
 func TestVolumeFileErr(t *testing.T) {
-	impl := &mockFileEntry{EntryBase: plugin.NewEntryBase(), err: errors.New("fail")}
-	impl.SetName("parent")
+	impl := &mockFileEntry{EntryBase: plugin.NewEntry("parent"), err: errors.New("fail")}
 	vf := newFile("mine", plugin.EntryAttributes{}, impl, "my path")
 
 	rdr, err := vf.Open(context.Background())
diff --git a/volume/fs_test.go b/volume/fs_test.go
index 0a28844..69cbd0a 100644
--- a/volume/fs_test.go
+++ b/volume/fs_test.go
@@ -63,8 +63,7 @@ func (suite *fsTestSuite) createResult(data string) plugin.ExecCommand {
 }
 
 func (suite *fsTestSuite) createExec(fixt string, depth int) *mockExecutor {
-	exec := &mockExecutor{EntryBase: plugin.NewEntryBase()}
-	exec.SetName("instance")
+	exec := &mockExecutor{EntryBase: plugin.NewEntry("instance")}
 	// Used when recording activity.
 	exec.SetTestID("/instance")
 	cmd := StatCmd("/", depth)
@@ -181,8 +180,7 @@ func (suite *fsTestSuite) TestFSListExpiredCache() {
 96 1550611510 1550611448 1550611448 41ed /var/log/path
 `
 	depth := 3
-	exec := &mockExecutor{EntryBase: plugin.NewEntryBase()}
-	exec.SetName("instance")
+	exec := &mockExecutor{EntryBase: plugin.NewEntry("instance")}
 	exec.SetTestID("/instance")
 	cmd := StatCmd("/", depth)
 	exec.On("Exec", mock.Anything, cmd[0], cmd[1:], plugin.ExecOptions{Elevate: true}).Return(mockExecCmd{shortFixture}, nil)
@@ -243,6 +241,10 @@ func (m *mockExecutor) Exec(ctx context.Context, cmd string, args []string, opts
 	return arger.Get(0).(plugin.ExecCommand), arger.Error(1)
 }
 
+func (m *mockExecutor) Schema() *plugin.EntrySchema {
+	return nil
+}
+
 // Mock ExecCommand that can be used repeatedly when mocking a repeated call.
 type mockExecCmd struct {
 	data string