Repository: puppetlabs/wash (go)

Issue:
Find and view log files for services on a VM
Mount `/var/log` on targets to `<vm>/fs/var/log`.

The default method is to implement over exec. Add a helper to make that behavior easy to opt into for anything that implements `Group` and `Execable`. For other modes, such as downloading files using Docker's API, plugins will need to implement that behavior themselves.

Pull request:
Find and view log files on execable resources
Adds a `volume.FS` Entry-type helper for listing a remote volume on things that are Execable. Currently only exposes `/var/log` to keep performance reasonable for systems that have a lot of files.

Resolves #216.

Signed-off-by: Michael Smith <michael.smith@puppet.com>

Patch:
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index b99a82a..219ad9a 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -17,6 +17,7 @@ import (
 	"github.com/kballard/go-shellquote"
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/volume"
 )
 
 // ec2Instance represents an EC2 instance
@@ -185,6 +186,9 @@ func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
 		entries = append(entries, latestConsoleOutput)
 	}
 
+	// Include a view of the remote filesystem using volume.FS
+	entries = append(entries, volume.NewFS("fs", inst))
+
 	return entries, nil
 }
 
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index 3bba64c..0a1793a 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -10,6 +10,7 @@ import (
 	"github.com/docker/docker/pkg/stdcopy"
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
+	vol "github.com/puppetlabs/wash/volume"
 )
 
 type container struct {
@@ -51,7 +52,8 @@ func (c *container) List(ctx context.Context) ([]plugin.Entry, error) {
 	clfAttr.SetSize(uint64(content.Size()))
 	clf.SetAttributes(clfAttr)
 
-	return []plugin.Entry{cm, clf}, nil
+	// Include a view of the remote filesystem using volume.FS
+	return []plugin.Entry{cm, clf, vol.NewFS("fs", c)}, nil
 }
 
 func (c *container) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index 1da16f3..d8319e6 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -133,7 +133,7 @@ func (v *volume) VolumeList(ctx context.Context) (vol.DirMap, error) {
 	return vol.StatParseAll(output, mountpoint)
 }
 
-func (v *volume) VolumeRead(ctx context.Context, path string) (plugin.SizedReader, error) {
+func (v *volume) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
 	// Create a container that mounts a volume and waits. Use it to download a file.
 	cid, err := v.createContainer(ctx, []string{"sleep", "60"})
 	if err != nil {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index 1330539..d2b18d8 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -170,7 +170,7 @@ func (v *pvc) VolumeList(ctx context.Context) (volume.DirMap, error) {
 	return volume.StatParseAll(output, mountpoint)
 }
 
-func (v *pvc) VolumeRead(ctx context.Context, path string) (plugin.SizedReader, error) {
+func (v *pvc) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
 	// Create a container that mounts a pvc and waits. Use it to download a file.
 	pid, err := v.createPod([]string{"cat", mountpoint + path})
 	activity.Record(ctx, "Reading from: %v", mountpoint+path)
diff --git a/plugin/types.go b/plugin/types.go
index b4fa2cd..c487231 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -31,9 +31,14 @@ import (
 	"time"
 )
 
-// Entry is a basic named resource type. It is a sealed
-// interface, meaning you must use plugin.NewEntry when
+// Entry is the interface for things that are representable by Wash's filesystem. This includes
+// plugin roots; resources like containers and volumes; placeholders (e.g. the containers directory
+// in the Docker plugin); read-only files like the metadata.json files for containers and EC2
+// instances; and more. It is a sealed interface, meaning you must use plugin.NewEntry when
 // creating your plugin objects.
+//
+// Metadata returns a complete description of the entry. See the EntryBase documentation for more
+// details on when to override it.
 type Entry interface {
 	Metadata(ctx context.Context) (EntryMetadata, error)
 	name() string
@@ -64,6 +69,7 @@ type ExecOptions struct {
 }
 
 // ExecOutputChunk is a struct containing a chunk of the Exec'ed cmd's output.
+// For StreamID, 0 = stdout and 1 = stderr.
 type ExecOutputChunk struct {
 	StreamID  int8
 	Timestamp time.Time
@@ -72,7 +78,7 @@ type ExecOutputChunk struct {
 }
 
 // ExecResult is a struct that contains the result of invoking Execable#exec.
-// Any of these fields can be nil.
+// Any of these fields can be nil. The OutputCh will be closed when execution completes.
 type ExecResult struct {
 	OutputCh   <-chan ExecOutputChunk
 	ExitCodeCB func() (int, error)
diff --git a/volume/core.go b/volume/core.go
index 5b284f1..4d0a10f 100644
--- a/volume/core.go
+++ b/volume/core.go
@@ -7,130 +7,36 @@
 package volume
 
 import (
-	"bufio"
 	"context"
-	"fmt"
-	"io"
-	"path"
-	"strconv"
-	"strings"
+	"sort"
 	"time"
 
-	"github.com/puppetlabs/wash/munge"
 	"github.com/puppetlabs/wash/plugin"
 )
 
 // Interface presents methods to access the volume.
+//
+// Method names for this interface are chosen to make it simple to distinguish them from
+// methods implemented to satisfy plugin interfaces.
 type Interface interface {
 	plugin.Entry
 
 	// Returns a map of volume nodes to their stats, such as that returned by StatParseAll.
 	VolumeList(context.Context) (DirMap, error)
 	// Accepts a path and returns the content associated with that path.
-	VolumeRead(context.Context, string) (plugin.SizedReader, error)
-}
-
-// StatCmd returns the command required to stat all the files in a directory.
-func StatCmd(path string) []string {
-	// size, atime, mtime, ctime, mode, name
-	// %s - Total size, in bytes
-	// %X - Time of last access as seconds since Epoch
-	// %Y - Time of last data modification as seconds since Epoch
-	// %Z - Time of last status change as seconds since Epoch
-	// %f - Raw mode in hex
-	// %n - File name
-	return []string{"find", path, "-mindepth", "1", "-exec", "stat", "-c", "%s %X %Y %Z %f %n", "{}", "+"}
+	VolumeOpen(context.Context, string) (plugin.SizedReader, error)
+	// TODO: add VolumeStream
 }
 
-// Keep as its own specialized function as it will be faster than munge.ToTime.
-func parseTime(t string) (time.Time, error) {
-	epoch, err := strconv.ParseInt(t, 10, 64)
-	if err != nil {
-		return time.Time{}, err
-	}
-	return time.Unix(epoch, 0), nil
-}
-
-// StatParse parses a single line of the output of StatCmd into EntryAttributes and a name.
-func StatParse(line string) (plugin.EntryAttributes, string, error) {
-	var attr plugin.EntryAttributes
-	segments := strings.SplitN(line, " ", 6)
-	if len(segments) != 6 {
-		return attr, "", fmt.Errorf("Stat did not return 6 components: %v", line)
-	}
-
-	size, err := strconv.ParseUint(segments[0], 10, 64)
-	if err != nil {
-		return attr, "", err
-	}
-	attr.SetSize(size)
-
-	atime, err := parseTime(segments[1])
-	if err != nil {
-		return attr, "", err
-	}
-	attr.SetAtime(atime)
-
-	mtime, err := parseTime(segments[2])
-	if err != nil {
-		return attr, "", err
-	}
-	attr.SetMtime(mtime)
+// A Dir is a map of files in a directory to their attributes.
+type Dir = map[string]plugin.EntryAttributes
 
-	ctime, err := parseTime(segments[3])
-	if err != nil {
-		return attr, "", err
-	}
-	attr.SetCtime(ctime)
-
-	mode, err := munge.ToFileMode("0x" + segments[4])
-	if err != nil {
-		return attr, "", err
-	}
-	attr.SetMode(mode)
-
-	return attr, segments[5], nil
-}
-
-// A DirMap is a map of directory names to a map of their children and the children's attributes.
-type DirMap = map[string]map[string]plugin.EntryAttributes
-
-// StatParseAll an output stream that is the result of running StatCmd. Strips 'base' from the
-// file paths, and maps each directory to a map of files in that directory and their attr
-// (attributes).
-func StatParseAll(output io.Reader, base string) (DirMap, error) {
-	scanner := bufio.NewScanner(output)
-	// Create lookup table for directories to contents, and prepopulate the root entry because
-	// the mount point won't be included in the stat output.
-	attrs := DirMap{"": make(map[string]plugin.EntryAttributes)}
-	for scanner.Scan() {
-		text := strings.TrimSpace(scanner.Text())
-		if text != "" {
-			attr, fullpath, err := StatParse(text)
-			if err != nil {
-				return nil, err
-			}
-
-			relative := strings.TrimPrefix(fullpath, base)
-			// Create an entry for each directory.
-			if attr.Mode().IsDir() {
-				attrs[relative] = make(map[string]plugin.EntryAttributes)
-			}
-
-			// Add each entry to its parent's listing.
-			parent, file := path.Split(relative)
-			parent = strings.TrimSuffix(parent, "/")
-			attrs[parent][file] = attr
-		}
-	}
-	if err := scanner.Err(); err != nil {
-		return nil, err
-	}
-	return attrs, nil
-}
+// A DirMap is a map of directory names to a map of their directory contents.
+type DirMap = map[string]Dir
 
 // List constructs an array of entries for the given path from a DirMap.
-// The root path is an empty string.
+// The root path is an empty string. Requests are cached against the supplied Interface
+// using the VolumeListCB op.
 func List(ctx context.Context, impl Interface, path string) ([]plugin.Entry, error) {
 	result, err := plugin.CachedOp(ctx, "VolumeListCB", impl, 30*time.Second, func() (interface{}, error) {
 		return impl.VolumeList(ctx)
@@ -145,8 +51,10 @@ func List(ctx context.Context, impl Interface, path string) ([]plugin.Entry, err
 		if attr.Mode().IsDir() {
 			entries = append(entries, newDir(name, attr, impl, path+"/"+name))
 		} else {
-			entries = append(entries, newFile(name, attr, impl.VolumeRead, path+"/"+name))
+			entries = append(entries, newFile(name, attr, impl.VolumeOpen, path+"/"+name))
 		}
 	}
+	// Sort entries so they have a deterministic order.
+	sort.Slice(entries, func(i, j int) bool { return plugin.Name(entries[i]) < plugin.Name(entries[j]) })
 	return entries, nil
 }
diff --git a/volume/dir.go b/volume/dir.go
index 50cd994..a9ff5e7 100644
--- a/volume/dir.go
+++ b/volume/dir.go
@@ -7,16 +7,16 @@ import (
 	"github.com/puppetlabs/wash/plugin"
 )
 
-// Dir represents a directory in a volume. It populates a subtree with listcb as needed.
-type Dir struct {
+// dir represents a directory in a volume. It populates a subtree with listcb as needed.
+type dir struct {
 	plugin.EntryBase
 	impl Interface
 	path string
 }
 
-// newDir creates a Dir populated from dirs.
-func newDir(name string, attr plugin.EntryAttributes, impl Interface, path string) *Dir {
-	vd := &Dir{
+// newDir creates a dir populated from dirs.
+func newDir(name string, attr plugin.EntryAttributes, impl Interface, path string) *dir {
+	vd := &dir{
 		EntryBase: plugin.NewEntry(name),
 		impl:      impl,
 		path:      path,
@@ -30,6 +30,6 @@ func newDir(name string, attr plugin.EntryAttributes, impl Interface, path strin
 }
 
 // List lists the children of the directory.
-func (v *Dir) List(ctx context.Context) ([]plugin.Entry, error) {
+func (v *dir) List(ctx context.Context) ([]plugin.Entry, error) {
 	return List(ctx, v.impl, v.path)
 }
diff --git a/volume/file.go b/volume/file.go
index d164df9..b0b7cb4 100644
--- a/volume/file.go
+++ b/volume/file.go
@@ -10,16 +10,16 @@ import (
 // contentCB accepts a path and returns the content associated with that path.
 type contentCB = func(context.Context, string) (plugin.SizedReader, error)
 
-// File represents a file in a volume that has content we can access.
-type File struct {
+// file represents a file in a volume that has content we can access.
+type file struct {
 	plugin.EntryBase
 	contentcb contentCB
 	path      string
 }
 
 // newFile creates a VolumeFile.
-func newFile(name string, attr plugin.EntryAttributes, cb contentCB, path string) *File {
-	vf := &File{
+func newFile(name string, attr plugin.EntryAttributes, cb contentCB, path string) *file {
+	vf := &file{
 		EntryBase: plugin.NewEntry(name),
 		contentcb: cb,
 		path:      path,
@@ -31,6 +31,6 @@ func newFile(name string, attr plugin.EntryAttributes, cb contentCB, path string
 }
 
 // Open returns the content of the file as a SizedReader.
-func (v *File) Open(ctx context.Context) (plugin.SizedReader, error) {
+func (v *file) Open(ctx context.Context) (plugin.SizedReader, error) {
 	return v.contentcb(ctx, v.path)
 }
diff --git a/volume/fs.go b/volume/fs.go
new file mode 100644
index 0000000..3ea5373
--- /dev/null
+++ b/volume/fs.go
@@ -0,0 +1,87 @@
+package volume
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+
+	"github.com/puppetlabs/wash/activity"
+	"github.com/puppetlabs/wash/plugin"
+)
+
+// FS presents a view of the filesystem of an Execable resource.
+type FS struct {
+	plugin.EntryBase
+	executor plugin.Execable
+}
+
+// NewFS creates a new FS entry with the given name, using the supplied executor to satisfy volume
+// operations.
+func NewFS(name string, executor plugin.Execable) *FS {
+	fs := &FS{EntryBase: plugin.NewEntry(name), executor: executor}
+	// Caching handled in List.
+	fs.DisableCachingFor(plugin.ListOp)
+	return fs
+}
+
+// List will attempt to list the filesystem of an Execable resource (the executor). It will list
+// a directory tree based on supplied configuration (defaulting to `/var/log` if not specified).
+func (d *FS) List(ctx context.Context) ([]plugin.Entry, error) {
+	return List(ctx, d, "")
+}
+
+func exec(ctx context.Context, executor plugin.Execable, cmdline []string) (*bytes.Buffer, error) {
+	result, err := executor.Exec(ctx, cmdline[0], cmdline[1:], plugin.ExecOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	var buf bytes.Buffer
+	var errs []error
+	for chunk := range result.OutputCh {
+		if chunk.Err != nil {
+			errs = append(errs, chunk.Err)
+		} else if chunk.StreamID == 0 {
+			activity.Record(ctx, "Stdout: %v", chunk.Data)
+			fmt.Fprint(&buf, chunk.Data)
+		} else {
+			activity.Record(ctx, "Stderr: %v", chunk.Data)
+		}
+	}
+
+	if len(errs) > 0 {
+		return nil, fmt.Errorf("exec errored: %v", errs)
+	}
+
+	exitcode, err := result.ExitCodeCB()
+	if err != nil {
+		return nil, err
+	} else if exitcode != 0 {
+		return nil, fmt.Errorf("exec exited non-zero")
+	}
+	return &buf, nil
+}
+
+// VolumeList satisfies the Interface required by List to enumerate files.
+func (d *FS) VolumeList(ctx context.Context) (DirMap, error) {
+	cmdline := StatCmd("/var/log")
+	activity.Record(ctx, "Running %v on %v", cmdline, plugin.ID(d.executor))
+	buf, err := exec(ctx, d.executor, cmdline)
+	if err != nil {
+		activity.Record(ctx, "Exec error running %v in VolumeList: %v", cmdline, err)
+		return nil, err
+	}
+	activity.Record(ctx, "VolumeList complete")
+	return StatParseAll(buf, "")
+}
+
+// VolumeOpen satisfies the Interface required by List to read file contents.
+func (d *FS) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, error) {
+	activity.Record(ctx, "Reading %v on %v", path, plugin.ID(d.executor))
+	buf, err := exec(ctx, d.executor, []string{"cat", path})
+	if err != nil {
+		activity.Record(ctx, "Exec error running 'cat %v' in VolumeOpen: %v", path, err)
+		return nil, err
+	}
+	return bytes.NewReader(buf.Bytes()), nil
+}
diff --git a/volume/stat.go b/volume/stat.go
new file mode 100644
index 0000000..7e8a0fc
--- /dev/null
+++ b/volume/stat.go
@@ -0,0 +1,139 @@
+package volume
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/puppetlabs/wash/munge"
+	"github.com/puppetlabs/wash/plugin"
+)
+
+// StatCmd returns the command required to stat all the files in a directory.
+func StatCmd(path string) []string {
+	// size, atime, mtime, ctime, mode, name
+	// %s - Total size, in bytes
+	// %X - Time of last access as seconds since Epoch
+	// %Y - Time of last data modification as seconds since Epoch
+	// %Z - Time of last status change as seconds since Epoch
+	// %f - Raw mode in hex
+	// %n - File name
+	return []string{"find", path, "-mindepth", "1", "-exec", "stat", "-c", "%s %X %Y %Z %f %n", "{}", "+"}
+}
+
+// Keep as its own specialized function as it will be faster than munge.ToTime.
+func parseTime(t string) (time.Time, error) {
+	epoch, err := strconv.ParseInt(t, 10, 64)
+	if err != nil {
+		return time.Time{}, err
+	}
+	return time.Unix(epoch, 0), nil
+}
+
+// StatParse parses a single line of the output of StatCmd into EntryAttributes and a path.
+func StatParse(line string) (plugin.EntryAttributes, string, error) {
+	var attr plugin.EntryAttributes
+	segments := strings.SplitN(line, " ", 6)
+	if len(segments) != 6 {
+		return attr, "", fmt.Errorf("Stat did not return 6 components: %v", line)
+	}
+
+	size, err := strconv.ParseUint(segments[0], 10, 64)
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetSize(size)
+
+	atime, err := parseTime(segments[1])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetAtime(atime)
+
+	mtime, err := parseTime(segments[2])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetMtime(mtime)
+
+	ctime, err := parseTime(segments[3])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetCtime(ctime)
+
+	mode, err := munge.ToFileMode("0x" + segments[4])
+	if err != nil {
+		return attr, "", err
+	}
+	attr.SetMode(mode)
+
+	return attr, segments[5], nil
+}
+
+// Ensure the directory at newpath - and its parents - all exist. Return the contents of that
+// directory. The directory may not exist if we're parsing stat output for a basepath that's closer
+// to the root directory than where we searched because we want to preserve some of the hierarchy.
+func makedirs(dirmap DirMap, newpath string) Dir {
+	// If it exists, return the children map. Base case would be newpath == "", which we create at
+	// the start of StatParseAll.
+	if newchildren, ok := dirmap[newpath]; ok {
+		return newchildren
+	}
+
+	// Create the attributes map for the new path.
+	newchildren := make(Dir)
+	dirmap[newpath] = newchildren
+
+	// Check if we need to create the parent, and get its attributes map.
+	parent, file := path.Split(newpath)
+	parent = strings.TrimSuffix(parent, "/")
+	parentchildren := makedirs(dirmap, parent)
+
+	// Add attributes for the new path to the parent's attributes map. Then return the new map.
+	attr := plugin.EntryAttributes{}
+	attr.SetMode(os.ModeDir | 0550)
+	parentchildren[file] = attr
+	return newchildren
+}
+
+// StatParseAll an output stream that is the result of running StatCmd. Strips 'base' from the
+// file paths, and maps each directory to a map of files in that directory and their attr
+// (attributes).
+func StatParseAll(output io.Reader, base string) (DirMap, error) {
+	scanner := bufio.NewScanner(output)
+	// Create lookup table for directories to contents, and prepopulate the root entry because
+	// the mount point won't be included in the stat output.
+	dirmap := DirMap{"": make(Dir)}
+	for scanner.Scan() {
+		text := strings.TrimSpace(scanner.Text())
+		if text != "" {
+			attr, fullpath, err := StatParse(text)
+			if err != nil {
+				return nil, err
+			}
+
+			relative := strings.TrimPrefix(fullpath, base)
+			// Create an entry for each directory.
+			if attr.Mode().IsDir() {
+				dirmap[relative] = make(Dir)
+			}
+
+			// Add each entry to its parent's listing.
+			parent, file := path.Split(relative)
+			parent = strings.TrimSuffix(parent, "/")
+			parentchildren := makedirs(dirmap, parent)
+			// Attr + path represents a volume dir or file.
+			parentchildren[file] = attr
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+	return dirmap, nil
+}


Test patch:
diff --git a/volume/dir_test.go b/volume/dir_test.go
index bd542cc..f9a7adc 100644
--- a/volume/dir_test.go
+++ b/volume/dir_test.go
@@ -20,7 +20,7 @@ func (m *mockEntry) VolumeList(context.Context) (DirMap, error) {
 	return m.dmap, nil
 }
 
-func (m *mockEntry) VolumeRead(context.Context, string) (plugin.SizedReader, error) {
+func (m *mockEntry) VolumeOpen(context.Context, string) (plugin.SizedReader, error) {
 	return nil, nil
 }
 
@@ -43,7 +43,7 @@ func TestVolumeDir(t *testing.T) {
 	assert.Nil(t, err)
 	assert.Equal(t, 1, len(entries))
 	assert.Equal(t, "a file", plugin.Name(entries[0]))
-	if entry, ok := entries[0].(*File); assert.Equal(t, true, ok) {
+	if entry, ok := entries[0].(*file); assert.Equal(t, true, ok) {
 		assert.Equal(t, "/path1/a file", entry.path)
 	}
 
@@ -53,7 +53,7 @@ func TestVolumeDir(t *testing.T) {
 	assert.Nil(t, err)
 	assert.Equal(t, 1, len(entries))
 	assert.Equal(t, "dir", plugin.Name(entries[0]))
-	if entry, ok := entries[0].(*Dir); assert.Equal(t, true, ok) {
+	if entry, ok := entries[0].(*dir); assert.Equal(t, true, ok) {
 		assert.Equal(t, "/path2/dir", entry.path)
 	}
 
diff --git a/volume/fs_test.go b/volume/fs_test.go
new file mode 100644
index 0000000..3bad252
--- /dev/null
+++ b/volume/fs_test.go
@@ -0,0 +1,151 @@
+package volume
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/puppetlabs/wash/datastore"
+	"github.com/puppetlabs/wash/plugin"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/suite"
+)
+
+// Represents the output of StatCmd(/var/log)
+const varLogFixture = `
+96 1550611510 1550611448 1550611448 41ed /var/log/path
+96 1550611510 1550611448 1550611448 41ed /var/log/path/has
+96 1550611510 1550611448 1550611448 41ed /var/log/path/has/got
+96 1550611510 1550611458 1550611458 41ed /var/log/path/has/got/some
+0 1550611458 1550611458 1550611458 81a4 /var/log/path/has/got/some/legs
+96 1550611510 1550611453 1550611453 41ed /var/log/path1
+0 1550611453 1550611453 1550611453 81a4 /var/log/path1/a file
+96 1550611510 1550611441 1550611441 41ed /var/log/path2
+64 1550611510 1550611441 1550611441 41ed /var/log/path2/dir
+`
+
+type fsTestSuite struct {
+	suite.Suite
+}
+
+func (suite *fsTestSuite) SetupSuite() {
+	plugin.SetTestCache(datastore.NewMemCache())
+}
+
+func (suite *fsTestSuite) TearDownSuite() {
+	plugin.UnsetTestCache()
+}
+
+func createResult(data string) plugin.ExecResult {
+	outputch := make(chan plugin.ExecOutputChunk, 1)
+	execResult := plugin.ExecResult{
+		OutputCh:   outputch,
+		ExitCodeCB: func() (int, error) { return 0, nil },
+	}
+	outputch <- plugin.ExecOutputChunk{Data: data}
+	close(outputch)
+	return execResult
+}
+
+func createExec() *mockExecutor {
+	exec := &mockExecutor{EntryBase: plugin.NewEntry("instance")}
+	// Used when recording activity.
+	exec.SetTestID("/instance")
+	cmd := StatCmd("/var/log")
+	exec.On("Exec", mock.Anything, cmd[0], cmd[1:], plugin.ExecOptions{}).Return(createResult(varLogFixture), nil)
+	return exec
+}
+
+func (suite *fsTestSuite) find(grp plugin.Group, path string) plugin.Entry {
+	names := strings.Split(path, "/")
+	entry := plugin.Entry(grp)
+	for _, name := range names {
+		entries, err := entry.(plugin.Group).List(context.Background())
+		if !suite.NoError(err) {
+			suite.FailNow("Listing entries failed")
+		}
+		for _, match := range entries {
+			if plugin.Name(match) == name {
+				entry = match
+				break
+			}
+		}
+	}
+	suite.Equal(names[len(names)-1], plugin.Name(entry))
+	return entry
+}
+
+func (suite *fsTestSuite) TestFSList() {
+	exec := createExec()
+	fs := NewFS("fs", exec)
+	// ID would normally be set when listing FS within the parent instance.
+	fs.SetTestID("/instance/fs")
+
+	entry := suite.find(fs, "var/log").(plugin.Group)
+	entries, err := entry.List(context.Background())
+	if !suite.NoError(err) {
+		suite.FailNow("Listing entries failed")
+	}
+	suite.Equal(3, len(entries))
+
+	suite.Equal("path", plugin.Name(entries[0]))
+	suite.Equal("path1", plugin.Name(entries[1]))
+	suite.Equal("path2", plugin.Name(entries[2]))
+	for _, entry := range entries {
+		_, ok := entry.(plugin.Group)
+		if !suite.True(ok) {
+			suite.FailNow("Entry was not a Group")
+		}
+	}
+
+	entries1, err := entries[1].(plugin.Group).List(context.Background())
+	if suite.NoError(err) {
+		suite.Equal(1, len(entries1))
+		suite.Equal("a file", plugin.Name(entries1[0]))
+		_, ok := entries1[0].(plugin.Readable)
+		suite.True(ok)
+	}
+
+	entries2, err := entries[2].(plugin.Group).List(context.Background())
+	if suite.NoError(err) {
+		suite.Equal(1, len(entries2))
+		suite.Equal("dir", plugin.Name(entries2[0]))
+		_, ok := entries2[0].(plugin.Group)
+		suite.True(ok)
+	}
+}
+
+func (suite *fsTestSuite) TestFSRead() {
+	exec := createExec()
+	fs := NewFS("fs", exec)
+	// ID would normally be set when listing FS within the parent instance.
+	fs.SetTestID("/instance/fs")
+
+	entry := suite.find(fs, "var/log/path1/a file")
+	suite.Equal("a file", plugin.Name(entry))
+
+	execResult := createResult("hello")
+	exec.On("Exec", mock.Anything, "cat", []string{"/var/log/path1/a file"}, plugin.ExecOptions{}).Return(execResult, nil)
+	rdr, err := entry.(plugin.Readable).Open(context.Background())
+	suite.NoError(err)
+	suite.Equal(int64(5), rdr.Size())
+	buf := make([]byte, 5)
+	n, err := rdr.ReadAt(buf, 0)
+	suite.NoError(err)
+	suite.Equal(5, n)
+	suite.Equal("hello", string(buf))
+}
+
+func TestFS(t *testing.T) {
+	suite.Run(t, new(fsTestSuite))
+}
+
+type mockExecutor struct {
+	plugin.EntryBase
+	mock.Mock
+}
+
+func (m *mockExecutor) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
+	arger := m.Called(ctx, cmd, args, opts)
+	return arger.Get(0).(plugin.ExecResult), arger.Error(1)
+}
diff --git a/volume/core_test.go b/volume/stat_test.go
similarity index 71%
rename from volume/core_test.go
rename to volume/stat_test.go
index bb89995..9952304 100644
--- a/volume/core_test.go
+++ b/volume/stat_test.go
@@ -115,3 +115,51 @@ func TestStatParseAll(t *testing.T) {
 		SetSize(96)
 	assert.Equal(t, expectedAttr, dmap["/path"]["has"])
 }
+
+func TestStatParseAllRoot(t *testing.T) {
+	dmap, err := StatParseAll(strings.NewReader(fixture), "")
+	assert.Nil(t, err)
+	assert.NotNil(t, dmap)
+	assert.Equal(t, 9, len(dmap))
+	for _, dir := range []string{"", "mnt", "mnt/path", "mnt/path/has", "mnt/path/has/got", "mnt/path/has/got/some", "mnt/path1", "mnt/path2", "mnt/path2/dir"} {
+		assert.NotNil(t, dmap[dir])
+	}
+	for _, file := range []string{"mnt/path/has/got/some/legs", "mnt/path1/a file"} {
+		assert.Nil(t, dmap[file])
+	}
+
+	for _, node := range []string{"mnt/path", "mnt/path1", "mnt/path2"} {
+		assert.NotNil(t, dmap[""][node])
+	}
+
+	expectedAttr := plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611453, 0)).
+		SetMtime(time.Unix(1550611453, 0)).
+		SetCtime(time.Unix(1550611453, 0)).
+		SetMode(0644).
+		SetSize(0)
+	assert.Equal(t, expectedAttr, dmap["mnt/path1"]["a file"])
+
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611510, 0)).
+		SetMtime(time.Unix(1550611441, 0)).
+		SetCtime(time.Unix(1550611441, 0)).
+		SetMode(0755 | os.ModeDir).
+		SetSize(64)
+	assert.Equal(t, expectedAttr, dmap["mnt/path2"]["dir"])
+
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.
+		SetAtime(time.Unix(1550611510, 0)).
+		SetMtime(time.Unix(1550611448, 0)).
+		SetCtime(time.Unix(1550611448, 0)).
+		SetMode(0755 | os.ModeDir).
+		SetSize(96)
+	assert.Equal(t, expectedAttr, dmap["mnt/path"]["has"])
+
+	expectedAttr = plugin.EntryAttributes{}
+	expectedAttr.SetMode(0550 | os.ModeDir)
+	assert.Equal(t, expectedAttr, dmap[""]["mnt"])
+}