Repository: puppetlabs/wash
Pull request #158 (2019-03-21 00:24:39)

Attr() cleanup; Shrink Entry interface; use plugin.CachedOp + plugin.CachedMetadata


Patch:
diff --git a/.travis.yml b/.travis.yml
index 358535d..6027443 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -21,16 +21,14 @@ jobs:
     if: branch = master AND type = push
     env: LINKFLAGS="-w -s -X github.com/puppetlabs/wash/cmd.version=${TRAVIS_TAG}" CGO_ENABLED=0
     script:
-    - GOOS=darwin GOARCH=amd64 go build -ldflags="$LINKFLAGS"
-    - tar czf ${PROJECT_NAME}-${TRAVIS_TAG}-x86_64-apple-darwin.tgz wash
-    - GOOS=linux GOARCH=amd64 go build -ldflags="$LINKFLAGS"
-    - tar czf ${PROJECT_NAME}-${TRAVIS_TAG}-x86_64-unknown-linux.tgz wash
+    - GOOS=darwin GOARCH=amd64 go build -ldflags="$LINKFLAGS" -o ${PROJECT_NAME}-${TRAVIS_TAG}-x86_64-apple-darwin
+    - GOOS=linux GOARCH=amd64 go build -ldflags="$LINKFLAGS" -o ${PROJECT_NAME}-${TRAVIS_TAG}-x86_64-unknown-linux
     deploy:
       provider: releases
       api_key:
         secure: PVefYvPRdpOtWvvZEG9wxv2HxjBUQ1/t1XsXQFOuF30/YzjY4EHaYBH/RXtFzuiCqpyk5GA1dWVUSE356Aka2Y3JlqImv1d0n+prrG2fEJBEtaDlR7Q6DeIiONPsCMxWyJ22r+bBz3C1Qq+QwSLzFuTN1XKIQTFdzIaqnIO7rthPNZNSfMmreed6SXfFmnsIcA+7TG+k4iDYz8COSDrYdjaiHnejvHlUiXh9Rfa3LzTdC4rxtOKofbUa9Qei08mSaSuRGD0U0vymOfFKf/QjNuwFmGSbdIH+1KmCOD9MV/47l6xZMDHDL4mLabsDB9ipf9LFAWmN9pQ/a5UoPyh4IXgaP7NeS3ISNlbKSCx7S70In8/whQv7jB8a5dNuo8HtKx0wzdQ9pKDBDt/N3h1YYJhGR2UF4JJd1CkBytRgIKzjZ5D7Ky+j23Vr6/+lvCRL0CN3RvvBP91/rfyO61eW4FGo+J/Es2u5+HPjVST4S4ntZnTDA6I2K1js8AbG1ofalivpcth4i+2zoKANXEjlXgUUI63gypBvyKGgHHouQTzKuyeIPBzlhYCIrQVhN5QG2+Lc9w7A2KqeRm0vxQ9elEKO1i5bSzSxYUXLMVSPkted0+gSPRRp4i67wOUO+1V9cLubbXm8PG1E1DhpiOQnxQb2DnaaVlL1mr3XQVDl6BY=
       file_glob: true
-      file: ${PROJECT_NAME}-${TRAVIS_TAG}-*.tgz
+      file: ${PROJECT_NAME}-${TRAVIS_TAG}-*
       skip_cleanup: true
       on:
         repo: puppetlabs/wash
