Repository: puppetlabs/wash
Pull request #146 (2019-03-19 22:28:55)

Modest cleanup
Remove TAXONOMY.md. Relevant information should now be tracked in documentation (plugin/types.go) or the README's Features section.

Add `git commit -s` recommendation for signing commits for DCO.

Simplify the macOS known issue around kernel extensions. It should frequently be possible to fix without uninstalling VirtualBox.


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 8267fdc..2dfbbd0 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,1 +1,1 @@
-Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org)
\ No newline at end of file
+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.
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b5e5bd2..eda6c50 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,7 +54,7 @@ Tip: base new resources on the `EntryBase` struct to simplify creating them and
 ## Submitting Changes
 Fork the repo, make changes, file a Pull Request.
 
-Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org)
+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.
 
 ### Guidelines
 
diff --git a/README.md b/README.md
index de738fb..9964424 100644
--- a/README.md
+++ b/README.md
@@ -101,11 +101,7 @@ When done, `cd` out of `mnt`, then run `umount mnt` or `Ctrl-C` the server proce
 
 ### On macOS
 
-If the `wash` daemon exits with a exit status of 255, that typically means that `wash` couldn't load the FUSE extensions. MacOS only allows for a certain (small) number of virtual devices on the system, and if all available slots are taken up by other programs then we won't be able to run.
-
-The biggest culprit is VirtualBox, which creates many virtual devices and keeps them resident even if the program isn't running. Currently, we recommend uninstalling VirtualBox to make room for `wash`.
-
-More information in [this github issue for osxfuse](https://github.com/osxfuse/osxfuse/issues/358).
+If the `wash` daemon exits with a exit status of 255, that typically means that `wash` couldn't load the FUSE extensions. MacOS only allows for a certain (small) number of virtual devices on the system, and if all available slots are taken up by other programs then we won't be able to run. You can view loaded extensions with `kextstat`. More information in [this github issue for *FUSE for macOS*](https://github.com/osxfuse/osxfuse/issues/358).
 
 ## Roadmap
 
diff --git a/TAXONOMY.md b/TAXONOMY.md
deleted file mode 100644
index 32be817..0000000
--- a/TAXONOMY.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Taxonomy of Resources
-
-This is an attempt to construct a taxonomy for cloud resources. We start by defining different protocols we expect resources may conform to, then define some common types of resources that support a combination of protocols.
-
-## Protocols
-- metadata: everything has metadata
-- group traversal (ls, find, tree): this object has Cloud Resources that can be enumerated
-- file traversal (ls, find, tree): this object has Filesystem Resources that can be enumerated
-- content (cat): this object has content we can view
-- stream (tail -f/streaming): this content has a stream of data we can follow
-- editable (edit): this object has content we can modify
-- exec (bolt): we can invoke commands on this object
-
-## Types
-
-### Cloud Resources
-
-#### Compute
-
-Systems that provide compute capacity.
-
-Protocols: metadata, stream (logs), exec. Could arguably have filesystem traversal, but that's probably not feasible.
-
-Examples: AWS EC2, AWS Lambda, K8s Pod, network device (with shell access)
-
-#### Volume
-
-A place to store hierarchical data.
-
-Protocols: metadata, stream (access logs, optional), file traversal
-
-Examples: AWS EBS, K8s Volume
-
-#### Service
-
-An appliance that provides a service.
-
-Protocols: metadata (configuration), stream (logs, optional)
-
-Examples: AWS ELB, K8s Ingress, network device (with no shell access)
-
-#### Group
-
-Organizational tools for grouping resources.
-
-Protocols: metadata, group traversal
-
-Examples: user, K8s namespace, cloud service (such as K8s cluster, aws), region, K8s deployment, AWS group or tag
-
-### Filesystem Resources
-
-#### File
-
-Protocols: attributes, content, editable
-
-#### Directory
-
-Protocols: attributes, file traversal
diff --git a/fuse/dir.go b/fuse/dir.go
index 07626a9..eaf4a72 100644
--- a/fuse/dir.go
+++ b/fuse/dir.go
@@ -22,7 +22,7 @@ var _ = fs.NodeRequestLookuper(&dir{})
 var _ = fs.HandleReadDirAller(&dir{})
 
 func newDir(e plugin.Group) *dir {
-	return &dir{e, e.ID()}
+	return &dir{e, e.CacheConfig().ID()}
 }
 
 func (d *dir) Entry() plugin.Entry {
diff --git a/fuse/file.go b/fuse/file.go
index ef01e7c..a58f62f 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -24,7 +24,7 @@ var _ = fs.NodeGetxattrer(&file{})
 var _ = fs.NodeListxattrer(&file{})
 
 func newFile(e plugin.Entry) *file {
-	return &file{e, e.ID()}
+	return &file{e, e.CacheConfig().ID()}
 }
 
 func (f *file) Entry() plugin.Entry {
diff --git a/plugin/aws/ec2Dir.go b/plugin/aws/ec2Dir.go
index ff2172c..e2d3b17 100644
--- a/plugin/aws/ec2Dir.go
+++ b/plugin/aws/ec2Dir.go
@@ -23,7 +23,7 @@ func newEC2Dir(session *session.Session) *ec2Dir {
 		session:   session,
 		client:    ec2Client.New(session),
 	}
-	ec2Dir.TurnOffCaching()
+	ec2Dir.CacheConfig().TurnOffCaching()
 
 	ec2Dir.entries = []plugin.Entry{
 		newEC2InstancesDir(ec2Dir.session, ec2Dir.client),
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index eb884ef..20ec394 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -73,7 +73,7 @@ func newEC2Instance(ctx context.Context, ID string, session *session.Session, cl
 		cloudwatchClient: cloudwatchlogs.New(session),
 		attr:             attr,
 	}
-	ec2Instance.TurnOffCachingFor(plugin.List)
+	ec2Instance.CacheConfig().TurnOffCachingFor(plugin.List)
 
 	ec2Instance.entries = []plugin.Entry{
 		newEC2InstanceMetadataJSON(ec2Instance),
diff --git a/plugin/aws/profile.go b/plugin/aws/profile.go
index 358fc5d..d82563e 100644
--- a/plugin/aws/profile.go
+++ b/plugin/aws/profile.go
@@ -16,7 +16,7 @@ type profile struct {
 
 func newProfile(ctx context.Context, name string) (*profile, error) {
 	profile := profile{EntryBase: plugin.NewEntry(name)}
-	profile.TurnOffCaching()
+	profile.CacheConfig().TurnOffCaching()
 
 	journal.Record(ctx, "Creating a new AWS session for the %v profile", name)
 
diff --git a/plugin/aws/resourcesDir.go b/plugin/aws/resourcesDir.go
index f3e86ec..0d7d24c 100644
--- a/plugin/aws/resourcesDir.go
+++ b/plugin/aws/resourcesDir.go
@@ -19,7 +19,7 @@ func newResourcesDir(session *session.Session) *resourcesDir {
 		EntryBase: plugin.NewEntry("resources"),
 		session:   session,
 	}
-	resourcesDir.TurnOffCaching()
+	resourcesDir.CacheConfig().TurnOffCaching()
 
 	resourcesDir.resources = []plugin.Entry{
 		newS3Dir(resourcesDir.session),
diff --git a/plugin/aws/root.go b/plugin/aws/root.go
index 82fb288..1be8fb3 100644
--- a/plugin/aws/root.go
+++ b/plugin/aws/root.go
@@ -41,7 +41,7 @@ func awsCredentialsFile() (string, error) {
 // Init for root
 func (r *Root) Init() error {
 	r.EntryBase = plugin.NewEntry("aws")
-	r.SetTTLOf(plugin.List, 1*time.Minute)
+	r.CacheConfig().SetTTLOf(plugin.List, 1*time.Minute)
 
 	return nil
 }
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index e1011f9..db78d29 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -33,7 +33,7 @@ func newS3Object(attr plugin.Attributes, metadata plugin.MetadataMap, bucket str
 		key:       key,
 		client:    client,
 	}
-	o.TurnOffCachingFor(plugin.Metadata)
+	o.CacheConfig().TurnOffCachingFor(plugin.Metadata)
 
 	return o
 }
diff --git a/plugin/cache.go b/plugin/cache.go
index 7b502da..0a6ebb7 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -2,14 +2,86 @@ package plugin
 
 import (
 	"context"
+	"flag"
 	"regexp"
 	"strings"
+	"time"
 
 	"github.com/puppetlabs/wash/datastore"
 )
 
+type cachedOp int8
+
+const (
+	// List represents Group#List
+	List cachedOp = iota
+	// Open represents Readable#Open
+	Open
+	// Metadata represents Resource#Metadata
+	Metadata
+)
+
+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 {
+	config := &CacheConfig{}
+
+	for op := range config.ttl {
+		config.SetTTLOf(cachedOp(op), 15*time.Second)
+	}
+
+	return config
+}
+
+func (config *CacheConfig) getTTLOf(op cachedOp) time.Duration {
+	return config.ttl[op]
+}
+
+// SetTTLOf sets the specified op's TTL
+func (config *CacheConfig) SetTTLOf(op cachedOp, ttl time.Duration) {
+	config.ttl[op] = ttl
+}
+
+// TurnOffCachingFor turns off caching for the specified op
+func (config *CacheConfig) TurnOffCachingFor(op cachedOp) {
+	config.SetTTLOf(op, -1)
+}
+
+// TurnOffCaching turns off caching for all ops
+func (config *CacheConfig) TurnOffCaching() {
+	for op := range config.ttl {
+		config.TurnOffCachingFor(cachedOp(op))
+	}
+}
+
+// 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
 
+func notRunningTests() bool {
+	return flag.Lookup("test.v") == nil
+}
+
 // InitCache initializes the cache
 func InitCache() {
 	if notRunningTests() {
@@ -77,7 +149,11 @@ func ClearCacheFor(path string) ([]string, error) {
 	return cache.Delete(rx), nil
 }
 
-func cachedOpHelper(op cacheableOp, entry Entry, 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()")
@@ -86,13 +162,18 @@ func cachedOpHelper(op cacheableOp, entry Entry, generateValue func() (interface
 		}
 	}
 
-	ttl := entry.getTTLOf(op)
+	config := entry.CacheConfig()
+	if config == nil {
+		panicNilCacheConfig()
+	}
+
+	ttl := config.getTTLOf(op)
 	if ttl < 0 {
 		return generateValue()
 	}
 
-	opName := cacheableOpToNameMap[op]
-	return cache.GetOrUpdate(opName+"::"+entry.ID(), ttl, false, generateValue)
+	opName := cachedOpToNameMap[op]
+	return cache.GetOrUpdate(opName+"::"+entry.CacheConfig().ID(), ttl, false, generateValue)
 }
 
 // CachedList caches a Group object's List method
@@ -104,7 +185,12 @@ func CachedList(ctx context.Context, g Group) ([]Entry, error) {
 		}
 
 		for _, entry := range entries {
-			entry.setID(g.ID() + "/" + strings.Trim(entry.Name(), "/"))
+			cacheConfig := entry.CacheConfig()
+			if cacheConfig == nil {
+				panicNilCacheConfig()
+			}
+
+			cacheConfig.id = g.CacheConfig().ID() + "/" + strings.Trim(entry.Name(), "/")
 		}
 
 		return entries, nil
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index e9947d0..73a9ab7 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -23,7 +23,7 @@ func (r *Root) Init() error {
 	}
 
 	r.EntryBase = plugin.NewEntry("docker")
-	r.TurnOffCaching()
+	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/docker/volume.go b/plugin/docker/volume.go
index 1c678b9..447edd8 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -37,7 +37,7 @@ func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 		client:    c,
 		startTime: startTime,
 	}
-	vol.SetTTLOf(plugin.List, 60*time.Second)
+	vol.CacheConfig().SetTTLOf(plugin.List, 60*time.Second)
 
 	return vol, nil
 }
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
deleted file mode 100644
index abda9b3..0000000
--- a/plugin/entryBase.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package plugin
-
-import (
-	"flag"
-	"time"
-)
-
-type cacheableOp int8
-
-const (
-	// List represents Group#List
-	List cacheableOp = iota
-	// Open represents Readable#Open
-	Open
-	// Metadata represents Resource#Metadata
-	Metadata
-)
-
-var cacheableOpToNameMap = [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.
-type EntryBase struct {
-	name string
-	// id represents the entry's wash ID. It is set in CachedList.
-	id  string
-	ttl [3]time.Duration
-}
-
-// NewEntry creates a new entry
-func NewEntry(name string) EntryBase {
-	e := EntryBase{name: name}
-	for op := range e.ttl {
-		e.SetTTLOf(cacheableOp(op), 15*time.Second)
-	}
-
-	return e
-}
-
-// Name returns the entry's name.
-func (e *EntryBase) Name() string {
-	return e.name
-}
-
-// ID returns the entry's wash ID
-func (e *EntryBase) ID() string {
-	return e.id
-}
-
-// SetTTLOf sets the specified op's TTL
-func (e *EntryBase) SetTTLOf(op cacheableOp, ttl time.Duration) {
-	e.ttl[op] = ttl
-}
-
-// TurnOffCachingFor turns off caching for the specified op
-func (e *EntryBase) TurnOffCachingFor(op cacheableOp) {
-	e.SetTTLOf(op, -1)
-}
-
-// TurnOffCaching turns off caching for all ops
-func (e *EntryBase) TurnOffCaching() {
-	for op := range e.ttl {
-		e.TurnOffCachingFor(cacheableOp(op))
-	}
-}
-
-func (e *EntryBase) getTTLOf(op cacheableOp) time.Duration {
-	return e.ttl[op]
-}
-
-func (e *EntryBase) setID(id string) {
-	e.id = id
-}
-
-// SetTestID sets the entry's cache ID for testing.
-// It can only be called by the tests.
-func (e *EntryBase) SetTestID(id string) {
-	if notRunningTests() {
-		panic("SetTestID can be only be called by the tests")
-	}
-
-	e.setID(id)
-}
-
-func notRunningTests() bool {
-	return flag.Lookup("test.v") == nil
-}
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index 7c8d512..02b401d 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -59,6 +59,22 @@ type decodedCacheTTLs struct {
 	Metadata time.Duration `json:"metadata"`
 }
 
+func (c decodedCacheTTLs) toCacheConfig() *CacheConfig {
+	config := newCacheConfig()
+
+	if c.List != 0 {
+		config.SetTTLOf(List, c.List*time.Second)
+	}
+	if c.Open != 0 {
+		config.SetTTLOf(Open, c.Open*time.Second)
+	}
+	if c.Metadata != 0 {
+		config.SetTTLOf(Metadata, c.Metadata*time.Second)
+	}
+
+	return config
+}
+
 // decodedExternalPluginEntry describes a decoded serialized entry.
 type decodedExternalPluginEntry struct {
 	Name             string            `json:"name"`
@@ -82,13 +98,12 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*ExternalPluginEntr
 	}
 
 	entry := &ExternalPluginEntry{
-		EntryBase:        NewEntry(e.Name),
+		name:             e.Name,
 		supportedActions: e.SupportedActions,
 		state:            e.State,
 		attr:             attr,
+		cacheConfig:      e.CacheTTLs.toCacheConfig(),
 	}
-	entry.setCacheTTLs(e.CacheTTLs)
-
 	return entry, nil
 }
 
@@ -96,28 +111,28 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*ExternalPluginEntr
 // of its name, its object (as serialized JSON), and the path to its
 // main plugin script.
 type ExternalPluginEntry struct {
-	EntryBase
 	script           ExternalPluginScript
+	washPath         string
+	name             string
 	supportedActions []string
 	state            string
+	cacheConfig      *CacheConfig
 	attr             Attributes
 }
 
-func (e *ExternalPluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
-	if ttls.List != 0 {
-		e.SetTTLOf(List, ttls.List*time.Second)
-	}
-	if ttls.Open != 0 {
-		e.SetTTLOf(Open, ttls.Open*time.Second)
-	}
-	if ttls.Metadata != 0 {
-		e.SetTTLOf(Metadata, ttls.Metadata*time.Second)
-	}
+// Name returns the entry's name
+func (e *ExternalPluginEntry) Name() string {
+	return e.name
+}
+
+// CacheConfig returns the entry's cache config
+func (e *ExternalPluginEntry) CacheConfig() *CacheConfig {
+	return e.cacheConfig
 }
 
 // List lists the entry's children, if it has any.
 func (e *ExternalPluginEntry) List(ctx context.Context) ([]Entry, error) {
-	stdout, err := e.script.InvokeAndWait(ctx, "list", e.ID(), e.state)
+	stdout, err := e.script.InvokeAndWait(ctx, "list", e.washPath, e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -141,6 +156,7 @@ func (e *ExternalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 			return nil, err
 		}
 		entry.script = e.script
+		entry.washPath = e.washPath + "/" + entry.Name()
 
 		entries[i] = entry
 	}
@@ -150,7 +166,7 @@ func (e *ExternalPluginEntry) List(ctx context.Context) ([]Entry, error) {
 
 // Open returns the entry's content
 func (e *ExternalPluginEntry) Open(ctx context.Context) (SizedReader, error) {
-	stdout, err := e.script.InvokeAndWait(ctx, "read", e.ID(), e.state)
+	stdout, err := e.script.InvokeAndWait(ctx, "read", e.washPath, e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -160,7 +176,7 @@ func (e *ExternalPluginEntry) Open(ctx context.Context) (SizedReader, error) {
 
 // Metadata displays the resource's metadata
 func (e *ExternalPluginEntry) Metadata(ctx context.Context) (MetadataMap, error) {
-	stdout, err := e.script.InvokeAndWait(ctx, "metadata", e.ID(), e.state)
+	stdout, err := e.script.InvokeAndWait(ctx, "metadata", e.washPath, e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -223,7 +239,7 @@ func (e *ExternalPluginEntry) Stream(ctx context.Context) (io.Reader, error) {
 	cmd, stdoutR, stderrR, err := CreateCommand(
 		e.script.Path(),
 		"stream",
-		e.ID(),
+		e.washPath,
 		e.state,
 	)
 
@@ -231,7 +247,7 @@ func (e *ExternalPluginEntry) Stream(ctx context.Context) (io.Reader, error) {
 		return nil, err
 	}
 
-	cmdStr := fmt.Sprintf("%v %v %v %v", e.script.Path(), "stream", e.ID(), e.state)
+	cmdStr := fmt.Sprintf("%v %v %v %v", e.script.Path(), "stream", e.washPath, e.state)
 
 	journal.Record(ctx, "Starting command: %v", cmdStr)
 	if err := cmd.Start(); err != nil {
@@ -348,7 +364,7 @@ func (e *ExternalPluginEntry) Exec(ctx context.Context, cmd string, args []strin
 	cmdObj := exec.Command(
 		e.script.Path(),
 		append(
-			[]string{"exec", e.ID(), e.state, cmd},
+			[]string{"exec", e.washPath, e.state, cmd},
 			args...,
 		)...,
 	)
diff --git a/plugin/externalPluginRoot.go b/plugin/externalPluginRoot.go
index 2de6ebe..6db1bfa 100644
--- a/plugin/externalPluginRoot.go
+++ b/plugin/externalPluginRoot.go
@@ -57,6 +57,7 @@ func (r *ExternalPluginRoot) Init() error {
 
 	r.ExternalPluginEntry = entry
 	r.ExternalPluginEntry.script = script
+	r.ExternalPluginEntry.washPath = "/" + r.Name()
 
 	return nil
 }
diff --git a/plugin/helpers.go b/plugin/helpers.go
index cf06491..ca47ef9 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -18,6 +18,11 @@ import (
 // DefaultTimeout is the default timeout for prefetching
 var DefaultTimeout = 10 * time.Second
 
+// NewEntry creates a new named entry
+func NewEntry(name string) EntryBase {
+	return EntryBase{name, newCacheConfig()}
+}
+
 // ToMetadata converts an object to a metadata result. If the input is already an array of bytes, it
 // must contain a serialized JSON object. Will panic if given something besides a struct or []byte.
 func ToMetadata(obj interface{}) MetadataMap {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index c18eef3..dff2766 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -35,7 +35,7 @@ func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface,
 		podi:      pd,
 		startTime: p.CreationTimestamp.Time,
 	}
-	vol.SetTTLOf(plugin.List, 60*time.Second)
+	vol.CacheConfig().SetTTLOf(plugin.List, 60*time.Second)
 
 	return vol
 }
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index 09b4700..a669389 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -46,7 +46,7 @@ func (r *Root) Init() error {
 	}
 
 	r.EntryBase = plugin.NewEntry("kubernetes")
-	r.TurnOffCaching()
+	r.CacheConfig().TurnOffCaching()
 
 	contexts := make([]plugin.Entry, 0)
 	for name := range raw.Contexts {
diff --git a/plugin/registry.go b/plugin/registry.go
index 51f0dd5..915d99c 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -22,8 +22,8 @@ func NewRegistry() *Registry {
 	// 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.setID("")
-	r.TurnOffCaching()
+	r.CacheConfig().id = ""
+	r.CacheConfig().TurnOffCaching()
 
 	return r
 }
diff --git a/plugin/types.go b/plugin/types.go
index e2884df..343fce4 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -9,19 +9,25 @@ import (
 
 // ==== Wash Protocols and Resources ====
 
-// Entry is a basic named resource type. It is a sealed
-// interface, meaning you must use plugin.NewEntry when
-// creating your plugin objects.
+// Entry is a basic named resource type.
 type Entry interface {
 	Name() string
-	ID() string
-	SetTTLOf(op cacheableOp, ttl time.Duration)
-	TurnOffCachingFor(op cacheableOp)
-	TurnOffCaching()
-	getTTLOf(op cacheableOp) time.Duration
-	setID(id string)
+	CacheConfig() *CacheConfig
 }
 
+// EntryBase implements Entry, making it easy to create new entries.
+// EntryBase includes a default caching configuration.
+type EntryBase struct {
+	name        string
+	cacheConfig *CacheConfig
+}
+
+// Name returns the entry's name.
+func (e *EntryBase) Name() string { return e.name }
+
+// CacheConfig returns the entry's cache config.
+func (e *EntryBase) CacheConfig() *CacheConfig { return e.cacheConfig }
+
 // MetadataMap maps keys to arbitrary structured data.
 type MetadataMap = map[string]interface{}
 
diff --git a/volume/dir.go b/volume/dir.go
index 92f0410..62db6c3 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -31,7 +31,7 @@ func NewDir(name string, attr plugin.Attributes, cb ContentCB, path string, dirs
 		path:      path,
 		dirs:      dirs,
 	}
-	vd.SetTTLOf(plugin.Open, 60*time.Second)
+	vd.CacheConfig().SetTTLOf(plugin.Open, 60*time.Second)
 
 	return vd
 }
diff --git a/volume/file.go b/volume/file.go
index 4879e1a..2e8bb19 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -26,7 +26,7 @@ func NewFile(name string, attr plugin.Attributes, cb ContentCB, path string) *Fi
 		contentcb: cb,
 		path:      path,
 	}
-	vf.SetTTLOf(plugin.Open, 60*time.Second)
+	vf.CacheConfig().SetTTLOf(plugin.Open, 60*time.Second)
 
 	return vf
 }


Test patch:
diff --git a/api/cache_test.go b/api/cache_test.go
index 40aa342..06a6b37 100644
--- a/api/cache_test.go
+++ b/api/cache_test.go
@@ -90,7 +90,7 @@ func (suite *CacheHandlerTestSuite) TestRejectsGet() {
 func (suite *CacheHandlerTestSuite) TestClearCache() {
 	// Populate the cache with a mocked resource and plugin.Cached*
 	group := newMockedGroup()
-	group.SetTestID("/dir")
+	group.CacheConfig().SetTestID("/dir")
 	group.On("List", mock.Anything).Return([]plugin.Entry{}, nil)
 
 	if children, err := plugin.CachedList(context.Background(), group); suite.Nil(err) {
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index fbb3efa..fae8903 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -44,6 +44,25 @@ func (suite *CacheTestSuite) TearDownTest() {
 	UnsetTestCache()
 }
 
+func (suite *CacheTestSuite) TestDefaultCacheConfig() {
+	config := newCacheConfig()
+	assertOpTTL := func(op cachedOp, opName string, expectedTTL time.Duration) {
+		actualTTL := config.getTTLOf(op)
+		suite.Equal(
+			expectedTTL,
+			actualTTL,
+			"expected the TTL of %v to be %v, but got %v instead",
+			opName,
+			expectedTTL,
+			actualTTL,
+		)
+	}
+
+	assertOpTTL(List, "List", 15*time.Second)
+	assertOpTTL(Open, "Open", 15*time.Second)
+	assertOpTTL(Metadata, "Metadata", 15*time.Second)
+}
+
 func (suite *CacheTestSuite) opKeysRegex(path string) *regexp.Regexp {
 	rx, err := opKeysRegex(path)
 	if err != nil {
@@ -121,7 +140,7 @@ func (e *cacheTestsMockEntry) Metadata(ctx context.Context) (MetadataMap, error)
 
 type cachedOpFunc func(ctx context.Context, e Entry) (interface{}, error)
 
-func (suite *CacheTestSuite) testCachedOp(op cacheableOp, opName string, mockValue interface{}, cachedOp cachedOpFunc) {
+func (suite *CacheTestSuite) testCachedOp(op cachedOp, opName string, mockValue interface{}, cachedOp cachedOpFunc) {
 	ctx := context.Background()
 
 	// Test that cachedOp panics if the cache == nil
@@ -136,9 +155,9 @@ func (suite *CacheTestSuite) testCachedOp(op cacheableOp, opName string, mockVal
 	// Test that cachedOp does _not_ call cache#GetOrUpdate for an
 	// entry that's turned off caching
 	entry := newCacheTestsMockEntry("")
-	entry.SetTestID("id")
+	entry.CacheConfig().SetTestID("id")
 	entry.On(opName, ctx).Return(mockValue, nil)
-	entry.TurnOffCachingFor(op)
+	entry.CacheConfig().TurnOffCachingFor(op)
 	v, err := cachedOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
@@ -149,7 +168,7 @@ func (suite *CacheTestSuite) testCachedOp(op cacheableOp, opName string, mockVal
 	// entry that's enabled caching, and that it passes-in the
 	// right arguments.
 	opTTL := 5 * time.Second
-	entry.SetTTLOf(op, opTTL)
+	entry.CacheConfig().SetTTLOf(op, opTTL)
 	entry.On(opName, ctx).Return(mockValue, nil)
 	opKey := opName + "::" + "id"
 	generateValueMatcher := func(generateValue func() (interface{}, error)) bool {
@@ -179,8 +198,16 @@ func (suite *CacheTestSuite) TestCachedList() {
 	// Set-up another mock entry for additional CachedList tests
 	ctx := context.Background()
 	entry := newCacheTestsMockEntry("parent")
-	entry.SetTestID("parentID")
-	entry.TurnOffCaching()
+	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>
@@ -192,8 +219,8 @@ func (suite *CacheTestSuite) TestCachedList() {
 	children, err := CachedList(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockChildren, children)
-		suite.Equal("parentID/child1", children[0].ID())
-		suite.Equal("parentID/child2", children[1].ID())
+		suite.Equal("parentID/child1", children[0].CacheConfig().ID())
+		suite.Equal("parentID/child2", children[1].CacheConfig().ID())
 	}
 }
 
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
deleted file mode 100644
index c6e19d9..0000000
--- a/plugin/entryBase_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package plugin
-
-import (
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/suite"
-)
-
-type EntryBaseTestSuite struct {
-	suite.Suite
-}
-
-func (suite *EntryBaseTestSuite) TestNewEntry() {
-	e := NewEntry("foo")
-	assertOpTTL := func(op cacheableOp, opName string, expectedTTL time.Duration) {
-		actualTTL := e.getTTLOf(op)
-		suite.Equal(
-			expectedTTL,
-			actualTTL,
-			"expected the TTL of %v to be %v, but got %v instead",
-			opName,
-			expectedTTL,
-			actualTTL,
-		)
-	}
-
-	suite.Equal("foo", e.Name())
-	assertOpTTL(List, "List", 15*time.Second)
-	assertOpTTL(Open, "Open", 15*time.Second)
-	assertOpTTL(Metadata, "Metadata", 15*time.Second)
-
-	e.setID("/foo")
-	suite.Equal("/foo", e.ID())
-
-	e.SetTTLOf(List, 40*time.Second)
-	assertOpTTL(List, "List", 40*time.Second)
-
-	e.TurnOffCachingFor(List)
-	assertOpTTL(List, "List", -1)
-
-	e.TurnOffCaching()
-	assertOpTTL(Open, "Open", -1)
-	assertOpTTL(Metadata, "Metadata", -1)
-
-}
-
-func TestEntryBase(t *testing.T) {
-	suite.Run(t, new(EntryBaseTestSuite))
-}
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index a463b4c..38445ae 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -90,6 +90,20 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeAttributes() {
 	suite.Error(err)
 }
 
+func (suite *ExternalPluginEntryTestSuite) TestDecodeCacheConfig() {
+	decodedTTLs := decodedCacheTTLs{
+		List:     10,
+		Open:     15,
+		Metadata: 20,
+	}
+
+	config := decodedTTLs.toCacheConfig()
+
+	suite.Equal(decodedTTLs.List*time.Second, config.getTTLOf(List))
+	suite.Equal(decodedTTLs.Open*time.Second, config.getTTLOf(Open))
+	suite.Equal(decodedTTLs.Metadata*time.Second, config.getTTLOf(Metadata))
+}
+
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry() {
 	decodedEntry := decodedExternalPluginEntry{}
 
@@ -116,9 +130,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry() {
 	decodedEntry.CacheTTLs = decodedCacheTTLs{List: 1}
 	entryWithCacheConfig, err := decodedEntry.toExternalPluginEntry()
 	if suite.NoError(err) {
-		expectedTTLs := NewEntry("").ttl
-		expectedTTLs[List] = decodedEntry.CacheTTLs.List * time.Second
-		suite.Equal(expectedTTLs, entryWithCacheConfig.EntryBase.ttl)
+		expectedCacheConfig := newCacheConfig()
+		expectedCacheConfig.SetTTLOf(List, decodedEntry.CacheTTLs.List*time.Second)
+		suite.Equal(expectedCacheConfig, entryWithCacheConfig.CacheConfig())
 	}
 
 	decodedEntry.Attributes = decodedAttributes{Size: 10}
@@ -132,21 +146,14 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry() {
 	suite.Error(err)
 }
 
-func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
-	decodedTTLs := decodedCacheTTLs{
-		List:     10,
-		Open:     15,
-		Metadata: 20,
-	}
-
-	entry := ExternalPluginEntry{
-		EntryBase: NewEntry("foo"),
-	}
-	entry.setCacheTTLs(decodedTTLs)
+func (suite *ExternalPluginEntryTestSuite) TestName() {
+	entry := ExternalPluginEntry{name: "foo"}
+	suite.Equal("foo", entry.Name())
+}
 
-	suite.Equal(decodedTTLs.List*time.Second, entry.getTTLOf(List))
-	suite.Equal(decodedTTLs.Open*time.Second, entry.getTTLOf(Open))
-	suite.Equal(decodedTTLs.Metadata*time.Second, entry.getTTLOf(Metadata))
+func (suite *ExternalPluginEntryTestSuite) TestCacheConfig() {
+	entry := ExternalPluginEntry{cacheConfig: newCacheConfig()}
+	suite.Equal(newCacheConfig(), entry.CacheConfig())
 }
 
 // TODO: There's a bit of duplication between TestList, TestOpen,
@@ -158,14 +165,13 @@ func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := ExternalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		script:    mockScript,
+		script:   mockScript,
+		washPath: "/foo",
 	}
-	entry.SetTestID("/foo")
 
 	ctx := context.Background()
 	mockInvokeAndWait := func(stdout []byte, err error) {
-		mockScript.OnInvokeAndWait(ctx, "list", entry.ID(), entry.state).Return(stdout, err).Once()
+		mockScript.OnInvokeAndWait(ctx, "list", entry.washPath, entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then List returns its error
@@ -189,8 +195,10 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	if suite.NoError(err) {
 		expectedEntries := []Entry{
 			&ExternalPluginEntry{
-				EntryBase:        NewEntry("foo"),
+				name:             "foo",
 				supportedActions: []string{"list"},
+				cacheConfig:      newCacheConfig(),
+				washPath:         entry.washPath + "/" + "foo",
 				script:           entry.script,
 			},
 		}
@@ -202,14 +210,13 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := ExternalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		script:    mockScript,
+		script:   mockScript,
+		washPath: "/foo",
 	}
-	entry.SetTestID("/foo")
 
 	ctx := context.Background()
 	mockInvokeAndWait := func(stdout []byte, err error) {
-		mockScript.OnInvokeAndWait(ctx, "read", entry.ID(), entry.state).Return(stdout, err).Once()
+		mockScript.OnInvokeAndWait(ctx, "read", entry.washPath, entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then Open returns its error
@@ -231,14 +238,13 @@ func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 func (suite *ExternalPluginEntryTestSuite) TestMetadata() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	entry := ExternalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		script:    mockScript,
+		script:   mockScript,
+		washPath: "/foo",
 	}
-	entry.SetTestID("/foo")
 
 	ctx := context.Background()
 	mockInvokeAndWait := func(stdout []byte, err error) {
-		mockScript.OnInvokeAndWait(ctx, "metadata", entry.ID(), entry.state).Return(stdout, err).Once()
+		mockScript.OnInvokeAndWait(ctx, "metadata", entry.washPath, entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then Metadata returns its error
diff --git a/plugin/externalPluginRoot_test.go b/plugin/externalPluginRoot_test.go
index 50bf772..a4e69bc 100644
--- a/plugin/externalPluginRoot_test.go
+++ b/plugin/externalPluginRoot_test.go
@@ -15,8 +15,8 @@ type ExternalPluginRootTestSuite struct {
 func (suite *ExternalPluginRootTestSuite) TestInit() {
 	mockScript := &mockExternalPluginScript{path: "plugin_script"}
 	root := &ExternalPluginRoot{&ExternalPluginEntry{
-		EntryBase: NewEntry("foo"),
-		script:    mockScript,
+		script:   mockScript,
+		washPath: "/foo",
 	}}
 
 	mockInvokeAndWait := func(stdout []byte, err error) {
@@ -42,8 +42,10 @@ func (suite *ExternalPluginRootTestSuite) TestInit() {
 	if suite.NoError(err) {
 		expectedRoot := &ExternalPluginRoot{
 			ExternalPluginEntry: &ExternalPluginEntry{
-				EntryBase:        NewEntry("foo"),
+				name:             "foo",
 				supportedActions: []string{"list"},
+				cacheConfig:      newCacheConfig(),
+				washPath:         "/foo",
 				script:           root.script,
 			},
 		}
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 568ddf9..8160fa3 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -114,7 +114,7 @@ func newHelpersTestsMockEntry() *helpersTestsMockEntry {
 		EntryBase: NewEntry("mockEntry"),
 	}
 
-	e.TurnOffCaching()
+	e.CacheConfig().TurnOffCaching()
 
 	return e
 }
@@ -134,7 +134,7 @@ func newHelpersTestsMockGroup() *helpersTestsMockGroup {
 		EntryBase: NewEntry("mockEntry"),
 	}
 
-	e.TurnOffCaching()
+	e.CacheConfig().TurnOffCaching()
 
 	return e
 }