Repository: puppetlabs/wash
Pull request #351 (2019-06-24 23:39:15)

Add metadata schemas to plugin.EntrySchema
Metadata schemas will be used in `wash find` to optimize the meta
primary (and to make it less annoying to use).

plugin.Root now supports a WrappedTypes method. This is needed by the
Kubernetes plugin because the Kubernetes library creates wrappers around
basic Go types like time.Time. In the general case, WrappedTypes will be
implemented by core plugins whose SDKs create wrappers around primitive types

Signed-off-by: Enis Inan <enis.inan@puppet.com>


Patch:
diff --git a/api/schema.go b/api/schema.go
index 8186e65..ba8b2e0 100644
--- a/api/schema.go
+++ b/api/schema.go
@@ -13,7 +13,7 @@ var schemaHandler handler = func(w http.ResponseWriter, r *http.Request) *errorR
 	}
 	jsonEncoder := json.NewEncoder(w)
 	s := entry.Schema()
-	s.FillChildren()
+	s.Fill()
 	if err := jsonEncoder.Encode(s); err != nil {
 		return unknownErrorResponse(fmt.Errorf("Could not marshal schema for %v: %v", path, err))
 	}
diff --git a/go.mod b/go.mod
index f81c824..3f3be53 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
 	github.com/docker/go-units v0.3.3 // indirect
 	github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c // indirect
 	github.com/ekinanp/go-cache v2.1.0+incompatible
+	github.com/ekinanp/jsonschema v0.0.0-20190624212413-cd4dbe12fbae
 	github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a // indirect
 	github.com/fatih/color v1.7.0
 	github.com/ghodss/yaml v1.0.0
diff --git a/go.sum b/go.sum
index 64edbf7..3a09c05 100644
--- a/go.sum
+++ b/go.sum
@@ -46,6 +46,8 @@ github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c h1:ZfSZ3P3BedhKG
 github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
 github.com/ekinanp/go-cache v2.1.0+incompatible h1:N6iS7ERijdaiQrZA6jBcU7eneKeRI6qJmXhgfRvu8No=
 github.com/ekinanp/go-cache v2.1.0+incompatible/go.mod h1:KnixlqQu6vDC0WobcDRDmsQi8LOhJ4O0kdqw6vFKe8E=
+github.com/ekinanp/jsonschema v0.0.0-20190624212413-cd4dbe12fbae h1:EdHwFxUzvgUpQxtkCg8nKjsXwMCKPJoHDhnVYacDdoI=
+github.com/ekinanp/jsonschema v0.0.0-20190624212413-cd4dbe12fbae/go.mod h1:AvBPgqgjvOM/Ll97WMymsC7Xmo42g1qS+EQclTEguVQ=
 github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a h1:A4wNiqeKqU56ZhtnzJCTyPZ1+cyu8jKtIchQ3TtxHgw=
 github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 53a0fc5..4e9e981 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -102,7 +102,9 @@ func getAttributes(inst *ec2Client.Instance) plugin.EntryAttributes {
 }
 
 func (inst *ec2Instance) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(inst, "instance")
+	return plugin.
+		NewEntrySchema(inst, "instance").
+		SetMetaAttributeSchema(ec2Client.Instance{})
 }
 
 func (inst *ec2Instance) ChildSchemas() []*plugin.EntrySchema {
diff --git a/plugin/aws/s3Bucket.go b/plugin/aws/s3Bucket.go
index 71c8ec8..cdb9bb4 100644
--- a/plugin/aws/s3Bucket.go
+++ b/plugin/aws/s3Bucket.go
@@ -168,7 +168,9 @@ func newS3Bucket(name string, ctime time.Time, session *session.Session) *s3Buck
 }
 
 func (b *s3Bucket) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(b, "bucket")