diff --git a/README.md b/README.md
index 4e92df6..5e3d277 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) [![Go Report Card](https://goreportcard.com/badge/github.com/puppetlabs/wash)](https://goreportcard.com/report/github.com/puppetlabs/wash)
+[![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)
 
 `wash` helps you deal with all your remote or cloud-native infrastructure using the UNIX-y patterns and tools you already know and love!
 
diff --git a/api/list.go b/api/list.go
index 2847075..012ad7a 100644
--- a/api/list.go
+++ b/api/list.go
@@ -45,9 +45,11 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request) *errorRes
 			Errors:  make(map[string]*apitypes.ErrorObj),
 		}
 
-		err := plugin.FillAttr(r.Context(), entry, &result.Attributes)
+		attr, err := plugin.Attr(r.Context(), entry)
 		if err != nil {
 			result.Errors["attributes"] = newUnknownErrorObj(err)
+		} else {
+			result.Attributes = attr
 		}
 
 		return result
diff --git a/cmd/ps.go b/cmd/ps.go
index b652d5a..337bfb3 100644
--- a/cmd/ps.go
+++ b/cmd/ps.go
@@ -62,7 +62,7 @@ pid=$$
 for i in $procs; do
 	if [ -d $i -a ${i#/proc/} -ne $pid ]; then
 	  cmdline=$(cat $i/cmdline | tr '\0' ' ')
-		printf '%s\t%s\t%s\n' "$cmdline" "$(cat $i/stat)" "$(cat $i/statm)"
+		printf '%s\t%s\t%s' "$cmdline" "$(cat $i/stat)" "$(cat $i/statm)"
 	fi
 done
 `
@@ -80,7 +80,7 @@ type psresult struct {
 func parseEntry(line string) (psresult, error) {
 	tokens := strings.Split(strings.TrimSpace(line), "\t")
 	if len(tokens) != 3 {
-		return psresult{}, fmt.Errorf("Line had %v, not 3 tokens: %#v", len(tokens), tokens)
+		return psresult{}, fmt.Errorf("Line did not have 3 tokens: %v", tokens)
 	}
 	stat := string(tokens[1])
 	// statm := tokens[2]
diff --git a/fuse/core.go b/fuse/core.go
index 2f4ce4e..b042dc0 100644
--- a/fuse/core.go
+++ b/fuse/core.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"os/user"
 	"strconv"
+	"strings"
 	"time"
 
 	"bazil.org/fuse"
@@ -27,7 +28,7 @@ func newRoot(registry *plugin.Registry) Root {
 // Root presents the root of the filesystem.
 func (r *Root) Root() (fs.Node, error) {
 	log.Infof("Entering root of filesystem")
-	return newDir(r.registry), nil
+	return newDir("", r.registry), nil
 }
 
 func getIDs() (uint32, uint32) {
@@ -51,6 +52,14 @@ func getIDs() (uint32, uint32) {
 
 var uid, gid = getIDs()
 
+func joinPath(parentPath string, e plugin.Entry) string {
+	if parentPath == "" {
+		return e.Name()
+	}
+
+	return strings.TrimRight(parentPath, "/") + "/" + e.Name()
+}
+
 type fuseNode interface {
 	Entry() plugin.Entry
 	String() string
@@ -94,9 +103,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(), &attr)
+	attr, err := plugin.Attr(ctx, f.Entry())
 	if _, ok := err.(plugin.ErrCouldNotDetermineSizeAttr); ok {
 		log.Warnf("FUSE: Warn[Attr,%v]: %v", f, err)
 	} else if err != nil {
diff --git a/fuse/dir.go b/fuse/dir.go
index 07626a9..d0fdb79 100644
--- a/fuse/dir.go
+++ b/fuse/dir.go
@@ -21,8 +21,8 @@ var _ fs.Node = (*dir)(nil)
 var _ = fs.NodeRequestLookuper(&dir{})
 var _ = fs.HandleReadDirAller(&dir{})
 
-func newDir(e plugin.Group) *dir {
-	return &dir{e, e.ID()}
+func newDir(parentPath string, e plugin.Group) *dir {
+	return &dir{e, joinPath(parentPath, e)}
 }
 
 func (d *dir) Entry() plugin.Entry {
@@ -75,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.(plugin.Group))
+				childdir := newDir(d.path, entry.(plugin.Group))
 				journal.Record(ctx, "FUSE: Found directory %v", childdir)
 				// Prefetch directory entries into the cache
 				go func() {
@@ -86,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), nil
+			return newFile(d.path, 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 ef01e7c..312b79f 100644
--- a/fuse/file.go
+++ b/fuse/file.go
@@ -23,8 +23,8 @@ var _ = fs.NodeOpener(&file{})
 var _ = fs.NodeGetxattrer(&file{})
 var _ = fs.NodeListxattrer(&file{})
 
-func newFile(e plugin.Entry) *file {
-	return &file{e, e.ID()}
+func newFile(parentPath string, e plugin.Entry) *file {
+	return &file{e, joinPath(parentPath, e)}
 }
 
 func (f *file) Entry() plugin.Entry {
diff --git a/plugin/aws/ec2Dir.go b/plugin/aws/ec2Dir.go
index ff2172c..e247e19 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.DisableDefaultCaching()
 
 	ec2Dir.entries = []plugin.Entry{
 		newEC2InstancesDir(ec2Dir.session, ec2Dir.client),
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index b8a8293..ff9f541 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -2,6 +2,7 @@ package aws
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
 	"io/ioutil"
 	"sync"
@@ -20,26 +21,6 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
-// TODO: Once https://github.com/puppetlabs/wash/issues/123 is resolved,
-// make this use the cache
-func describeInstance(inst *ec2Instance) (*ec2Client.Instance, error) {
-	request := &ec2Client.DescribeInstancesInput{
-		InstanceIds: []*string{
-			awsSDK.String(inst.Name()),
-		},
-	}
-
-	resp, err := inst.client.DescribeInstances(request)
-	if err != nil {
-		return nil, err
-	}
-
-	// The API returns an error for an invalid instance ID. Since
-	// our API call succeeded, the response is guaranteed to contain
-	// the instance's metadata
-	return resp.Reservations[0].Instances[0], nil
-}
-
 // ec2Instance represents an EC2 instance
 type ec2Instance struct {
 	plugin.EntryBase
@@ -47,7 +28,6 @@ type ec2Instance struct {
 	client                  *ec2Client.EC2
 	ssmClient               *ssm.SSM
 	cloudwatchClient        *cloudwatchlogs.CloudWatchLogs
-	attr                    plugin.Attributes
 	latestConsoleOutputOnce sync.Once
 	entries                 []plugin.Entry
 }
@@ -64,16 +44,15 @@ const (
 	EC2InstanceStopped           = 80
 )
 
-func newEC2Instance(ctx context.Context, ID string, session *session.Session, client *ec2Client.EC2, attr plugin.Attributes) *ec2Instance {
+func newEC2Instance(ctx context.Context, ID string, session *session.Session, client *ec2Client.EC2) *ec2Instance {
 	ec2Instance := &ec2Instance{
 		EntryBase:        plugin.NewEntry(ID),
 		session:          session,
 		client:           client,
 		ssmClient:        ssm.New(session),
 		cloudwatchClient: cloudwatchlogs.New(session),
-		attr:             attr,
 	}
-	ec2Instance.TurnOffCachingFor(plugin.List)
+	ec2Instance.DisableDefaultCaching()
 
 	ec2Instance.entries = []plugin.Entry{
 		newEC2InstanceMetadataJSON(ec2Instance),
@@ -83,17 +62,77 @@ func newEC2Instance(ctx context.Context, ID string, session *session.Session, cl
 	return ec2Instance
 }
 
-func (inst *ec2Instance) Attr() plugin.Attributes {
-	return inst.attr
+type describeInstanceResult struct {
+	attr     plugin.Attributes
+	metadata *ec2Client.Instance
 }
 
-func (inst *ec2Instance) Metadata(context.Context) (plugin.MetadataMap, error) {
-	metadata, err := describeInstance(inst)
+func (inst *ec2Instance) cachedDescribeInstance(ctx context.Context) (describeInstanceResult, error) {
+	info, err := plugin.CachedOp("DescribeInstance", inst, 15*time.Second, func() (interface{}, error) {
+		request := &ec2Client.DescribeInstancesInput{
+			InstanceIds: []*string{
+				awsSDK.String(inst.Name()),
+			},
+		}
+
+		resp, err := inst.client.DescribeInstances(request)
+		if err != nil {
+			return nil, err
+		}
+
+		result := describeInstanceResult{}
+
+		// The API returns an error for an invalid instance ID. Since
+		// our API call succeeded, the response is guaranteed to contain
+		// the instance's metadata
+		result.metadata = resp.Reservations[0].Instances[0]
+
+		// AWS does not include the EC2 instance's ctime in its
+		// metadata. It also does not include the EC2 instance's
+		// last state transition time (mtime). Thus, we try to "guess"
+		// reasonable values for ctime and mtime by looping over each
+		// block device's attachment time and the instance's launch time.
+		// The oldest of these times is the ctime; the newest is the mtime.
+		result.attr.Ctime = awsSDK.TimeValue(result.metadata.LaunchTime)
+		result.attr.Mtime = result.attr.Ctime
+		for _, mapping := range result.metadata.BlockDeviceMappings {
+			attachTime := awsSDK.TimeValue(mapping.Ebs.AttachTime)
+
+			if attachTime.Before(result.attr.Ctime) {
+				result.attr.Ctime = attachTime
+			}
+
+			if attachTime.After(result.attr.Mtime) {
+				result.attr.Mtime = attachTime
+			}
+		}
+
+		return result, nil
+	})
+
+	if err != nil {
+		return describeInstanceResult{}, err
+	}
+
+	return info.(describeInstanceResult), nil
+}
+
+func (inst *ec2Instance) Attr(ctx context.Context) (plugin.Attributes, error) {
+	result, err := inst.cachedDescribeInstance(ctx)
+	if err != nil {
+		return plugin.Attributes{}, err
+	}
+
+	return result.attr, nil
+}
+
+func (inst *ec2Instance) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
+	result, err := inst.cachedDescribeInstance(ctx)
 	if err != nil {
 		return nil, err
 	}
 
-	return plugin.ToMetadata(metadata), nil
+	return plugin.ToMetadata(result.metadata), nil
 }
 
 func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -106,6 +145,48 @@ func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
 	return inst.entries, nil
 }
 
+type consoleOutput struct {
+	mtime   time.Time
+	content []byte
+}
+
+func (inst *ec2Instance) cachedConsoleOutput(ctx context.Context, latest bool) (consoleOutput, error) {
+	opName := "ConsoleOutput"
+	if latest {
+		opName = "Latest" + opName
+	}
+
+	output, err := plugin.CachedOp(opName, inst, 30*time.Second, func() (interface{}, error) {
+		request := &ec2Client.GetConsoleOutputInput{
+			InstanceId: awsSDK.String(inst.Name()),
+		}
+		if latest {
+			request.Latest = awsSDK.Bool(latest)
+		}
+
+		resp, err := inst.client.GetConsoleOutputWithContext(ctx, request)
+		if err != nil {
+			return nil, err
+		}
+
+		content, err := base64.StdEncoding.DecodeString(awsSDK.StringValue(resp.Output))
+		if err != nil {
+			return nil, err
+		}
+
+		return consoleOutput{
+			mtime:   awsSDK.TimeValue(resp.Timestamp),
+			content: content,
+		}, nil
+	})
+
+	if err != nil {
+		return consoleOutput{}, err
+	}
+
+	return output.(consoleOutput), nil
+}
+
 // According to https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-console.html,
 // only instance types that use the Nitro hypervisor can retrieve the
 // latest console output. For all other instance types, AWS will return
@@ -172,16 +253,16 @@ const (
 func (inst *ec2Instance) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
 	execResult := plugin.ExecResult{}
 
-	metadata, err := describeInstance(inst)
+	result, err := inst.cachedDescribeInstance(ctx)
 	if err != nil {
 		return execResult, nil
 	}
 
 	// Exec only makes sense on a running EC2 instance.
-	if awsSDK.Int64Value(metadata.State.Code) != EC2InstanceRunningState {
+	if awsSDK.Int64Value(result.metadata.State.Code) != EC2InstanceRunningState {
 		err := fmt.Errorf(
 			"instance is not in the running state. Its current state is: %v",
-			awsSDK.StringValue(metadata.State.Name),
+			awsSDK.StringValue(result.metadata.State.Name),
 		)
 
 		return execResult, err
@@ -479,7 +560,7 @@ func sendEvent(e *cloudwatchlogs.OutputLogEvent, stream *plugin.OutputStream) er
 	//
 	// NOTE: Since SSM uses bufio.Scanner to parse each message, there's a
 	// chance that the printed stdout/stderr stream can end in an extra
-	// newline character. Unfortunately, we don't have an easy way of determining
+	// newline character. Unfortunately, we don't have an easy way of determing
 	// whether the last message did end in a new line, so let this be a known
 	// issue until there's a good reason for us to address it.
 	message := awsSDK.StringValue(e.Message) + "\n"
diff --git a/plugin/aws/ec2InstanceConsoleOutput.go b/plugin/aws/ec2InstanceConsoleOutput.go
index 49750c9..cde8c7d 100644
--- a/plugin/aws/ec2InstanceConsoleOutput.go
+++ b/plugin/aws/ec2InstanceConsoleOutput.go
@@ -3,10 +3,7 @@ package aws
 import (
 	"bytes"
 	"context"
-	"encoding/base64"
 
-	awsSDK "github.com/aws/aws-sdk-go/aws"
-	ec2Client "github.com/aws/aws-sdk-go/service/ec2"
 	"github.com/puppetlabs/wash/plugin"
 )
 
@@ -29,30 +26,30 @@ func newEC2InstanceConsoleOutput(inst *ec2Instance, latest bool) *ec2InstanceCon
 	} else {
 		cl.EntryBase = plugin.NewEntry("console.out")
 	}
+	cl.DisableDefaultCaching()
 
 	return cl
 }
 
-// TODO: Once https://github.com/puppetlabs/wash/issues/123 is resolved, create
-// a CachedConsoleLog method that we can use to generate the content + retrieve
-// the console log file's attributes (e.g. like Ctime/Mtime)
-func (cl *ec2InstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
-	request := &ec2Client.GetConsoleOutputInput{
-		InstanceId: awsSDK.String(cl.inst.Name()),
-	}
-	if cl.latest {
-		request.Latest = awsSDK.Bool(cl.latest)
-	}
-
-	resp, err := cl.inst.client.GetConsoleOutputWithContext(ctx, request)
+func (cl *ec2InstanceConsoleOutput) Attr(ctx context.Context) (plugin.Attributes, error) {
+	output, err := cl.inst.cachedConsoleOutput(ctx, cl.latest)
 	if err != nil {
-		return nil, err
+		return plugin.Attributes{}, err
 	}
 
-	content, err := base64.StdEncoding.DecodeString(awsSDK.StringValue(resp.Output))
+	return plugin.Attributes{
+		Ctime: output.mtime,
+		Mtime: output.mtime,
+		Atime: output.mtime,
+		Size:  uint64(len(output.content)),
+	}, nil
+}
+
+func (cl *ec2InstanceConsoleOutput) Open(ctx context.Context) (plugin.SizedReader, error) {
+	output, err := cl.inst.cachedConsoleOutput(ctx, cl.latest)
 	if err != nil {
 		return nil, err
 	}
 
-	return bytes.NewReader(content), nil
+	return bytes.NewReader(output.content), nil
 }
diff --git a/plugin/aws/ec2InstanceMetadataJSON.go b/plugin/aws/ec2InstanceMetadataJSON.go
index a4602bf..59686a3 100644
--- a/plugin/aws/ec2InstanceMetadataJSON.go
+++ b/plugin/aws/ec2InstanceMetadataJSON.go
@@ -16,14 +16,17 @@ type ec2InstanceMetadataJSON struct {
 }
 
 func newEC2InstanceMetadataJSON(inst *ec2Instance) *ec2InstanceMetadataJSON {
-	return &ec2InstanceMetadataJSON{
+	im := &ec2InstanceMetadataJSON{
 		EntryBase: plugin.NewEntry("metadata.json"),
 		inst:      inst,
 	}
+	im.DisableDefaultCaching()
+
+	return im
 }
 
 func (im *ec2InstanceMetadataJSON) Open(ctx context.Context) (plugin.SizedReader, error) {
-	metadata, err := im.inst.Metadata(ctx)
+	metadata, err := plugin.CachedMetadata(ctx, im.inst)
 	if err != nil {
 		return nil, err
 	}
diff --git a/plugin/aws/ec2InstancesDir.go b/plugin/aws/ec2InstancesDir.go
index 4b095f6..54cd7b7 100644
--- a/plugin/aws/ec2InstancesDir.go
+++ b/plugin/aws/ec2InstancesDir.go
@@ -50,33 +50,11 @@ func (is *ec2InstancesDir) List(ctx context.Context) ([]plugin.Entry, error) {
 
 		instances := make([]plugin.Entry, len(reservation.Instances))
 		for i, instance := range reservation.Instances {
-			// AWS does not include the EC2 instance's ctime in its
-			// metadata. It also does not include the EC2 instance's
-			// last state transition time (mtime). Thus, we try to "guess"
-			// reasonable values for ctime and mtime by looping over each
-			// block device's attachment time and the instance's launch time.
-			// The oldest of these times is the ctime; the newest is the mtime.
-			var attr plugin.Attributes
-			attr.Ctime = awsSDK.TimeValue(instance.LaunchTime)
-			attr.Mtime = attr.Ctime
-			for _, mapping := range instance.BlockDeviceMappings {
-				attachTime := awsSDK.TimeValue(mapping.Ebs.AttachTime)
-
-				if attachTime.Before(attr.Ctime) {
-					attr.Ctime = attachTime
-				}
-
-				if attachTime.After(attr.Mtime) {
-					attr.Mtime = attachTime
-				}
-			}
-
 			instances[i] = newEC2Instance(
 				ctx,
 				awsSDK.StringValue(instance.InstanceId),
 				is.session,
 				is.client,
-				attr,
 			)
 		}
 
diff --git a/plugin/aws/profile.go b/plugin/aws/profile.go
index 358fc5d..fbc38b7 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.DisableDefaultCaching()
 
 	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..a70f06c 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.DisableDefaultCaching()
 
 	resourcesDir.resources = []plugin.Entry{
 		newS3Dir(resourcesDir.session),
diff --git a/plugin/aws/s3Bucket.go b/plugin/aws/s3Bucket.go
index 7a992ee..60aeee9 100644
--- a/plugin/aws/s3Bucket.go
+++ b/plugin/aws/s3Bucket.go
@@ -2,7 +2,7 @@ package aws
 
 import (
 	"context"
-	"fmt"
+	"time"
 
 	"github.com/puppetlabs/wash/journal"
 	"github.com/puppetlabs/wash/plugin"
@@ -55,34 +55,11 @@ func listObjects(ctx context.Context, client *s3Client.S3, bucket string, prefix
 	}
 
 	for i, o := range resp.Contents {
-		request := &s3Client.HeadObjectInput{
-			Bucket: awsSDK.String(bucket),
-			Key:    o.Key,
-		}
-
-		key := awsSDK.StringValue(o.Key)
-
-		// TODO: Once https://github.com/puppetlabs/wash/issues/123
-		// is resolved, we should move the HeadObject calls over to
-		// s3Object and cache its response. This way, we can re-use
-		// it for Attr and Metadata
-		resp, err := client.HeadObjectWithContext(ctx, request)
-		if err != nil {
-			return nil, fmt.Errorf("could not get the metadata + attributes for object %v: %v", key, err)
-		}
-
-		size := awsSDK.Int64Value(resp.ContentLength)
-		if size < 0 {
-			return nil, fmt.Errorf("got a negative value of %v for the size of the %v object's content", size, key)
-		}
-
-		attr := plugin.Attributes{
-			Mtime: awsSDK.TimeValue(resp.LastModified),
-			Size:  uint64(size),
-		}
-		metadata := plugin.ToMetadata(resp)
-
-		entries[numPrefixes+i] = newS3Object(attr, metadata, bucket, key, client)
+		entries[numPrefixes+i] = newS3Object(
+			bucket,
+			awsSDK.StringValue(o.Key),
+			client,
+		)
 	}
 
 	return entries, nil
@@ -91,26 +68,32 @@ func listObjects(ctx context.Context, client *s3Client.S3, bucket string, prefix
 // s3Bucket represents an S3 bucket.
 type s3Bucket struct {
 	plugin.EntryBase
-	attr   plugin.Attributes
+	ctime  time.Time
 	region string
 	client *s3Client.S3
 }
 
-func newS3Bucket(name string, attr plugin.Attributes, region string, client *s3Client.S3) *s3Bucket {
-	return &s3Bucket{
+func newS3Bucket(name string, ctime time.Time, region string, client *s3Client.S3) *s3Bucket {
+	bucket := &s3Bucket{
 		EntryBase: plugin.NewEntry(name),
-		attr:      attr,
+		ctime:     ctime,
 		region:    region,
 		client:    client,
 	}
+
+	return bucket
 }
 
-func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
-	return listObjects(ctx, b.client, b.Name(), "")
+func (b *s3Bucket) Attr(ctx context.Context) (plugin.Attributes, error) {
+	return plugin.Attributes{
+		Ctime: b.ctime,
+		Mtime: b.ctime,
+		Atime: b.ctime,
+	}, nil
 }
 
-func (b *s3Bucket) Attr() plugin.Attributes {
-	return b.attr
+func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
+	return listObjects(ctx, b.client, b.Name(), "")
 }
 
 func (b *s3Bucket) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
diff --git a/plugin/aws/s3Dir.go b/plugin/aws/s3Dir.go
index 927b6d4..b378633 100644
--- a/plugin/aws/s3Dir.go
+++ b/plugin/aws/s3Dir.go
@@ -59,13 +59,9 @@ func (s *s3Dir) List(ctx context.Context) ([]plugin.Entry, error) {
 		cfg := awsSDK.NewConfig()
 		cfg.WithRegion(region)
 
-		attr := plugin.Attributes{
-			Ctime: awsSDK.TimeValue(bucket.CreationDate),
-		}
-
 		buckets[i] = newS3Bucket(
 			awsSDK.StringValue(name),
-			attr,
+			awsSDK.TimeValue(bucket.CreationDate),
 			region,
 			s3Client.New(s.session, cfg),
 		)
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index e1011f9..f21f5e1 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -3,9 +3,11 @@ package aws
 import (
 	"context"
 	"errors"
+	"fmt"
 	"io"
 	"path"
 	"strconv"
+	"time"
 
 	"github.com/puppetlabs/wash/journal"
 	"github.com/puppetlabs/wash/plugin"
@@ -17,33 +19,69 @@ import (
 // s3Object represents an S3 object.
 type s3Object struct {
 	plugin.EntryBase
-	attr     plugin.Attributes
-	metadata plugin.MetadataMap
-	bucket   string
-	key      string
-	client   *s3Client.S3
+	bucket string
+	key    string
+	client *s3Client.S3
 }
 
-func newS3Object(attr plugin.Attributes, metadata plugin.MetadataMap, bucket string, key string, client *s3Client.S3) *s3Object {
+func newS3Object(bucket string, key string, client *s3Client.S3) *s3Object {
 	o := &s3Object{
 		EntryBase: plugin.NewEntry(path.Base(key)),
-		attr:      attr,
-		metadata:  metadata,
 		bucket:    bucket,
 		key:       key,
 		client:    client,
 	}
-	o.TurnOffCachingFor(plugin.Metadata)
+	o.DisableCachingFor(plugin.Metadata)
 
 	return o
 }
 
-func (o *s3Object) Attr() plugin.Attributes {
-	return o.attr
+func (o *s3Object) cachedHeadObject(ctx context.Context) (*s3Client.HeadObjectOutput, error) {
+	resp, err := plugin.CachedOp("HeadObject", o, 15*time.Second, func() (interface{}, error) {
+		request := &s3Client.HeadObjectInput{
+			Bucket: awsSDK.String(o.bucket),
+			Key:    awsSDK.String(o.key),
+		}
+
+		return o.client.HeadObjectWithContext(ctx, request)
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.(*s3Client.HeadObjectOutput), nil
+}
+
+func (o *s3Object) Attr(ctx context.Context) (plugin.Attributes, error) {
+	metadata, err := o.cachedHeadObject(ctx)
+	if err != nil {
+		return plugin.Attributes{}, nil
+	}
+
+	size := awsSDK.Int64Value(metadata.ContentLength)
+	if size < 0 {
+		err := fmt.Errorf("got a negative value of %v for the size of the %v object's content", size, o.key)
+		return plugin.Attributes{}, err
+	}
+
+	mtime := awsSDK.TimeValue(metadata.LastModified)
+
+	return plugin.Attributes{
+		Ctime: mtime,
+		Mtime: mtime,
+		Atime: mtime,
+		Size:  uint64(size),
+	}, nil
 }
 
 func (o *s3Object) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
-	return o.metadata, nil
+	metadata, err := o.cachedHeadObject(ctx)
+	if err != nil {
+		return nil, nil
+	}
+
+	return plugin.ToMetadata(metadata), nil
 }
 
 func (o *s3Object) fetchContent(off int64) (io.ReadCloser, error) {
@@ -62,7 +100,12 @@ func (o *s3Object) fetchContent(off int64) (io.ReadCloser, error) {
 }
 
 func (o *s3Object) Open(ctx context.Context) (plugin.SizedReader, error) {
-	return &s3ObjectReader{o}, nil
+	attr, err := o.Attr(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return &s3ObjectReader{o: o, size: int64(attr.Size)}, nil
 }
 
 func (o *s3Object) Stream(context.Context) (io.Reader, error) {
@@ -78,6 +121,9 @@ func (o *s3Object) Stream(context.Context) (io.Reader, error) {
 // art we could use to optimize this.
 type s3ObjectReader struct {
 	o *s3Object
+	// See the comments in s3ObjectReader#Size to understand how this field's
+	// used.
+	size int64
 }
 
 func (s *s3ObjectReader) closeContent(content io.ReadCloser) {
@@ -105,5 +151,18 @@ func (s *s3ObjectReader) ReadAt(p []byte, off int64) (int, error) {
 }
 
 func (s *s3ObjectReader) Size() int64 {
-	return int64(s.o.Attr().Size)
+	ctx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancelFunc()
+
+	// We'd like to return s.o.Attr().Size here. Unfortunately since
+	// s.o.Attr() is calculated via. an API request, there's a chance
+	// that it could error. If that happens, we return s.size instead
+	// as a fallback. We could change Size()'s return signature to
+	// (int64, error), but there's no good reason to do that right now.
+	attr, err := s.o.Attr(ctx)
+	if err != nil {
+		return s.size
+	}
+
+	return int64(attr.Size)
 }
diff --git a/plugin/cache.go b/plugin/cache.go
index c345388..933694c 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -49,12 +49,13 @@ func UnsetTestCache() {
 	cache = nil
 }
 
+var opNameRegex = regexp.MustCompile("^[a-zA-Z]+$")
+const opQualifier = "^[a-zA-Z]+::"
+
 // This method exists to simplify ClearCacheFor's tests.
 // Specifically, it lets us decouple the regex's correctness
 // from the cache's implementation.
 func opKeysRegex(path string) (*regexp.Regexp, error) {
-	opQualifier := "^[a-zA-Z]*::"
-
 	var expr string
 	if path == "/" {
 		expr = opQualifier + "/.*"
@@ -87,6 +88,10 @@ type opFunc func() (interface{}, error)
 // For example, CachedOp could be useful to cache an API request whose response
 // lets you implement Attr() and Metadata() for the given entry.
 func CachedOp(opName string, entry Entry, ttl time.Duration, op opFunc) (interface{}, error) {
+	if !opNameRegex.MatchString(opName) {
+		panic(fmt.Sprintf("The opName %v does not match %v", opName, opNameRegex.String()))
+	}
+
 	for _, actionOpName := range actionOpCodeToNameMap {
 		if opName == actionOpName {
 			panic(fmt.Sprintf("The opName %v conflicts with Cached%v", opName, actionOpName))
@@ -110,13 +115,7 @@ func CachedList(ctx context.Context, g Group) ([]Entry, error) {
 		}
 
 		for _, entry := range entries {
-			var id string
-			if g.ID() == "/" {
-				id = g.ID() + entry.Name()
-			} else {
-				id = g.ID() + "/" + entry.Name()
-			}
-
+			id := strings.TrimRight(g.id(), "/") + "/" + entry.Name()
 			entry.setID(id)
 		}
 
@@ -177,15 +176,15 @@ func cachedOp(opName string, entry Entry, ttl time.Duration, op opFunc) (interfa
 		}
 	}
 
-	if entry.ID() == "" {
+	if entry.id() == "" {
 		// The Registry's ID is set to "/", while all other entries' IDs are
 		// set in CachedList. Thus, we should never hit this code-path.
-		panic("entry.ID() returned an empty ID")
+		panic("entry.id() returned an empty ID")
 	}
 
 	if ttl < 0 {
 		return op()
 	}
 
-	return cache.GetOrUpdate(opName+"::"+entry.ID(), ttl, false, op)
+	return cache.GetOrUpdate(opName+"::"+entry.id(), ttl, false, op)
 }
diff --git a/plugin/docker/container-metadata.go b/plugin/docker/container-metadata.go
index b9b6f62..8b17df9 100644
--- a/plugin/docker/container-metadata.go
+++ b/plugin/docker/container-metadata.go
@@ -14,7 +14,7 @@ type containerMetadata struct {
 }
 
 func (cm *containerMetadata) Open(ctx context.Context) (plugin.SizedReader, error) {
-	metadata, err := cm.container.Metadata(ctx)
+	metadata, err := plugin.CachedMetadata(ctx, cm.container)
 	if err != nil {
 		return nil, err
 	}
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index fa54659..1e7959e 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -31,19 +31,21 @@ func (c *container) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 }
 
 // Attr
-func (c *container) Attr() plugin.Attributes {
+func (c *container) Attr(ctx context.Context) (plugin.Attributes, error) {
 	return plugin.Attributes{
 		Ctime: c.startTime,
 		Mtime: c.startTime,
 		Atime: c.startTime,
-	}
+	}, nil
 }
 
 func (c *container) List(ctx context.Context) ([]plugin.Entry, error) {
-	return []plugin.Entry{
-		&containerMetadata{plugin.NewEntry("metadata.json"), c},
-		&containerLogFile{plugin.NewEntry("log"), c.Name(), c.client},
-	}, nil
+	cm := &containerMetadata{plugin.NewEntry("metadata.json"), c}
+	cm.DisableDefaultCaching()
+
+	clf := &containerLogFile{plugin.NewEntry("log"), c.Name(), c.client}
+
+	return []plugin.Entry{cm, clf}, nil
 }
 
 func (c *container) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
@@ -78,8 +80,7 @@ func (c *container) Exec(ctx context.Context, cmd string, args []string, opts pl
 	if opts.Stdin != nil {
 		go func() {
 			_, writeErr = io.Copy(resp.Conn, opts.Stdin)
-			respErr := resp.CloseWrite()
-			journal.Record(ctx, "Closed execution input stream for %v: %v, %v", c.Name(), writeErr, respErr)
+			journal.Record(ctx, "Closed execution response stream for %v: %v", c.Name(), writeErr)
 		}()
 	}
 
diff --git a/plugin/docker/root.go b/plugin/docker/root.go
index 3c79da0..6377e05 100644
--- a/plugin/docker/root.go
+++ b/plugin/docker/root.go
@@ -27,7 +27,7 @@ func (r *Root) Init() error {
 	}
 
 	r.EntryBase = plugin.NewEntry("docker")
-	r.TurnOffCaching()
+	r.DisableDefaultCaching()
 	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 7e0f111..f77f611 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -50,12 +50,12 @@ func (v *volume) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 	return plugin.ToMetadata(raw), nil
 }
 
-func (v *volume) Attr() plugin.Attributes {
+func (v *volume) Attr(ctx context.Context) (plugin.Attributes, error) {
 	return plugin.Attributes{
 		Ctime: v.startTime,
 		Mtime: v.startTime,
 		Atime: v.startTime,
-	}
+	}, nil
 }
 
 func (v *volume) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -128,7 +128,7 @@ func (v *volume) List(ctx context.Context) ([]plugin.Entry, error) {
 func (v *volume) createContainer(ctx context.Context, cmd []string) (string, error) {
 	// Use tty to avoid messing with the extra log formatting.
 	cfg := docontainer.Config{Image: "busybox", Cmd: cmd, Tty: true}
-	mounts := []mount.Mount{{
+	mounts := []mount.Mount{mount.Mount{
 		Type:     mount.TypeVolume,
 		Source:   v.Name(),
 		Target:   mountpoint,
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 0a54a73..7cdec32 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -1,6 +1,7 @@
 package plugin
 
 import (
+	"context"
 	"flag"
 	"fmt"
 	"strings"
@@ -24,9 +25,9 @@ var actionOpCodeToNameMap = [3]string{"List", "Open", "Metadata"}
 // 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
+	// washID represents the entry's wash ID. It is set in CachedList.
+	washID string
+	ttl    [3]time.Duration
 }
 
 // newEntryBase is needed by NewEntry, NewRegistry,
@@ -53,41 +54,59 @@ func NewEntry(name string) EntryBase {
 	return newEntryBase(name)
 }
 
-// Name returns the entry's name.
+// ENTRY INTERFACE
+
+// Name returns the entry's name. Do not override this.
 func (e *EntryBase) Name() string {
 	return e.name
 }
 
-// ID returns the entry's wash ID
-func (e *EntryBase) ID() string {
-	return e.id
+// Attr returns the entry's attributes. The default return value
+// is a zero'ed attributes struct with the Size field set to
+// SizeUnknown. You should override Attr() if you'd like to return
+// a different set of attributes.
+//
+// NOTE: See the comments for plugin.Attr if you're interested in
+// knowing why the Size field is set to SizeUnknown.
+func (e *EntryBase) Attr(ctx context.Context) (Attributes, error) {
+	return Attributes{
+		Size: SizeUnknown,
+	}, nil
+}
+
+func (e *EntryBase) id() string {
+	return e.washID
+}
+
+func (e *EntryBase) setID(id string) {
+	e.washID = id
+}
+
+func (e *EntryBase) getTTLOf(op actionOpCode) time.Duration {
+	return e.ttl[op]
 }
 
+// OTHER METHODS USED TO FACILITATE PLUGIN DEVELOPMENT
+// AND TESTING
+
 // SetTTLOf sets the specified op's TTL
 func (e *EntryBase) SetTTLOf(op actionOpCode, ttl time.Duration) {
 	e.ttl[op] = ttl
 }
 
-// TurnOffCachingFor turns off caching for the specified op
-func (e *EntryBase) TurnOffCachingFor(op actionOpCode) {
+// DisableCachingFor disables caching for the specified op
+func (e *EntryBase) DisableCachingFor(op actionOpCode) {
 	e.SetTTLOf(op, -1)
 }
 
-// TurnOffCaching turns off caching for all ops
-func (e *EntryBase) TurnOffCaching() {
+// DisableDefaultCaching disables the default caching
+// for List, Open and Metadata.
+func (e *EntryBase) DisableDefaultCaching() {
 	for op := range e.ttl {
-		e.TurnOffCachingFor(actionOpCode(op))
+		e.DisableCachingFor(actionOpCode(op))
 	}
 }
 
-func (e *EntryBase) getTTLOf(op actionOpCode) 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) {
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index 691f6dd..2ec0f3d 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -117,7 +117,7 @@ func (e *externalPluginEntry) setCacheTTLs(ttls decodedCacheTTLs) {
 
 // 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.id(), e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -150,7 +150,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.id(), e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -160,7 +160,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.id(), e.state)
 	if err != nil {
 		return nil, err
 	}
@@ -181,8 +181,8 @@ func (e *externalPluginEntry) Metadata(ctx context.Context) (MetadataMap, error)
 }
 
 // Attr returns the entry's filesystem attributes
-func (e *externalPluginEntry) Attr() Attributes {
-	return e.attr
+func (e *externalPluginEntry) Attr(ctx context.Context) (Attributes, error) {
+	return e.attr, nil
 }
 
 type stdoutStreamer struct {
@@ -223,7 +223,7 @@ func (e *externalPluginEntry) Stream(ctx context.Context) (io.Reader, error) {
 	cmd, stdoutR, stderrR, err := CreateCommand(
 		e.script.Path(),
 		"stream",
-		e.ID(),
+		e.id(),
 		e.state,
 	)
 
@@ -231,7 +231,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.id(), e.state)
 
 	journal.Record(ctx, "Starting command: %v", cmdStr)
 	if err := cmd.Start(); err != nil {
@@ -348,7 +348,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.id(), e.state, cmd},
 			args...,
 		)...,
 	)
diff --git a/plugin/helpers.go b/plugin/helpers.go
index aff2308..5024828 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -124,23 +124,26 @@ func ToFileMode(mode interface{}) (os.FileMode, error) {
 	return fileMode, nil
 }
 
-// FillAttr fills the given attributes struct with the entry's attributes.
-func FillAttr(ctx context.Context, entry Entry, attr *Attributes) error {
-	attr.Size = SizeUnknown
-
-	if item, ok := entry.(File); ok {
-		attrRet := item.Attr()
-
-		// We need the zero-value check for external plugin entries,
-		// b/c the externalPluginEntry class has Attr() implemented,
-		// but not all external plugin entries have attributes.
-		if attrRet != (Attributes{}) {
-			(*attr) = attrRet
-		}
+// Attr returns the entry's attributes in a form that's consummable by
+// FUSE and the API. This may or may not match entry.Attr(). Specifically,
+//   * If entry.Attr() sets the size field to SizeUnknown and the entry supports
+//   the Read action, then the returned attributes' size will be set to
+//   the size of the entry's content as returned by Open.
+//
+//   * If entry.Attr() does not set the mode, then the returned attributes'
+//   mode will be set to "os.ModeDir | 0550" if the entry supports the List
+//   action; otherwise, it will be set to 0440. This is needed by FUSE.
+//
+//   * Otherwise, the returned attributes will match what's returned by
+//   entry.Attr().
+//
+func Attr(ctx context.Context, entry Entry) (Attributes, error) {
+	attr, err := entry.Attr(ctx)
+	if err != nil {
+		return attr, err
 	}
 
-	var err error
-	if ReadAction.IsSupportedOn(entry) && attr.Size == SizeUnknown {
+	if attr.Size == SizeUnknown && ReadAction.IsSupportedOn(entry) {
 		content, openErr := CachedOpen(ctx, entry.(Readable))
 		if openErr != nil {
 			err = ErrCouldNotDetermineSizeAttr{openErr.Error()}
@@ -148,7 +151,7 @@ func FillAttr(ctx context.Context, entry Entry, attr *Attributes) error {
 		} else {
 			size := content.Size()
 			if size < 0 {
-				return ErrNegativeSizeAttr{size}
+				return attr, ErrNegativeSizeAttr{size}
 			}
 
 			attr.Size = uint64(size)
@@ -163,7 +166,7 @@ func FillAttr(ctx context.Context, entry Entry, attr *Attributes) error {
 		}
 	}
 
-	return err
+	return attr, err
 }
 
 // CreateCommand creates a cmd object encapsulating the given cmd and its args.
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index a61af2d..a334a72 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -47,13 +47,13 @@ func (p *pod) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 	return plugin.ToMetadata(pd), nil
 }
 
-func (p *pod) Attr() plugin.Attributes {
+func (p *pod) Attr(ctx context.Context) (plugin.Attributes, error) {
 	return plugin.Attributes{
 		Ctime: p.startTime,
 		Mtime: time.Now(),
 		Atime: p.startTime,
 		Size:  plugin.SizeUnknown,
-	}
+	}, nil
 }
 
 func (p *pod) Open(ctx context.Context) (plugin.SizedReader, error) {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index 2b94224..bdde8f9 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -50,12 +50,12 @@ func (v *pvc) Metadata(ctx context.Context) (plugin.MetadataMap, error) {
 	return plugin.ToMetadata(obj), nil
 }
 
-func (v *pvc) Attr() plugin.Attributes {
+func (v *pvc) Attr(ctx context.Context) (plugin.Attributes, error) {
 	return plugin.Attributes{
 		Ctime: v.startTime,
 		Mtime: v.startTime,
 		Atime: v.startTime,
-	}
+	}, nil
 }
 
 func (v *pvc) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -117,12 +117,12 @@ func (v *pvc) createPod(cmd []string) (string, error) {
 		},
 		Spec: corev1.PodSpec{
 			Containers: []corev1.Container{
-				{
+				corev1.Container{
 					Name:  "busybox",
 					Image: "busybox",
 					Args:  cmd,
 					VolumeMounts: []corev1.VolumeMount{
-						{
+						corev1.VolumeMount{
 							Name:      v.Name(),
 							MountPath: mountpoint,
 							ReadOnly:  true,
@@ -132,7 +132,7 @@ func (v *pvc) createPod(cmd []string) (string, error) {
 			},
 			RestartPolicy: corev1.RestartPolicyNever,
 			Volumes: []corev1.Volume{
-				{
+				corev1.Volume{
 					Name: v.Name(),
 					VolumeSource: corev1.VolumeSource{
 						PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index ab25b0a..e92fb4b 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -49,7 +49,7 @@ func (r *Root) Init() error {
 	}
 
 	r.EntryBase = plugin.NewEntry("kubernetes")
-	r.TurnOffCaching()
+	r.DisableDefaultCaching()
 
 	contexts := make([]plugin.Entry, 0)
 	for name := range raw.Contexts {
diff --git a/plugin/registry.go b/plugin/registry.go
index bfeffe6..f539cd9 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -20,7 +20,7 @@ func NewRegistry() *Registry {
 		plugins:   make(map[string]Root),
 	}
 	r.setID("/")
-	r.TurnOffCaching()
+	r.DisableDefaultCaching()
 
 	return r
 }
diff --git a/plugin/types.go b/plugin/types.go
index c6d7f02..3e45890 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -16,8 +16,6 @@ The Group interface identifies the resource as a container for other things. Imp
 enables displaying it as a directory in the filesystem. Anything that does not implement
 Group will be displayed as a file.
 
-The File interface allows control over its filesystem attributes.
-
 The Readable interface gives a file its contents when read via the filesystem.
 
 All of the above, as well as other types - Resource, Execable, Pipe - provide
@@ -39,12 +37,10 @@ import (
 // creating your plugin objects.
 type Entry interface {
 	Name() string
-	ID() string
-	SetTTLOf(op actionOpCode, ttl time.Duration)
-	TurnOffCachingFor(op actionOpCode)
-	TurnOffCaching()
-	getTTLOf(op actionOpCode) time.Duration
+	Attr(ctx context.Context) (Attributes, error)
+	id() string
 	setID(id string)
+	getTTLOf(op actionOpCode) time.Duration
 }
 
 // MetadataMap maps keys to arbitrary structured data.
@@ -96,12 +92,6 @@ type Execable interface {
 	Exec(ctx context.Context, cmd string, args []string, opts ExecOptions) (ExecResult, error)
 }
 
-// File is an entry that specifies filesystem attributes.
-type File interface {
-	Entry
-	Attr() Attributes
-}
-
 // Pipe is an entry that returns a stream of updates.
 type Pipe interface {
 	Entry
diff --git a/volume/dir.go b/volume/dir.go
index 92f0410..c7ea7a9 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -37,8 +37,8 @@ func NewDir(name string, attr plugin.Attributes, cb ContentCB, path string, dirs
 }
 
 // Attr returns the attributes of the directory.
-func (v *Dir) Attr() plugin.Attributes {
-	return v.attr
+func (v *Dir) Attr(ctx context.Context) (plugin.Attributes, error) {
+	return v.attr, nil
 }
 
 // List lists the children of the directory.
diff --git a/volume/file.go b/volume/file.go
index 4879e1a..7fa255b 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -32,8 +32,8 @@ func NewFile(name string, attr plugin.Attributes, cb ContentCB, path string) *Fi
 }
 
 // Attr returns the attributes of the file.
-func (v *File) Attr() plugin.Attributes {
-	return v.attr
+func (v *File) Attr(ctx context.Context) (plugin.Attributes, error) {
+	return v.attr, nil
 }
 
 // Open returns the content of the file as a SizedReader.


Test patch:
diff --git a/plugin/cache_test.go b/plugin/cache_test.go
index 145c9fc..b40fbb9 100644
--- a/plugin/cache_test.go
+++ b/plugin/cache_test.go
@@ -68,24 +68,34 @@ func (suite *CacheTestSuite) opKeysRegex(path string) *regexp.Regexp {
 	return rx
 }
 
+func (suite *CacheTestSuite) TestOpNameRegex() {
+	suite.Regexp(opNameRegex, "a")
+	suite.Regexp(opNameRegex, "A")
+	suite.Regexp(opNameRegex, "op")
+	suite.Regexp(opNameRegex, "Op")
+	suite.Regexp(opNameRegex, "List")
+	suite.Regexp(opNameRegex, "Open")
+	suite.Regexp(opNameRegex, "Metadata")
+
+	suite.NotRegexp(opNameRegex, "")
+	suite.NotRegexp(opNameRegex, " op")
+	suite.NotRegexp(opNameRegex, "123")
+	suite.NotRegexp(opNameRegex, "abc  ")
+}
+
 func (suite *CacheTestSuite) TestOpKeysRegex() {
 	rx := suite.opKeysRegex("/a")
 
-	// Test that it matches all of the op keys
-	suite.Regexp(rx, "List::/a")
-	suite.Regexp(rx, "Open::/a")
-	suite.Regexp(rx, "Metadata::/a")
-
 	// Test that it matches children
-	suite.Regexp(rx, "List::/a/b")
-	suite.Regexp(rx, "List::/a/b/c")
-	suite.Regexp(rx, "List::/a/bcd/ef/g")
-	suite.Regexp(rx, "List::/a/a space")
+	suite.Regexp(rx, "Test::/a/b")
+	suite.Regexp(rx, "Test::/a/b/c")
+	suite.Regexp(rx, "Test::/a/bcd/ef/g")
+	suite.Regexp(rx, "Test::/a/a space")
 
 	// Test that it does not match other entries
-	suite.NotRegexp(rx, "List::/")
-	suite.NotRegexp(rx, "List::/ab")
-	suite.NotRegexp(rx, "List::/bc/d")
+	suite.NotRegexp(rx, "Test::/")
+	suite.NotRegexp(rx, "Test::/ab")
+	suite.NotRegexp(rx, "Test::/bc/d")
 
 	// Test that it matches root, and children of root
 	rx = suite.opKeysRegex("/")
@@ -142,6 +152,9 @@ func (suite *CacheTestSuite) TestCachedOp() {
 		}
 	}
 
+	// Test that CachedOp panics if opName does not match opNameRegex
+	suite.Panics(makePanicFunc("123", 15), fmt.Sprintf("The opName 123 does not match %v", opNameRegex.String()))
+
 	// Test that CachedOp panics if an opName == an action op's name
 	suite.Panics(makePanicFunc("List", 15), "The opName List conflicts with CachedList")
 
@@ -153,8 +166,8 @@ func (suite *CacheTestSuite) TestCachedOp() {
 	suite.Panics(makePanicFunc("Op", 15))
 	SetTestCache(suite.cache)
 
-	// Test that CachedOp panics if entry.ID() == ""
-	suite.Panics(makePanicFunc("Op", 15), "entry.ID() returned an empty ID")
+	// Test that CachedOp panics if entry.id() == ""
+	suite.Panics(makePanicFunc("Op", 15), "entry.id() returned an empty ID")
 
 	// Test that CachedOp calls cache#GetOrUpdate with the right parameters
 	entry := newCacheTestsMockEntry("mock")
@@ -184,15 +197,15 @@ func (suite *CacheTestSuite) testCachedActionOp(op actionOpCode, opName string,
 	suite.Panics(panicFunc)
 	SetTestCache(suite.cache)
 
-	// Test that cachedActionOp panics if entry.ID() == ""
-	suite.Panics(panicFunc, "entry.ID() returned an empty ID")
+	// Test that cachedActionOp panics if entry.id() == ""
+	suite.Panics(panicFunc, "entry.id() returned an empty ID")
 
 	// Test that cachedActionOp does _not_ call cache#GetOrUpdate for an
 	// entry that's turned off caching
 	entry := newCacheTestsMockEntry("mock")
 	entry.SetTestID("id")
 	entry.On(opName, ctx).Return(mockValue, nil)
-	entry.TurnOffCachingFor(op)
+	entry.DisableCachingFor(op)
 	v, err := cachedActionOp(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(mockValue, v)
@@ -232,26 +245,26 @@ func (suite *CacheTestSuite) TestCachedList() {
 	// When the parent's the root
 	entry := newCacheTestsMockEntry("/")
 	entry.SetTestID("/")
-	entry.TurnOffCaching()
+	entry.DisableDefaultCaching()
 	entry.On("List", ctx).Return(mockChildren, nil).Once()
 	children, err := CachedList(ctx, entry)
 	if suite.NoError(err) {
 		if suite.Equal(mockChildren, children) {
-			suite.Equal("/child1", children[0].ID())
-			suite.Equal("/child2", children[1].ID())
+			suite.Equal("/child1", children[0].id())
+			suite.Equal("/child2", children[1].id())
 		}
 	}
 
 	// When the parent's some other entry
 	entry = newCacheTestsMockEntry("parent")
 	entry.SetTestID("/parent")
-	entry.TurnOffCaching()
+	entry.DisableDefaultCaching()
 	entry.On("List", ctx).Return(mockChildren, nil).Once()
 	children, err = CachedList(ctx, entry)
 	if suite.NoError(err) {
 		if suite.Equal(mockChildren, children) {
-			suite.Equal("/parent/child1", children[0].ID())
-			suite.Equal("/parent/child2", children[1].ID())
+			suite.Equal("/parent/child1", children[0].id())
+			suite.Equal("/parent/child2", children[1].id())
 		}
 	}
 }
diff --git a/plugin/entryBase_test.go b/plugin/entryBase_test.go
index c25cc49..0cf44cb 100644
--- a/plugin/entryBase_test.go
+++ b/plugin/entryBase_test.go
@@ -1,6 +1,7 @@
 package plugin
 
 import (
+	"context"
 	"testing"
 	"time"
 
@@ -40,17 +41,27 @@ func (suite *EntryBaseTestSuite) TestNewEntry() {
 	assertOpTTL(Metadata, "Metadata", 15*time.Second)
 
 	e.setID("/foo")
-	suite.Equal("/foo", e.ID())
+	suite.Equal("/foo", e.id())
 
 	e.SetTTLOf(List, 40*time.Second)
 	assertOpTTL(List, "List", 40*time.Second)
 
-	e.TurnOffCachingFor(List)
+	e.DisableCachingFor(List)
 	assertOpTTL(List, "List", -1)
 
-	e.TurnOffCaching()
+	e.DisableDefaultCaching()
 	assertOpTTL(Open, "Open", -1)
 	assertOpTTL(Metadata, "Metadata", -1)
+
+	ctx := context.Background()
+	attr, err := e.Attr(ctx)
+	if suite.NoError(err) {
+		expectedAttr := Attributes{
+			Size: SizeUnknown,
+		}
+
+		suite.Equal(expectedAttr, attr)
+	}
 }
 
 func TestEntryBase(t *testing.T) {
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index 3edb668..6a9250c 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -165,7 +165,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 
 	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.id(), entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then List returns its error
@@ -209,7 +209,7 @@ func (suite *ExternalPluginEntryTestSuite) TestOpen() {
 
 	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.id(), entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then Open returns its error
@@ -238,7 +238,7 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata() {
 
 	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.id(), entry.state).Return(stdout, err).Once()
 	}
 
 	// Test that if InvokeAndWait errors, then Metadata returns its error
@@ -265,7 +265,8 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata() {
 
 func (suite *ExternalPluginEntryTestSuite) TestAttr() {
 	entry := externalPluginEntry{attr: Attributes{Size: 10}}
-	suite.Equal(entry.attr, entry.Attr())
+	attr, _ := entry.Attr(context.Background())
+	suite.Equal(entry.attr, attr)
 }
 
 // TODO: Add tests for stdoutStreamer, Stream and Exec
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 41f217b..b788f7b 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -114,14 +114,14 @@ func newHelpersTestsMockEntry() *helpersTestsMockEntry {
 		EntryBase: NewEntry("mockEntry"),
 	}
 	e.SetTestID("id")
-	e.TurnOffCaching()
+	e.DisableDefaultCaching()
 
 	return e
 }
 
-func (e *helpersTestsMockEntry) Attr() Attributes {
-	args := e.Called()
-	return args.Get(0).(Attributes)
+func (e *helpersTestsMockEntry) Attr(ctx context.Context) (Attributes, error) {
+	args := e.Called(ctx)
+	return args.Get(0).(Attributes), args.Error(1)
 }
 
 type helpersTestsMockGroup struct {
@@ -134,7 +134,7 @@ func newHelpersTestsMockGroup() *helpersTestsMockGroup {
 		EntryBase: NewEntry("mockEntry"),
 	}
 	e.SetTestID("id")
-	e.TurnOffCaching()
+	e.DisableDefaultCaching()
 
 	return e
 }
@@ -144,7 +144,9 @@ func (e *helpersTestsMockGroup) List(ctx context.Context) ([]Entry, error) {
 	return args.Get(0).([]Entry), args.Error(1)
 }
 
-func (suite *HelpersTestSuite) TestFillAttrAnyEntry() {
+func (suite *HelpersTestSuite) TestAttrAnyEntry() {
+	ctx := context.Background()
+
 	// Test that Attr()'s result is used if the entry implements it
 	// and if entry.Attr() returns a non-zero set of attributes
 	entry := newHelpersTestsMockEntry()
@@ -153,41 +155,38 @@ func (suite *HelpersTestSuite) TestFillAttrAnyEntry() {
 		Size:  10,
 		Mode:  0777,
 	}
-	entry.On("Attr").Return(expectedAttributes)
-	attr := Attributes{}
-	err := FillAttr(context.Background(), entry, &attr)
+	entry.On("Attr", ctx).Return(expectedAttributes, nil)
+	attr, err := Attr(context.Background(), entry)
 	if suite.NoError(err) {
 		suite.Equal(
 			expectedAttributes,
 			attr,
-			"FillAttr should use the entry's filesystem attributes if they're provided",
+			"Attr should use the entry's filesystem attributes if they're provided",
 		)
 	}
 
 	// Test that if the entry supports the list action, then it sets
 	// the mode to 0550
 	group := newHelpersTestsMockGroup()
-	attr = Attributes{}
-	err = FillAttr(context.Background(), group, &attr)
+	attr, err = Attr(ctx, group)
 	if suite.NoError(err) {
 		suite.Equal(
 			Attributes{Mode: os.ModeDir | 0550, Size: SizeUnknown},
 			attr,
-			"FillAttr does not set the default mode to os.ModeDir | 0550 for entries that support the list action",
+			"Attr does not set the default mode to os.ModeDir | 0550 for entries that support the list action",
 		)
 	}
 
 	// Test that if the entry does not support the list action, then it sets
 	// the mode to 0440
 	entry = newHelpersTestsMockEntry()
-	entry.On("Attr").Return(Attributes{})
-	attr = Attributes{}
-	err = FillAttr(context.Background(), entry, &attr)
+	entry.On("Attr", ctx).Return(Attributes{}, nil)
+	attr, err = Attr(context.Background(), entry)
 	if suite.NoError(err) {
 		suite.Equal(
-			Attributes{Mode: 0440, Size: SizeUnknown},
+			Attributes{Mode: 0440},
 			attr,
-			"FillAttr does not set the default mode to 0440 for entries that do notsupport the list action",
+			"Attr does not set the default mode to 0440 for entries that do notsupport the list action",
 		)
 	}
 }
@@ -196,12 +195,12 @@ type helpersTestsMockReadableEntry struct {
 	*helpersTestsMockEntry
 }
 
-func newHelpersTestsMockReadableEntry(attr Attributes) *helpersTestsMockReadableEntry {
+func newHelpersTestsMockReadableEntry(ctx context.Context, attr Attributes) *helpersTestsMockReadableEntry {
 	e := &helpersTestsMockReadableEntry{
 		newHelpersTestsMockEntry(),
 	}
 
-	e.On("Attr").Return(attr)
+	e.On("Attr", ctx).Return(attr, nil)
 	return e
 }
 
@@ -220,57 +219,53 @@ func (r negativeSizedReader) Size() int64 {
 	return -1
 }
 
-func (suite *HelpersTestSuite) TestFillAttrReadableEntry() {
+func (suite *HelpersTestSuite) TestAttrReadableEntry() {
 	ctx := context.Background()
 	mockRdr := strings.NewReader("foo")
 
 	// Test that the size attribute is calculated from the content
 	// if it is not known
-	entry := newHelpersTestsMockReadableEntry(Attributes{})
+	entry := newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
 	entry.On("Open", ctx).Return(mockRdr, nil)
-	attr := Attributes{}
-	err := FillAttr(ctx, entry, &attr)
+	attr, err := Attr(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(
 			uint64(mockRdr.Size()),
 			attr.Size,
-			"FillAttr should calculate the size attribute from the entry's content if its value is SizeUnknown",
+			"Attr should calculate the size attribute from the entry's content if its value is SizeUnknown",
 		)
 	}
 
 	// Test that the size attribute is _not_ calculated from the content
 	// if it is already known
-	entry = newHelpersTestsMockReadableEntry(Attributes{Size: 10})
+	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: 10})
 	entry.On("Open", ctx).Return(mockRdr, nil)
-	attr = Attributes{}
-	err = FillAttr(ctx, entry, &attr)
+	attr, err = Attr(ctx, entry)
 	if suite.NoError(err) {
 		suite.Equal(
 			uint64(10),
 			attr.Size,
-			"FillAttr should _not_ calculate the size attribute from the entry's content if it is already known",
+			"Attr should _not_ calculate the size attribute from the entry's content if it is already known",
 		)
 	}
 
-	// Test that FillAttr returns an ErrCouldNotDetermineSizeAttr error if
+	// Test that Attr returns an ErrCouldNotDetermineSizeAttr error if
 	// Open errors, and that it still proceeds to fill-in the mode for FUSE
-	entry = newHelpersTestsMockReadableEntry(Attributes{})
+	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
 	entry.On("Open", ctx).Return(mockRdr, fmt.Errorf("could not open"))
-	attr = Attributes{}
-	err = FillAttr(ctx, entry, &attr)
+	attr, err = Attr(ctx, entry)
 	suite.EqualError(err, ErrCouldNotDetermineSizeAttr{"could not open"}.Error())
 	suite.Equal(
 		os.FileMode(0440),
 		attr.Mode,
-		"FillAttr should fill-in the Mode attribute for FUSE even when Open errors",
+		"Attr should fill-in the Mode attribute for FUSE even when Open errors",
 	)
 
-	// Test that FillAttr returns an ErrNegativeSizeAttr error if the content
+	// Test that Attr returns an ErrNegativeSizeAttr error if the content
 	// has negative size
-	entry = newHelpersTestsMockReadableEntry(Attributes{})
+	entry = newHelpersTestsMockReadableEntry(ctx, Attributes{Size: SizeUnknown})
 	entry.On("Open", ctx).Return(negativeSizedReader{}, nil)
-	attr = Attributes{}
-	err = FillAttr(ctx, entry, &attr)
+	attr, err = Attr(ctx, entry)
 	suite.EqualError(err, ErrNegativeSizeAttr{-1}.Error())
 }
 
diff --git a/volume/dir_test.go b/volume/dir_test.go
index f0593b7..e4d27c1 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -19,7 +19,8 @@ func TestVolumeDir(t *testing.T) {
 
 	assert.NotNil(t, dmap[""]["path"])
 	vd := NewDir("path", dmap[""]["path"], contentcb, "/path", dmap)
-	assert.Equal(t, 0755|os.ModeDir, vd.Attr().Mode)
+	attr, _ := vd.Attr(context.Background())
+	assert.Equal(t, 0755|os.ModeDir, attr.Mode)
 
 	assert.NotNil(t, dmap[""]["path1"])
 	vd = NewDir("path", dmap[""]["path1"], contentcb, "/path1", dmap)
diff --git a/volume/file_test.go b/volume/file_test.go
index 216b4dd..3991111 100644
--- a/volume/file_test.go
+++ b/volume/file_test.go
@@ -18,7 +18,8 @@ func TestVolumeFile(t *testing.T) {
 		return strings.NewReader("hello"), nil
 	}, "my path")
 
-	assert.Equal(t, plugin.Attributes{Ctime: now}, vf.Attr())
+	attr, _ := vf.Attr(context.Background())
+	assert.Equal(t, plugin.Attributes{Ctime: now}, attr)
 	rdr, err := vf.Open(context.Background())
 	assert.Nil(t, err)
 	if assert.NotNil(t, rdr) {