Repository: vmware-tanzu/octant
Pull request #885 (2020-04-27 21:11:57)

Enable editing of objects
**What this PR does / why we need it**:

This change adds a preview of an editor for object YAML


wwitzel3: Taking a look this morning.

Patch:
diff --git a/internal/api/container_logs.go b/internal/api/container_logs.go
index 6b669101..d1266c8e 100644
--- a/internal/api/container_logs.go
+++ b/internal/api/container_logs.go
@@ -8,12 +8,13 @@ package api
 import (
 	"context"
 	"fmt"
-	"github.com/vmware-tanzu/octant/internal/gvk"
-	"github.com/vmware-tanzu/octant/pkg/store"
 	"strings"
 	"sync"
 	"time"
 
+	"github.com/vmware-tanzu/octant/internal/gvk"
+	"github.com/vmware-tanzu/octant/pkg/store"
+
 	"github.com/vmware-tanzu/octant/internal/config"
 	"github.com/vmware-tanzu/octant/internal/modules/overview/container"
 	"github.com/vmware-tanzu/octant/internal/octant"
@@ -26,10 +27,6 @@ type logEntry struct {
 	Message   string     `json:"message,omitempty"`
 }
 
-type logResponse struct {
-	Entries []logEntry `json:"entries,omitempty"`
-}
-
 const (
 	RequestPodLogsSubscribe   = "action.octant.dev/podLogs/subscribe"
 	RequestPodLogsUnsubscribe = "action.octant.dev/podLogs/unsubscribe"
@@ -67,7 +64,7 @@ func (s *podLogsStateManager) Handlers() []octant.ClientRequestHandler {
 	}
 }
 
-func (s *podLogsStateManager) StreamPodLogsSubscribe(state octant.State, payload action.Payload) error {
+func (s *podLogsStateManager) StreamPodLogsSubscribe(_ octant.State, payload action.Payload) error {
 	namespace, err := payload.String("namespace")
 	if err != nil {
 		return fmt.Errorf("getting namespace from payload: %w", err)
@@ -107,7 +104,7 @@ func (s *podLogsStateManager) StreamPodLogsSubscribe(state octant.State, payload
 	return nil
 }
 
-func (s *podLogsStateManager) StreamPodLogsUnsubscribe(state octant.State, payload action.Payload) error {
+func (s *podLogsStateManager) StreamPodLogsUnsubscribe(_ octant.State, payload action.Payload) error {
 	namespace, err := payload.String("namespace")
 	if err != nil {
 		return fmt.Errorf("getting namespace from payload: %w", err)
@@ -131,18 +128,17 @@ func (s *podLogsStateManager) StreamPodLogsUnsubscribe(state octant.State, paylo
 	return nil
 }
 
-func (s *podLogsStateManager) Start(ctx context.Context, state octant.State, client OctantClient) {
+func (s *podLogsStateManager) Start(ctx context.Context, _ octant.State, client OctantClient) {
 	s.client = client
 	s.ctx = ctx
 }
 
-func (s *podLogsStateManager) sendLogEvents(ctx context.Context, logEventType octant.EventType, logCh <-chan container.LogEntry) {
-	ctx, cancelFn := context.WithCancel(s.ctx)
-	for {
+func (s *podLogsStateManager) streamEventsToClient(ctx context.Context, logEventType octant.EventType, logCh <-chan container.LogEntry) {
+	done := false
+	for !done {
 		select {
 		case <-ctx.Done():
-			cancelFn()
-			return
+			done = true
 		case entry, ok := <-logCh:
 			if ok {
 				le := newLogEntry(entry.Line(), entry.Container())
@@ -153,8 +149,7 @@ func (s *podLogsStateManager) sendLogEvents(ctx context.Context, logEventType oc
 				}
 				s.client.Send(logEvent)
 			} else {
-				cancelFn()
-				return
+				done = true
 			}
 		}
 	}
@@ -165,7 +160,7 @@ func (s *podLogsStateManager) startStream(key store.Key, logStreamer container.L
 
 	eventType := octant.NewLoggingEventType(key.Namespace, key.Name)
 	logCh := make(chan container.LogEntry)
-	go s.sendLogEvents(ctx, eventType, logCh)
+	go s.streamEventsToClient(ctx, eventType, logCh)
 
 	logStreamer.Stream(ctx, logCh)
 
diff --git a/internal/api/websocket_client.go b/internal/api/websocket_client.go
index cedf8f82..606c017c 100644
--- a/internal/api/websocket_client.go
+++ b/internal/api/websocket_client.go
@@ -34,7 +34,7 @@ const (
 	pingPeriod = (pongWait * 9) / 10
 
 	// maxMessageSize is the maximum message size allowed from peer.
-	maxMessageSize = 512
+	maxMessageSize = 2 * 1024 * 1024 // 2MiB
 )
 
 // WebsocketClient manages websocket clients.
diff --git a/internal/modules/overview/overview.go b/internal/modules/overview/overview.go
index c09bc06a..0058bf22 100644
--- a/internal/modules/overview/overview.go
+++ b/internal/modules/overview/overview.go
@@ -278,6 +278,7 @@ func (co *Overview) ActionPaths() map[string]action.DispatcherFunc {
 		octant.NewCordon(co.dashConfig.ObjectStore(), co.dashConfig.ClusterClient()),
 		octant.NewUncordon(co.dashConfig.ObjectStore(), co.dashConfig.ClusterClient()),
 		octant.NewCronJobTrigger(co.dashConfig.ObjectStore(), co.dashConfig.ClusterClient()),
+		octant.NewObjectUpdaterDispatcher(co.dashConfig.ObjectStore()),
 	}
 
 	return dispatchers.ToActionPaths()
diff --git a/internal/modules/overview/yamlviewer/yamlviewer.go b/internal/modules/overview/yamlviewer/yamlviewer.go
index e9682a27..28bca2ce 100644
--- a/internal/modules/overview/yamlviewer/yamlviewer.go
+++ b/internal/modules/overview/yamlviewer/yamlviewer.go
@@ -39,7 +39,7 @@ func new(object runtime.Object) (*yamlViewer, error) {
 
 // ToComponent converts the YAMLViewer to a component.
 func (yv *yamlViewer) ToComponent() (*component.Editor, error) {
-	y := component.NewEditor(component.TitleFromString("YAML"), "", true)
+	y := component.NewEditor(component.TitleFromString("YAML"), "", false)
 	if err := y.SetValueFromObject(yv.object); err != nil {
 		return nil, errors.Wrap(err, "add YAML data")
 	}
diff --git a/internal/octant/actions.go b/internal/octant/actions.go
index 75ce56a8..6404c098 100644
--- a/internal/octant/actions.go
+++ b/internal/octant/actions.go
@@ -1,5 +1,11 @@
 package octant
 
+import (
+	"time"
+
+	"github.com/vmware-tanzu/octant/pkg/action"
+)
+
 const (
 	ActionDeleteObject            = "action.octant.dev/deleteObject"
 	ActionOverviewCordon          = "action.octant.dev/cordon"
@@ -8,4 +14,15 @@ const (
 	ActionOverviewCronjob         = "action.octant.dev/cronJob"
 	ActionOverviewServiceEditor   = "action.octant.dev/serviceEditor"
 	ActionDeploymentConfiguration = "action.octant.dev/deploymentConfiguration"
+	ActionUpdateObject            = "action.octant.dev/update"
 )
+
+func sendAlert(alerter action.Alerter, alertType action.AlertType, message string, expiration *time.Time) {
+	alert := action.Alert{
+		Type:       alertType,
+		Message:    message,
+		Expiration: expiration,
+	}
+
+	alerter.SendAlert(alert)
+}
diff --git a/internal/octant/object_updater.go b/internal/octant/object_updater.go
new file mode 100644
index 00000000..cb0c4831
--- /dev/null
+++ b/internal/octant/object_updater.go
@@ -0,0 +1,122 @@
+/*
+ *  Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ *  SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+package octant
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/vmware-tanzu/octant/internal/log"
+	"github.com/vmware-tanzu/octant/internal/util/kubernetes"
+	"github.com/vmware-tanzu/octant/pkg/action"
+	"github.com/vmware-tanzu/octant/pkg/store"
+)
+
+// ObjectUpdateFromPayload loads an object from the payload.
+// The object source in YAML format should exist in the `update` key.
+func ObjectUpdateFromPayload(payload action.Payload) (*unstructured.Unstructured, error) {
+	s, err := payload.String("update")
+	if err != nil {
+		return nil, fmt.Errorf("read object source from payload: %w", err)
+	}
+
+	object, err := kubernetes.ReadObject(strings.NewReader(s))
+	if err != nil {
+		return nil, fmt.Errorf("read object from payload: %w", err)
+	}
+
+	return object, nil
+}
+
+type ObjectUpdaterDispatcherOption func(dispatcher *ObjectUpdaterDispatcher)
+
+// ObjectUpdaterDispatcher is an action that updates an object.
+type ObjectUpdaterDispatcher struct {
+	store             store.Store
+	objectFromPayload func(payload action.Payload) (*unstructured.Unstructured, error)
+}
+
+var _ action.Dispatcher = &ObjectUpdaterDispatcher{}
+
+// NewObjectUpdaterDispatcher creates an instance of ObjectUpdaterDispatcher.
+func NewObjectUpdaterDispatcher(objectStore store.Store, options ...ObjectUpdaterDispatcherOption) *ObjectUpdaterDispatcher {
+	o := ObjectUpdaterDispatcher{
+		store:             objectStore,
+		objectFromPayload: ObjectUpdateFromPayload,
+	}
+
+	for _, option := range options {
+		option(&o)
+	}
+
+	return &o
+}
+
+// ActionName returns the action name this dispatcher responds to.
+func (o ObjectUpdaterDispatcher) ActionName() string {
+	return ActionUpdateObject
+}
+
+// Handle updates an object using a payload if possible.
+func (o ObjectUpdaterDispatcher) Handle(ctx context.Context, alerter action.Alerter, payload action.Payload) error {
+	logger := log.From(ctx)
+	expiration := time.Now().Add(10 * time.Second)
+
+	object, err := o.objectFromPayload(payload)
+	if err != nil {
+		sendAlert(
+			alerter,
+			action.AlertTypeError,
+			fmt.Sprintf("load object from payload: %v", err.Error()),
+			&expiration)
+		return nil
+	}
+
+	key, _ := store.KeyFromPayload(payload)
+	err = o.store.Update(ctx, key, func(u *unstructured.Unstructured) error {
+		if object.GetAPIVersion() != u.GetAPIVersion() {
+			return fmt.Errorf("object API version cannot be updated")
+		}
+		if object.GetKind() != u.GetKind() {
+			return fmt.Errorf("object kind cannot be updated")
+		}
+		if object.GetName() != u.GetName() {
+			return fmt.Errorf("object name cannot be updated")
+		}
+
+		delete(object.Object, "status")
+
+		for k := range object.Object {
+			u.Object[k] = object.Object[k]
+		}
+		return nil
+	})
+
+	if err != nil {
+		sendAlert(
+			alerter,
+			action.AlertTypeError,
+			fmt.Sprintf("update object: %s", err.Error()),
+			&expiration)
+
+		logger.WithErr(err).Errorf("update object")
+		return nil
+	}
+
+	successMessage := fmt.Sprintf("Updated %s (%s) %s in %s",
+		object.GetKind(),
+		object.GetAPIVersion(),
+		object.GetName(),
+		object.GetNamespace())
+	sendAlert(alerter, action.AlertTypeInfo, successMessage, &expiration)
+
+	return nil
+}
diff --git a/internal/util/kubernetes/object.go b/internal/util/kubernetes/object.go
new file mode 100644
index 00000000..95c5b143
--- /dev/null
+++ b/internal/util/kubernetes/object.go
@@ -0,0 +1,64 @@
+/*
+ *  Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ *  SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+package kubernetes
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	k8sJSON "k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/apimachinery/pkg/util/yaml"
+	"k8s.io/client-go/tools/clientcmd/api/latest"
+)
+
+// ReadObject reads an unstructured object from a reader.
+func ReadObject(r io.Reader) (*unstructured.Unstructured, error) {
+	d := yaml.NewYAMLOrJSONDecoder(r, 4096)
+	ext := runtime.RawExtension{}
+	if err := d.Decode(&ext); err != nil {
+		return nil, fmt.Errorf("decode YAML: %w", err)
+	}
+
+	obj, _, err := unstructured.UnstructuredJSONScheme.Decode(ext.Raw, nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("decode YAML into object: %w", err)
+	}
+
+	u, ok := obj.(*unstructured.Unstructured)
+	if !ok {
+		return nil, fmt.Errorf("obect is not unstructured (%T)", obj)
+	}
+
+	return u, nil
+}
+
+// SerializeToString serializes an object to a YAML string.
+func SerializeToString(object runtime.Object) (string, error) {
+	if object == nil {
+		return "", fmt.Errorf("object is nil")
+	}
+
+	options := k8sJSON.SerializerOptions{
+		Yaml:   true,
+		Pretty: true,
+		Strict: false,
+	}
+	yamlSerializer := k8sJSON.NewSerializerWithOptions(k8sJSON.DefaultMetaFactory, latest.Scheme, latest.Scheme, options)
+
+	var sb strings.Builder
+	if _, err := sb.WriteString("---\n"); err != nil {
+		return "", err
+	}
+	if err := yamlSerializer.Encode(object, &sb); err != nil {
+		return "", fmt.Errorf("encoding object as YAML: %w", err)
+	}
+
+	return sb.String(), nil
+}
diff --git a/pkg/action/manager.go b/pkg/action/manager.go
index fdf0b462..56a6e75d 100644
--- a/pkg/action/manager.go
+++ b/pkg/action/manager.go
@@ -33,6 +33,9 @@ const (
 
 	// AlertTypeInfo is for info alerts.
 	AlertTypeInfo AlertType = "INFO"
+
+	// AlertTypeSuccess is for success alerts.
+	AlertTypeSuccess AlertType = "SUCCESS"
 )
 
 // Alert is an alert message.
@@ -90,7 +93,7 @@ func (d Dispatchers) ToActionPaths() map[string]DispatcherFunc {
 
 // Manager manages actions.
 type Manager struct {
-	logger     log.Logger
+	logger log.Logger
 
 	// key: string, value: []DispatcherFunc
 	dispatches sync.Map
diff --git a/pkg/view/component/editor.go b/pkg/view/component/editor.go
index 1a522556..7fff0426 100644
--- a/pkg/view/component/editor.go
+++ b/pkg/view/component/editor.go
@@ -7,12 +7,12 @@ package component
 
 import (
 	"encoding/json"
-	"strings"
+	"fmt"
 
-	"github.com/pkg/errors"
 	"k8s.io/apimachinery/pkg/runtime"
-	k8sJSON "k8s.io/apimachinery/pkg/runtime/serializer/json"
-	"k8s.io/client-go/tools/clientcmd/api/latest"
+
+	"github.com/vmware-tanzu/octant/internal/util/kubernetes"
+	"github.com/vmware-tanzu/octant/pkg/store"
 )
 
 // Value is a component for code
@@ -23,8 +23,9 @@ type Editor struct {
 
 // CodeConfig is the contents of Value
 type EditorConfig struct {
-	Value    string `json:"value"`
-	ReadOnly bool   `json:"readOnly"`
+	Value    string            `json:"value"`
+	ReadOnly bool              `json:"readOnly"`
+	Metadata map[string]string `json:"metadata"`
 }
 
 // NewCodeBlock creates a code component
@@ -39,17 +40,24 @@ func NewEditor(title []TitleComponent, value string, readOnly bool) *Editor {
 }
 
 func (e *Editor) SetValueFromObject(object runtime.Object) error {
-	yamlSerializer := k8sJSON.NewYAMLSerializer(k8sJSON.DefaultMetaFactory, latest.Scheme, latest.Scheme)
-
-	var sb strings.Builder
-	if _, err := sb.WriteString("---\n"); err != nil {
-		return err
+	s, err := kubernetes.SerializeToString(object)
+	if err != nil {
+		return fmt.Errorf("serialize object: %w", err)
 	}
-	if err := yamlSerializer.Encode(object, &sb); err != nil {
-		return errors.Wrap(err, "encoding object as YAML")
+
+	e.Config.Value = s
+
+	key, err := store.KeyFromObject(object)
+	if err != nil {
+		return fmt.Errorf("create key from object: %w", err)
 	}
 
-	e.Config.Value = sb.String()
+	e.Config.Metadata = map[string]string{
+		"namespace":  key.Namespace,
+		"apiVersion": key.APIVersion,
+		"kind":       key.Kind,
+		"name":       key.Name,
+	}
 
 	return nil
 }
diff --git a/web/package-lock.json b/web/package-lock.json
index d690b39a..1f0b72be 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -18,6 +18,17 @@
       "requires": {
         "@angular-devkit/core": "9.1.0",
         "rxjs": "6.5.4"
+      },
+      "dependencies": {
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
+        }
       }
     },
     "@angular-devkit/build-angular": {
@@ -178,6 +189,15 @@
             "glob": "^7.1.3"
           }
         },
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
+        },
         "semver": {
           "version": "7.1.3",
           "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz",
@@ -208,6 +228,17 @@
         "@angular-devkit/architect": "0.901.0",
         "@angular-devkit/core": "9.1.0",
         "rxjs": "6.5.4"
+      },
+      "dependencies": {
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
+        }
       }
     },
     "@angular-devkit/core": {
@@ -255,6 +286,15 @@
           "requires": {
             "sourcemap-codec": "^1.4.4"
           }
+        },
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
         }
       }
     },
@@ -314,6 +354,15 @@
           "requires": {
             "sourcemap-codec": "^1.4.4"
           }
+        },
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
         }
       }
     },
@@ -358,6 +407,17 @@
           "requires": {
             "@angular-devkit/core": "9.1.0",
             "rxjs": "6.5.4"
+          },
+          "dependencies": {
+            "rxjs": {
+              "version": "6.5.4",
+              "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+              "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+              "dev": true,
+              "requires": {
+                "tslib": "^1.9.0"
+              }
+            }
           }
         },
         "@angular-devkit/core": {
@@ -371,6 +431,17 @@
             "magic-string": "0.25.7",
             "rxjs": "6.5.4",
             "source-map": "0.7.3"
+          },
+          "dependencies": {
+            "rxjs": {
+              "version": "6.5.4",
+              "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+              "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+              "dev": true,
+              "requires": {
+                "tslib": "^1.9.0"
+              }
+            }
           }
         },
         "ajv": {
@@ -2017,6 +2088,17 @@
         "enhanced-resolve": "4.1.1",
         "rxjs": "6.5.4",
         "webpack-sources": "1.4.3"
+      },
+      "dependencies": {
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
+        }
       }
     },
     "@nodelib/fs.stat": {
@@ -2105,6 +2187,17 @@
             "magic-string": "0.25.7",
             "rxjs": "6.5.4",
             "source-map": "0.7.3"
+          },
+          "dependencies": {
+            "rxjs": {
+              "version": "6.5.4",
+              "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+              "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+              "dev": true,
+              "requires": {
+                "tslib": "^1.9.0"
+              }
+            }
           }
         },
         "ajv": {
@@ -2205,6 +2298,15 @@
             "sourcemap-codec": "^1.4.4"
           }
         },
+        "rxjs": {
+          "version": "6.5.4",
+          "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
+          "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+          "dev": true,
+          "requires": {
+            "tslib": "^1.9.0"
+          }
+        },
         "semver": {
           "version": "7.1.3",
           "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz",
@@ -12908,9 +13010,9 @@
       "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q="
     },
     "rxjs": {
-      "version": "6.5.4",
-      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
-      "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
+      "version": "6.5.5",
+      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz",
+      "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==",
       "requires": {
         "tslib": "^1.9.0"
       }
diff --git a/web/package.json b/web/package.json
index 119db778..e035f5cb 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,7 +60,7 @@
     "ng-monaco-editor": "^2.2.2",
     "ngx-highlightjs": "^4.0.2",
     "ngx-markdown": "^8.2.1",
-    "rxjs": "^6.5.4",
+    "rxjs": "^6.5.5",
     "serialize-javascript": "^3.0.0",
     "tslib": "^1.11.1",
     "uuid": "^7.0.2",
diff --git a/web/src/app/modules/shared/components/smart/editor/editor.component.html b/web/src/app/modules/shared/components/smart/editor/editor.component.html
index a1ebcd4c..062eb476 100644
--- a/web/src/app/modules/shared/components/smart/editor/editor.component.html
+++ b/web/src/app/modules/shared/components/smart/editor/editor.component.html
@@ -1,5 +1,15 @@
-<ng-monaco-editor
-  modelUri="file:text.yaml"
-  [options]="options"
-  [(ngModel)]="value"
-></ng-monaco-editor>
+<div class="editor-container">
+    <div class="editor">
+        <ng-monaco-editor
+                [(ngModel)]="value"
+                [options]="options"
+                modelUri="file:text.yaml"
+        ></ng-monaco-editor>
+    </div>
+
+    <div class="controls">
+        <button (click)="reset()" [disabled]="isUpdateEnabled()" class="btn">Reset</button>
+        <button (click)="update()" [disabled]="isUpdateEnabled()" class="btn btn-primary">Update</button>
+    </div>
+
+</div>
diff --git a/web/src/app/modules/shared/components/smart/editor/editor.component.scss b/web/src/app/modules/shared/components/smart/editor/editor.component.scss
index 696f43f8..f6a3b574 100644
--- a/web/src/app/modules/shared/components/smart/editor/editor.component.scss
+++ b/web/src/app/modules/shared/components/smart/editor/editor.component.scss
@@ -2,7 +2,26 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-ng-monaco-editor {
-  height: 92%;
+.editor-container {
+  height: 90%;
+  display: flex;
+  flex-direction: column;
+}
+
+.editor {
+  flex: 1;
+  height: 0;
   margin: 1.25em 0;
 }
+
+ng-monaco-editor {
+  height: 95%;
+}
+
+.controls {
+  height: 50px;
+
+  button {
+    float: right;
+  }
+}
diff --git a/web/src/app/modules/shared/components/smart/editor/editor.component.ts b/web/src/app/modules/shared/components/smart/editor/editor.component.ts
index 0406da08..a316de6e 100644
--- a/web/src/app/modules/shared/components/smart/editor/editor.component.ts
+++ b/web/src/app/modules/shared/components/smart/editor/editor.component.ts
@@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0
 import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
 import { EditorView, View } from 'src/app/modules/shared/models/content';
 import { ThemeService } from '../../../../sugarloaf/components/smart/theme-switch/theme-switch.service';
+import { ActionService } from '../../../services/action/action.service';
 
 interface Options {
   readOnly: boolean;
@@ -29,21 +30,63 @@ export class EditorComponent implements OnChanges {
     return this.v;
   }
 
-  value: string;
+  set value(v: string) {
+    if (v !== this.editorValue) {
+      this.isModified = true;
+    }
+    this.editorValue = v;
+  }
+
+  get value() {
+    return this.editorValue;
+  }
+
+  private editorValue: string;
+  private pristineValue: string;
+  private metadata: { [p: string]: string };
+
+  isModified = false;
+
   options: Options;
 
-  constructor(private themeService: ThemeService) {
+  constructor(
+    private themeService: ThemeService,
+    private actionService: ActionService
+  ) {
     this.options = {} as Options;
   }
 
   ngOnChanges(changes: SimpleChanges) {
     if (changes.view.currentValue) {
       const view = changes.view.currentValue as EditorView;
-      this.value = view.config.value;
-      this.options.readOnly = view.config.readOnly;
-      this.options.language = view.config.language;
+      console.log(view);
+
+      if (!this.isModified) {
+        this.editorValue = view.config.value;
+        this.metadata = view.config.metadata;
+        this.pristineValue = view.config.value;
+        this.options.readOnly = view.config.readOnly;
+        this.options.language = view.config.language;
+      }
       this.options.theme =
         this.themeService.currentType() === 'dark' ? 'vs-dark' : 'vs';
     }
   }
+
+  update() {
+    const payload = {
+      action: 'action.octant.dev/update',
+      update: this.value,
+      ...this.metadata,
+    };
+    this.actionService.perform(payload);
+  }
+
+  isUpdateEnabled() {
+    return !this.isModified;
+  }
+
+  reset() {
+    this.value = this.pristineValue;
+  }
 }
diff --git a/web/src/app/modules/shared/models/content.ts b/web/src/app/modules/shared/models/content.ts
index 204c138b..1a0a8730 100644
--- a/web/src/app/modules/shared/models/content.ts
+++ b/web/src/app/modules/shared/models/content.ts
@@ -424,6 +424,7 @@ export interface EditorView extends View {
     value: string;
     language: string;
     readOnly: boolean;
+    metadata: { [key: string]: string };
   };
 }
 
diff --git a/web/src/app/modules/shared/notifier/notifier.service.ts b/web/src/app/modules/shared/notifier/notifier.service.ts
index eb83b92d..7fd9678e 100644
--- a/web/src/app/modules/shared/notifier/notifier.service.ts
+++ b/web/src/app/modules/shared/notifier/notifier.service.ts
@@ -15,6 +15,7 @@ export enum NotifierSignalType {
   ERROR = 'ERROR',
   WARNING = 'WARNING',
   INFO = 'INFO',
+  SUCCESS = 'SUCCESS',
 }
 
 export interface NotifierSignal {
diff --git a/web/src/app/modules/shared/pipes/relative/relative.pipe.ts b/web/src/app/modules/shared/pipes/relative/relative.pipe.ts
index d41c79f9..a3fb67a4 100644
--- a/web/src/app/modules/shared/pipes/relative/relative.pipe.ts
+++ b/web/src/app/modules/shared/pipes/relative/relative.pipe.ts
@@ -4,13 +4,26 @@
  */
 
 import {
-  Pipe,
-  PipeTransform,
   ChangeDetectorRef,
-  OnDestroy,
   NgZone,
+  OnDestroy,
+  Pipe,
+  PipeTransform,
 } from '@angular/core';
 
+const changeDetectionFrequency = (seconds: number) => {
+  switch (true) {
+    case seconds < 60:
+      return 1;
+    case seconds < 3600:
+      return 60;
+    case seconds < 86400:
+      return 600;
+    default:
+      return 3600;
+  }
+};
+
 @Pipe({
   name: 'relative',
   pure: false,
@@ -25,10 +38,12 @@ import {
  */
 export class RelativePipe implements PipeTransform, OnDestroy {
   private timer: number;
+
   constructor(
     private changeDetectorRef: ChangeDetectorRef,
     private ngZone: NgZone
   ) {}
+
   transform(ts: number, base?: Date): string {
     this.removeTimer();
 
@@ -41,12 +56,16 @@ export class RelativePipe implements PipeTransform, OnDestroy {
 
     const then = now.getTime() / 1000 - ts;
 
-    const updateInterval = this.changeDetectionFrequency(then) * 1000;
+    const updateInterval = changeDetectionFrequency(then) * 1000;
 
     this.timer = this.ngZone.runOutsideAngular(() => {
       if (typeof window !== 'undefined') {
         return window.setTimeout(() => {
-          this.ngZone.run(() => this.changeDetectorRef.markForCheck());
+          this.ngZone.run(() => {
+            if (this.changeDetectorRef) {
+              this.changeDetectorRef.markForCheck();
+            }
+          });
         }, updateInterval);
       }
       return null;
@@ -73,17 +92,4 @@ export class RelativePipe implements PipeTransform, OnDestroy {
       this.timer = null;
     }
   }
-
-  private changeDetectionFrequency(seconds: number) {
-    switch (true) {
-      case seconds < 60:
-        return 1;
-      case seconds < 3600:
-        return 60;
-      case seconds < 86400:
-        return 600;
-      default:
-        return 3600;
-    }
-  }
 }
diff --git a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
index 5a1a12c8..4f383fd8 100644
--- a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
@@ -22,8 +22,6 @@ import { IconService } from '../../../../shared/services/icon/icon.service';
 import { ContentService } from '../../../../shared/services/content/content.service';
 import isEqual from 'lodash/isEqual';
 import { Subscription } from 'rxjs';
-import { take } from 'rxjs/operators';
-import { Location } from '@angular/common';
 
 @Component({
   selector: 'app-overview',
@@ -48,8 +46,7 @@ export class ContentComponent implements OnInit, OnDestroy {
   constructor(
     private router: Router,
     private iconService: IconService,
-    private contentService: ContentService,
-    private location: Location
+    private contentService: ContentService
   ) {}
 
   updatePath(url: string) {
diff --git a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
index 6f834d52..2f910909 100644
--- a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
@@ -45,3 +45,18 @@
     </clr-alert-item>
   </clr-alert>
 </ng-container>
+
+<ng-container *ngIf="!loading && success">
+  <clr-alert
+          [clrAlertType]="'success'"
+          [clrAlertIcon]="'check-circle'"
+          [clrAlertAppLevel]="true"
+  >
+    <clr-alert-item>
+      <span class="alert-text">
+        {{ success }}
+      </span>
+    </clr-alert-item>
+  </clr-alert>
+</ng-container>
+
diff --git a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.ts b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.ts
index 41a2a1ba..49233bee 100644
--- a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.ts
@@ -21,6 +21,7 @@ export class NotifierComponent implements OnInit, OnDestroy {
   error: string;
   warning: string;
   info: string;
+  success: string;
 
   constructor(private notifierService: NotifierService) {}
 
@@ -48,6 +49,13 @@ export class NotifierComponent implements OnInit, OnDestroy {
           type: NotifierSignalType.INFO,
         });
         this.info = lastInfoSignal ? (lastInfoSignal.data as string) : '';
+
+        const lastSuccessSignal = findLast(currentSignals, {
+          type: NotifierSignalType.SUCCESS,
+        });
+        this.success = lastSuccessSignal
+          ? (lastSuccessSignal.data as string)
+          : '';
       }
     );
   }


Test patch:
diff --git a/internal/api/container_logs_test.go b/internal/api/container_logs_test.go
index bf523259..46622968 100644
--- a/internal/api/container_logs_test.go
+++ b/internal/api/container_logs_test.go
@@ -2,15 +2,17 @@ package api
 
 import (
 	"context"
+	"sync"
+	"testing"
+	"time"
+
 	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/assert"
+
 	configFake "github.com/vmware-tanzu/octant/internal/config/fake"
 	"github.com/vmware-tanzu/octant/internal/modules/overview/container"
 	"github.com/vmware-tanzu/octant/internal/octant"
 	"github.com/vmware-tanzu/octant/pkg/store"
-	"sync"
-	"testing"
-	"time"
 )
 
 func TestContainerLogs_NewLogEntry(t *testing.T) {
@@ -49,7 +51,7 @@ func TestContainerLogs_SendLogEventsStops(t *testing.T) {
 	var wg sync.WaitGroup
 	wg.Add(1)
 	go func() {
-		s.sendLogEvents(s.ctx, eventType, logCh)
+		s.streamEventsToClient(s.ctx, eventType, logCh)
 		wg.Done()
 	}()
 
@@ -65,7 +67,7 @@ func TestContainerLogs_SendLogEventsClientSend(t *testing.T) {
 	defer controller.Finish()
 
 	dashConfig := configFake.NewMockDash(controller)
-	client := &octantClient{}
+	client := newOctantClient()
 
 	key := store.Key{
 		Namespace: "test-ns",
@@ -73,32 +75,47 @@ func TestContainerLogs_SendLogEventsClientSend(t *testing.T) {
 	}
 
 	eventType := octant.NewLoggingEventType(key.Namespace, key.Name)
-	logCh := make(chan container.LogEntry)
+	logCh := make(chan container.LogEntry, 1)
 
 	s := NewPodLogsStateManager(dashConfig)
-	s.Start(context.Background(), nil, client)
+	ctx, cancel := context.WithCancel(context.Background())
+	s.Start(ctx, nil, client)
 
 	go func() {
-		s.sendLogEvents(s.ctx, eventType, logCh)
+		s.streamEventsToClient(ctx, eventType, logCh)
 	}()
 
 	le := container.NewLogEntry("container-a", "testing log line")
 	logCh <- le
+
+	<-client.ch
+	cancel()
 	close(logCh)
 
-	assert.NotNil(t, client.sendCalledWith)
-	assert.Equal(t, eventType, client.sendCalledWith.Type)
+	if assert.NotNil(t, client.sendCalledWith) {
+		assert.Equal(t, eventType, client.sendCalledWith.Type)
 
-	clientLe, ok := client.sendCalledWith.Data.(logEntry)
-	assert.True(t, ok)
-	assert.Equal(t, "container-a", clientLe.Container)
-	assert.Equal(t, "testing log line", clientLe.Message)
-	assert.Nil(t, clientLe.Timestamp)
+		clientLe, ok := client.sendCalledWith.Data.(logEntry)
+		assert.True(t, ok)
+		assert.Equal(t, "container-a", clientLe.Container)
+		assert.Equal(t, "testing log line", clientLe.Message)
+		assert.Nil(t, clientLe.Timestamp)
+	}
 }
 
 type octantClient struct {
 	sendCalledWith octant.Event
+	ch             chan bool
 }
 
-func (oc *octantClient) Send(event octant.Event) { oc.sendCalledWith = event }
-func (oc *octantClient) ID() string              { return "" }
+func newOctantClient() *octantClient {
+	return &octantClient{
+		ch: make(chan bool, 1),
+	}
+}
+
+func (oc *octantClient) Send(event octant.Event) {
+	oc.sendCalledWith = event
+	oc.ch <- true
+}
+func (oc *octantClient) ID() string { return "" }
diff --git a/internal/modules/overview/yamlviewer/yamlviewer_test.go b/internal/modules/overview/yamlviewer/yamlviewer_test.go
index ea049328..d7a2b2bb 100644
--- a/internal/modules/overview/yamlviewer/yamlviewer_test.go
+++ b/internal/modules/overview/yamlviewer/yamlviewer_test.go
@@ -8,9 +8,9 @@ package yamlviewer
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/vmware-tanzu/octant/internal/testutil"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 
 	corev1 "k8s.io/api/core/v1"
@@ -23,7 +23,8 @@ func Test_ToComponent(t *testing.T) {
 	require.NoError(t, err)
 
 	data := "---\nmetadata:\n  creationTimestamp: null\nspec:\n  containers: null\nstatus: {}\n"
-	expected := component.NewEditor(component.TitleFromString("YAML"), data, true)
+	expected := component.NewEditor(component.TitleFromString("YAML"), data, false)
+	require.NoError(t, expected.SetValueFromObject(object))
 
-	assert.Equal(t, expected, got)
+	testutil.AssertJSONEqual(t, expected, got)
 }
diff --git a/internal/octant/object_updater_test.go b/internal/octant/object_updater_test.go
new file mode 100644
index 00000000..18dffdc9
--- /dev/null
+++ b/internal/octant/object_updater_test.go
@@ -0,0 +1,178 @@
+/*
+ *  Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ *  SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+package octant
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/vmware-tanzu/octant/internal/testutil"
+	"github.com/vmware-tanzu/octant/internal/util/kubernetes"
+	"github.com/vmware-tanzu/octant/pkg/action"
+	actionFake "github.com/vmware-tanzu/octant/pkg/action/fake"
+	"github.com/vmware-tanzu/octant/pkg/store"
+	storeFake "github.com/vmware-tanzu/octant/pkg/store/fake"
+)
+
+func TestObjectUpdateFromPayload(t *testing.T) {
+	pod := testutil.ToUnstructured(t, testutil.CreatePod("pod"))
+	podS, err := kubernetes.SerializeToString(pod)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name    string
+		payload action.Payload
+		wanted  *unstructured.Unstructured
+		wantErr bool
+	}{
+		{
+			name: "in general",
+			payload: action.Payload{
+				"update": podS,
+			},
+			wanted: pod,
+		},
+		{
+			name:    "no update in payload",
+			payload: action.Payload{},
+			wantErr: true,
+		},
+		{
+			name: "upload is not yaml",
+			payload: action.Payload{
+				"update": "<<<",
+			},
+			wantErr: true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual, err := ObjectUpdateFromPayload(test.payload)
+			if test.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, test.wanted, actual)
+		})
+	}
+}
+
+func TestObjectUpdaterDispatcher_Handle(t *testing.T) {
+	pod := testutil.ToUnstructured(t, testutil.CreatePod("pod"))
+	podKey, err := store.KeyFromObject(pod)
+	require.NoError(t, err)
+	podPayload := action.Payload{
+		"namespace":  pod.GetNamespace(),
+		"apiVersion": pod.GetAPIVersion(),
+		"kind":       pod.GetKind(),
+		"name":       pod.GetName(),
+	}
+
+	tests := []struct {
+		name              string
+		payload           action.Payload
+		objectFromPayload func(action.Payload) (*unstructured.Unstructured, error)
+		initStore         func(ctrl *gomock.Controller) *storeFake.MockStore
+		initAlerter       func(ctrl *gomock.Controller) *actionFake.MockAlerter
+		wantErr           bool
+	}{
+		{
+			name:    "in general",
+			payload: podPayload,
+			objectFromPayload: func(payload action.Payload) (*unstructured.Unstructured, error) {
+				return pod, nil
+			},
+			initStore: func(ctrl *gomock.Controller) *storeFake.MockStore {
+				objectStore := storeFake.NewMockStore(ctrl)
+				objectStore.EXPECT().
+					Update(gomock.Any(), podKey, gomock.Any()).Return(nil)
+
+				return objectStore
+			},
+			initAlerter: func(ctrl *gomock.Controller) *actionFake.MockAlerter {
+				alerter := actionFake.NewMockAlerter(ctrl)
+				alerter.EXPECT().
+					SendAlert(gomock.Any()).
+					DoAndReturn(func(alert action.Alert) {
+						require.Equal(t, action.AlertTypeInfo, alert.Type)
+					})
+				return alerter
+			},
+		},
+		{
+			name:    "unable to load object",
+			payload: podPayload,
+			objectFromPayload: func(payload action.Payload) (*unstructured.Unstructured, error) {
+				return nil, fmt.Errorf("error")
+			},
+			initStore: func(ctrl *gomock.Controller) *storeFake.MockStore {
+				objectStore := storeFake.NewMockStore(ctrl)
+				return objectStore
+			},
+			initAlerter: func(ctrl *gomock.Controller) *actionFake.MockAlerter {
+				alerter := actionFake.NewMockAlerter(ctrl)
+				alerter.EXPECT().
+					SendAlert(gomock.Any()).
+					DoAndReturn(func(alert action.Alert) {
+						require.Equal(t, action.AlertTypeError, alert.Type)
+					})
+				return alerter
+			},
+		},
+		{
+			name:    "update failed",
+			payload: podPayload,
+			objectFromPayload: func(payload action.Payload) (*unstructured.Unstructured, error) {
+				return pod, nil
+			},
+			initStore: func(ctrl *gomock.Controller) *storeFake.MockStore {
+				objectStore := storeFake.NewMockStore(ctrl)
+				objectStore.EXPECT().
+					Update(gomock.Any(), podKey, gomock.Any()).Return(fmt.Errorf("error"))
+
+				return objectStore
+			},
+			initAlerter: func(ctrl *gomock.Controller) *actionFake.MockAlerter {
+				alerter := actionFake.NewMockAlerter(ctrl)
+				alerter.EXPECT().
+					SendAlert(gomock.Any()).
+					DoAndReturn(func(alert action.Alert) {
+						require.Equal(t, action.AlertTypeError, alert.Type)
+					})
+				return alerter
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			objectStore := test.initStore(ctrl)
+			alerter := test.initAlerter(ctrl)
+
+			o := NewObjectUpdaterDispatcher(objectStore,
+				func(dispatcher *ObjectUpdaterDispatcher) {
+					dispatcher.objectFromPayload = test.objectFromPayload
+				})
+			err := o.Handle(ctx, alerter, test.payload)
+			if test.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+		})
+	}
+}
diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go
index b5283a7e..4c20260f 100644
--- a/internal/testutil/testutil.go
+++ b/internal/testutil/testutil.go
@@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0
 package testutil
 
 import (
+	"bytes"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -41,6 +42,17 @@ func LoadObjectFromFile(t *testing.T, objectFile string) runtime.Object {
 	return object
 }
 
+// LoadTestData loads a file in the testdata directory.
+func LoadTestData(t *testing.T, fileName string) []byte {
+	data, err := ioutil.ReadFile(filepath.Join("testdata", fileName))
+	require.NoError(t, err)
+
+	// strip carriage returns for Widows
+	data = bytes.Replace(data, []byte{13, 10}, []byte{10}, -1)
+
+	return data
+}
+
 // LoadUnstructuredFromFile loads an object from a file in the in `testdata` directory.
 // It will assign a `default` namespace if one is not set. This helper does not support
 // multiple objects in a YAML file.
diff --git a/internal/util/kubernetes/object_test.go b/internal/util/kubernetes/object_test.go
new file mode 100644
index 00000000..2ea80f3f
--- /dev/null
+++ b/internal/util/kubernetes/object_test.go
@@ -0,0 +1,64 @@
+/*
+ *  Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ *  SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+package kubernetes
+
+import (
+	"bytes"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/vmware-tanzu/octant/internal/testutil"
+)
+
+func TestReadObject(t *testing.T) {
+	data, err := ioutil.ReadFile(filepath.Join("testdata", "deployment.yaml"))
+	require.NoError(t, err)
+
+	r := bytes.NewReader(data)
+
+	o, err := ReadObject(r)
+	require.NoError(t, err)
+
+	require.Equal(t, "nginx-deployment", o.GetName())
+}
+
+func TestSerializeToString(t *testing.T) {
+	pod := testutil.CreatePod("pod")
+
+	tests := []struct {
+		name    string
+		object  runtime.Object
+		wanted  string
+		wantErr bool
+	}{
+		{
+			name:   "in general",
+			object: pod,
+			wanted: string(testutil.LoadTestData(t, "pod.yaml")),
+		},
+		{
+			name:    "nil object",
+			object:  nil,
+			wantErr: true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual, err := SerializeToString(test.object)
+			if test.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, test.wanted, actual)
+		})
+	}
+}
diff --git a/internal/util/kubernetes/testdata/deployment.yaml b/internal/util/kubernetes/testdata/deployment.yaml
new file mode 100644
index 00000000..6b832309
--- /dev/null
+++ b/internal/util/kubernetes/testdata/deployment.yaml
@@ -0,0 +1,32 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  labels:
+    app.kubernetes.io/name: nginx
+    app.kubernetes.io/instance: sample
+    app.kubernetes.io/version: v1
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: nginx
+      app.kubernetes.io/instance: sample
+      app.kubernetes.io/version: v1
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: nginx
+        app.kubernetes.io/instance: sample
+        app.kubernetes.io/version: v1
+    spec:
+      containers:
+        - name: nginx
+          image: nginx:1.13.6
+          ports:
+            - containerPort: 80
+          env:
+            - name: DEMO_GREETING
+              value: "Hello from the environment"
+            - name: DEMO_FAREWELL
+              value: "Such a sweet sorrow"
diff --git a/internal/util/kubernetes/testdata/pod.yaml b/internal/util/kubernetes/testdata/pod.yaml
new file mode 100644
index 00000000..0ed93325
--- /dev/null
+++ b/internal/util/kubernetes/testdata/pod.yaml
@@ -0,0 +1,11 @@
+---
+apiVersion: v1
+kind: Pod
+metadata:
+  creationTimestamp: null
+  name: pod
+  namespace: namespace
+  uid: pod
+spec:
+  containers: null
+status: {}