+	return plugin.
+		NewEntrySchema(b, "bucket").
+		SetMetadataSchema(bucketMetadata{})
 }
 
 func (b *s3Bucket) ChildSchemas() []*plugin.EntrySchema {
@@ -182,6 +184,12 @@ func (b *s3Bucket) List(ctx context.Context) ([]plugin.Entry, error) {
 	return listObjects(ctx, b.client, b.Name(), "")
 }
 
+type bucketMetadata struct {
+	TagSet []*s3Client.Tag
+	Region string
+	Ctime  time.Time
+}
+
 func (b *s3Bucket) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	request := &s3Client.GetBucketTaggingInput{
 		Bucket: awsSDK.String(b.Name()),
@@ -189,9 +197,9 @@ func (b *s3Bucket) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 
 	resp, err := b.client.GetBucketTaggingWithContext(ctx, request)
 
-	var metadata plugin.JSONObject
+	var metadata bucketMetadata
 	if err == nil {
-		metadata = plugin.ToJSONObject(resp)
+		metadata.TagSet = resp.TagSet
 	} else if awserr, ok := err.(awserr.Error); ok {
 		// Check if this is a NoSuchTagSet error. If yes, then that means
 		// this bucket doesn't have any tags.
@@ -200,7 +208,7 @@ func (b *s3Bucket) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 		// if you're interested in knowing why AWS does not return
 		// an empty TagSet instead of a NoSuchTagSet error
 		if awserr.Code() == "NoSuchTagSet" {
-			metadata = plugin.JSONObject{}
+			// Pass-thru, this is OK.
 		} else {
 			return nil, err
 		}
@@ -213,10 +221,10 @@ func (b *s3Bucket) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 	if err != nil {
 		return nil, err
 	}
-	metadata["region"] = region
-	metadata["ctime"] = b.ctime
+	metadata.Region = region
+	metadata.Ctime = b.ctime
 
-	return metadata, nil
+	return plugin.ToJSONObject(metadata), nil
 }
 
 func (b *s3Bucket) getRegion(ctx context.Context) (string, error) {
diff --git a/plugin/aws/s3Object.go b/plugin/aws/s3Object.go
index c7cd76c..369c97d 100644
--- a/plugin/aws/s3Object.go
+++ b/plugin/aws/s3Object.go
@@ -68,7 +68,10 @@ func (o *s3Object) cachedHeadObject(ctx context.Context) (*s3Client.HeadObjectOu
 }
 
 func (o *s3Object) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(o, "object")
+	return plugin.
+		NewEntrySchema(o, "object").
+		SetMetaAttributeSchema(s3Client.Object{}).
+		SetMetadataSchema(s3Client.HeadObjectOutput{})
 }
 
 func (o *s3Object) Metadata(ctx context.Context) (plugin.JSONObject, error) {
diff --git a/plugin/cache.go b/plugin/cache.go
index b0908f4..3c24bc5 100644
--- a/plugin/cache.go
+++ b/plugin/cache.go
@@ -117,12 +117,12 @@ func CachedOp(ctx context.Context, opName string, entry Entry, ttl time.Duration
 // DuplicateCNameErr represents a duplicate cname error, which
 // occurs when at least two children have the same cname.
 type DuplicateCNameErr struct {
-	ParentID                        string
-	FirstChildName                  string
-	FirstChildSlashReplacer         rune
-	SecondChildName                 string
-	SecondChildSlashReplacer        rune
-	CName                           string
+	ParentID                 string
+	FirstChildName           string
+	FirstChildSlashReplacer  rune
+	SecondChildName          string
+	SecondChildSlashReplacer rune
+	CName                    string
 }
 
 func (c DuplicateCNameErr) Error() string {
@@ -163,12 +163,12 @@ func CachedList(ctx context.Context, p Parent) (map[string]Entry, error) {
 
 			if duplicateEntry, ok := searchedEntries[cname]; ok {
 				return nil, DuplicateCNameErr{
-					ParentID:                        p.id(),
-					FirstChildName:                  duplicateEntry.name(),
-					FirstChildSlashReplacer:         duplicateEntry.slashReplacer(),
-					SecondChildName:                 entry.name(),
-					SecondChildSlashReplacer:        entry.slashReplacer(),
-					CName:                           cname,
+					ParentID:                 p.id(),
+					FirstChildName:           duplicateEntry.name(),
+					FirstChildSlashReplacer:  duplicateEntry.slashReplacer(),
+					SecondChildName:          entry.name(),
+					SecondChildSlashReplacer: entry.slashReplacer(),
+					CName:                    cname,
 				}
 			}
 			searchedEntries[cname] = entry
@@ -177,6 +177,8 @@ func CachedList(ctx context.Context, p Parent) (map[string]Entry, error) {
 			// where the context doesn't include the parent's ID.
 			id := strings.TrimRight(p.id(), "/") + "/" + cname
 			entry.setID(id)
+
+			passAlongWrappedTypes(p, entry)
 		}
 
 		return searchedEntries, nil
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index e68b735..a971874 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -59,7 +59,10 @@ func (c *container) Metadata(ctx context.Context) (plugin.JSONObject, error) {
 }
 
 func (c *container) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(c, "container")
+	return plugin.
+		NewEntrySchema(c, "container").
+		SetMetaAttributeSchema(types.Container{}).
+		SetMetadataSchema(types.ContainerJSON{})
 }
 
 func (c *container) ChildSchemas() []*plugin.EntrySchema {
diff --git a/plugin/docker/volume.go b/plugin/docker/volume.go
index dad59d5..f55aa66 100644
--- a/plugin/docker/volume.go
+++ b/plugin/docker/volume.go
@@ -47,7 +47,9 @@ func newVolume(c *client.Client, v *types.Volume) (*volume, error) {
 }
 
 func (v *volume) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(v, "volume")
+	return plugin.
+		NewEntrySchema(v, "volume").
+		SetMetaAttributeSchema(types.Volume{})
 }
 
 func (v *volume) ChildSchemas() []*plugin.EntrySchema {
diff --git a/plugin/entryBase.go b/plugin/entryBase.go
index 7097479..2edcfe8 100644
--- a/plugin/entryBase.go
+++ b/plugin/entryBase.go
@@ -35,12 +35,13 @@ to do something like
 		SetMeta(meta)
 */
 type EntryBase struct {
-	entryName          string
-	attr               EntryAttributes
-	slashReplacerCh    rune
+	entryName       string
+	attr            EntryAttributes
+	slashReplacerCh rune
 	// washID represents the entry's wash ID. It is set in CachedList.
-	washID string
-	ttl    [3]time.Duration
+	washID          string
+	ttl             [3]time.Duration
+	wrappedTypesMap SchemaMap
 }
 
 // NewEntry creates a new entry
@@ -50,7 +51,7 @@ func NewEntry(name string) EntryBase {
 	}
 
 	e := EntryBase{
-		entryName:          name,
+		entryName:       name,
 		slashReplacerCh: '#',
 	}
 	for op := range e.ttl {
@@ -95,6 +96,14 @@ func (e *EntryBase) getTTLOf(op defaultOpCode) time.Duration {
 	return e.ttl[op]
 }
 
+func (e *EntryBase) wrappedTypes() SchemaMap {
+	return e.wrappedTypesMap
+}
+
+func (e *EntryBase) setWrappedTypes(wrappedTypes SchemaMap) {
+	e.wrappedTypesMap = wrappedTypes
+}
+
 // OTHER METHODS USED TO FACILITATE PLUGIN DEVELOPMENT
 // AND TESTING
 
diff --git a/plugin/entrySchema.go b/plugin/entrySchema.go
index 235422f..3741ffa 100644
--- a/plugin/entrySchema.go
+++ b/plugin/entrySchema.go
@@ -5,15 +5,19 @@ import (
 	"fmt"
 	"reflect"
 	"strings"
+
+	"github.com/ekinanp/jsonschema"
 )
 
 type entrySchema struct {
-	TypeID    string         `json:"type_id"`
-	Label     string         `json:"label"`
-	Singleton bool           `json:"singleton"`
-	Actions   []string       `json:"actions"`
-	Children  []*EntrySchema `json:"children"`
-	entry     Entry
+	TypeID              string         `json:"type_id"`
+	Label               string         `json:"label"`
+	Singleton           bool           `json:"singleton"`
+	Actions             []string       `json:"actions"`
+	MetaAttributeSchema *JSONSchema    `json:"meta_attribute_schema"`
+	MetadataSchema      *JSONSchema    `json:"metadata_schema"`
+	Children            []*EntrySchema `json:"children"`
+	entry               Entry
 }
 
 // EntrySchema represents an entry's schema. Use plugin.NewEntrySchema
@@ -61,6 +65,8 @@ type EntrySchema struct {
 	//
 	// This pattern was obtained from https://stackoverflow.com/a/11129474
 	entrySchema
+	metaAttributeSchemaObj interface{}
+	metadataSchemaObj      interface{}
 }
 
 // NewEntrySchema returns a new EntrySchema object with the specified label.
@@ -159,6 +165,29 @@ func (s *EntrySchema) SetActions(actions []string) *EntrySchema {
 	return s
 }
 
+// SetMetaAttributeSchema sets the meta attribute's schema. obj is an empty struct
+// that will be marshalled into a JSON schema. SetMetaSchema will panic
+// if obj is not a struct.
+func (s *EntrySchema) SetMetaAttributeSchema(obj interface{}) *EntrySchema {
+	// We need to know if s.entry has any wrapped types in order to correctly
+	// compute the schema. However that information is known when s.fill() is
+	// called. Thus, we'll set the schema object to obj so s.fill() can properly
+	// calculate the schema.
+	s.metaAttributeSchemaObj = obj
+	return s
+}
+
+// SetMetadataSchema sets Entry#Metadata's schema. obj is an empty struct that will be
+// marshalled into a JSON schema. SetMetadataSchema will panic if obj is not a struct.
+//
+// NOTE: Only use SetMetadataSchema if you're overriding Entry#Metadata. Otherwise, use
+// SetMetaAttributeSchema.
+func (s *EntrySchema) SetMetadataSchema(obj interface{}) *EntrySchema {
+	// See the comments in SetMetaAttributeSchema to understand why this line's necessary
+	s.metadataSchemaObj = obj
+	return s
+}
+
 // Children returns the entry's child schemas
 func (s *EntrySchema) Children() []*EntrySchema {
 	return s.entrySchema.Children
@@ -174,12 +203,13 @@ func (s *EntrySchema) SetChildren(children []*EntrySchema) *EntrySchema {
 	return s
 }
 
-// FillChildren fills s' children.
-func (s *EntrySchema) FillChildren() {
-	s.fillChildren(make(map[string]bool))
+// Fill fills s' children, its meta attribute schema, and its metadata
+// schema. This is needed by the API. Plugin authors should ignore this.
+func (s *EntrySchema) Fill() {
+	s.fill(make(map[string]bool))
 }
 
-func (s *EntrySchema) fillChildren(visited map[string]bool) {
+func (s *EntrySchema) fill(visited map[string]bool) {
 	if s.entrySchema.Children != nil {
 		return
 	}
@@ -191,17 +221,82 @@ func (s *EntrySchema) fillChildren(visited map[string]bool) {
 		// true if s is e.g. a volume directory.
 		return
 	}
-	children := s.entry.(Parent).ChildSchemas()
+
+	// Fill-in the meta attribute + metadata schemas
+	var err error
+	if s.metaAttributeSchemaObj != nil {
+		s.MetaAttributeSchema, err = s.schemaOf(s.metaAttributeSchemaObj)
+		if err != nil {
+			s.fillPanicf("bad value passed into SetMetaAttributeSchema: %v", err)
+		}
+	}
+	if s.metadataSchemaObj != nil {
+		s.MetadataSchema, err = s.schemaOf(s.metadataSchemaObj)
+		if err != nil {
+			s.fillPanicf("bad value passed into SetMetadataSchema: %v", err)
+		}
+	}
+
 	visited[s.TypeID()] = true
+
+	// "sParent" is read as "s.parent"
+	sParent := s.entry.(Parent)
+	children := sParent.ChildSchemas()
 	for _, child := range children {
 		if child == nil {
-			msg := fmt.Sprintf("s.fillChildren: found a nil child schema for %v", s.TypeID())
-			panic(msg)
+			s.fillPanicf("found a nil child schema")
 		}
 		s.entrySchema.Children = append(s.entrySchema.Children, child)
-		child.fillChildren(visited)
+		passAlongWrappedTypes(sParent, child.entry)
+		child.fill(visited)
 	}
 	// Delete "s" from visited so that siblings or ancestors that
 	// also use "s" won't be affected.
 	delete(visited, s.TypeID())
 }
+
+// This helper's used by CachedList + EntrySchema#fill(). The reason for
+// the helper is because /fs/schema uses repeated calls to CachedList when
+// fetching the entry, so we need to pass-along the wrapped types when
+// searching for it. However, Parent#ChildSchemas uses empty Entry objects
+// that do not go through CachedList (by definition). Thus, the entry found
+// by /fs/schema needs to pass its wrapped types along to the children to
+// determine their metadata schemas. This is done in s.fill().
+func passAlongWrappedTypes(p Parent, child Entry) {
+	var wrappedTypes SchemaMap
+	if root, ok := child.(HasWrappedTypes); ok {
+		wrappedTypes = root.WrappedTypes()
+	} else {
+		wrappedTypes = p.wrappedTypes()
+	}
+	child.setWrappedTypes(wrappedTypes)
+}
+
+// Helper that wraps the common code shared by the SetMeta*Schema methods
+func (s *EntrySchema) schemaOf(obj interface{}) (*JSONSchema, error) {
+	typeMappings := make(map[reflect.Type]*jsonschema.Type)
+	for t, s := range s.entry.wrappedTypes() {
+		typeMappings[reflect.TypeOf(t)] = s.Type
+	}
+	r := jsonschema.Reflector{
+		// Setting this option ensures that the schema's root is obj's
+		// schema instead of a reference to a definition containing obj's
+		// schema. This way, we can validate that "obj" is a JSON object's
+		// schema. Otherwise, the check below will always fail.
+		ExpandedStruct: true,
+		TypeMappings:   typeMappings,
+	}
+	schema := r.Reflect(obj)
+	if schema.Type.Type != "object" {
+		return nil, fmt.Errorf("expected a JSON object but got %v", schema.Type.Type)
+	}
+	return schema, nil
+}
+
+// Helper for s.fill(). We make it a separate method to avoid re-creating
+// closures for each recursive s.fill() call.
+func (s *EntrySchema) fillPanicf(format string, a ...interface{}) {
+	formatStr := fmt.Sprintf("s.fill (%v): %v", s.TypeID(), format)
+	msg := fmt.Sprintf(formatStr, a...)
+	panic(msg)
+}
diff --git a/plugin/externalPluginRoot.go b/plugin/externalPluginRoot.go
index 8cbc5a9..eb9595a 100644
--- a/plugin/externalPluginRoot.go
+++ b/plugin/externalPluginRoot.go
@@ -65,3 +65,9 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 	r.externalPluginEntry.script = script
 	return nil
 }
+
+func (r *externalPluginRoot) WrappedTypes() SchemaMap {
+	// This only makes sense for core plugins because it is a Go-specific
+	// limitation.
+	return nil
+}
diff --git a/plugin/jsonSchema.go b/plugin/jsonSchema.go
new file mode 100644
index 0000000..71ecc5a
--- /dev/null
+++ b/plugin/jsonSchema.go
@@ -0,0 +1,43 @@
+package plugin
+
+import (
+	"github.com/ekinanp/jsonschema"
+)
+
+// This file has wrappers around the jsonschema library so that
+// core plugin authors do not need to understand how it works in
+// order to use it.
+
+// JSONSchema represents a JSON schema
+type JSONSchema = jsonschema.Schema
+
+// TimeSchema represents the schema of a time.Time object
+func TimeSchema() *JSONSchema {
+	return jsonTypeToSchema(jsonschema.TimeType)
+}
+
+// IntegerSchema represents an integer's schema (int)
+func IntegerSchema() *JSONSchema {
+	return jsonTypeToSchema(jsonschema.IntegerType)
+}
+
+// NumberSchema represents a number's schema (float64)
+func NumberSchema() *JSONSchema {
+	return jsonTypeToSchema(jsonschema.NumberType)
+}
+
+// BooleanSchema represents a boolean's schema (bool)
+func BooleanSchema() *JSONSchema {
+	return jsonTypeToSchema(jsonschema.BoolType)
+}
+
+// StringSchema represents a string's schema (string)
+func StringSchema() *JSONSchema {
+	return jsonTypeToSchema(jsonschema.StringType)
+}
+
+func jsonTypeToSchema(t *jsonschema.Type) *JSONSchema {
+	return &jsonschema.Schema{
+		Type: t,
+	}
+}
diff --git a/plugin/kubernetes/namespace.go b/plugin/kubernetes/namespace.go
index fb0130f..4ea71c2 100644
--- a/plugin/kubernetes/namespace.go
+++ b/plugin/kubernetes/namespace.go
@@ -32,7 +32,9 @@ func newNamespace(name string, meta *corev1.Namespace, c *k8s.Clientset, cfg *re
 }
 
 func (n *namespace) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(n, "namespace")
+	return plugin.
+		NewEntrySchema(n, "namespace").
+		SetMetaAttributeSchema(corev1.Namespace{})
 }
 
 func (n *namespace) ChildSchemas() []*plugin.EntrySchema {
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index 6447fcd..94e837e 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -35,36 +35,33 @@ func newPod(ctx context.Context, client *k8s.Clientset, config *rest.Config, ns
 	pd.ns = ns
 
 	pdInfo := podInfoResult{
-		pd:         p,
+		Pod:        p,
 		logContent: pd.fetchLogContent(ctx),
 	}
+	pdInfo.LogSize = uint64(len(pdInfo.logContent))
 
-	meta := pdInfo.toMeta()
 	pd.
 		Attributes().
 		SetCtime(p.CreationTimestamp.Time).
 		SetAtime(p.CreationTimestamp.Time).
-		SetSize(uint64(meta["LogSize"].(int))).
-		SetMeta(meta)
+		SetSize(pdInfo.LogSize).
+		SetMeta(plugin.ToJSONObject(pdInfo))
 
 	return pd, nil
 }
 
 func (p *pod) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(p, "pod")
+	return plugin.
+		NewEntrySchema(p, "pod").
+		SetMetaAttributeSchema(podInfoResult{})
 }
 
 type podInfoResult struct {
-	pd         *corev1.Pod
+	*corev1.Pod
+	LogSize    uint64 `json:"LogSize"`
 	logContent []byte
 }
 
-func (pdInfo podInfoResult) toMeta() plugin.JSONObject {
-	meta := plugin.ToJSONObject(pdInfo.pd)
-	meta["LogSize"] = len(pdInfo.logContent)
-	return meta
-}
-
 func (p *pod) fetchLogContent(ctx context.Context) []byte {
 	req := p.client.CoreV1().Pods(p.ns).GetLogs(p.Name(), &corev1.PodLogOptions{})
 	rdr, err := req.Stream()
@@ -87,8 +84,9 @@ func (p *pod) cachedPodInfo(ctx context.Context) (podInfoResult, error) {
 		if err != nil {
 			return result, err
 		}
-		result.pd = pd
+		result.Pod = pd
 		result.logContent = p.fetchLogContent(ctx)
+		result.LogSize = uint64(len(result.logContent))
 		return result, nil
 	})
 	if err != nil {
diff --git a/plugin/kubernetes/pvc.go b/plugin/kubernetes/pvc.go
index 1f26ebb..9458376 100644
--- a/plugin/kubernetes/pvc.go
+++ b/plugin/kubernetes/pvc.go
@@ -46,7 +46,9 @@ func newPVC(pi typedv1.PersistentVolumeClaimInterface, pd typedv1.PodInterface,
 }
 
 func (v *pvc) Schema() *plugin.EntrySchema {
-	return plugin.NewEntrySchema(v, "persistentvolumeclaim")
+	return plugin.
+		NewEntrySchema(v, "persistentvolumeclaim").
+		SetMetaAttributeSchema(corev1.PersistentVolumeClaim{})
 }
 
 func (v *pvc) ChildSchemas() []*plugin.EntrySchema {
diff --git a/plugin/kubernetes/root.go b/plugin/kubernetes/root.go
index 35f8cce..9c1c278 100644
--- a/plugin/kubernetes/root.go
+++ b/plugin/kubernetes/root.go
@@ -8,6 +8,8 @@ import (
 
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
+	"k8s.io/apimachinery/pkg/api/resource"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	k8s "k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -77,6 +79,14 @@ func (r *Root) ChildSchemas() []*plugin.EntrySchema {
 	}
 }
 
+// WrappedTypes implements plugin.Root#WrappedTypes
+func (r *Root) WrappedTypes() plugin.SchemaMap {
+	return map[interface{}]*plugin.JSONSchema{
+		v1.Time{}:           plugin.TimeSchema(),
+		resource.Quantity{}: plugin.StringSchema(),
+	}
+}
+
 // List returns available contexts.
 func (r *Root) List(ctx context.Context) ([]plugin.Entry, error) {
 	return r.contexts, nil
diff --git a/plugin/types.go b/plugin/types.go
index fe43e32..7ec7bd7 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -47,6 +47,8 @@ type Entry interface {
 	id() string
 	setID(id string)
 	getTTLOf(op defaultOpCode) time.Duration
+	wrappedTypes() map[interface{}]*JSONSchema
+	setWrappedTypes(map[interface{}]*JSONSchema)
 }
 
 /*
@@ -80,6 +82,9 @@ type Parent interface {
 	List(context.Context) ([]Entry, error)
 }
 
+// SchemaMap represents a map of <type> => <JSON schema>.
+type SchemaMap = map[interface{}]*JSONSchema
+
 // Root represents the plugin root. The Init function is passed a config map representing
 // plugin-specific configuration.
 type Root interface {
@@ -87,6 +92,23 @@ type Root interface {
 	Init(map[string]interface{}) error
 }
 
+// HasWrappedTypes is an interface that's used by the EntrySchema#SetMeta*Schema methods to return
+// the right metadata schema for wrapped types. Plugin roots should implement this interface if the
+// plugin's SDK wraps primitive types like date, integer, number, string, boolean, etc. See
+// kubernetes/root.go for an example of when WrappedTypes is used.
+//
+// NOTE: Type aliases like "type Time = time.Time" do NOT count as wrapped types. Pointers
+// also don't count. Only promoted types ("type Time time.Time") and wrapper structs
+// ("struct Time { t time.Time }") count.
+//
+// NOTE: Without WrappedTypes, the underlying JSON schema library will treat promoted types and
+// wrapper structs as JSON objects, which would be incorrect. This, unfortunately, is a
+// limitation of Go's reflect package.
+type HasWrappedTypes interface {
+	Root
+	WrappedTypes() SchemaMap
+}
+
 // ExecOptions is a struct we can add new features to that must be serializable to JSON.
 // Examples of potential features: user, privileged, map of environment variables, timeout.
 type ExecOptions struct {


Test patch:
diff --git a/api/helpers_test.go b/api/helpers_test.go
index dccc26f..c69d2fc 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -137,6 +137,10 @@ func (m *mockRoot) ChildSchemas() []*plugin.EntrySchema {
 	return nil
 }
 
+func (m *mockRoot) WrappedTypes() plugin.SchemaMap {
+	return nil
+}
+
 func getRequest(ctx context.Context, path string) *http.Request {
 	return (&http.Request{URL: &url.URL{RawQuery: url.Values{"path": []string{path}}.Encode()}}).WithContext(ctx)
 }
diff --git a/plugin/registry_test.go b/plugin/registry_test.go
index 5873b1f..8d01071 100644
--- a/plugin/registry_test.go
+++ b/plugin/registry_test.go
@@ -41,6 +41,10 @@ func (m *mockRoot) Schema() *EntrySchema {
 	return nil
 }
 
+func (m *mockRoot) WrappedTypes() SchemaMap {
+	return nil
+}
+
 func (suite *RegistryTestSuite) TestPluginNameRegex() {
 	suite.Regexp(pluginNameRegex, "a")
 	suite.Regexp(pluginNameRegex, "A")