Repository: puppetlabs/wash
Pull request #142 (2019-03-19 19:36:10)

Centralize the cache ID in CacheConfig
This way, CachedList, CachedOpen, and CachedMetadata do not require the
caller to pass-in a cache ID. Thus, plugin authors are free to use them.
CachedMetadata is especially useful for a container/ec2 instance's metadata.json
file.

This commit also paves the way for creating a more general CachedOp
method that plugin authors can use if they need more fine-grained
caching.


Patch:
diff --git a/README.md b/README.md
index b0b0ba8..1738563 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # wash (Wide Area SHell)
 
-[![GitHub release](https://img.shields.io/github/release/puppetlabs/wash.svg)](https://github.com/puppetlabs/wash/releases/)[![Build Status](https://travis-ci.com/puppetlabs/wash.svg)](https://travis-ci.com/puppetlabs/wash)[![GoDoc](https://godoc.org/github.com/puppetlabs/wash?status.svg)](https://godoc.org/github.com/puppetlabs/wash)
+_Badges: latest version, CI status, godoc reference
 
 `wash` helps you deal with all your remote or cloud-native infrastructure using the UNIX-y patterns and tools you already know and love!
 
@@ -15,46 +15,22 @@ Exploring, understanding, and inspecting modern infrastructure should be simple
 * render into text that which can be rendered into text (cuz text is a universal interface!) for easy viewing, editing, and UNIXy slicing-and-dicing
 * build new versions of basic, UNIX tools to support the above goals (but reuse existing ones if they work!)
 
-## Features
-
-We've implemented some neat features inside of `wash` to support the above goals:
-
-* The `wash` daemon
-    * presents a FUSE filesystem hierarchy for all of your resources, letting you navigate them in normal, filesystem-y ways
-    * preserves history of all executed commands, facilitating debugging
-    * serves up an HTTP API for everything
-    * caches information, for better performance
-
-* Primitives - the basic building blocks that form the guts of `wash`, and dictate what kinds of things you can do to all the resources `wash` knows about
-    * `list` - lets you ask any resource what's contained inside of it, and what primitives it supports. 
-        - _e.g. listing a Kubernetes pod returns its constituent containers_
-    * `metadata` - returns the metadata for any resource
-        - _e.g. you can use this to get all the metadata for a docker container, or a file in an S3 bucket, etc._
-    * `read` - lets you read the contents of a given resource
-        - _e.g. represent an EC2 instance's console output as a regular file you can open in a regular editor_
-    * `stream` - gives you streaming-read access to a resource
-        - _e.g. to let you follow a container's output as its running_
-    * `exec` - lets you execute a command against a resource
-        - _e.g. run a shell command inside a container, or on an EC2 vm, or on a routerOS device, etc._
-
-* CLI tools
-    * `wash ls` - a version of `ls` that uses our API to enhance directory listings with `wash`-specific info
-        - _e.g. show you what primitives are supported for each resource_
-    * `wash meta` - uses the `metadata` primitive to emit a resource's metadata to standard out
-    * `wash exec` - uses the `exec` primtitive to let you invoke commands against resources
-
-* Core plugins (see the _Roadmap_ below for more details)
-    * `docker` - presents a filesystem hierarchy of containers and volumes
-        - found from the local socket or via `DOCKER` environment variables
-    * `kubernetes` - presents a filesystem hierarchy of pods, containers, and persistent volume claims
-        - uses contexts from `~/.kube/config`
-    * `aws` - presents a filesystem hierarchy for EC2 and S3
-        - uses `AWS_SHARED_CREDENTIALS_FILE` environment variable or `$HOME/.aws/credentials`
-
-* [External plugins](https://github.com/puppetlabs/wash/tree/master/docs/external_plugins)
-    * `wash` allows for easy creation of out-of-process plugins using any language you want, from `bash` to `go` or anything in-between!
-    * `wash` handles the plugin lifecycle. it invokes your plugin with a certain calling convention; all you have to do is supply the business logic
-    * users interact with external plugins the exact same way as core plugins; they are first-class citizens
+## Features (TBD)
+
+`wash`'s interactions center around resources that implement common primitives. Those resources are implemented in plugins.
+
+### Primitives
+
+_Supersede TAXONOMY.md_
+
+### Plugins
+
+In the core distribution
+- Docker: containers and volumes. Found from the local socket or via `DOCKER` environment variables.
+- Kubernetes: containers and persistent volume claims. Uses contexts from `~/.kube/config`.
+- AWS: EC2 instances and S3 buckets. Uses `AWS_SHARED_CREDENTIALS_FILE` environment variable or `$HOME/.aws/credentials`.
+
+You can also create external plugins: [docs](https://github.com/puppetlabs/wash/tree/master/docs/external_plugins).
 
 ## Installation
 
diff --git a/api/helpers.go b/api/helpers.go
index ce483b0..a86bbe6 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -19,7 +19,7 @@ func findEntry(ctx context.Context, root plugin.Entry, segments []string) (plugi
 		switch curGroup := curEntry.(type) {
 		case plugin.Group:
 			// Get the entries via. List()
-			entries, err := plugin.CachedList(ctx, curGroup, curEntryID)
+			entries, err := plugin.CachedList(ctx, curGroup)
 			if err != nil {
 				return nil, entryNotFoundResponse(path, err.Error())
 			}
@@ -68,14 +68,10 @@ func getEntryFromPath(ctx context.Context, path string) (plugin.Entry, *errorRes
 
 	// Get the registry from context (added by registry middleware).
 	registry := ctx.Value(pluginRegistryKey).(*plugin.Registry)
-	root, ok := registry.Plugins[pluginName]
+	root, ok := registry.Plugins()[pluginName]
 	if !ok {
 		return nil, pluginDoesNotExistResponse(pluginName)
 	}
 
 	return findEntry(ctx, root, segments)
 }
-
-func toID(path string) string {
-	return "/" + strings.TrimSuffix(path, "/")
-}
diff --git a/api/list.go b/api/list.go
index 008f617..2847075 100644
--- a/api/list.go
+++ b/api/list.go
@@ -32,21 +32,20 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request) *errorRes
 
 	journal.Record(ctx, "API: List %v", path)
 	group := entry.(plugin.Group)
-	groupID := toID(path)
-	entries, err := plugin.CachedList(ctx, group, groupID)
+	entries, err := plugin.CachedList(ctx, group)
 	if err != nil {
 		journal.Record(ctx, "API: List %v errored: %v", path, err)
 		return erroredActionResponse(path, plugin.ListAction, err.Error())
 	}
 
-	info := func(entry plugin.Entry, entryID string) apitypes.ListEntry {
+	info := func(entry plugin.Entry) apitypes.ListEntry {
 		result := apitypes.ListEntry{
 			Name:    entry.Name(),
 			Actions: plugin.SupportedActionsOf(entry),
 			Errors:  make(map[string]*apitypes.ErrorObj),
 		}
 
-		err := plugin.FillAttr(r.Context(), entry, entryID, &result.Attributes)
+		err := plugin.FillAttr(r.Context(), entry, &result.Attributes)
 		if err != nil {
 			result.Errors["attributes"] = newUnknownErrorObj(err)
 		}
@@ -55,11 +54,11 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request) *errorRes
 	}
 
 	result := make([]apitypes.ListEntry, len(entries)+1)
-	result[0] = info(group, groupID)
+	result[0] = info(group)
 	result[0].Name = "."
 
 	for i, entry := range entries {
-		result[i+1] = info(entry, groupID+"/"+entry.Name())
+		result[i+1] = info(entry)
 	}
 	journal.Record(ctx, "API: List %v %+v", path, result)
 
diff --git a/api/metadata.go b/api/metadata.go
index 119696c..fc97239 100644
--- a/api/metadata.go
+++ b/api/metadata.go
@@ -30,7 +30,7 @@ var metadataHandler handler = func(w http.ResponseWriter, r *http.Request) *erro
 	}
 
 	journal.Record(ctx, "API: Metadata %v", path)
-	metadata, err := plugin.CachedMetadata(ctx, entry.(plugin.Resource), toID(path))
+	metadata, err := plugin.CachedMetadata(ctx, entry.(plugin.Resource))
 
 	if err != nil {
 		journal.Record(ctx, "API: Metadata %v errored: %v", path, err)
diff --git a/api/read.go b/api/read.go
index 8a43931..627b7aa 100644
--- a/api/read.go
+++ b/api/read.go
@@ -29,7 +29,7 @@ var readHandler handler = func(w http.ResponseWriter, r *http.Request) *errorRes
 	}
 
 	journal.Record(ctx, "API: Read %v", path)
-	content, err := plugin.CachedOpen(ctx, entry.(plugin.Readable), toID(path))
+	content, err := plugin.CachedOpen(ctx, entry.(plugin.Readable))
 
 	if err != nil {
 		journal.Record(ctx, "API: Read %v errored: %v", path, err)
diff --git a/cmd/server.go b/cmd/server.go
index 461d773..151dcb9 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -68,20 +68,19 @@ func serverMain(cmd *cobra.Command, args []string) exitCode {
 		}()
 	}
 
-	var registry plugin.Registry
-	registry.Plugins = make(map[string]plugin.Root)
-	loadInternalPlugins(&registry)
+	registry := plugin.NewRegistry()
+	loadInternalPlugins(registry)
 	if externalPluginsPath != "" {
-		loadExternalPlugins(&registry, externalPluginsPath)
+		loadExternalPlugins(registry, externalPluginsPath)
 	}
-	if len(registry.Plugins) == 0 {
+	if len(registry.Plugins()) == 0 {
 		log.Warn("No plugins loaded")
 		return exitCode{1}
 	}
 
 	plugin.InitCache()
 
-	apiServerStopCh, apiServerStoppedCh, err := api.StartAPI(&registry, config.Socket)
+	apiServerStopCh, apiServerStoppedCh, err := api.StartAPI(registry, config.Socket)
 	if err != nil {
 		log.Warn(err)
 		return exitCode{1}
@@ -95,7 +94,7 @@ func serverMain(cmd *cobra.Command, args []string) exitCode {
 		<-apiServerStoppedCh
 	}
 
-	fuseServerStopCh, fuseServerStoppedCh, err := fuse.ServeFuseFS(&registry, mountpoint)
+	fuseServerStopCh, fuseServerStoppedCh, err := fuse.ServeFuseFS(registry, mountpoint)
 	if err != nil {
 		stopAPIServer()
 		log.Warn(err)
@@ -176,7 +175,7 @@ func loadPlugin(registry *plugin.Registry, name string, root plugin.Root) {
 		// %+v is a convention used by some errors to print additional context such as a stack trace
 		log.Warnf("%v failed to load: %+v", name, err)
 	} else {
-		registry.Plugins[name] = root
+		registry.RegisterPlugin(name, root)
 	}
 }
 
diff --git a/fuse/core.go b/fuse/core.go
index 376a0ef..daaeef6 100644
--- a/fuse/core.go
+++ b/fuse/core.go
@@ -16,34 +16,17 @@ var startTime = time.Now()
 
 // Root represents the root of the FUSE filesystem
 type Root struct {
-	plugins []plugin.Entry
+	registry *plugin.Registry
 }
 
-func newRoot(plugins map[string]plugin.Root) Root {
-	root := Root{}
-
-	root.plugins = make([]plugin.Entry, 0, len(plugins))
-	for _, v := range plugins {
-		root.plugins = append(root.plugins, v)
-	}
-
-	return root
-}
-
-// Name returns '/', the name for the filesystem root.
-func (r *Root) Name() string {
-	return "/"
+func newRoot(registry *plugin.Registry) Root {
+	return Root{registry: registry}
 }
 
 // Root presents the root of the filesystem.
 func (r *Root) Root() (fs.Node, error) {
 	log.Infof("Entering root of filesystem")
-	return newDir(r, ""), nil
-}
-
-// List lists all clients as directories.
-func (r *Root) List(_ context.Context) ([]plugin.Entry, error) {
-	return r.plugins, nil
+	return newDir(r.registry), nil
 }
 
 func getIDs() (uint32, uint32) {
@@ -112,7 +95,7 @@ func applyAttr(a *fuse.Attr, attr *plugin.Attributes) {
 func attr(ctx context.Context, f fuseNode, a *fuse.Attr) error {
 	attr := plugin.Attributes{}
 
-	err := plugin.FillAttr(ctx, f.Entry(), f.String(), &attr)
+	err := plugin.FillAttr(ctx, f.Entry(), &attr)
 	if _, ok := err.(plugin.ErrCouldNotDetermineSizeAttr); ok {
 		log.Warnf("FUSE: Warn[Attr,%v]: %v", f, err)
 	} else if err != nil {
@@ -172,7 +155,7 @@ func ServeFuseFS(filesys *plugin.Registry, mountpoint string) (chan<- bool, <-ch
 
 		log.Infof("FUSE: Serving filesystem")
 
-		root := newRoot(filesys.Plugins)
+		root := newRoot(filesys)
 		if err := fs.Serve(fuseConn, &root); err != nil {
 			log.Warnf("FUSE: fs.Serve errored with: %v", err)
 		}
diff --git a/fuse/dir.go b/fuse/dir.go
index 56e5638..eaf4a72 100644
--- a/fuse/dir.go
+++ b/fuse/dir.go
@@ -2,7 +2,6 @@ package fuse
 
 import (
 	"context"
-	"strings"
 
 	"bazil.org/fuse"
 	"bazil.org/fuse/fs"
@@ -15,16 +14,15 @@ import (
 
 type dir struct {
 	entry plugin.Entry
-	id    string
+	path  string
 }
 
 var _ fs.Node = (*dir)(nil)
 var _ = fs.NodeRequestLookuper(&dir{})
 var _ = fs.HandleReadDirAller(&dir{})
 
-func newDir(e plugin.Entry, parent string) *dir {
-	id := strings.TrimSuffix(parent, "/") + "/" + strings.TrimPrefix(e.Name(), "/")
-	return &dir{e, id}
+func newDir(e plugin.Group) *dir {
+	return &dir{e, e.CacheConfig().ID()}
 }
 
 func (d *dir) Entry() plugin.Entry {
@@ -32,7 +30,7 @@ func (d *dir) Entry() plugin.Entry {
 }
 
 func (d *dir) String() string {
-	return d.id
+	return d.path
 }
 
 // Attr returns the attributes of a directory.
@@ -54,7 +52,7 @@ func (d *dir) Getxattr(ctx context.Context, req *fuse.GetxattrRequest, resp *fus
 func (d *dir) children(ctx context.Context) ([]plugin.Entry, error) {
 	// Cache List requests. FUSE often lists the contents then immediately calls find on individual entries.
 	if plugin.ListAction.IsSupportedOn(d.Entry()) {
-		return plugin.CachedList(ctx, d.Entry().(plugin.Group), d.id)
+		return plugin.CachedList(ctx, d.Entry().(plugin.Group))
 	}
 
 	return []plugin.Entry{}, fuse.ENOENT
@@ -77,7 +75,7 @@ func (d *dir) Lookup(ctx context.Context, req *fuse.LookupRequest, resp *fuse.Lo
 		if entry.Name() == req.Name {
 			log.Infof("FUSE: Find[d,pid=%v] %v/%v", req.Pid, d, entry.Name())
 			if plugin.ListAction.IsSupportedOn(entry) {
-				childdir := newDir(entry, d.String())
+				childdir := newDir(entry.(plugin.Group))
 				journal.Record(ctx, "FUSE: Found directory %v", childdir)
 				// Prefetch directory entries into the cache
 				go func() {
@@ -88,7 +86,7 @@ func (d *dir) Lookup(ctx context.Context, req *fuse.LookupRequest, resp *fuse.Lo
 			}
 
 			journal.Record(ctx, "FUSE: Found file %v/%v", d, entry.Name())
-			return newFile(entry, d.String()), nil
+			return newFile(entry), nil
 		}
 	}
 	journal.Record(ctx, "FUSE: %v not found in %v", req.Name, d)
diff --git a/fuse/file.go b/fuse/file.go
index a5c42ee..a58f62f 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -15,7 +15,7 @@ import (
 
 type file struct {
 	entry plugin.Entry
-	id    string
+	path  string
 }
 
 var _ fs.Node = (*file)(nil)
@@ -23,8 +23,8 @@ var _ = fs.NodeOpener(&file{})
 var _ = fs.NodeGetxattrer(&file{})
 var _ = fs.NodeListxattrer(&file{})
 
-func newFile(e plugin.Entry, parent string) *file {
-	return &file{e, parent + "/" + e.Name()}
+func newFile(e plugin.Entry) *file {
+	return &file{e, e.CacheConfig().ID()}
 }
 
 func (f *file) Entry() plugin.Entry {
@@ -32,7 +32,7 @@ func (f *file) Entry() plugin.Entry {
 }
 
 func (f *file) String() string {
-	return f.id
+	return f.path
 }
 
 // Attr returns the attributes of a file.
@@ -59,7 +59,7 @@ func (f *file) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenR
 	// Initiate content request and return a channel providing the results.
 	log.Infof("FUSE: Opening[pid=%v] %v", req.Pid, f)
 	if plugin.ReadAction.IsSupportedOn(f.Entry()) {
-		content, err := plugin.CachedOpen(ctx, f.Entry().(plugin.Readable), f.id)
+		content, err := plugin.CachedOpen(ctx, f.Entry().(plugin.Readable))
 		if err != nil {
 			log.Warnf("FUSE: Error[Open,%v]: %v", f, err)
 			journal.Record(ctx, "FUSE: Open %v errored: %v", f, err)
diff --git a/plugin/cache.go b/plugin/cache.go
index ab05b8a..0a6ebb7 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -26,6 +26,8 @@ var cachedOpToNameMap = [3]string{"List", "Open", "Metadata"}
 // CacheConfig represents an entry's cache configuration
 type CacheConfig struct {
 	ttl [3]time.Duration
+	// id represents the entry's cache ID. It is set in CachedList.
+	id string
 }
 
 func newCacheConfig() *CacheConfig {
@@ -59,8 +61,22 @@ func (config *CacheConfig) TurnOffCaching() {
 	}
 }
 
+// ID returns the entry's cache ID
+func (config *CacheConfig) ID() string {
+	return config.id
+}
+
+// SetTestID sets the entry's cache ID for testing.
+// It can only be called by the tests.
+func (config *CacheConfig) SetTestID(id string) {
+	if notRunningTests() {
+		panic("SetTestID can be only be called by the tests")
+	}
+
+	config.id = id
+}
+
 var cache datastore.Cache
-var defaultConfig CacheConfig
 
 func notRunningTests() bool {
 	return flag.Lookup("test.v") == nil
@@ -70,7 +86,6 @@ func notRunningTests() bool {
 func InitCache() {
 	if notRunningTests() {
 		cache = datastore.NewMemCache()
-		defaultConfig = *newCacheConfig()
 	} else {
 		panic("InitCache can only be called in production. Tests should call SetTestCache instead.")
 	}
@@ -134,7 +149,11 @@ func ClearCacheFor(path string) ([]string, error) {
 	return cache.Delete(rx), nil
 }
 
-func cachedOpHelper(op cachedOp, entry Entry, id string, generateValue func() (interface{}, error)) (interface{}, error) {
+func panicNilCacheConfig() {
+	panic("The entry's cache config is nil. Did you initialize the entry's EntryBase field with plugin.NewEntry(<name>)?")
+}
+
+func cachedOpHelper(op cachedOp, entry Entry, generateValue func() (interface{}, error)) (interface{}, error) {
 	if cache == nil {
 		if notRunningTests() {
 			panic("The cache was not initialized. You can initialize the cache by invoking plugin.InitCache()")
@@ -143,14 +162,9 @@ func cachedOpHelper(op cachedOp, entry Entry, id string, generateValue func() (i
 		}
 	}
 
-	cached, ok := entry.(Cached)
-	if !ok {
-		return generateValue()
-	}
-
-	config := cached.CacheConfig()
+	config := entry.CacheConfig()
 	if config == nil {
-		config = &defaultConfig
+		panicNilCacheConfig()
 	}
 
 	ttl := config.getTTLOf(op)
@@ -159,13 +173,27 @@ func cachedOpHelper(op cachedOp, entry Entry, id string, generateValue func() (i
 	}
 
 	opName := cachedOpToNameMap[op]
-	return cache.GetOrUpdate(opName+"::"+id, ttl, false, generateValue)
+	return cache.GetOrUpdate(opName+"::"+entry.CacheConfig().ID(), ttl, false, generateValue)
 }
 
 // CachedList caches a Group object's List method
-func CachedList(ctx context.Context, g Group, id string) ([]Entry, error) {
-	cachedEntries, err := cachedOpHelper(List, g, id, func() (interface{}, error) {
-		return g.List(ctx)
+func CachedList(ctx context.Context, g Group) ([]Entry, error) {
+	cachedEntries, err := cachedOpHelper(List, g, func() (interface{}, error) {
+		entries, err := g.List(ctx)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, entry := range entries {
+			cacheConfig := entry.CacheConfig()
+			if cacheConfig == nil {
+				panicNilCacheConfig()
+			}
+
+			cacheConfig.id = g.CacheConfig().ID() + "/" + strings.Trim(entry.Name(), "/")
+		}
+
+		return entries, nil
 	})
 
 	if err != nil {
@@ -179,8 +207,8 @@ func CachedList(ctx context.Context, g Group, id string) ([]Entry, error) {
 // When using the reader returned by this method, use idempotent read operations
 // such as ReadAt or wrap it in a SectionReader. Using Read operations on the cached
 // reader will change it and make subsequent uses of the cached reader invalid.
-func CachedOpen(ctx context.Context, r Readable, id string) (SizedReader, error) {
-	cachedContent, err := cachedOpHelper(Open, r, id, func() (interface{}, error) {
+func CachedOpen(ctx context.Context, r Readable) (SizedReader, error) {
+	cachedContent, err := cachedOpHelper(Open, r, func() (interface{}, error) {
 		return r.Open(ctx)
 	})
 
@@ -192,8 +220,8 @@ func CachedOpen(ctx context.Context, r Readable, id string) (SizedReader, error)
 }
 
 // CachedMetadata caches a Resource object's Metadata method
-func CachedMetadata(ctx context.Context, r Resource, id string) (MetadataMap, error) {
-	cachedMetadata, err := cachedOpHelper(Metadata, r, id, func() (interface{}, error) {
+func CachedMetadata(ctx context.Context, r Resource) (MetadataMap, error) {
+	cachedMetadata, err := cachedOpHelper(Metadata, r, func() (interface{}, error) {
 		return r.Metadata(ctx)
 	})
 
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index 471a734..73a9ab7 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -11,12 +11,10 @@ import (
 
 // Root of the Docker plugin
 type Root struct {
+	plugin.EntryBase
 	resources []plugin.Entry
 }
 
-// Name returns 'docker'
-func (r *Root) Name() string { return "docker" }
-
 // Init for root
 func (r *Root) Init() error {
 	dockerCli, err := client.NewClientWithOpts(client.FromEnv)
@@ -24,6 +22,8 @@ func (r *Root) Init() error {
 		return err
 	}
 
+	r.EntryBase = plugin.NewEntry("docker")
+	r.CacheConfig().TurnOffCaching()
 	r.resources = []plugin.Entry{
 		&containers{EntryBase: plugin.NewEntry("containers"), client: dockerCli},
 		&volumes{EntryBase: plugin.NewEntry("volumes"), client: dockerCli},
diff --git a/plugin/helpers.go b/plugin/helpers.go
index fa8aad7..ca47ef9 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -130,7 +130,7 @@ func ToFileMode(mode interface{}) (os.FileMode, error) {
 }
 
 // FillAttr fills the given attributes struct with the entry's attributes.
-func FillAttr(ctx context.Context, entry Entry, entryID string, attr *Attributes) error {
+func FillAttr(ctx context.Context, entry Entry, attr *Attributes) error {
 	attr.Size = SizeUnknown
 
 	if item, ok := entry.(File); ok {
@@ -146,7 +146,7 @@ func FillAttr(ctx context.Context, entry Entry, entryID string, attr *Attributes
 
 	var err error
 	if ReadAction.IsSupportedOn(entry) && attr.Size == SizeUnknown {
-		content, openErr := CachedOpen(ctx, entry.(Readable), entryID)
+		content, openErr := CachedOpen(ctx, entry.(Readable))
 		if openErr != nil {
 			err = ErrCouldNotDetermineSizeAttr{openErr.Error()}
 			attr.Size = 0
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index bdca5c1..a669389 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -15,6 +15,7 @@ import (
 
 // Root of the Kubernetes plugin
 type Root struct {
+	plugin.EntryBase
 	contexts []plugin.Entry
 }
 
@@ -35,9 +36,6 @@ func createContext(raw clientcmdapi.Config, name string, access clientcmd.Config
 	return &k8context{plugin.NewEntry(name), clientset, cfg, defaultns}, nil
 }
 
-// Name returns 'kubernetes'
-func (r *Root) Name() string { return "kubernetes" }
-
 // Init for root
 func (r *Root) Init() error {
 	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
@@ -47,6 +45,9 @@ func (r *Root) Init() error {
 		return err
 	}
 
+	r.EntryBase = plugin.NewEntry("kubernetes")
+	r.CacheConfig().TurnOffCaching()
+
 	contexts := make([]plugin.Entry, 0)
 	for name := range raw.Contexts {
 		ctx, err := createContext(raw, name, config.ConfigAccess())
diff --git a/plugin/registry.go b/plugin/registry.go
new file mode 100644
index 0000000..915d99c
--- /dev/null
+++ b/plugin/registry.go
@@ -0,0 +1,46 @@
+package plugin
+
+import (
+	"context"
+)
+
+// Registry represents the plugin registry. It is also Wash's root.
+type Registry struct {
+	EntryBase
+	plugins     map[string]Root
+	pluginRoots []Entry
+}
+
+// NewRegistry creates a new plugin registry object
+func NewRegistry() *Registry {
+	r := &Registry{
+		EntryBase: NewEntry("/"),
+		plugins:   make(map[string]Root),
+	}
+
+	// Set the registry's ID to the empty string. This way,
+	// CachedList sets the cache IDs of the Plugin roots to
+	// "/<root_name>" (e.g. "/docker", "/kubernetes", "/aws"),
+	// and all other IDs are correctly set to <parent_id> + "/" + <name>.
+	r.CacheConfig().id = ""
+	r.CacheConfig().TurnOffCaching()
+
+	return r
+}
+
+// Plugins returns a map of the currently registered
+// plugins
+func (r *Registry) Plugins() map[string]Root {
+	return r.plugins
+}
+
+// RegisterPlugin registers the given plugin
+func (r *Registry) RegisterPlugin(name string, root Root) {
+	r.plugins[name] = root
+	r.pluginRoots = append(r.pluginRoots, root)
+}
+
+// List all of Wash's loaded plugins
+func (r *Registry) List(ctx context.Context) ([]Entry, error) {
+	return r.pluginRoots, nil
+}
diff --git a/plugin/types.go b/plugin/types.go
index 59d3fa2..343fce4 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -12,11 +12,6 @@ import (
 // Entry is a basic named resource type.
 type Entry interface {
 	Name() string
-}
-
-// Cached is a resource that expects its method invocations to be cached.
-// If CacheConfig() returns nil, a default config will be used.
-type Cached interface {
 	CacheConfig() *CacheConfig
 }
 
@@ -124,9 +119,3 @@ type Attributes struct {
 
 // SizeUnknown can be used to denote that the size is unknown and should be queried from content.
 const SizeUnknown = ^uint64(0)
-
-// The Registry contains the core filesystem data.
-// Plugins: maps plugin mount points to their implementations.
-type Registry struct {
-	Plugins map[string]Root
-}


Test patch:
diff --git a/api/cache_test.go b/api/cache_test.go
index 1df9773..06a6b37 100644
--- a/api/cache_test.go
+++ b/api/cache_test.go
@@ -89,10 +89,11 @@ func (suite *CacheHandlerTestSuite) TestRejectsGet() {
 
 func (suite *CacheHandlerTestSuite) TestClearCache() {
 	// Populate the cache with a mocked resource and plugin.Cached*
-	var group mockedGroup
+	group := newMockedGroup()
+	group.CacheConfig().SetTestID("/dir")
 	group.On("List", mock.Anything).Return([]plugin.Entry{}, nil)
 
-	if children, err := plugin.CachedList(context.Background(), &group, "/dir"); suite.Nil(err) {
+	if children, err := plugin.CachedList(context.Background(), group); suite.Nil(err) {
 		suite.Equal([]plugin.Entry{}, children)
 	}
 
@@ -103,7 +104,7 @@ func (suite *CacheHandlerTestSuite) TestClearCache() {
 	suite.Equal(http.StatusOK, w.Code)
 	suite.Equal("[]\n", w.Body.String())
 
-	if children, err := plugin.CachedList(context.Background(), &group, "/dir"); suite.Nil(err) {
+	if children, err := plugin.CachedList(context.Background(), group); suite.Nil(err) {
 		suite.Equal([]plugin.Entry{}, children)
 	}
 
@@ -114,7 +115,7 @@ func (suite *CacheHandlerTestSuite) TestClearCache() {
 	suite.Equal(http.StatusOK, w.Code)
 	suite.Equal(`["List::/dir"]`, strings.TrimSpace(w.Body.String()))
 
-	if children, err := plugin.CachedList(context.Background(), &group, "/dir"); suite.Nil(err) {
+	if children, err := plugin.CachedList(context.Background(), group); suite.Nil(err) {
 		suite.Equal([]plugin.Entry{}, children)
 	}
 
@@ -130,6 +131,14 @@ type mockedGroup struct {
 	mock.Mock
 }
 
+func newMockedGroup() *mockedGroup {
+	g := &mockedGroup{
+		EntryBase: plugin.NewEntry("mockGroup"),
+	}
+
+	return g
+}
+
 func (g *mockedGroup) List(ctx context.Context) ([]plugin.Entry, error) {
 	args := g.Called(ctx)
 	return args.Get(0).([]plugin.Entry), args.Error(1)
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index fa7a760..fae8903 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -113,11 +113,14 @@ func (suite *CacheTestSuite) TestClearCache() {
 }
 
 type cacheTestsMockEntry struct {
+	EntryBase
 	mock.Mock
 }
 
-func (e *cacheTestsMockEntry) Name() string {
-	return ""
+func newCacheTestsMockEntry(name string) *cacheTestsMockEntry {
+	return &cacheTestsMockEntry{
+		EntryBase: NewEntry(name),
+	}
 }
 
 func (e *cacheTestsMockEntry) List(ctx context.Context) ([]Entry, error) {
@@ -135,61 +138,38 @@ func (e *cacheTestsMockEntry) Metadata(ctx context.Context) (MetadataMap, error)
 	return args.Get(0).(MetadataMap), args.Error(1)
 }
 
-type cacheTestsMockCacheableEntry struct {
-	*cacheTestsMockEntry
-	cacheConfig *CacheConfig
-}
-
-func newCacheTestsMockCacheableEntry() *cacheTestsMockCacheableEntry {
-	return &cacheTestsMockCacheableEntry{
-		&cacheTestsMockEntry{},
-		newCacheConfig(),
-	}
-}
-
-func (e *cacheTestsMockCacheableEntry) CacheConfig() *CacheConfig {
-	return e.cacheConfig
-}
-
-type cachedOpFunc func(ctx context.Context, e Entry, id string) (interface{}, error)
+type cachedOpFunc func(ctx context.Context, e Entry) (interface{}, error)
 
 func (suite *CacheTestSuite) testCachedOp(op cachedOp, opName string, mockValue interface{}, cachedOp cachedOpFunc) {
 	ctx := context.Background()
 
 	// Test that cachedOp panics if the cache == nil
 	panicFunc := func() {
-		entry := &cacheTestsMockEntry{}
-		_, _ = cachedOp(ctx, entry, "id")
+		entry := newCacheTestsMockEntry("")
+		_, _ = cachedOp(ctx, entry)
 	}
 	UnsetTestCache()
 	suite.Panics(panicFunc)
 	SetTestCache(suite.cache)
 
-	// Test that cachedOp returns e.Op for an uncacheable entry
-	entry := &cacheTestsMockEntry{}
-	entry.On(opName, ctx).Return(mockValue, nil)
-	v, err := cachedOp(ctx, entry, "id")
-	if suite.NoError(err) {
-		suite.Equal(mockValue, v)
-	}
-	suite.cache.AssertNotCalled(suite.T(), "GetOrUpdate")
-
-	// Test that cachedOp does _not_ call cache#GetOrUpdate for a cacheable
+	// Test that cachedOp does _not_ call cache#GetOrUpdate for an
 	// entry that's turned off caching
-	cacheableEntry := newCacheTestsMockCacheableEntry()
-	cacheableEntry.On(opName, ctx).Return(mockValue, nil)
-	cacheableEntry.CacheConfig().TurnOffCachingFor(op)
-	v, err = cachedOp(ctx, entry, "id")
+	entry := newCacheTestsMockEntry("")
+	entry.CacheConfig().SetTestID("id")
+	entry.On(opName, ctx).Return(mockValue, nil)
+	entry.CacheConfig().TurnOffCachingFor(op)
+	v, err := cachedOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
 	}
 	suite.cache.AssertNotCalled(suite.T(), "GetOrUpdate")
 
-	// Test that cachedOp does call cache#GetOrUpdate for a cacheable
-	// entry, and that it passes-in the right arguments.
+	// Test that cachedOp does call cache#GetOrUpdate for an
+	// entry that's enabled caching, and that it passes-in the
+	// right arguments.
 	opTTL := 5 * time.Second
-	cacheableEntry.CacheConfig().SetTTLOf(op, opTTL)
-	cacheableEntry.On(opName, ctx).Return(mockValue, nil)
+	entry.CacheConfig().SetTTLOf(op, opTTL)
+	entry.On(opName, ctx).Return(mockValue, nil)
 	opKey := opName + "::" + "id"
 	generateValueMatcher := func(generateValue func() (interface{}, error)) bool {
 		// This matcher ensures that cachedOp is passing-in the right generator function to
@@ -202,7 +182,7 @@ func (suite *CacheTestSuite) testCachedOp(op cachedOp, opName string, mockValue
 		return false
 	}
 	suite.cache.On("GetOrUpdate", opKey, opTTL, false, mock.MatchedBy(generateValueMatcher)).Return(mockValue, nil).Once()
-	v, err = cachedOp(ctx, cacheableEntry, "id")
+	v, err = cachedOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
 	}
@@ -210,23 +190,51 @@ func (suite *CacheTestSuite) testCachedOp(op cachedOp, opName string, mockValue
 }
 
 func (suite *CacheTestSuite) TestCachedList() {
-	mockChildren := []Entry{&cacheTestsMockEntry{}}
-	suite.testCachedOp(List, "List", mockChildren, func(ctx context.Context, e Entry, id string) (interface{}, error) {
-		return CachedList(ctx, e.(Group), id)
+	mockChildren := []Entry{newCacheTestsMockEntry("")}
+	suite.testCachedOp(List, "List", mockChildren, func(ctx context.Context, e Entry) (interface{}, error) {
+		return CachedList(ctx, e.(Group))
 	})
+
+	// Set-up another mock entry for additional CachedList tests
+	ctx := context.Background()
+	entry := newCacheTestsMockEntry("parent")
+	entry.CacheConfig().SetTestID("parentID")
+	entry.CacheConfig().TurnOffCaching()
+
+	// Test that CachedList panics if the children have a nil
+	// CacheConfig()
+	mockChildren = []Entry{&cacheTestsMockEntry{}}
+	entry.On("List", ctx).Return(mockChildren, nil).Once()
+	suite.Panics(func() {
+		_, _ = CachedList(ctx, entry)
+	})
+
+	// Test that CachedList sets the children's cache IDs
+	// to <parent_cache_id>/<child_name>
+	mockChildren = []Entry{
+		newCacheTestsMockEntry("/child1/"),
+		newCacheTestsMockEntry("child2"),
+	}
+	entry.On("List", ctx).Return(mockChildren, nil).Once()
+	children, err := CachedList(ctx, entry)
+	if suite.NoError(err) {
+		suite.Equal(mockChildren, children)
+		suite.Equal("parentID/child1", children[0].CacheConfig().ID())
+		suite.Equal("parentID/child2", children[1].CacheConfig().ID())
+	}
 }
 
 func (suite *CacheTestSuite) TestCachedOpen() {
 	mockReader := strings.NewReader("foo")
-	suite.testCachedOp(Open, "Open", mockReader, func(ctx context.Context, e Entry, id string) (interface{}, error) {
-		return CachedOpen(ctx, e.(Readable), id)
+	suite.testCachedOp(Open, "Open", mockReader, func(ctx context.Context, e Entry) (interface{}, error) {
+		return CachedOpen(ctx, e.(Readable))
 	})
 }
 
 func (suite *CacheTestSuite) TestCachedMetadata() {
 	mockMetadataMap := MetadataMap{"foo": "bar"}
-	suite.testCachedOp(Metadata, "Metadata", mockMetadataMap, func(ctx context.Context, e Entry, id string) (interface{}, error) {
-		return CachedMetadata(ctx, e.(Resource), id)
+	suite.testCachedOp(Metadata, "Metadata", mockMetadataMap, func(ctx context.Context, e Entry) (interface{}, error) {
+		return CachedMetadata(ctx, e.(Resource))
 	})
 }
 
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index d0fe686..8160fa3 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -155,7 +155,7 @@ func (suite *HelpersTestSuite) TestFillAttrAnyEntry() {
 	}
 	entry.On("Attr").Return(expectedAttributes)
 	attr := Attributes{}
-	err := FillAttr(context.Background(), entry, "id", &attr)
+	err := FillAttr(context.Background(), entry, &attr)
 	if suite.NoError(err) {
 		suite.Equal(
 			expectedAttributes,
@@ -168,7 +168,7 @@ func (suite *HelpersTestSuite) TestFillAttrAnyEntry() {
 	// the mode to 0550
 	group := newHelpersTestsMockGroup()
 	attr = Attributes{}
-	err = FillAttr(context.Background(), group, "id", &attr)
+	err = FillAttr(context.Background(), group, &attr)
 	if suite.NoError(err) {
 		suite.Equal(
 			Attributes{Mode: os.ModeDir | 0550, Size: SizeUnknown},
@@ -182,7 +182,7 @@ func (suite *HelpersTestSuite) TestFillAttrAnyEntry() {
 	entry = newHelpersTestsMockEntry()
 	entry.On("Attr").Return(Attributes{})
 	attr = Attributes{}
-	err = FillAttr(context.Background(), entry, "id", &attr)
+	err = FillAttr(context.Background(), entry, &attr)
 	if suite.NoError(err) {
 		suite.Equal(
 			Attributes{Mode: 0440, Size: SizeUnknown},
@@ -229,7 +229,7 @@ func (suite *HelpersTestSuite) TestFillAttrReadableEntry() {
 	entry := newHelpersTestsMockReadableEntry(Attributes{})
 	entry.On("Open", ctx).Return(mockRdr, nil)
 	attr := Attributes{}
-	err := FillAttr(ctx, entry, "id", &attr)
+	err := FillAttr(ctx, entry, &attr)
 	if suite.NoError(err) {
 		suite.Equal(
 			uint64(mockRdr.Size()),
@@ -243,7 +243,7 @@ func (suite *HelpersTestSuite) TestFillAttrReadableEntry() {
 	entry = newHelpersTestsMockReadableEntry(Attributes{Size: 10})
 	entry.On("Open", ctx).Return(mockRdr, nil)
 	attr = Attributes{}
-	err = FillAttr(ctx, entry, "id", &attr)
+	err = FillAttr(ctx, entry, &attr)
 	if suite.NoError(err) {
 		suite.Equal(
 			uint64(10),
@@ -257,7 +257,7 @@ func (suite *HelpersTestSuite) TestFillAttrReadableEntry() {
 	entry = newHelpersTestsMockReadableEntry(Attributes{})
 	entry.On("Open", ctx).Return(mockRdr, fmt.Errorf("could not open"))
 	attr = Attributes{}
-	err = FillAttr(ctx, entry, "id", &attr)
+	err = FillAttr(ctx, entry, &attr)
 	suite.EqualError(err, ErrCouldNotDetermineSizeAttr{"could not open"}.Error())
 	suite.Equal(
 		os.FileMode(0440),
@@ -270,7 +270,7 @@ func (suite *HelpersTestSuite) TestFillAttrReadableEntry() {
 	entry = newHelpersTestsMockReadableEntry(Attributes{})
 	entry.On("Open", ctx).Return(negativeSizedReader{}, nil)
 	attr = Attributes{}
-	err = FillAttr(ctx, entry, "id", &attr)
+	err = FillAttr(ctx, entry, &attr)
 	suite.EqualError(err, ErrNegativeSizeAttr{-1}.Error())
 }