Repository: vmware/govmomi
Pull request #1755 (2019-12-18 17:16:32)

Add OVF properties to library deploy
This addresses issue #1718 so you can deploy an OVA from the content library _and_ specify properties typically required to deploy an OVA.

We're using this to deploy nsx-unified-appliance-2.5.0.0.0.14663978 from our local content library.


Patch:
diff --git a/govc/host/esxcli/esxcli.go b/govc/host/esxcli/esxcli.go
index b27e1811..79951ad5 100644
--- a/govc/host/esxcli/esxcli.go
+++ b/govc/host/esxcli/esxcli.go
@@ -91,6 +91,12 @@ func (cmd *esxcli) Run(ctx context.Context, f *flag.FlagSet) error {
 	}
 
 	if len(res.Values) == 0 {
+		if res.String != "" {
+			fmt.Print(res.String)
+			if !strings.HasSuffix(res.String, "\n") {
+				fmt.Println()
+			}
+		}
 		return nil
 	}
 
diff --git a/govc/host/esxcli/response.go b/govc/host/esxcli/response.go
index 2957748c..e1c70b76 100644
--- a/govc/host/esxcli/response.go
+++ b/govc/host/esxcli/response.go
@@ -26,6 +26,7 @@ type Values map[string][]string
 type Response struct {
 	Info   *CommandInfoMethod
 	Values []Values
+	String string
 }
 
 func (v Values) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
@@ -67,6 +68,10 @@ func (r *Response) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
 	stype := r.Type(start)
 
 	if stype != "ArrayOfDataObject" {
+		switch stype {
+		case "xsd:string":
+			return d.DecodeElement(&r.String, &start)
+		}
 		v := Values{}
 		if err := d.DecodeElement(&v, &start); err != nil {
 			return err
diff --git a/govc/library/deploy.go b/govc/library/deploy.go
index 15af7541..b19a6ea5 100644
--- a/govc/library/deploy.go
+++ b/govc/library/deploy.go
@@ -164,6 +164,14 @@ func (cmd *deploy) Run(ctx context.Context, f *flag.FlagSet) error {
 			})
 		}
 
