Repository: puppetlabs/wash
Pull request #321 (2019-06-10 22:59:19)

Add plugin.EntrySchema + schema endpoint
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/api/fs/core.go b/api/fs/core.go
index bf7daf4..880b876 100644
--- a/api/fs/core.go
+++ b/api/fs/core.go
@@ -21,10 +21,11 @@ func newFSNode(ctx context.Context, finfo os.FileInfo, path string) *fsnode {
 	// but it's platform-specific. We should eventually use it for
 	// a more complete implementation of apifs.
 	n := &fsnode{
-		EntryBase: plugin.NewEntry(finfo.Name()),
+		EntryBase: plugin.NewEntryBase(),
 		path:      path,
 	}
 	n.
+		SetName(finfo.Name()).
 		DisableDefaultCaching().
 		Attributes().
 		SetMtime(finfo.ModTime()).
@@ -34,9 +35,9 @@ func newFSNode(ctx context.Context, finfo os.FileInfo, path string) *fsnode {
 	return n
 }
 
-// NewEntry constructs a new Wash entry from the given FS
+// NewEntryBase constructs a new Wash entry from the given FS
 // path
-func NewEntry(ctx context.Context, path string) (plugin.Entry, error) {
+func NewEntryBase(ctx context.Context, path string) (plugin.Entry, error) {
 	finfo, err := os.Stat(path)
 	if err != nil {
 		return nil, err
diff --git a/api/fs/dir.go b/api/fs/dir.go
index fa328a0..150b92d 100644
--- a/api/fs/dir.go
+++ b/api/fs/dir.go
@@ -26,7 +26,7 @@ func (d *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 
 	entries := make([]plugin.Entry, len(matches))
 	for i, match := range matches {
-		entry, err := NewEntry(ctx, match)
+		entry, err := NewEntryBase(ctx, match)
 		if err != nil {
 			return nil, err
 		}
diff --git a/api/helpers.go b/api/helpers.go
index 3737667..2b1cf38 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -124,7 +124,7 @@ func getEntryFromRequest(r *http.Request) (plugin.Entry, string, *errorResponse)
 		// but it does mean that we'll need to re-evaluate this code once
 		// we get to the point where supporting remote Wash servers is
 		// desirable.
-		e, err := apifs.NewEntry(ctx, path)
+		e, err := apifs.NewEntryBase(ctx, path)
 		if err != nil {
 			if os.IsNotExist(err) {
 				return nil, "", entryNotFoundResponse(path, err.Error())
diff --git a/api/schema.go b/api/schema.go
new file mode 100644
index 0000000..0f00b1d
--- /dev/null
+++ b/api/schema.go
@@ -0,0 +1,21 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/puppetlabs/wash/plugin"
+)
+
+var schemaHandler handler = func(w http.ResponseWriter, r *http.Request) *errorResponse {
+	entry, path, errResp := getEntryFromRequest(r)
+	if errResp != nil {
+		return errResp
+	}
+	jsonEncoder := json.NewEncoder(w)
+	if err := jsonEncoder.Encode(plugin.Schema(entry)); err != nil {
+		return unknownErrorResponse(fmt.Errorf("Could not marshal schema for %v: %v", path, err))
+	}
+	return nil
+}
diff --git a/api/server.go b/api/server.go
index 88229ba..14e54bf 100644
--- a/api/server.go
+++ b/api/server.go
@@ -114,6 +114,7 @@ func StartAPI(registry *plugin.Registry, mountpoint string, socketPath string) (
 	r.Handle("/fs/read", readHandler).Methods(http.MethodGet)
 	r.Handle("/fs/stream", streamHandler).Methods(http.MethodGet)
 	r.Handle("/fs/exec", execHandler).Methods(http.MethodPost)
+	r.Handle("/fs/schema", schemaHandler).Methods(http.MethodGet)
 	r.Handle("/cache", cacheHandler).Methods(http.MethodDelete)
 	r.Handle("/history", historyHandler).Methods(http.MethodGet)
 	r.Handle("/history/{index:[0-9]+}", historyEntryHandler).Methods(http.MethodGet)
diff --git a/cmd/internal/find/clientWrappers.go b/cmd/internal/find/clientWrappers.go
index dfc04dd..c23c599 100644
--- a/cmd/internal/find/clientWrappers.go
+++ b/cmd/internal/find/clientWrappers.go
@@ -11,7 +11,7 @@ func info(c client.Client, path string) (types.Entry, error) {
 	if err != nil {
 		return types.Entry{}, err
 	}
-	return types.NewEntry(e, path), nil
+	return types.NewEntryBase(e, path), nil
 }
 
 // list is a wrapper to c.List that handles normalizing the children's
@@ -23,7 +23,7 @@ func list(c client.Client, e types.Entry) ([]types.Entry, error) {
 	}
 	children := make([]types.Entry, len(rawChildren))
 	for i, rawChild := range rawChildren {
-		children[i] = types.NewEntry(rawChild, e.NormalizedPath + "/" + rawChild.CName)
+		children[i] = types.NewEntryBase(rawChild, e.NormalizedPath + "/" + rawChild.CName)
 	}
 	return children, nil
 }
diff --git a/cmd/internal/find/types/entry.go b/cmd/internal/find/types/entry.go
index 6b3e28e..695ffa4 100644
--- a/cmd/internal/find/types/entry.go
+++ b/cmd/internal/find/types/entry.go
@@ -13,8 +13,8 @@ type Entry struct {
 	Metadata       plugin.JSONObject
 }
 
-// NewEntry constructs a new `wash find` entry
-func NewEntry(e apitypes.Entry, normalizedPath string) Entry {
+// NewEntryBase constructs a new `wash find` entry
+func NewEntryBase(e apitypes.Entry, normalizedPath string) Entry {
 	return Entry{
 		Entry: e,
 		NormalizedPath: normalizedPath,
diff --git a/plugin/aws/ec2Dir.go b/plugin/aws/ec2Dir.go
index e247e19..82eb432 100644
--- a/plugin/aws/ec2Dir.go
+++ b/plugin/aws/ec2Dir.go
@@ -17,13 +17,21 @@ type ec2Dir struct {
 	entries []plugin.Entry
 }
 
-func newEC2Dir(session *session.Session) *ec2Dir {
+func ec2DirBase() *ec2Dir {
 	ec2Dir := &ec2Dir{
-		EntryBase: plugin.NewEntry("ec2"),
-		session:   session,
-		client:    ec2Client.New(session),
+		EntryBase: plugin.NewEntryBase(),
 	}
-	ec2Dir.DisableDefaultCaching()
+	ec2Dir.
+		SetName("ec2").
+		IsSingleton().
+		DisableDefaultCaching()
+	return ec2Dir
+}
+
+func newEC2Dir(session *session.Session) *ec2Dir {
+	ec2Dir := ec2DirBase()
+	ec2Dir.session = session
+	ec2Dir.client = ec2Client.New(session)
 
 	ec2Dir.entries = []plugin.Entry{
 		newEC2InstancesDir(ec2Dir.session, ec2Dir.client),
@@ -32,6 +40,10 @@ func newEC2Dir(session *session.Session) *ec2Dir {
 	return ec2Dir
 }
 
+func (e *ec2Dir) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(ec2InstancesDirBase())
+}
+
 func (e *ec2Dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	return e.entries, nil
 }
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 43ca4e8..134c056 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -37,6 +37,17 @@ 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
@@ -50,15 +61,12 @@ func newEC2Instance(ctx context.Context, inst *ec2Client.Instance, session *sess
 			break
 		}
 	}
-	ec2Instance := &ec2Instance{
-		EntryBase: plugin.NewEntry(name),
-		id:        id,
-		session:   session,
-		client:    client,
-	}
+	ec2Instance := ec2InstanceBase()
+	ec2Instance.id = id
+	ec2Instance.session = session
+	ec2Instance.client = client
 	ec2Instance.
-		SetTTLOf(plugin.ListOp, 30*time.Second).
-		DisableCachingFor(plugin.MetadataOp).
+		SetName(name).
 		SetAttributes(getAttributes(inst))
 
 	return ec2Instance
@@ -99,6 +107,14 @@ func getAttributes(inst *ec2Client.Instance) plugin.EntryAttributes {
 	return attr
 }
 
+func (inst *ec2Instance) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(
+		ec2InstanceConsoleOutputBase(),
+		ec2InstanceMetadataJSONBase(),
+		volume.FSBase(),
+	)
+}
+
 func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
 	var latestConsoleOutput *ec2InstanceConsoleOutput
 	var err error
diff --git a/plugin/aws/ec2InstanceConsoleOutput.go b/plugin/aws/ec2InstanceConsoleOutput.go
index f85c30d..c2bb47e 100644
--- a/plugin/aws/ec2InstanceConsoleOutput.go
+++ b/plugin/aws/ec2InstanceConsoleOutput.go
@@ -19,18 +19,24 @@ type ec2InstanceConsoleOutput struct {
 	latest bool
 }
 
-func newEC2InstanceConsoleOutput(ctx context.Context, inst *ec2Instance, latest bool) (*ec2InstanceConsoleOutput, error) {
+func ec2InstanceConsoleOutputBase() *ec2InstanceConsoleOutput {
 	cl := &ec2InstanceConsoleOutput{
-		inst:   inst,
-		latest: latest,
+		EntryBase: plugin.NewEntryBase(),
 	}
+	cl.SetLabel("console.out").DisableDefaultCaching()
+	return cl
+}
+
+func newEC2InstanceConsoleOutput(ctx context.Context, inst *ec2Instance, latest bool) (*ec2InstanceConsoleOutput, error) {
+	cl := ec2InstanceConsoleOutputBase()
+	cl.inst = inst
+	cl.latest = latest
 
 	if cl.latest {
-		cl.EntryBase = plugin.NewEntry("console-latest.out")
+		cl.SetName("console-latest.out")
 	} else {
-		cl.EntryBase = plugin.NewEntry("console.out")
+		cl.SetName("console.out")
 	}
-	cl.DisableDefaultCaching()
 
 	output, err := cl.cachedConsoleOutput(ctx)
 	if err != nil {
diff --git a/plugin/aws/ec2InstanceMetadataJSON.go b/plugin/aws/ec2InstanceMetadataJSON.go
index aca7769..e361ede 100644
--- a/plugin/aws/ec2InstanceMetadataJSON.go
+++ b/plugin/aws/ec2InstanceMetadataJSON.go
@@ -15,12 +15,20 @@ type ec2InstanceMetadataJSON struct {
 	inst *ec2Instance
 }
 
-func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2InstanceMetadataJSON, error) {
+func ec2InstanceMetadataJSONBase() *ec2InstanceMetadataJSON {
 	im := &ec2InstanceMetadataJSON{
-		EntryBase: plugin.NewEntry("metadata.json"),
-		inst:      inst,
+		EntryBase: plugin.NewEntryBase(),
 	}
-	im.DisableDefaultCaching()
+	im.
+		SetName("metadata.json").
+		IsSingleton().
+		DisableDefaultCaching()
+	return im
+}
+
+func newEC2InstanceMetadataJSON(ctx context.Context, inst *ec2Instance) (*ec2InstanceMetadataJSON, error) {
+	im := ec2InstanceMetadataJSONBase()
+	im.inst = inst
 
 	content, err := im.Open(ctx)
 	if err != nil {
diff --git a/plugin/aws/ec2InstancesDir.go b/plugin/aws/ec2InstancesDir.go
index 04f7578..2a5792f 100644
--- a/plugin/aws/ec2InstancesDir.go
+++ b/plugin/aws/ec2InstancesDir.go
@@ -21,16 +21,25 @@ type ec2InstancesDir struct {
 	client  *ec2Client.EC2
 }
 
-func newEC2InstancesDir(session *session.Session, client *ec2Client.EC2) *ec2InstancesDir {
+func ec2InstancesDirBase() *ec2InstancesDir {
 	ec2InstancesDir := &ec2InstancesDir{
-		EntryBase: plugin.NewEntry("instances"),
-		session:   session,
-		client:    client,
+		EntryBase: plugin.NewEntryBase(),
 	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(ec2InstanceBase())
+}
+
 func (is *ec2InstancesDir) List(ctx context.Context) ([]plugin.Entry, error) {
 	resp, err := is.client.DescribeInstancesWithContext(ctx, nil)
 	if err != nil {
diff --git a/plugin/aws/profile.go b/plugin/aws/profile.go
index fbf9422..1dc1e43 100644
--- a/plugin/aws/profile.go
+++ b/plugin/aws/profile.go
@@ -18,9 +18,17 @@ type profile struct {
 	resourcesDir []plugin.Entry
 }
 
+func profileBase() *profile {
+	profile := &profile{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	profile.SetLabel("profile").DisableDefaultCaching()
+	return profile
+}
+
 func newProfile(ctx context.Context, name string) (*profile, error) {
-	profile := profile{EntryBase: plugin.NewEntry(name)}
-	profile.DisableDefaultCaching()
+	profile := profileBase()
+	profile.SetName(name)
 
 	activity.Record(ctx, "Creating a new AWS session for the %v profile", name)
 
@@ -57,7 +65,11 @@ func newProfile(ctx context.Context, name string) (*profile, error) {
 	profile.session = sess
 	profile.resourcesDir = []plugin.Entry{newResourcesDir(sess)}
 
-	return &profile, nil
+	return profile, nil
+}
+
+func (p *profile) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(resourcesDirBase())
 }
 
 // List lists the resources directory
diff --git a/plugin/aws/resourcesDir.go b/plugin/aws/resourcesDir.go
index a70f06c..24c5dcd 100644
--- a/plugin/aws/resourcesDir.go
+++ b/plugin/aws/resourcesDir.go
@@ -14,12 +14,20 @@ type resourcesDir struct {
 	resources []plugin.Entry
 }
 
-func newResourcesDir(session *session.Session) *resourcesDir {
+func resourcesDirBase() *resourcesDir {
 	resourcesDir := &resourcesDir{
-		EntryBase: plugin.NewEntry("resources"),
-		session:   session,
+		EntryBase: plugin.NewEntryBase(),
 	}
-	resourcesDir.DisableDefaultCaching()
+	resourcesDir.
+		SetName("resources").
+		IsSingleton().
+		DisableDefaultCaching()
+	return resourcesDir
+}
+
+func newResourcesDir(session *session.Session) *resourcesDir {
+	resourcesDir := resourcesDirBase()
+	resourcesDir.session = session
 
 	resourcesDir.resources = []plugin.Entry{
 		newS3Dir(resourcesDir.session),
@@ -29,6 +37,10 @@ func newResourcesDir(session *session.Session) *resourcesDir {
 	return resourcesDir
 }
 
+func (r *resourcesDir) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(s3DirBase(), ec2DirBase())
+}
+
 // List lists the available AWS resources
 func (r *resourcesDir) List(ctx context.Context) ([]plugin.Entry, error) {
 	return r.resources, nil
diff --git a/plugin/aws/root.go b/plugin/aws/root.go
index 8f4df7f..1d0d7b0 100644
--- a/plugin/aws/root.go
+++ b/plugin/aws/root.go
@@ -61,7 +61,8 @@ func exists(path string) error {
 
 // Init for root
 func (r *Root) Init() error {
-	r.EntryBase = plugin.NewEntry("aws")
+	r.EntryBase = plugin.NewEntryBase()
+	r.SetName("aws")
 	r.SetTTLOf(plugin.ListOp, 1*time.Minute)
 
 	// Force authorizing profiles on startup
@@ -69,6 +70,11 @@ func (r *Root) Init() error {
 	return err
 }
 
+// ChildSchemas returns the root's child schema
+func (r *Root) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(profileBase())
+}
+
 // List the available AWS profiles
 func (r *Root) List(ctx context.Context) ([]plugin.Entry, error) {
 	awsCredentials, err := awsCredentialsFile()
diff --git a/plugin/aws/s3Bucket.go b/plugin/aws/s3Bucket.go
index 45b94aa..029f0cf 100644
--- a/plugin/aws/s3Bucket.go
+++ b/plugin/aws/s3Bucket.go
@@ -151,14 +151,21 @@ type s3Bucket struct {
 	session *session.Session
 }
 
-func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Bucket {
+func s3BucketBase() *s3Bucket {
 	bucket := &s3Bucket{
-		EntryBase: plugin.NewEntry(name),
-		ctime:     ctime,
-		client:    s3Client.New(session),
-		session:   session,
+		EntryBase: plugin.NewEntryBase(),
 	}
+	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).
@@ -167,6 +174,10 @@ func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Buck
 	return bucket
 }
 
+func (b *s3Bucket) ChildSchemas() []plugin.EntrySchema {
+	return s3ObjectPrefixBase().ChildSchemas()
+}
+
 func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
 	if _, err := b.getRegion(ctx); err != nil {
 		return nil, err
diff --git a/plugin/aws/s3Dir.go b/plugin/aws/s3Dir.go
index 8d939ce..3261e6a 100644
--- a/plugin/aws/s3Dir.go
+++ b/plugin/aws/s3Dir.go
@@ -18,12 +18,23 @@ type s3Dir struct {
 	client  *s3Client.S3
 }
 
-func newS3Dir(session *session.Session) *s3Dir {
-	return &s3Dir{
-		EntryBase: plugin.NewEntry("s3"),
-		session:   session,
-		client:    s3Client.New(session),
+func s3DirBase() *s3Dir {
+	s3Dir := &s3Dir{
+		EntryBase: plugin.NewEntryBase(),
 	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(s3BucketBase())
 }
 
 // List lists the buckets.
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index dbbae7c..6e1ca06 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -22,14 +22,22 @@ type s3Object struct {
 	client *s3Client.S3
 }
 
-func newS3Object(o *s3Client.Object, name string, bucket string, key string, client *s3Client.S3) *s3Object {
+func s3ObjectBase() *s3Object {
 	s3Obj := &s3Object{
-		EntryBase: plugin.NewEntry(name),
-		bucket:    bucket,
-		key:       key,
-		client:    client,
+		EntryBase: plugin.NewEntryBase(),
 	}
-	s3Obj.DisableCachingFor(plugin.MetadataOp)
+	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.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
diff --git a/plugin/aws/s3ObjectPrefix.go b/plugin/aws/s3ObjectPrefix.go
index faf2c22..c2d99a5 100644
--- a/plugin/aws/s3ObjectPrefix.go
+++ b/plugin/aws/s3ObjectPrefix.go
@@ -19,13 +19,25 @@ type s3ObjectPrefix struct {
 	client *s3Client.S3
 }
 
-func newS3ObjectPrefix(name string, bucket string, prefix string, client *s3Client.S3) *s3ObjectPrefix {
-	return &s3ObjectPrefix{
-		EntryBase: plugin.NewEntry(name),
-		bucket:    bucket,
-		prefix:    prefix,
-		client:    client,
+func s3ObjectPrefixBase() *s3ObjectPrefix {
+	objPrefix := &s3ObjectPrefix{
+		EntryBase: plugin.NewEntryBase(),
 	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(s3ObjectPrefixBase(), s3ObjectBase())
 }
 
 // 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 ae436c9..e4f4e3a 100644
--- a/plugin/docker/container-log.go
+++ b/plugin/docker/container-log.go
@@ -18,6 +18,21 @@ type containerLogFile struct {
 	client        *client.Client
 }
 
+func containerLogFileBase() *containerLogFile {
+	clf := &containerLogFile{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	clf.SetName("log").IsSingleton()
+	return clf
+}
+
+func newContainerLogFile(container *container) *containerLogFile {
+	clf := containerLogFileBase()
+	clf.containerName = container.id
+	clf.client = container.client
+	return clf
+}
+
 func (clf *containerLogFile) isTty(ctx context.Context) bool {
 	meta, err := clf.client.ContainerInspect(ctx, clf.containerName)
 	if err == nil {
diff --git a/plugin/docker/container-metadata.go b/plugin/docker/container-metadata.go
index 8b17df9..9619023 100644
--- a/plugin/docker/container-metadata.go
+++ b/plugin/docker/container-metadata.go
@@ -13,6 +13,23 @@ type containerMetadata struct {
 	container *container
 }
 
+func containerMetadataBase() *containerMetadata {
+	cm := &containerMetadata{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	cm.
+		SetName("metadata.json").
+		IsSingleton().
+		DisableDefaultCaching()
+	return cm
+}
+
+func newContainerMetadata(container *container) *containerMetadata {
+	cm := containerMetadataBase()
+	cm.container = container
+	return cm
+}
+
 func (cm *containerMetadata) Open(ctx context.Context) (plugin.SizedReader, error) {
 	metadata, err := plugin.CachedMetadata(ctx, cm.container)
 	if err != nil {
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index ea903d5..a75d8d0 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"strings"
+	"time"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/client"
@@ -19,6 +21,41 @@ 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.
+		// We don't append ID because names must currently be unique in the docker runtime.
+		// It's also not clear why 'Names' is an array; `/containers/{id}/json` returns a single
+		// Name field while '/containers/json' uses a Names array for each instance. In practice
+		// 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)
+
+	startTime := time.Unix(inst.Created, 0)
+	cont.
+		Attributes().
+		SetCtime(startTime).
+		SetMtime(startTime).
+		SetAtime(startTime).
+		SetMeta(inst)
+
+	return cont
+}
+
 func (c *container) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	// Use raw to also get the container size.
 	_, raw, err := c.client.ContainerInspectWithRaw(ctx, c.id, true)
@@ -29,19 +66,25 @@ func (c *container) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	return plugin.ToJSONObject(raw), nil
 }
 
+func (c *container) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(
+		containerLogFileBase(),
+		containerMetadataBase(),
+		vol.FSBase(),
+	)
+}
+
 func (c *container) List(ctx context.Context) ([]plugin.Entry, error) {
 	// TODO: May be worth creating a helper that makes it easy to create
 	// read-only files. Lots of shared code between these two.
-	cm := &containerMetadata{plugin.NewEntry("metadata.json"), c}
-	cm.DisableDefaultCaching()
+	cm := newContainerMetadata(c)
 	content, err := cm.Open(ctx)
 	if err != nil {
 		return nil, err
 	}
 	cm.Attributes().SetSize(uint64(content.Size()))
 
-	clf := &containerLogFile{plugin.NewEntry("log"), c.id, c.client}
-	clf.DisableCachingFor(plugin.MetadataOp)
+	clf := newContainerLogFile(c)
 	content, err = clf.Open(ctx)
 	if err != nil {
 		return nil, err
diff --git a/plugin/docker/containers.go b/plugin/docker/containers.go
deleted file mode 100644
index c043962..0000000
--- a/plugin/docker/containers.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package docker
-
-import (
-	"context"
-	"strings"
-	"time"
-
-	"github.com/docker/docker/api/types"
-	"github.com/docker/docker/client"
-	"github.com/puppetlabs/wash/activity"
-	"github.com/puppetlabs/wash/plugin"
-)
-
-type containers struct {
-	plugin.EntryBase
-	client *client.Client
-}
-
-// List
-func (cs *containers) List(ctx context.Context) ([]plugin.Entry, error) {
-	containers, err := cs.client.ContainerList(ctx, types.ContainerListOptions{})
-	if err != nil {
-		return nil, err
-	}
-
-	activity.Record(ctx, "Listing %v containers in %v", len(containers), cs)
-	keys := make([]plugin.Entry, len(containers))
-	for i, inst := range containers {
-		name := inst.ID
-		if len(inst.Names) > 0 {
-			// The docker API prefixes all names with '/', so remove that.
-			// We don't append ID because names must currently be unique in the docker runtime.
-			// It's also not clear why 'Names' is an array; `/containers/{id}/json` returns a single
-			// Name field while '/containers/json' uses a Names array for each instance. In practice
-			// it appears to always be a single name, so take the first as the canonical name.
-			name = strings.TrimPrefix(inst.Names[0], "/")
-		}
-		cont := &container{
-			EntryBase: plugin.NewEntry(name),
-			id:        inst.ID,
-			client:    cs.client,
-		}
-
-		startTime := time.Unix(inst.Created, 0)
-		cont.
-			Attributes().
-			SetCtime(startTime).
-			SetMtime(startTime).
-			SetAtime(startTime).
-			SetMeta(inst)
-
-		keys[i] = cont
-	}
-	return keys, nil
-}
diff --git a/plugin/docker/containersDir.go b/plugin/docker/containersDir.go
new file mode 100644
index 0000000..5dc25b7
--- /dev/null
+++ b/plugin/docker/containersDir.go
@@ -0,0 +1,48 @@
+package docker
+
+import (
+	"context"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/client"
+	"github.com/puppetlabs/wash/activity"
+	"github.com/puppetlabs/wash/plugin"
+)
+
+type containersDir struct {
+	plugin.EntryBase
+	client *client.Client
+}
+
+func containersDirBase() *containersDir {
+	containersDir := &containersDir{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	containersDir.SetName("containers").IsSingleton()
+	return containersDir
+}
+
+func newContainersDir(client *client.Client) *containersDir {
+	containersDir := containersDirBase()
+	containersDir.client = client
+	return containersDir
+}
+
+func (cs *containersDir) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(containersDirBase())
+}
+
+// List
+func (cs *containersDir) List(ctx context.Context) ([]plugin.Entry, error) {
+	containers, err := cs.client.ContainerList(ctx, types.ContainerListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	activity.Record(ctx, "Listing %v containers in %v", len(containers), cs)
+	keys := make([]plugin.Entry, len(containers))
+	for i, inst := range containers {
+		keys[i] = newContainer(inst, cs.client)
+	}
+	return keys, nil
+}
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index 6377e05..5e9431b 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -26,16 +26,22 @@ func (r *Root) Init() error {
 		return err
 	}
 
-	r.EntryBase = plugin.NewEntry("docker")
+	r.EntryBase = plugin.NewEntryBase()
+	r.SetName("docker")
 	r.DisableDefaultCaching()
 	r.resources = []plugin.Entry{
-		&containers{EntryBase: plugin.NewEntry("containers"), client: dockerCli},
-		&volumes{EntryBase: plugin.NewEntry("volumes"), client: dockerCli},
+		newContainersDir(dockerCli),
+		newVolumesDir(dockerCli),
 	}
 
 	return nil
 }
 
+// ChildSchemas returns the root's child schema
+func (r *Root) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(containersDirBase(), volumesDirBase())
+}
+
 // List lists the types of resources the Docker plugin exposes.
 func (r *Root) List(ctx context.Context) ([]plugin.Entry, error) {
 	return r.resources, nil
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index eeafcda..55bbcc9 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -26,18 +26,24 @@ 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 := &volume{
-		EntryBase: plugin.NewEntry(v.Name),
-		client:    c,
-	}
-
+	vol := volumeBase()
+	vol.client = c
 	vol.
+		SetName(v.Name).
 		Attributes().
 		SetCtime(startTime).
 		SetMtime(startTime).
@@ -47,6 +53,10 @@ func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 	return vol, nil
 }
 
+func (v *volume) ChildSchemas() []plugin.EntrySchema {
+	return vol.ChildSchemas()
+}
+
 func (v *volume) List(ctx context.Context) ([]plugin.Entry, error) {
 	return vol.List(ctx, v, "")
 }
diff --git a/plugin/docker/volumes.go b/plugin/docker/volumesDir.go
similarity index 54%
rename from plugin/docker/volumes.go
rename to plugin/docker/volumesDir.go
index 1265b8d..732cbaf 100644
--- a/plugin/docker/volumes.go
+++ b/plugin/docker/volumesDir.go
@@ -9,13 +9,31 @@ import (
 	"github.com/puppetlabs/wash/plugin"
 )
 
-type volumes struct {
+type volumesDir struct {
 	plugin.EntryBase
 	client *client.Client
 }
 
+func volumesDirBase() *volumesDir {
+	volumesDir := &volumesDir{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	volumesDir.SetName("volumes").IsSingleton()
+	return volumesDir
+}
+
+func newVolumesDir(client *client.Client) *volumesDir {
+	volumesDir := volumesDirBase()
+	volumesDir.client = client
+	return volumesDir
+}
+
+func (vs *volumesDir) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(volumeBase())
+}
+
 // List
-func (vs *volumes) List(ctx context.Context) ([]plugin.Entry, error) {
+func (vs *volumesDir) List(ctx context.Context) ([]plugin.Entry, error) {
 	volumes, err := vs.client.VolumeList(ctx, filters.Args{})
 	if err != nil {
 		return nil, err
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 7097479..6f4376c 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.NewEntry to create new EntryBase objects.
+You should use plugin.NewEntryBaseBase to create new EntryBase objects.
 
 Each of the setters supports the builder pattern, which enables you
 to do something like
 
-	e := plugin.NewEntry("foo")
+	e := plugin.NewEntryBaseBase()
 	e.
 		DisableCachingFor(plugin.ListOp).
 		Attributes().
@@ -39,18 +39,15 @@ 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
+	washID             string
+	ttl                [3]time.Duration
+	label              string
+	isSingleton        bool
 }
 
-// NewEntry creates a new entry
-func NewEntry(name string) EntryBase {
-	if name == "" {
-		panic("plugin.NewEntry: received an empty name")
-	}
-
+// NewEntryBase creates a new EntryBase object
+func NewEntryBase() EntryBase {
 	e := EntryBase{
-		entryName:          name,
 		slashReplacerCh: '#',
 	}
 	for op := range e.ttl {
@@ -95,16 +92,29 @@ 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
-// plugin.NewEntry. You should use e.Name() when making
-// the appropriate API calls within your plugin.
+// e.SetName. 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 {
@@ -133,6 +143,44 @@ 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/externalPluginEntry.go b/plugin/externalPluginEntry.go
index c1b020a..218ca0e 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -38,10 +38,11 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*externalPluginEntr
 		return nil, fmt.Errorf("the entry's methods must be provided")
 	}
 	entry := &externalPluginEntry{
-		EntryBase:        NewEntry(e.Name),
+		EntryBase:        NewEntryBase(),
 		methods:          e.Methods,
 		state:            e.State,
 	}
+	entry.SetName(e.Name)
 	entry.SetAttributes(e.Attributes)
 	entry.setCacheTTLs(e.CacheTTLs)
 	if e.SlashReplacer != "" {
@@ -86,6 +87,11 @@ func (e *externalPluginEntry) implements(method string) bool {
 	return false
 }
 
+func (e *externalPluginEntry) ChildSchemas() []EntrySchema {
+	// ChildSchema's meant for core plugins.
+	return []EntrySchema{}
+}
+
 func (e *externalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 	stdout, err := e.script.InvokeAndWait(ctx, "list", e)
 	if err != nil {
diff --git a/plugin/helpers.go b/plugin/helpers.go
index 3a19d79..8d9583c 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -13,14 +13,14 @@ var DefaultTimeout = 10 * time.Second
 
 /*
 Name returns the entry's name as it was passed into
-plugin.NewEntry. It is meant to be called by other
+plugin.NewEntryBase. It is meant to be called by other
 Wash packages. Plugin authors should use EntryBase#Name
 when writing their plugins.
 */
 func Name(e Entry) string {
 	// The reason we don't expose EntryBase#Name in the Entry
 	// interface is so plugin authors don't override it. It ensures
-	// that whatever name they pass into plugin.NewEntry is the
+	// that whatever name they pass into plugin.NewEntryBase is the
 	// name received by Wash.
 	return e.name()
 }
@@ -42,6 +42,9 @@ e.SetSlashReplacer(<char>) to change the default slash replacer from
 a '#' to <char>.
 */
 func CName(e Entry) string {
+	if len(e.name()) == 0 {
+		panic("plugin.CName: e.name() is empty")
+	}
 	// We make the CName a separate function instead of embedding it
 	// in the Entry interface because doing so prevents plugin authors
 	// from overriding it.
diff --git a/plugin/kubernetes/context.go b/plugin/kubernetes/context.go
index 449e86e..d335f73 100644
--- a/plugin/kubernetes/context.go
+++ b/plugin/kubernetes/context.go
@@ -17,6 +17,27 @@ type k8context struct {
 	defaultns string
 }
 
+func k8ContextBase() *k8context {
+	context := &k8context{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(namespaceBase())
+}
+
 func (c *k8context) List(ctx context.Context) ([]plugin.Entry, error) {
 	nsi := c.client.CoreV1().Namespaces()
 	nsList, err := nsi.List(metav1.ListOptions{})
diff --git a/plugin/kubernetes/namespace.go b/plugin/kubernetes/namespace.go
index 3bcfe0a..395239b 100644
--- a/plugin/kubernetes/namespace.go
+++ b/plugin/kubernetes/namespace.go
@@ -2,8 +2,7 @@ package kubernetes
 
 import (
 	"context"
-
-	"github.com/puppetlabs/wash/activity"
+	
 	"github.com/puppetlabs/wash/plugin"
 	corev1 "k8s.io/api/core/v1"
 	k8s "k8s.io/client-go/kubernetes"
@@ -14,21 +13,35 @@ type namespace struct {
 	plugin.EntryBase
 	client        *k8s.Clientset
 	config        *rest.Config
-	resourcetypes []plugin.Entry
+	resources     []plugin.Entry
+}
+
+func namespaceBase() *namespace {
+	ns := &namespace{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	ns.SetLabel("namespace")
+	return ns
 }
 
 func newNamespace(name string, meta *corev1.Namespace, c *k8s.Clientset, cfg *rest.Config) *namespace {
-	ns := &namespace{EntryBase: plugin.NewEntry(name), client: c, config: cfg}
-	ns.resourcetypes = []plugin.Entry{
-		&pods{plugin.NewEntry("pods"), c, cfg, name},
-		&pvcs{plugin.NewEntry("persistentvolumeclaims"), c, name},
+	ns := namespaceBase()
+	ns.client = c
+	ns.config = cfg
+	ns.SetName(name)
+	ns.resources = []plugin.Entry{
+		newPodsDir(ns),
+		newPVCSDir(ns),
 	}
 	// TODO: Figure out other attributes that we could set here, if any.
 	ns.Attributes().SetMeta(meta)
 	return ns
 }
 
+func (n *namespace) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(podsDirBase(), pvcsDirBase())
+}
+
 func (n *namespace) List(ctx context.Context) ([]plugin.Entry, error) {
-	activity.Record(ctx, "Listing resource types for namespace %v: %v", n.Name(), n.resourcetypes)
-	return n.resourcetypes, nil
+	return n.resources, nil
 }
\ No newline at end of file
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index 04b70ed..e747ce1 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -25,14 +25,19 @@ type pod struct {
 	ns     string
 }
 
-func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns string, p *corev1.Pod) (*pod, error) {
+func podBase() *pod {
 	pd := &pod{
-		EntryBase: plugin.NewEntry(p.Name),
-		client:    client,
-		config:    config,
-		ns:        ns,
+		EntryBase: plugin.NewEntryBase(),
 	}
-	pd.DisableDefaultCaching()
+	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.client = client
+	pd.config = config
+	pd.ns = ns
 
 	pdInfo := podInfoResult{
 		pd:         p,
@@ -41,6 +46,7 @@ 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).
diff --git a/plugin/kubernetes/pods.go b/plugin/kubernetes/podsDir.go
similarity index 60%
rename from plugin/kubernetes/pods.go
rename to plugin/kubernetes/podsDir.go
index dcde3a4..f2211ff 100644
--- a/plugin/kubernetes/pods.go
+++ b/plugin/kubernetes/podsDir.go
@@ -9,14 +9,34 @@ import (
 	"k8s.io/client-go/rest"
 )
 
-type pods struct {
+type podsDir struct {
 	plugin.EntryBase
 	client *k8s.Clientset
 	config *rest.Config
 	ns     string
 }
 
-func (ps *pods) List(ctx context.Context) ([]plugin.Entry, error) {
+func podsDirBase() *podsDir {
+	pds := &podsDir{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(podBase())
+}
+
+func (ps *podsDir) List(ctx context.Context) ([]plugin.Entry, error) {
 	// TODO: identify whether we have permission to get logs for this namespace early, so
 	// we can return quickly for Attributes.
 	podList, err := ps.client.CoreV1().Pods(ps.ns).List(metav1.ListOptions{})
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index dcdbb9e..c2c5c06 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -28,14 +28,21 @@ const mountpoint = "/mnt"
 
 var errPodTerminated = errors.New("Pod terminated unexpectedly")
 
-func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface, p *corev1.PersistentVolumeClaim) *pvc {
+func pvcBase() *pvc {
 	vol := &pvc{
-		EntryBase: plugin.NewEntry(p.Name),
-		pvci:      pi,
-		podi:      pd,
+		EntryBase: plugin.NewEntryBase(),
 	}
+	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).
@@ -45,6 +52,10 @@ func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface,
 	return vol
 }
 
+func (v *pvc) ChildSchemas() []plugin.EntrySchema {
+	return volume.ChildSchemas()
+}
+
 func (v *pvc) List(ctx context.Context) ([]plugin.Entry, error) {
 	return volume.List(ctx, v, "")
 }
diff --git a/plugin/kubernetes/pvcs.go b/plugin/kubernetes/pvcsDir.go
similarity index 59%
rename from plugin/kubernetes/pvcs.go
rename to plugin/kubernetes/pvcsDir.go
index 6236e54..96e28bb 100644
--- a/plugin/kubernetes/pvcs.go
+++ b/plugin/kubernetes/pvcsDir.go
@@ -8,13 +8,32 @@ import (
 	k8s "k8s.io/client-go/kubernetes"
 )
 
-type pvcs struct {
+type pvcsDir struct {
 	plugin.EntryBase
 	client *k8s.Clientset
 	ns     string
 }
 
-func (pv *pvcs) List(ctx context.Context) ([]plugin.Entry, error) {
+func pvcsDirBase() *pvcsDir {
+	pv := &pvcsDir{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	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) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(pvcBase())
+}
+
+func (pv *pvcsDir) List(ctx context.Context) ([]plugin.Entry, error) {
 	// TODO: identify whether we have permission to run pods for this namespace early, so
 	// we can return quickly on expensive commands.
 	pvcI := pv.client.CoreV1().PersistentVolumeClaims(pv.ns)
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index f5722b7..2794e36 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -36,7 +36,7 @@ func createContext(raw clientcmdapi.Config, name string, access clientcmd.Config
 	if err != nil {
 		return nil, err
 	}
-	return &k8context{plugin.NewEntry(name), clientset, cfg, defaultns}, nil
+	return newK8Context(name, clientset, cfg, defaultns), nil
 }
 
 // Init for root
@@ -48,7 +48,8 @@ func (r *Root) Init() error {
 		return err
 	}
 
-	r.EntryBase = plugin.NewEntry("kubernetes")
+	r.EntryBase = plugin.NewEntryBase()
+	r.SetName("kubernetes")
 	r.DisableDefaultCaching()
 
 	contexts := make([]plugin.Entry, 0)
@@ -65,6 +66,11 @@ func (r *Root) Init() error {
 	return nil
 }
 
+// ChildSchemas returns the root's child schemas
+func (r *Root) ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(k8ContextBase())
+}
+
 // List returns available contexts.
 func (r *Root) List(ctx context.Context) ([]plugin.Entry, error) {
 	return r.contexts, nil
diff --git a/plugin/registry.go b/plugin/registry.go
index e7432fb..1c5cf6b 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -10,16 +10,17 @@ import (
 // Registry represents the plugin registry. It is also Wash's root.
 type Registry struct {
 	EntryBase
-	plugins     map[string]Root
-	pluginRoots []Entry
+	plugins           map[string]Root
+	pluginRoots       []Entry
 }
 
 // NewRegistry creates a new plugin registry object
 func NewRegistry() *Registry {
 	r := &Registry{
-		EntryBase: NewEntry("/"),
+		EntryBase: NewEntryBase(),
 		plugins:   make(map[string]Root),
 	}
+	r.SetName("/")
 	r.setID("/")
 	r.DisableDefaultCaching()
 
@@ -51,6 +52,7 @@ func (r *Registry) RegisterPlugin(root Root) error {
 		panic(msg)
 	}
 
+	root.entryBase().IsSingleton()
 	r.plugins[root.name()] = root
 	r.pluginRoots = append(r.pluginRoots, root)
 	return nil
@@ -77,6 +79,11 @@ func (r *Registry) RegisterExternalPlugin(spec ExternalPluginSpec) error {
 	return r.RegisterPlugin(root)
 }
 
+// ChildSchemas returns the child schemas of the plugin registry
+func (r *Registry) ChildSchemas() []EntrySchema {
+	return ChildSchemas(r.pluginRoots...)
+}
+
 // List all of Wash's loaded plugins
 func (r *Registry) List(ctx context.Context) ([]Entry, error) {
 	return r.pluginRoots, nil
diff --git a/plugin/schema.go b/plugin/schema.go
new file mode 100644
index 0000000..29d1f3d
--- /dev/null
+++ b/plugin/schema.go
@@ -0,0 +1,88 @@
+package plugin
+
+import (
+	"reflect"
+	"strings"
+)
+
+// EntrySchema represents an entry's schema.
+type EntrySchema struct {
+	Type      string        `json:"type"`
+	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
+}
+
+// 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 EntrySchema{
+			Type: "external-plugin-root",
+		}
+	case *externalPluginEntry:
+		return EntrySchema{
+			Type: "external-plugin-entry",
+		}
+	}
+
+	s := EntrySchema{
+		Type:      strings.TrimPrefix(reflect.TypeOf(e).String(), "*"),
+		Label:     e.entryBase().label,
+		Singleton: e.entryBase().isSingleton,
+		Actions:   SupportedActionsOf(e),
+		entry:     e,
+	}
+	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.Type] {
+		// This means that s' children can have s' type, which is
+		// true if s is e.g. a volume directory.
+		return
+	}
+	s.Children = s.entry.(Parent).ChildSchemas()
+	visited[s.Type] = true
+	for i, child := range s.Children {
+		child.fillChildren(visited)
+		// Need to re-assign because child is not a pointer,
+		// so s.Children[i] won't be updated.
+		s.Children[i] = child
+	}
+	// Delete "s" from visited so that siblings or ancestors that
+	// also use "s" won't be affected.
+	delete(visited, s.Type)
+}
\ No newline at end of file
diff --git a/plugin/types.go b/plugin/types.go
index bf654b5..5834369 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -3,12 +3,12 @@ Package plugin defines a set of interfaces that plugins must implement to enable
 functonality.
 
 All resources must implement the Entry interface. To do so they should include the EntryBase
-type, and initialize it via NewEntry. For example
+type, and initialize it via NewEntryBase. For example
 	type myResource struct {
 		plugin.EntryBase
 	}
 	...
-	rsc := myResource{plugin.NewEntry("a resource")}
+	rsc := myResource{plugin.NewEntryBase("a resource")}
 EntryBase gives the resource a name - which is how it will be displayed in the filesystem
 or referenced via the API - and tools for controlling how its data is cached.
 
@@ -33,7 +33,7 @@ import (
 // Entry is the interface for things that are representable by Wash's filesystem. This includes
 // plugin roots; resources like containers and volumes; placeholders (e.g. the containers directory
 // in the Docker plugin); read-only files like the metadata.json files for containers and EC2
-// instances; and more. It is a sealed interface, meaning you must use plugin.NewEntry when
+// instances; and more. It is a sealed interface, meaning you must use plugin.NewEntryBase when
 // creating your plugin objects.
 //
 // Metadata returns a complete description of the entry. See the EntryBase documentation for more
@@ -46,12 +46,14 @@ type Entry interface {
 	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.
 type Parent interface {
 	Entry
+	ChildSchemas() []EntrySchema
 	List(context.Context) ([]Entry, error)
 }
 
diff --git a/volume/core.go b/volume/core.go
index 6513173..9dc6bf4 100644
--- a/volume/core.go
+++ b/volume/core.go
@@ -36,6 +36,11 @@ type Dir = map[string]plugin.EntryAttributes
 // A DirMap is a map of directory names to a map of their directory contents.
 type DirMap = map[string]Dir
 
+// ChildSchemas returns a volume's child schema
+func ChildSchemas() []plugin.EntrySchema {
+	return plugin.ChildSchemas(dirBase(), fileBase())
+}
+
 // List constructs an array of entries for the given path from a DirMap.
 // The root path is an empty string. Requests are cached against the supplied Interface
 // using the VolumeListCB op.
diff --git a/volume/dir.go b/volume/dir.go
index a9ff5e7..4cb0ac2 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -14,13 +14,20 @@ type dir struct {
 	path string
 }
 
-// newDir creates a dir populated from dirs.
-func newDir(name string, attr plugin.EntryAttributes, impl Interface, path string) *dir {
+func dirBase() *dir {
 	vd := &dir{
-		EntryBase: plugin.NewEntry(name),
-		impl:      impl,
-		path:      path,
+		EntryBase: plugin.NewEntryBase(),
 	}
+	vd.SetLabel("dir")
+	return vd
+}
+
+// newDir creates a dir populated from dirs.
+func newDir(name string, attr plugin.EntryAttributes, impl Interface, path string) *dir {
+	vd := dirBase()
+	vd.impl = impl
+	vd.path = path
+	vd.SetName(name)
 	vd.SetAttributes(attr)
 	vd.SetTTLOf(plugin.OpenOp, 60*time.Second)
 	// Caching handled in List based on 'impl'.
@@ -29,6 +36,10 @@ func newDir(name string, attr plugin.EntryAttributes, impl Interface, path strin
 	return vd
 }
 
+func (v *dir) ChildSchemas() []plugin.EntrySchema {
+	return ChildSchemas()
+}
+
 // List lists the children of the directory.
 func (v *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	return List(ctx, v.impl, v.path)
diff --git a/volume/file.go b/volume/file.go
index 80a9ad8..945275b 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -15,13 +15,20 @@ type file struct {
 	path string
 }
 
-// newFile creates a VolumeFile.
-func newFile(name string, attr plugin.EntryAttributes, impl Interface, path string) *file {
+func fileBase() *file {
 	vf := &file{
-		EntryBase: plugin.NewEntry(name),
-		impl:      impl,
-		path:      path,
+		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.impl = impl
+	vf.path = path
+	vf.SetName(name)
 	vf.SetAttributes(attr)
 	vf.SetTTLOf(plugin.OpenOp, 60*time.Second)
 
diff --git a/volume/fs.go b/volume/fs.go
index 28958c0..a6f19b7 100644
--- a/volume/fs.go
+++ b/volume/fs.go
@@ -16,15 +16,32 @@ type FS struct {
 	executor plugin.Execable
 }
 
+// FSBase returns a base FS entry
+func FSBase() *FS {
+	fs := &FS{
+		EntryBase: plugin.NewEntryBase(),
+	}
+	fs.
+		SetLabel("fs").
+		// 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) *FS {
-	fs := &FS{EntryBase: plugin.NewEntry(name), executor: executor}
-	// Caching handled in List.
-	fs.DisableCachingFor(plugin.ListOp)
+	fs := FSBase()
+	fs.executor = executor
+	fs.SetName(name)
 	return fs
 }
 
+// ChildSchemas returns the FS entry's child schema
+func (d *FS) ChildSchemas() []plugin.EntrySchema {
+	return ChildSchemas()
+}
+
 // List will attempt to list the filesystem of an Execable resource (the executor). It will list
 // a directory tree based on supplied configuration (defaulting to `/var/log` if not specified).
 func (d *FS) List(ctx context.Context) ([]plugin.Entry, error) {


Test patch:
diff --git a/api/cache_test.go b/api/cache_test.go
index 739aa1e..f8e73bc 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.NewEntry("mockParent"),
+		EntryBase: plugin.NewEntryBase(),
 	}
-
+	p.SetName("mockParent")
 	return p
 }
 
@@ -162,3 +162,7 @@ func (p *mockedParent) List(ctx context.Context) ([]plugin.Entry, error) {
 	args := p.Called(ctx)
 	return args.Get(0).([]plugin.Entry), args.Error(1)
 }
+
+func (p *mockedParent) ChildSchemas() []plugin.EntrySchema {
+	return nil
+}
diff --git a/api/helpers_test.go b/api/helpers_test.go
index 1444484..7278495 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -20,6 +20,10 @@ func (g *mockParent) List(context.Context) ([]plugin.Entry, error) {
 	return g.entries, nil
 }
 
+func (g *mockParent) ChildSchemas() []plugin.EntrySchema {
+	return nil
+}
+
 type HelpersTestSuite struct {
 	suite.Suite
 }
@@ -52,8 +56,10 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 		}
 	}
 
-	foo := plugin.NewEntry("foo/bar")
-	parent := &mockParent{plugin.NewEntry("root"), []plugin.Entry{&foo}}
+	foo := plugin.NewEntryBase()
+	foo.SetName("foo/bar")
+	parent := &mockParent{plugin.NewEntryBase(), []plugin.Entry{&foo}}
+	parent.SetName("root")
 	parent.SetTestID("/root")
 	parent.DisableDefaultCaching()
 	for _, c := range []testcase{
@@ -64,8 +70,10 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 		runTestCase(parent, c)
 	}
 
-	baz := plugin.NewEntry("baz")
-	nestedParent := &mockParent{plugin.NewEntry("bar"), []plugin.Entry{&baz}}
+	baz := plugin.NewEntryBase()
+	baz.SetName("baz")
+	nestedParent := &mockParent{plugin.NewEntryBase(), []plugin.Entry{&baz}}
+	nestedParent.SetName("bar")
 	nestedParent.DisableDefaultCaching()
 	parent.entries = append(parent.entries, nestedParent)
 	for _, c := range []testcase{
@@ -77,7 +85,8 @@ func (suite *HelpersTestSuite) TestFindEntry() {
 	}
 
 	// Finally, test the duplicate cname error response
-	duplicateFoo := plugin.NewEntry("foo#bar")
+	duplicateFoo := plugin.NewEntryBase()
+	duplicateFoo.SetName("foo#bar")
 	parent.entries = append(parent.entries, &duplicateFoo)
 	expectedErr := plugin.DuplicateCNameErr{
 		ParentID:                        plugin.ID(parent),
@@ -107,13 +116,22 @@ func (m *mockRoot) List(ctx context.Context) ([]plugin.Entry, error) {
 	return args.Get(0).([]plugin.Entry), args.Error(1)
 }
 
+func (m *mockRoot) ChildSchemas() []plugin.EntrySchema {
+	return []plugin.EntrySchema{
+		plugin.EntrySchema{
+			Type: "mockEntry",
+		},
+	}
+}
+
 func getRequest(ctx context.Context, path string) *http.Request {
 	return (&http.Request{URL: &url.URL{RawQuery: url.Values{"path": []string{path}}.Encode()}}).WithContext(ctx)
 }
 
 func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	reg := plugin.NewRegistry()
-	plug := &mockRoot{EntryBase: plugin.NewEntry("mine")}
+	plug := &mockRoot{EntryBase: plugin.NewEntryBase()}
+	plug.SetName("mine")
 	plug.SetTestID("/mine")
 	suite.NoError(reg.RegisterPlugin(plug))
 	ctx := context.WithValue(context.Background(), pluginRegistryKey, reg)
@@ -147,7 +165,8 @@ func (suite *HelpersTestSuite) TestGetEntryFromPath() {
 	_, _, err = getEntryFromRequest(getRequest(ctx, mountpoint+"/yours"))
 	suite.Error(err)
 
-	file := plugin.NewEntry("a file")
+	file := plugin.NewEntryBase()
+	file.SetName("a file")
 	file.SetTestID("/mine/a file")
 	plug.On("List", mock.Anything).Return([]plugin.Entry{&file}, nil)
 
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 2668ab7..0d3ace5 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -122,9 +122,11 @@ type cacheTestsMockEntry struct {
 }
 
 func newCacheTestsMockEntry(name string) *cacheTestsMockEntry {
-	return &cacheTestsMockEntry{
-		EntryBase: NewEntry(name),
+	e := &cacheTestsMockEntry{
+		EntryBase: NewEntryBase(),
 	}
+	e.SetName(name)
+	return e
 }
 
 func (e *cacheTestsMockEntry) List(ctx context.Context) ([]Entry, error) {
@@ -132,6 +134,10 @@ func (e *cacheTestsMockEntry) List(ctx context.Context) ([]Entry, error) {
 	return args.Get(0).([]Entry), args.Error(1)
 }
 
+func (e *cacheTestsMockEntry) ChildSchemas() []EntrySchema {
+	return nil
+}
+
 func (e *cacheTestsMockEntry) Open(ctx context.Context) (SizedReader, error) {
 	args := e.Called(ctx)
 	return args.Get(0).(SizedReader), args.Error(1)
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
index c8549b6..063eb70 100644
--- a/plugin/entryBase_test.go
+++ b/plugin/entryBase_test.go
@@ -24,20 +24,17 @@ func (suite *EntryBaseTestSuite) assertOpTTL(e EntryBase, op defaultOpCode, opNa
 	)
 }
 
-func (suite *EntryBaseTestSuite) TestNewEntry() {
-	suite.Panics(
-		func() { NewEntry("") },
-		"plugin.NewEntry: received an empty name",
-	)
-
+func (suite *EntryBaseTestSuite) TestNewEntryBase() {
 	initialAttr := EntryAttributes{}
 	initialAttr.SetCtime(time.Now())
-	e := NewEntry("foo")
+	e := NewEntryBase()
 
 	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)
@@ -57,7 +54,7 @@ func (suite *EntryBaseTestSuite) TestNewEntry() {
 }
 
 func (suite *EntryBaseTestSuite) TestMetadata() {
-	e := NewEntry("foo")
+	e := NewEntryBase()
 
 	meta, err := e.Metadata(context.Background())
 	if suite.NoError(err) {
@@ -67,7 +64,7 @@ func (suite *EntryBaseTestSuite) TestMetadata() {
 }
 
 func (suite *EntryBaseTestSuite) TestSetSlashReplacer() {
-	e := NewEntry("foo/bar")
+	e := NewEntryBase()
 
 	suite.Panics(
 		func() { e.SetSlashReplacer('/') },
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index cc528d8..cfd4536 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 := NewEntry("mock").ttl
+		expectedTTLs := NewEntryBase().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: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 	}
 	entry.setCacheTTLs(decodedTTLs)
 
@@ -157,7 +157,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -186,9 +186,11 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockInvokeAndWait([]byte(stdout), nil)
 	entries, err := entry.List(ctx)
 	if suite.NoError(err) {
+		entryBase := NewEntryBase()
+		entryBase.SetName("foo")
 		expectedEntries := []Entry{
 			&externalPluginEntry{
-				EntryBase:        NewEntry("foo"),
+				EntryBase:        entryBase,
 				methods:          []string{"list"},
 				script:           entry.script,
 			},
@@ -201,7 +203,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 		script:    mockScript,
 	}
 	entry.SetTestID("/foo")
@@ -230,7 +232,7 @@ func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 		script:    mockScript,
 	}
 	expectedMeta := JSONObject{"foo": "bar"}
@@ -249,7 +251,7 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := &externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 		methods:   []string{"metadata"},
 		script:    mockScript,
 	}
diff --git a/plugin/externalPluginRoot_test.go b/plugin/externalPluginRoot_test.go
index 8b8ae09..6f3da3c 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/externalPluginRoot_test.go
@@ -16,7 +16,7 @@ type ExternalPluginRootTestSuite struct {
 func (suite *ExternalPluginRootTestSuite) TestInit() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	root := &externalPluginRoot{&externalPluginEntry{
-		EntryBase: NewEntry("foo"),
+		EntryBase: NewEntryBase(),
 		script:    mockScript,
 	}}
 
@@ -53,11 +53,12 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 	if suite.NoError(err) {
 		expectedRoot := &externalPluginRoot{
 			externalPluginEntry: &externalPluginEntry{
-				EntryBase:        NewEntry("foo"),
+				EntryBase:        NewEntryBase(),
 				methods:          []string{"list"},
 				script:           root.script,
 			},
 		}
+		expectedRoot.SetName("foo")
 
 		suite.Equal(expectedRoot, root)
 	}
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 9f7234e..3934a9d 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -22,12 +22,14 @@ func (suite *HelpersTestSuite) TearDownSuite() {
 }
 
 func (suite *HelpersTestSuite) TestName() {
-	e := NewEntry("foo")
+	e := NewEntryBase()
+	e.SetName("foo")
 	suite.Equal(Name(&e), "foo")
 }
 
 func (suite *HelpersTestSuite) TestCName() {
-	e := NewEntry("foo/bar/baz")
+	e := NewEntryBase()
+	e.SetName("foo/bar/baz")
 	suite.Equal("foo#bar#baz", CName(&e))
 
 	e.SetSlashReplacer(':')
@@ -35,7 +37,8 @@ func (suite *HelpersTestSuite) TestCName() {
 }
 
 func (suite *HelpersTestSuite) TestID() {
-	e := NewEntry("foo/bar")
+	e := NewEntryBase()
+	e.SetName("foo/bar")
 
 	suite.Panics(
 		func() { ID(&e) },
@@ -52,8 +55,9 @@ type helpersTestsMockEntry struct {
 
 func newHelpersTestsMockEntry() *helpersTestsMockEntry {
 	e := &helpersTestsMockEntry{
-		EntryBase: NewEntry("mockEntry"),
+		EntryBase: NewEntryBase(),
 	}
+	e.SetName("mockEntry")
 	e.SetTestID("id")
 	e.DisableDefaultCaching()
 
diff --git a/plugin/registry_test.go b/plugin/registry_test.go
index 9df2ba9..d3f4c24 100644
--- a/plugin/registry_test.go
+++ b/plugin/registry_test.go
@@ -33,6 +33,14 @@ func (m *mockRoot) List(ctx context.Context) ([]Entry, error) {
 	return args.Get(0).([]Entry), args.Error(1)
 }
 
+func (m *mockRoot) ChildSchemas() []EntrySchema {
+	return []EntrySchema{
+		EntrySchema{
+			Type: "entry",
+		},
+	}
+}
+
 func (suite *RegistryTestSuite) TestPluginNameRegex() {
 	suite.Regexp(pluginNameRegex, "a")
 	suite.Regexp(pluginNameRegex, "A")
@@ -49,7 +57,8 @@ func (suite *RegistryTestSuite) TestPluginNameRegex() {
 
 func (suite *RegistryTestSuite) TestRegisterPlugin() {
 	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntry("mine")}
+	m := &mockRoot{EntryBase: NewEntryBase()}
+	m.SetName("mine")
 	m.On("Init").Return(nil)
 
 	suite.NoError(reg.RegisterPlugin(m))
@@ -59,7 +68,8 @@ func (suite *RegistryTestSuite) TestRegisterPlugin() {
 
 func (suite *RegistryTestSuite) TestRegisterPluginInitError() {
 	reg := NewRegistry()
-	m := &mockRoot{EntryBase: NewEntry("mine")}
+	m := &mockRoot{EntryBase: NewEntryBase()}
+	m.SetName("mine")
 	m.On("Init").Return(errors.New("failed"))
 
 	suite.EqualError(reg.RegisterPlugin(m), "failed")
@@ -70,7 +80,8 @@ func (suite *RegistryTestSuite) TestRegisterPluginInitError() {
 func (suite *RegistryTestSuite) TestRegisterPluginInvalidPluginName() {
 	panicFunc := func() {
 		reg := NewRegistry()
-		m := &mockRoot{EntryBase: NewEntry("b@dname")}
+		m := &mockRoot{EntryBase: NewEntryBase()}
+		m.SetName("b@dname")
 		_ = reg.RegisterPlugin(m)
 	}
 
@@ -83,7 +94,8 @@ func (suite *RegistryTestSuite) TestRegisterPluginInvalidPluginName() {
 func (suite *RegistryTestSuite) TestRegisterPluginRegisteredPlugin() {
 	panicFunc := func() {
 		reg := NewRegistry()
-		m1 := &mockRoot{EntryBase: NewEntry("mine")}
+		m1 := &mockRoot{EntryBase: NewEntryBase()}
+		m1.SetName("mine")
 		_ = reg.RegisterPlugin(m1)
 		_ = reg.RegisterPlugin(m1)
 	}
diff --git a/volume/dir_test.go b/volume/dir_test.go
index 371dd49..8b195ec 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -34,7 +34,8 @@ func TestVolumeDir(t *testing.T) {
 	assert.Nil(t, err)
 
 	plugin.SetTestCache(datastore.NewMemCache())
-	entry := mockDirEntry{EntryBase: plugin.NewEntry("mine"), dmap: dmap}
+	entry := mockDirEntry{EntryBase: plugin.NewEntryBase(), dmap: dmap}
+	entry.SetName("mine")
 	entry.SetTestID("/mine")
 
 	assert.NotNil(t, dmap[""]["path"])
diff --git a/volume/file_test.go b/volume/file_test.go
index c32a2ab..943cc12 100644
--- a/volume/file_test.go
+++ b/volume/file_test.go
@@ -42,7 +42,8 @@ func TestVolumeFile(t *testing.T) {
 	initialAttr := plugin.EntryAttributes{}
 	initialAttr.SetCtime(now)
 
-	impl := &mockFileEntry{EntryBase: plugin.NewEntry("parent"), content: "hello"}
+	impl := &mockFileEntry{EntryBase: plugin.NewEntryBase(), content: "hello"}
+	impl.SetName("parent")
 	vf := newFile("mine", initialAttr, impl, "my path")
 
 	attr := plugin.Attributes(vf)
@@ -71,7 +72,8 @@ func TestVolumeFile(t *testing.T) {
 }
 
 func TestVolumeFileErr(t *testing.T) {
-	impl := &mockFileEntry{EntryBase: plugin.NewEntry("parent"), err: errors.New("fail")}
+	impl := &mockFileEntry{EntryBase: plugin.NewEntryBase(), err: errors.New("fail")}
+	impl.SetName("parent")
 	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 60b5a69..b030361 100644
--- a/volume/fs_test.go
+++ b/volume/fs_test.go
@@ -59,7 +59,8 @@ func (suite *fsTestSuite) createResult(data string) plugin.ExecCommand {
 }
 
 func (suite *fsTestSuite) createExec() *mockExecutor {
-	exec := &mockExecutor{EntryBase: plugin.NewEntry("instance")}
+	exec := &mockExecutor{EntryBase: plugin.NewEntryBase()}
+	exec.SetName("instance")
 	// Used when recording activity.
 	exec.SetTestID("/instance")
 	cmd := StatCmd("/var/log")