+		var properties []vcenter.Property
+		for _, prop := range cmd.Options.PropertyMapping {
+			properties = append(properties, vcenter.Property{
+				ID:    prop.Key,
+				Value: prop.Value,
+			})
+		}
+
 		dsID := ""
 		if ds != nil {
 			dsID = ds.Reference().Value
@@ -174,7 +182,7 @@ func (cmd *deploy) Run(ctx context.Context, f *flag.FlagSet) error {
 		var ref *types.ManagedObjectReference
 
 		switch item.Type {
-		case "ovf":
+		case library.ItemTypeOVF:
 			deploy := vcenter.Deploy{
 				DeploymentSpec: vcenter.DeploymentSpec{
 					Name:               name,
@@ -187,6 +195,12 @@ func (cmd *deploy) Run(ctx context.Context, f *flag.FlagSet) error {
 							Type:        vcenter.TypeDeploymentOptionParams,
 							SelectedKey: cmd.Options.Deployment,
 						},
+						{
+							Class:       vcenter.ClassPropertyParams,
+							Type:        vcenter.TypePropertyParams,
+							SelectedKey: cmd.Options.Deployment,
+							Properties:  properties,
+						},
 					},
 					NetworkMappings:     networks,
 					StorageProvisioning: cmd.Options.DiskProvisioning,
@@ -202,7 +216,7 @@ func (cmd *deploy) Run(ctx context.Context, f *flag.FlagSet) error {
 			if err != nil {
 				return err
 			}
-		case "vm-template":
+		case library.ItemTypeVMTX:
 			storage := &vcenter.DiskStorage{
 				Datastore: dsID,
 				StoragePolicy: &vcenter.StoragePolicy{
diff --git a/govc/library/import.go b/govc/library/import.go
index 178883dc..baee0cf8 100644
--- a/govc/library/import.go
+++ b/govc/library/import.go
@@ -113,18 +113,18 @@ func (cmd *item) Run(ctx context.Context, f *flag.FlagSet) error {
 		archive.Archive = &importx.TapeArchive{Path: file, Opener: opener}
 		base = "*.ovf"
 		mf = "*.mf"
-		kind = "ovf"
+		kind = library.ItemTypeOVF
 	case ".ovf":
-		kind = "ovf"
+		kind = library.ItemTypeOVF
 	case ".iso":
-		kind = "iso"
+		kind = library.ItemTypeISO
 	}
 
 	if cmd.Type == "" {
 		cmd.Type = kind
 	}
 
-	if !cmd.pull && cmd.Type == "ovf" {
+	if !cmd.pull && cmd.Type == library.ItemTypeOVF {
 		f, _, err := archive.Open(mf)
 		if err == nil {
 			sums, err := library.ReadManifest(f)
@@ -223,7 +223,7 @@ func (cmd *item) Run(ctx context.Context, f *flag.FlagSet) error {
 			return err
 		}
 
-		if cmd.Type == "ovf" {
+		if cmd.Type == library.ItemTypeOVF {
 			o, err := archive.ReadOvf(base)
 			if err != nil {
 				return err
diff --git a/govc/library/info.go b/govc/library/info.go
index 78e37b7e..141c0975 100644
--- a/govc/library/info.go
+++ b/govc/library/info.go
@@ -18,21 +18,25 @@ package library
 
 import (
 	"context"
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io"
+	"path"
+	"strings"
 	"text/tabwriter"
+	"time"
 
 	"github.com/vmware/govmomi/govc/cli"
 	"github.com/vmware/govmomi/govc/flags"
+	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/units"
 	"github.com/vmware/govmomi/vapi/library"
 	"github.com/vmware/govmomi/vapi/library/finder"
 	"github.com/vmware/govmomi/vapi/rest"
 
 	"github.com/vmware/govmomi/property"
-	"github.com/vmware/govmomi/vim25"
 	"github.com/vmware/govmomi/vim25/mo"
-	"github.com/vmware/govmomi/vim25/soap"
 	"github.com/vmware/govmomi/vim25/types"
 )
 
@@ -40,6 +44,11 @@ type info struct {
 	*flags.ClientFlag
 	*flags.OutputFlag
 	*flags.DatacenterFlag
+
+	long bool
+	link bool
+
+	names map[string]string
 }
 
 func init() {
@@ -53,6 +62,11 @@ func (cmd *info) Register(ctx context.Context, f *flag.FlagSet) {
 	cmd.OutputFlag.Register(ctx, f)
 	cmd.DatacenterFlag, ctx = flags.NewDatacenterFlag(ctx)
 	cmd.DatacenterFlag.Register(ctx, f)
+
+	f.BoolVar(&cmd.long, "l", false, "Long listing format")
+	f.BoolVar(&cmd.link, "L", false, "List Datastore path only")
+
+	cmd.names = make(map[string]string)
 }
 
 func (cmd *info) Process(ctx context.Context) error {
@@ -68,30 +82,56 @@ func (cmd *info) Description() string {
 Examples:
   govc library.info
   govc library.info /lib1
+  govc library.info -l /lib1 | grep Size:
   govc library.info /lib1/item1
   govc library.info /lib1/item1/
   govc library.info */
+  govc device.cdrom.insert -vm $vm -device cdrom-3000 $(govc library.info -L /lib1/item1/file1)
   govc library.info -json | jq .
   govc library.info /lib1/item1 -json | jq .`
 }
 
-type infoResultsWriter []finder.FindResult
+type infoResultsWriter struct {
+	Result []finder.FindResult
+	m      *library.Manager
+	cmd    *info
+}
+
+func (r infoResultsWriter) MarshalJSON() ([]byte, error) {
+	return json.Marshal(r.Result)
+}
 
 func (r infoResultsWriter) Write(w io.Writer) error {
+	if r.cmd.link {
+		for _, j := range r.Result {
+			p, err := r.cmd.getDatastoreFilePath(j)
+			if err != nil {
+				return err
+			}
+			if !r.cmd.long {
+				var path object.DatastorePath
+				path.FromString(p)
+				p = path.Path
+			}
+			fmt.Fprintln(w, p)
+		}
+		return nil
+	}
+
 	tw := tabwriter.NewWriter(w, 2, 0, 2, ' ', 0)
 	defer tw.Flush()
-	for _, j := range r {
+	for _, j := range r.Result {
 		switch t := j.GetResult().(type) {
 		case library.Library:
-			if err := r.writeLibrary(tw, t, j.GetPath()); err != nil {
+			if err := r.writeLibrary(tw, t, j); err != nil {
 				return err
 			}
 		case library.Item:
-			if err := r.writeItem(tw, t, j.GetPath()); err != nil {
+			if err := r.writeItem(tw, t, j); err != nil {
 				return err
 			}
 		case library.File:
-			if err := r.writeFile(tw, t, j.GetPath()); err != nil {
+			if err := r.writeFile(tw, t, j); err != nil {
 				return err
 			}
 		}
@@ -101,25 +141,39 @@ func (r infoResultsWriter) Write(w io.Writer) error {
 }
 
 func (r infoResultsWriter) writeLibrary(
-	w io.Writer, v library.Library, ipath string) error {
+	w io.Writer, v library.Library, res finder.FindResult) error {
 
 	fmt.Fprintf(w, "Name:\t%s\n", v.Name)
 	fmt.Fprintf(w, "  ID:\t%s\n", v.ID)
-	fmt.Fprintf(w, "  Path:\t%s\n", ipath)
+	fmt.Fprintf(w, "  Path:\t%s\n", res.GetPath())
 	fmt.Fprintf(w, "  Description:\t%s\n", v.Description)
 	fmt.Fprintf(w, "  Version:\t%s\n", v.Version)
-	fmt.Fprintf(w, "  StorageBackings:\n")
+	fmt.Fprintf(w, "  Created:\t%s\n", v.CreationTime.Format(time.ANSIC))
+	fmt.Fprintf(w, "  StorageBackings:\t\n")
 	for _, d := range v.Storage {
 		fmt.Fprintf(w, "    DatastoreID:\t%s\n", d.DatastoreID)
 		fmt.Fprintf(w, "    Type:\t%s\n", d.Type)
 	}
+	if r.cmd.long {
+		fmt.Fprintf(w, "  Datastore Path:\t%s\n", r.cmd.getDatastorePath(res))
+		items, err := r.m.GetLibraryItems(context.Background(), v.ID)
+		if err != nil {
+			return err
+		}
+		var size int64
+		for i := range items {
+			size += items[i].Size
+		}
+		fmt.Fprintf(w, "  Size:\t%s\n", units.ByteSize(size))
+		fmt.Fprintf(w, "  Items:\t%d\n", len(items))
+	}
 	if v.Subscription != nil {
 		dl := "All"
 		if v.Subscription.OnDemand != nil && *v.Subscription.OnDemand {
 			dl = "On Demand"
 		}
 
-		fmt.Fprintf(w, "  Subscription:\n")
+		fmt.Fprintf(w, "  Subscription:\t\n")
 		fmt.Fprintf(w, "    AutoSync:\t%t\n", *v.Subscription.AutomaticSyncEnabled)
 		fmt.Fprintf(w, "    URL:\t%s\n", v.Subscription.SubscriptionURL)
 		fmt.Fprintf(w, "    Auth:\t%s\n", v.Subscription.AuthenticationMethod)
@@ -127,34 +181,47 @@ func (r infoResultsWriter) writeLibrary(
 	}
 	return nil
 }
+
 func (r infoResultsWriter) writeItem(
-	w io.Writer, v library.Item, ipath string) error {
+	w io.Writer, v library.Item, res finder.FindResult) error {
 
 	fmt.Fprintf(w, "Name:\t%s\n", v.Name)
 	fmt.Fprintf(w, "  ID:\t%s\n", v.ID)
-	fmt.Fprintf(w, "  Path:\t%s\n", ipath)
+	fmt.Fprintf(w, "  Path:\t%s\n", res.GetPath())
 	fmt.Fprintf(w, "  Description:\t%s\n", v.Description)
 	fmt.Fprintf(w, "  Type:\t%s\n", v.Type)
+	fmt.Fprintf(w, "  Size:\t%s\n", units.ByteSize(v.Size))
+	fmt.Fprintf(w, "  Created:\t%s\n", v.CreationTime.Format(time.ANSIC))
+	fmt.Fprintf(w, "  Modified:\t%s\n", v.LastModifiedTime.Format(time.ANSIC))
 	fmt.Fprintf(w, "  Version:\t%s\n", v.Version)
+
+	if r.cmd.long {
+		fmt.Fprintf(w, "  Datastore Path:\t%s\n", r.cmd.getDatastorePath(res))
+	}
+
 	return nil
 }
+
 func (r infoResultsWriter) writeFile(
-	w io.Writer, v library.File, ipath string) error {
+	w io.Writer, v library.File, res finder.FindResult) error {
 
+	size := "-"
+	if v.Size != nil {
+		size = units.ByteSize(*v.Size).String()
+	}
 	fmt.Fprintf(w, "Name:\t%s\n", v.Name)
-	fmt.Fprintf(w, "  Path:\t%s\n", ipath)
-	fmt.Fprintf(w, "  Size:\t%d\n", v.Size)
+	fmt.Fprintf(w, "  Path:\t%s\n", res.GetPath())
+	fmt.Fprintf(w, "  Size:\t%s\n", size)
 	fmt.Fprintf(w, "  Version:\t%s\n", v.Version)
+
+	if r.cmd.long {
+		fmt.Fprintf(w, "  Datastore Path:\t%s\n", r.cmd.getDatastorePath(res))
+	}
+
 	return nil
 }
 
 func (cmd *info) Run(ctx context.Context, f *flag.FlagSet) error {
-
-	client, err := cmd.Client()
-	if err != nil {
-		return err
-	}
-
 	return cmd.WithRestClient(ctx, func(c *rest.Client) error {
 		m := library.NewManager(c)
 		finder := finder.NewFinder(m)
@@ -167,34 +234,113 @@ func (cmd *info) Run(ctx context.Context, f *flag.FlagSet) error {
 			if t, ok := findResults[i].GetResult().(library.Library); ok {
 				for j := range t.Storage {
 					if t.Storage[j].Type == "DATASTORE" {
-						t.Storage[j].DatastoreID = getDatastoreName(
-							ctx, client, t.Storage[j].DatastoreID)
+						t.Storage[j].DatastoreID = cmd.getDatastoreName(t.Storage[j].DatastoreID)
 					}
 				}
 			}
 		}
-		return cmd.WriteResult(infoResultsWriter(findResults))
+		return cmd.WriteResult(&infoResultsWriter{findResults, m, cmd})
 	})
 }
 
-func getDatastoreName(ctx context.Context, c *vim25.Client, managedObject string) string {
+func (cmd *info) getDatastoreName(id string) string {
+	if name, ok := cmd.names[id]; ok {
+		return name
+	}
+
+	c, err := cmd.Client()
+	if err != nil {
+		return id
+	}
+
 	obj := types.ManagedObjectReference{
 		Type:  "Datastore",
-		Value: managedObject,
+		Value: id,
 	}
 	pc := property.DefaultCollector(c)
 	var me mo.ManagedEntity
 
-	err := pc.RetrieveOne(ctx, obj, []string{"name"}, &me)
+	err = pc.RetrieveOne(context.Background(), obj, []string{"name"}, &me)
 	if err != nil {
-		if soap.IsSoapFault(err) {
-			_, notFound := soap.ToSoapFault(err).VimFault().(types.ManagedObjectNotFound)
-			if notFound {
-				return managedObject
-			}
-		}
-		return managedObject
+		return id
 	}
 
+	cmd.names[id] = me.Name
 	return me.Name
 }
+
+func (cmd *info) getDatastorePath(r finder.FindResult) string {
+	p, _ := cmd.getDatastoreFilePath(r)
+	return p
+}
+
+func (cmd *info) getDatastoreFilePath(r finder.FindResult) (string, error) {
+	switch x := r.GetResult().(type) {
+	case library.Library:
+		id := ""
+		if len(x.Storage) != 0 {
+			id = cmd.getDatastoreName(x.Storage[0].DatastoreID)
+		}
+		return fmt.Sprintf("[%s] contentlib-%s", id, x.ID), nil
+	case library.Item:
+		return fmt.Sprintf("%s/%s", cmd.getDatastorePath(r.GetParent()), x.ID), nil
+	case library.File:
+		return cmd.getDatastoreFileItemPath(r)
+	default:
+		return "", fmt.Errorf("unsupported type=%T", x)
+	}
+}
+
+// getDatastoreFileItemPath returns the absolute datastore path for a library.File
+func (cmd *info) getDatastoreFileItemPath(r finder.FindResult) (string, error) {
+	name := r.GetName()
+	dir := cmd.getDatastorePath(r.GetParent())
+	p := path.Join(dir, name)
+
+	lib := r.GetParent().GetParent().GetResult().(library.Library)
+	if len(lib.Storage) == 0 {
+		return p, nil
+	}
+
+	ctx := context.Background()
+	c, err := cmd.Client()
+	if err != nil {
+		return p, err
+	}
+
+	ref := types.ManagedObjectReference{Type: "Datastore", Value: lib.Storage[0].DatastoreID}
+	ds := object.NewDatastore(c, ref)
+
+	b, err := ds.Browser(ctx)
+	if err != nil {
+		return p, err
+	}
+
+	// The file ID isn't available via the API, so we use DatastoreBrowser to search
+	ext := path.Ext(name)
+	pat := strings.Replace(name, ext, "*"+ext, 1)
+	spec := types.HostDatastoreBrowserSearchSpec{
+		MatchPattern: []string{pat},
+	}
+
+	task, err := b.SearchDatastore(ctx, dir, &spec)
+	if err != nil {
+		return p, err
+	}
+
+	info, err := task.WaitForResult(ctx, nil)
+	if err != nil {
+		return p, err
+	}
+
+	res, ok := info.Result.(types.HostDatastoreBrowserSearchResults)
+	if !ok {
+		return p, fmt.Errorf("search(%s) result type=%T", pat, info.Result)
+	}
+
+	if len(res.File) != 1 {
+		return p, fmt.Errorf("search(%s) result files=%d", pat, len(res.File))
+	}
+
+	return path.Join(dir, res.File[0].GetFileInfo().Path), nil
+}
diff --git a/govc/task/recent.go b/govc/task/recent.go
index 33264cdb..b880030c 100644
--- a/govc/task/recent.go
+++ b/govc/task/recent.go
@@ -79,12 +79,12 @@ func (cmd *recent) Process(ctx context.Context) error {
 	return nil
 }
 
-func chop(s string) string {
-	if len(s) < 30 {
+func chop(s string, n int) string {
+	if len(s) < n {
 		return s
 	}
 
-	return s[:29] + "*"
+	return s[:n-1] + "*"
 }
 
 // taskName describes the tasks similar to the ESX ui
@@ -155,7 +155,7 @@ func (cmd *recent) Run(ctx context.Context, f *flag.FlagSet) error {
 	v.Follow = cmd.follow
 
 	stamp := "15:04:05"
-	tmpl := "%-40s %-30s %15s %9s %9s %9s %s\n"
+	tmpl := "%-40s %-30s %-30s %9s %9s %9s %s\n"
 	fmt.Fprintf(cmd.Out, tmpl, "Task", "Target", "Initiator", "Queued", "Started", "Completed", "Result")
 
 	var last string
@@ -208,7 +208,7 @@ func (cmd *recent) Run(ctx context.Context, f *flag.FlagSet) error {
 
 			result := fmt.Sprintf("%-7s [%s]", info.State, msg)
 
-			item := fmt.Sprintf(tmpl, tn(&info), chop(info.EntityName), user, queued, start, end, result)
+			item := fmt.Sprintf(tmpl, chop(tn(&info), 40), chop(info.EntityName, 30), chop(user, 30), queued, start, end, result)
 
 			if item == last {
 				continue // task info was updated, but the fields we display were not
diff --git a/govc/vm/guest/run.go b/govc/vm/guest/run.go
index 6aa20bd6..279ee6f5 100644
--- a/govc/vm/guest/run.go
+++ b/govc/vm/guest/run.go
@@ -35,6 +35,7 @@ type run struct {
 
 	data    string
 	verbose bool
+	toolbox bool
 	dir     string
 	vars    env
 }
@@ -49,6 +50,7 @@ func (cmd *run) Register(ctx context.Context, f *flag.FlagSet) {
 
 	f.StringVar(&cmd.data, "d", "", "Input data")
 	f.BoolVar(&cmd.verbose, "v", false, "Verbose")
+	f.BoolVar(&cmd.toolbox, "T", false, "Use govmomi/toolbox process I/O")
 	f.StringVar(&cmd.dir, "C", "", "The absolute path of the working directory for the program to start")
 	f.Var(&cmd.vars, "e", "Set environment variable or HTTP header")
 }
@@ -61,20 +63,19 @@ func (cmd *run) Process(ctx context.Context) error {
 }
 
 func (cmd *run) Usage() string {
-	return "NAME [ARG]..."
+	return "PATH [ARG]..."
 }
 
 func (cmd *run) Description() string {
-	return `Run program NAME in VM and display output.
+	return `Run program PATH in VM and display output.
 
-This command depends on govmomi/toolbox running in the VM guest and does not work with standard VMware tools.
-
-If the program NAME is an HTTP verb, the toolbox's http.RoundTripper will be used as the HTTP transport.
+If the program PATH is an HTTP verb, the toolbox's http.RoundTripper will be used as the HTTP transport.
+HTTP commands depend on govmomi/toolbox running in the VM guest and do not work with standard VMware tools.
 
 Examples:
-  govc guest.run -vm $name kubectl get pods
-  govc guest.run -vm $name -d - kubectl create -f - <svc.json
-  govc guest.run -vm $name kubectl delete pod,service my-service
+  govc guest.run -vm $name /usr/bin/kubectl get pods
+  govc guest.run -vm $name -d - /usr/bin/kubectl create -f - <svc.json
+  govc guest.run -vm $name /usr/bin/kubectl delete pod,service my-service
   govc guest.run -vm $name GET http://localhost:8080/api/v1/nodes
   govc guest.run -vm $name -e Content-Type:application/json -d - POST http://localhost:8080/api/v1/namespaces/default/pods <svc.json
   govc guest.run -vm $name DELETE http://localhost:8080/api/v1/namespaces/default/services/my-service`
@@ -153,6 +154,9 @@ func (cmd *run) Run(ctx context.Context, f *flag.FlagSet) error {
 			ecmd.Stdin = bytes.NewBuffer([]byte(cmd.data))
 		}
 
+		if cmd.toolbox {
+			return tc.RunToolbox(ctx, ecmd)
+		}
 		return tc.Run(ctx, ecmd)
 	}
 }
diff --git a/guest/toolbox/client.go b/guest/toolbox/client.go
index 88e5728d..005e44ee 100644
--- a/guest/toolbox/client.go
+++ b/guest/toolbox/client.go
@@ -22,11 +22,13 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"log"
 	"net/http"
 	"net/url"
 	"os"
 	"os/exec"
 	"strings"
+	"time"
 
 	"github.com/vmware/govmomi/guest"
 	"github.com/vmware/govmomi/vim25/soap"
@@ -137,8 +139,8 @@ func (c *Client) RoundTrip(req *http.Request) (*http.Response, error) {
 	return http.ReadResponse(bufio.NewReader(f), req)
 }
 
-// Run implements exec.Cmd.Run over vmx guest RPC.
-func (c *Client) Run(ctx context.Context, cmd *exec.Cmd) error {
+// Run implements exec.Cmd.Run over vmx guest RPC against govmomi/toolbox.
+func (c *Client) RunToolbox(ctx context.Context, cmd *exec.Cmd) error {
 	vc := c.ProcessManager.Client()
 
 	spec := types.GuestProgramSpec{
@@ -229,6 +231,145 @@ func (c *Client) Run(ctx context.Context, cmd *exec.Cmd) error {
 	return nil
 }
 
+func (c *Client) rm(ctx context.Context, path string) {
+	err := c.FileManager.DeleteFile(ctx, c.Authentication, path)
+	if err != nil {
+		log.Printf("rm %q: %s", path, err)
+	}
+}
+
+func (c *Client) mktemp(ctx context.Context) (string, error) {
+	return c.FileManager.CreateTemporaryFile(ctx, c.Authentication, "govmomi-", "", "")
+}
+
+// Run implements exec.Cmd.Run over vmx guest RPC against standard vmware-tools or toolbox.
+func (c *Client) Run(ctx context.Context, cmd *exec.Cmd) error {
+	vc := c.ProcessManager.Client()
+
+	if cmd.Stdin != nil {
+		dst, err := c.mktemp(ctx)
+		if err != nil {
+			return err
+		}
+
+		defer c.rm(ctx, dst)
+
+		var buf bytes.Buffer
+		size, err := io.Copy(&buf, cmd.Stdin)
+		if err != nil {
+			return err
+		}
+
+		attr := new(types.GuestPosixFileAttributes)
+
+		url, err := c.FileManager.InitiateFileTransferToGuest(ctx, c.Authentication, dst, attr, size, true)
+		if err != nil {
+			return err
+		}
+
+		u, err := c.FileManager.TransferURL(ctx, url)
+		if err != nil {
+			return err
+		}
+
+		p := soap.DefaultUpload
+		p.ContentLength = size
+
+		err = vc.Client.Upload(ctx, &buf, u, &p)
+		if err != nil {
+			return err
+		}
+
+		cmd.Args = append(cmd.Args, "<", dst)
+	}
+
+	output := []struct {
+		io.Writer
+		fd   string
+		path string
+	}{
+		{cmd.Stdout, "1", ""},
+		{cmd.Stderr, "2", ""},
+	}
+
+	for i, out := range output {
+		if out.Writer == nil {
+			continue
+		}
+
+		dst, err := c.mktemp(ctx)
+		if err != nil {
+			return err
+		}
+
+		defer c.rm(ctx, dst)
+
+		cmd.Args = append(cmd.Args, out.fd+">", dst)
+		output[i].path = dst
+	}
+
+	spec := types.GuestProgramSpec{
+		ProgramPath:      cmd.Path,
+		Arguments:        strings.Join(cmd.Args, " "),
+		EnvVariables:     cmd.Env,
+		WorkingDirectory: cmd.Dir,
+	}
+
+	pid, err := c.ProcessManager.StartProgram(ctx, c.Authentication, &spec)
+	if err != nil {
+		return err
+	}
+
+	for {
+		procs, err := c.ProcessManager.ListProcesses(ctx, c.Authentication, []int64{pid})
+		if err != nil {
+			return err
+		}
+
+		p := procs[0]
+		if p.EndTime != nil {
+			<-time.After(time.Second / 2)
+			continue
+		}
+
+		rc := p.ExitCode
+		if rc != 0 {
+			return fmt.Errorf("%s: exit %d", cmd.Path, rc)
+		}
+
+		break
+	}
+
+	for _, out := range output {
+		if out.Writer == nil {
+			continue
+		}
+
+		info, err := c.procReader(ctx, out.path)
+		if err != nil {
+			return err
+		}
+
+		u, err := c.FileManager.TransferURL(ctx, info.Url)
+		if err != nil {
+			return err
+		}
+
+		f, _, err := vc.Client.Download(ctx, u, &soap.DefaultDownload)
+		if err != nil {
+			return err
+		}
+
+		_, err = io.Copy(out.Writer, f)
+		_ = f.Close()
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // archiveReader wraps an io.ReadCloser to support streaming download
 // of a guest directory, stops reading once it sees the stream trailer.
 // This is only useful when guest tools is the Go toolbox.
diff --git a/simulator/simulator.go b/simulator/simulator.go
index 944fa987..ae58106e 100644
--- a/simulator/simulator.go
+++ b/simulator/simulator.go
@@ -50,11 +50,16 @@ import (
 	"github.com/vmware/govmomi/vim25/xml"
 )
 
-// Trace when set to true, writes SOAP traffic to stderr
-var Trace = false
+var (
+	// Trace when set to true, writes SOAP traffic to stderr
+	Trace = false
+
+	// TraceFile is the output file when Trace = true
+	TraceFile = os.Stderr
 
-// DefaultLogin for authentication
-var DefaultLogin = url.UserPassword("user", "pass")
+	// DefaultLogin for authentication
+	DefaultLogin = url.UserPassword("user", "pass")
+)
 
 // Method encapsulates a decoded SOAP client request
 type Method struct {
@@ -412,6 +417,11 @@ func (s *Service) RegisterSDK(r *Registry) {
 	s.ServeMux.HandleFunc(r.Path, s.ServeSDK)
 }
 
+// StatusSDK can be used to simulate an /sdk HTTP response code other than 200.
+// The value of StatusSDK is restored to http.StatusOK after 1 response.
+// This can be useful to test vim25.Retry() for example.
+var StatusSDK = http.StatusOK
+
 // ServeSDK implements the http.Handler interface
 func (s *Service) ServeSDK(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
@@ -419,6 +429,12 @@ func (s *Service) ServeSDK(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if StatusSDK != http.StatusOK {
+		w.WriteHeader(StatusSDK)
+		StatusSDK = http.StatusOK // reset
+		return
+	}
+
 	body, err := s.readAll(r.Body)
 	_ = r.Body.Close()
 	if err != nil {
@@ -428,7 +444,7 @@ func (s *Service) ServeSDK(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if Trace {
-		fmt.Fprintf(os.Stderr, "Request: %s\n", string(body))
+		fmt.Fprintf(TraceFile, "Request: %s\n", string(body))
 	}
 
 	ctx := &Context{
@@ -499,7 +515,7 @@ func (s *Service) ServeSDK(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if Trace {
-		fmt.Fprintf(os.Stderr, "Response: %s\n", out.String())
+		fmt.Fprintf(TraceFile, "Response: %s\n", out.String())
 	}
 
 	_, _ = w.Write(out.Bytes())
diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go
index cfc19a64..144bf9b8 100644
--- a/simulator/virtual_machine.go
+++ b/simulator/virtual_machine.go
@@ -1537,6 +1537,10 @@ func (vm *VirtualMachine) CloneVMTask(ctx *Context, req *types.CloneVM_Task) soa
 			clone.configureDevices(&types.VirtualMachineConfigSpec{DeviceChange: req.Spec.Config.DeviceChange})
 		}
 
+		if req.Spec.Template {
+			_ = clone.MarkAsTemplate(&types.MarkAsTemplate{This: clone.Self})
+		}
+
 		ctx.postEvent(&types.VmClonedEvent{
 			VmCloneEvent: types.VmCloneEvent{VmEvent: clone.event()},
 			SourceVm:     *event.Vm,
@@ -1843,6 +1847,7 @@ func (vm *VirtualMachine) MarkAsTemplate(req *types.MarkAsTemplate) soap.HasFaul
 	}
 
 	vm.Config.Template = true
+	vm.Summary.Config.Template = true
 
 	r.Res = &types.MarkAsTemplateResponse{}
 
diff --git a/vapi/library/library_item.go b/vapi/library/library_item.go
index 6b98d280..c5364177 100644
--- a/vapi/library/library_item.go
+++ b/vapi/library/library_item.go
@@ -25,6 +25,12 @@ import (
 	"github.com/vmware/govmomi/vapi/internal"
 )
 
+const (
+	ItemTypeISO  = "iso"
+	ItemTypeOVF  = "ovf"
+	ItemTypeVMTX = "vm-template"
+)
+
 // Item provides methods to create, read, update, delete, and enumerate library items.
 type Item struct {
 	Cached           bool       `json:"cached,omitempty"`
diff --git a/vapi/simulator/simulator.go b/vapi/simulator/simulator.go
index 22947517..ce89784a 100644
--- a/vapi/simulator/simulator.go
+++ b/vapi/simulator/simulator.go
@@ -660,6 +660,8 @@ func (s *handler) library(w http.ResponseWriter, r *http.Request) {
 		case "":
 			id := uuid.New().String()
 			spec.Library.ID = id
+			spec.Library.CreationTime = types.NewTime(time.Now())
+			spec.Library.LastModifiedTime = types.NewTime(time.Now())
 			dir := libraryPath(&spec.Library, "")
 			if err := os.Mkdir(dir, 0750); err != nil {
 				s.error(w, err)
@@ -765,6 +767,8 @@ func (s *handler) libraryItem(w http.ResponseWriter, r *http.Request) {
 			}
 			id = uuid.New().String()
 			spec.Item.ID = id
+			spec.Item.CreationTime = types.NewTime(time.Now())
+			spec.Item.LastModifiedTime = types.NewTime(time.Now())
 			l.Item[id] = &item{Item: &spec.Item}
 			OK(w, id)
 		}
@@ -1563,10 +1567,12 @@ func (s *handler) templateCreate(l content, deploy vcenter.Template) error {
 	id := uuid.New().String()
 	l.Item[id] = &item{
 		Item: &library.Item{
-			ID:        id,
-			LibraryID: l.Library.ID,
-			Name:      deploy.Name,
-			Type:      "vm-template",
+			ID:               id,
+			LibraryID:        l.Library.ID,
+			Name:             deploy.Name,
+			Type:             library.ItemTypeVMTX,
+			CreationTime:     types.NewTime(time.Now()),
+			LastModifiedTime: types.NewTime(time.Now()),
 		},
 		Template: ref,
 	}
@@ -1602,10 +1608,12 @@ func (s *handler) libraryItemCreateTemplate(w http.ResponseWriter, r *http.Reque
 	id := uuid.New().String()
 	l.Item[id] = &item{
 		Item: &library.Item{
-			ID:        id,
-			LibraryID: l.Library.ID,
-			Name:      spec.Name,
-			Type:      "vm-template",
+			ID:               id,
+			LibraryID:        l.Library.ID,
+			Name:             spec.Name,
+			Type:             library.ItemTypeVMTX,
+			CreationTime:     types.NewTime(time.Now()),
+			LastModifiedTime: types.NewTime(time.Now()),
 		},
 		Template: ref,
 	}
@@ -1639,7 +1647,7 @@ func (s *handler) libraryItemTemplateID(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	if item.Type != "vm-template" {
+	if item.Type != library.ItemTypeVMTX {
 		BadRequest(w, "com.vmware.vapi.std.errors.invalid_argument")
 		return
 	}
diff --git a/vapi/vcenter/vcenter_ovf.go b/vapi/vcenter/vcenter_ovf.go
index 2799177f..de7c3d28 100644
--- a/vapi/vcenter/vcenter_ovf.go
+++ b/vapi/vcenter/vcenter_ovf.go
@@ -61,6 +61,7 @@ type AdditionalParams struct {
 
 const (
 	ClassOvfParams             = "com.vmware.vcenter.ovf.ovf_params"
+	ClassPropertyParams        = "com.vmware.vcenter.ovf.property_params"
 	TypeDeploymentOptionParams = "DeploymentOptionParams"
 	TypeExtraConfigParams      = "ExtraConfigParams"
 	TypeExtraConfigs           = "ExtraConfigs"
diff --git a/vcsim/main.go b/vcsim/main.go
index b571fafd..246bda0a 100644
--- a/vcsim/main.go
+++ b/vcsim/main.go
@@ -72,7 +72,8 @@ func main() {
 	user := flag.String("username", "", "Login username for vcsim (any username allowed by default)")
 	pass := flag.String("password", "", "Login password for vcsim (any password allowed by default)")
 	tunnel := flag.Int("tunnel", -1, "SDK tunnel port")
-	flag.BoolVar(&simulator.Trace, "trace", simulator.Trace, "Trace SOAP to stderr")
+	flag.BoolVar(&simulator.Trace, "trace", simulator.Trace, "Trace SOAP to -trace-file")
+	trace := flag.String("trace-file", "", "Trace output file (defaults to stderr)")
 	stdinExit := flag.Bool("stdinexit", false, "Press any key to exit")
 	dir := flag.String("load", "", "Load model from directory")
 
@@ -81,6 +82,16 @@ func main() {
 	flag.Float64Var(&model.DelayConfig.DelayJitter, "delay-jitter", model.DelayConfig.DelayJitter, "Delay jitter coefficient of variation (tip: 0.5 is a good starting value)")
 
 	flag.Parse()
+
+	if *trace != "" {
+		var err error
+		simulator.TraceFile, err = os.Create(*trace)
+		if err != nil {
+			log.Fatal(err)
+		}
+		simulator.Trace = true
+	}
+
 	methodDelay := *methodDelayP
 	u := &url.URL{Host: *listen}
 	if *user != "" {
@@ -211,6 +222,10 @@ func main() {
 	<-sig
 
 	model.Remove()
+
+	if *trace != "" {
+		_ = simulator.TraceFile.Close()
+	}
 }
 
 func updateHostTemplate(ip string) error {
diff --git a/vim25/retry.go b/vim25/retry.go
index b8807eef..f10e2cb6 100644
--- a/vim25/retry.go
+++ b/vim25/retry.go
@@ -18,8 +18,6 @@ package vim25
 
 import (
 	"context"
-	"net"
-	"net/url"
 	"time"
 
 	"github.com/vmware/govmomi/vim25/soap"
@@ -32,22 +30,18 @@ type RetryFunc func(err error) (retry bool, delay time.Duration)
 // network error (for example: a connect timeout).
 func TemporaryNetworkError(n int) RetryFunc {
 	return func(err error) (retry bool, delay time.Duration) {
-		var nerr net.Error
 		var ok bool
 
-		// Never retry if this is not a network error.
-		switch rerr := err.(type) {
-		case *url.Error:
-			if nerr, ok = rerr.Err.(net.Error); !ok {
-				return false, 0
-			}
-		case net.Error:
-			nerr = rerr
-		default:
+		t, ok := err.(interface {
+			// Temporary is implemented by url.Error and net.Error
+			Temporary() bool
+		})
+		if !ok {
+			// Never retry if this is not a Temporary error.
 			return false, 0
 		}
 
-		if !nerr.Temporary() {
+		if !t.Temporary() {
 			return false, 0
 		}
 
diff --git a/vim25/soap/client.go b/vim25/soap/client.go
index 5b725f33..e6acd3d0 100644
--- a/vim25/soap/client.go
+++ b/vim25/soap/client.go
@@ -532,6 +532,32 @@ func (c *Client) WithHeader(ctx context.Context, header Header) context.Context
 	return context.WithValue(ctx, headerContext{}, header)
 }
 
+type statusError struct {
+	res *http.Response
+}
+
+// Temporary returns true for HTTP response codes that can be retried
+// See vim25.TemporaryNetworkError
+func (e *statusError) Temporary() bool {
+	switch e.res.StatusCode {
+	case http.StatusBadGateway:
+		return true
+	}
+	return false
+}
+
+func (e *statusError) Error() string {
+	return e.res.Status
+}
+
+func newStatusError(res *http.Response) error {
+	return &url.Error{
+		Op:  res.Request.Method,
+		URL: res.Request.URL.Path,
+		Err: &statusError{res},
+	}
+}
+
 func (c *Client) RoundTrip(ctx context.Context, reqBody, resBody HasFault) error {
 	var err error
 	var b []byte
@@ -587,7 +613,7 @@ func (c *Client) RoundTrip(ctx context.Context, reqBody, resBody HasFault) error
 		case http.StatusInternalServerError:
 			// Error, but typically includes a body explaining the error
 		default:
-			return errors.New(res.Status)
+			return newStatusError(res)
 		}
 
 		dec := xml.NewDecoder(res.Body)
diff --git a/vslm/global_object_manager.go b/vslm/global_object_manager.go
index 60cb208f..29c8f4f3 100644
--- a/vslm/global_object_manager.go
+++ b/vslm/global_object_manager.go
@@ -530,6 +530,22 @@ func (this *GlobalObjectManager) Revert(ctx context.Context, id vim.ID, snapshot
 	return NewTask(this.c, res.Returnval), nil
 }
 
+func (this *GlobalObjectManager) RetrieveSnapshotDetails(ctx context.Context, id vim.ID, snapshotId vim.ID) (
+	*vim.VStorageObjectSnapshotDetails, error) {
+	req := types.VslmRetrieveSnapshotDetails{
+		This:       this.Reference(),
+		Id:         id,
+		SnapshotId: snapshotId,
+	}
+
+	res, err := methods.VslmRetrieveSnapshotDetails(ctx, this.c, &req)
+	if err != nil {
+		return nil, err
+	}
+
+	return &res.Returnval, nil
+}
+
 func (this *GlobalObjectManager) QueryChangedDiskAreas(ctx context.Context, id vim.ID, snapshotId vim.ID, startOffset int64,
 	changeId string) (*vim.DiskChangeInfo, error) {
 	req := types.VslmQueryChangedDiskAreas{


Test patch:
diff --git a/govc/test/library.bats b/govc/test/library.bats
index e1c6421e..f89ea4d9 100755
--- a/govc/test/library.bats
+++ b/govc/test/library.bats
@@ -136,6 +136,16 @@ load test_helper
   run govc library.import -n ttylinux-live /my-content "$GOVC_IMAGES/$TTYLINUX_NAME.iso"
   assert_success
 
+  run govc library.info -l "/my-content/ttylinux-live/$TTYLINUX_NAME.iso"
+  assert_success
+
+  run govc library.info -L /my-content/ttylinux-live/
+  assert_success
+  assert_matches contentlib-
+  file="$output"
+  run govc datastore.ls "$file"
+  assert_matches "$TTYLINUX_NAME.iso"
+
   run govc library.ls "/my-content/ttylinux-live/*"
   assert_success
   assert_matches "$TTYLINUX_NAME.iso"
@@ -258,6 +268,12 @@ EOF
   run govc library.checkin -vm my-vm-checkout my-content/$item
   assert_success
 
+  run govc object.collect -s vm/$item config.template
+  assert_success "true"
+
+  run govc object.collect -s vm/$item summary.config.template
+  assert_success "true"
+
   run govc vm.destroy $item
   assert_success # expected to delete the CL item too
 
diff --git a/govc/test/vcsim.bats b/govc/test/vcsim.bats
index 7a848bbf..c9010bf9 100755
--- a/govc/test/vcsim.bats
+++ b/govc/test/vcsim.bats
@@ -418,3 +418,19 @@ docker_name() {
   objs=$(govc find / | wc -l)
   assert_equal 23 "$objs"
 }
+
+@test "vcsim trace file" {
+  file="$BATS_TMPDIR/$(new_id).trace"
+
+  vcsim_start -trace-file "$file"
+
+  run govc ls
+  assert_success
+
+  vcsim_stop
+
+  run ls -l "$file"
+  assert_success
+
+  rm "$file"
+}
diff --git a/vim25/examples_test.go b/vim25/examples_test.go
new file mode 100644
index 00000000..f4bd6999
--- /dev/null
+++ b/vim25/examples_test.go
@@ -0,0 +1,52 @@
+/*
+Copyright (c) 2019 VMware, Inc. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package vim25_test
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/vmware/govmomi/find"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vim25"
+)
+
+func ExampleTemporaryNetworkError() {
+	simulator.Run(func(ctx context.Context, c *vim25.Client) error {
+		// Configure retry handler
+		c.RoundTripper = vim25.Retry(c.Client, vim25.TemporaryNetworkError(3))
+
+		vm, err := find.NewFinder(c).VirtualMachine(ctx, "DC0_H0_VM0")
+		if err != nil {
+			return err
+		}
+
+		// Tell vcsim to respond with 502 on the 1st request
+		simulator.StatusSDK = http.StatusBadGateway
+
+		state, err := vm.PowerState(ctx)
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(state)
+
+		return nil
+	})
+	// Output: poweredOn
+}