Repository: linkerd/linkerd2
Pull request #5499 (2021-01-09 00:48:19)

Add collector.jaegerAddr value to allow BYOJ
Users may have an existing Jaeger deployment and want to send traces to it from Linkerd.

We add the `collector.jaegerAddr` value to the Linkerd-Jaeger chart which configures the address of the jaeger backend which the opencensus collector sends to.  If left unspecified, the collector will use the jaeger instance in the linkerd-jaeger extension.

To test:

Install Jaeger backend separately:

```
curl https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/examples/simplest.yaml | docker run -i --rm jaegertracing/jaeger-operator:master generate | kubectl apply -n jaeger-test -f -
```

Install Linkerd and Linkerd-jaeger, specifying the existing jaeger backend

```
linkerd install | kubectl apply -f -
linkerd jaeger install --set collector.jaegerAddr='http://my-jaeger-collector.jaeger-test:14268/api/traces' | kubectl apply -f -
```

Install emojivoto and configure it:

```
linkerd inject https://run.linkerd.io/emojivoto.yml  | kubectl apply -f -
kubectl -n emojivoto set env --all deploy OC_AGENT_HOST=collector.linkerd-jaeger:55678
```

View traces in your custom jaeger backend:

```
kubectl -n jaeger-test port-forward svc/my-jaeger-query 16686 &
open http://localhost:16686
```

Signed-off-by: Alex Leong <alex@buoyant.io>

<!--  Thanks for sending a pull request!

If you already have a well-structured git commit message, chances are GitHub
set the title and description of this PR to the git commit message subject and
body, respectively. If so, you may delete these instructions and submit your PR.

If this is your first time, please read our contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md

The title and description of your Pull Request should match the git commit
subject and body, respectively. Git commit messages are structured as follows:

```
Subject

Problem

Solution

Validation

Fixes #[GitHub issue ID]

DCO Sign off
```

Example git commit message:

```
Introduce Pull Request Template

GitHub's community guidelines recommend a pull request template, the repo was
lacking one.

Introduce a `PULL_REQUEST_TEMPLATE.md` file.

Once merged, the
[Community profile checklist](https://github.com/linkerd/linkerd2/community)
should indicate the repo now provides a pull request template.

Fixes #3321

Signed-off-by: Jane Smith <jane.smith@example.com>
```

Note the git commit message subject becomes the pull request title.

For more details around git commits, see the section on Committing in our
contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md#committing
-->



Patch:
diff --git a/.gitignore b/.gitignore
index 808bb9c84..bd39aead6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ vendor
 **/*.swp
 **/charts/**/charts
 package-lock.json
+.vscode
diff --git a/charts/partials/templates/_proxy-config-ann.tpl b/charts/partials/templates/_proxy-config-ann.tpl
new file mode 100644
index 000000000..9651b3bd1
--- /dev/null
+++ b/charts/partials/templates/_proxy-config-ann.tpl
@@ -0,0 +1,18 @@
+{{- define "partials.proxy.config.annotations" -}}
+{{- with .cpu }}
+{{- with .request -}}
+config.linkerd.io/proxy-cpu-request: {{. | quote}}
+{{end}}
+{{- with .limit -}}
+config.linkerd.io/proxy-cpu-limit: {{. | quote}}
+{{- end}}
+{{- end}}
+{{- with .memory }}
+{{- with .request }}
+config.linkerd.io/proxy-memory-request: {{. | quote}}
+{{end}}
+{{- with .limit -}}
+config.linkerd.io/proxy-memory-limit: {{. | quote}}
+{{- end}}
+{{- end }}
+{{- end }}
diff --git a/cli/cmd/endpoints.go b/cli/cmd/endpoints.go
index cff6abc6a..7bae8db4f 100644
--- a/cli/cmd/endpoints.go
+++ b/cli/cmd/endpoints.go
@@ -16,6 +16,8 @@ import (
 	destinationPb "github.com/linkerd/linkerd2-proxy-api/go/destination"
 	netPb "github.com/linkerd/linkerd2-proxy-api/go/net"
 	"github.com/linkerd/linkerd2/controller/api/public"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -35,7 +37,9 @@ type (
 )
 
 const (
-	podHeader = "POD"
+	podHeader       = "POD"
+	namespaceHeader = "NAMESPACE"
+	padding         = 3
 )
 
 // validate performs all validation on the command-line options.
@@ -85,7 +89,14 @@ destination.`,
 				return err
 			}
 
-			endpoints, err := requestEndpointsFromAPI(checkPublicAPIClientOrExit(), args)
+			endpoints, err := requestEndpointsFromAPI(api.CheckPublicAPIClientOrExit(healthcheck.Options{
+				ControlPlaneNamespace: controlPlaneNamespace,
+				KubeConfig:            kubeconfigPath,
+				Impersonate:           impersonate,
+				ImpersonateGroup:      impersonateGroup,
+				KubeContext:           kubeContext,
+				APIAddr:               apiAddr,
+			}), args)
 			if err != nil {
 				return fmt.Errorf("Destination API error: %s", err)
 			}
diff --git a/cli/cmd/inject.go b/cli/cmd/inject.go
index 603e857e6..a2a4a6589 100644
--- a/cli/cmd/inject.go
+++ b/cli/cmd/inject.go
@@ -18,6 +18,7 @@ import (
 	"github.com/linkerd/linkerd2/pkg/healthcheck"
 	"github.com/linkerd/linkerd2/pkg/inject"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"sigs.k8s.io/yaml"
@@ -333,7 +334,15 @@ func (resourceTransformerInject) generateReport(reports []inject.Report, output
 
 func fetchConfigs(ctx context.Context) (*linkerd2.Values, error) {
 
-	checkPublicAPIClientOrExit()
+	api.CheckPublicAPIClientOrExit(healthcheck.Options{
+		ControlPlaneNamespace: controlPlaneNamespace,
+		KubeConfig:            kubeconfigPath,
+		Impersonate:           impersonate,
+		ImpersonateGroup:      impersonateGroup,
+		KubeContext:           kubeContext,
+		APIAddr:               apiAddr,
+	})
+
 	api, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
 	if err != nil {
 		return nil, err
diff --git a/cli/cmd/profile.go b/cli/cmd/profile.go
index 450d838eb..54cfa76e0 100644
--- a/cli/cmd/profile.go
+++ b/cli/cmd/profile.go
@@ -20,6 +20,7 @@ type profileOptions struct {
 	openAPI       string
 	proto         string
 	tap           string
+	ignoreCluster bool
 	tapDuration   time.Duration
 	tapRouteLimit uint
 }
@@ -32,6 +33,7 @@ func newProfileOptions() *profileOptions {
 		openAPI:       "",
 		proto:         "",
 		tap:           "",
+		ignoreCluster: false,
 		tapDuration:   5 * time.Second,
 		tapRouteLimit: 20,
 	}
@@ -55,6 +57,10 @@ func (options *profileOptions) validate() error {
 		return errors.New("You must specify exactly one of --template or --open-api or --proto or --tap")
 	}
 
+	// service profile generation based on tap data requires access to k8s cluster
+	if options.ignoreCluster && options.tap != "" {
+		return errors.New("--ignore-cluster and --tap flags are mutually exclusive; SP generation based on tap data requires access-check to k8s cluster")
+	}
 	// a DNS-1035 label must consist of lower case alphanumeric characters or '-',
 	// start with an alphabetic character, and end with an alphanumeric character
 	if errs := validation.IsDNS1035Label(options.name); len(errs) != 0 {
@@ -94,25 +100,32 @@ func newCmdProfile() *cobra.Command {
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			options.name = args[0]
+			clusterDomain := defaultClusterDomain
+			var k8sAPI *k8s.KubernetesAPI
 
 			err := options.validate()
 			if err != nil {
 				return err
 			}
-
-			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
-			if err != nil {
-				return err
-			}
-
-			_, values, err := healthcheck.FetchCurrentConfiguration(cmd.Context(), k8sAPI, controlPlaneNamespace)
-			if err != nil {
-				return err
-			}
-
-			clusterDomain := values.GetGlobal().ClusterDomain
-			if clusterDomain == "" {
-				clusterDomain = defaultClusterDomain
+			// performs an online profile generation and access-check to k8s cluster to extract
+			// clusterDomain from linkerd configuration
+			// profile generation based on tap data requires access to k8s cluster
+			if !options.ignoreCluster {
+				var err error
+				k8sAPI, err = k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+
+				if err != nil {
+					return err
+				}
+
+				_, values, err := healthcheck.FetchCurrentConfiguration(cmd.Context(), k8sAPI, controlPlaneNamespace)
+				if err != nil {
+					return err
+				}
+
+				if cd := values.GetGlobal().ClusterDomain; cd != "" {
+					clusterDomain = cd
+				}
 			}
 
 			if options.template {
@@ -137,6 +150,7 @@ func newCmdProfile() *cobra.Command {
 	cmd.PersistentFlags().UintVar(&options.tapRouteLimit, "tap-route-limit", options.tapRouteLimit, "Max number of routes to add to the profile")
 	cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the service")
 	cmd.PersistentFlags().StringVar(&options.proto, "proto", options.proto, "Output a service profile based on the given Protobuf spec file")
+	cmd.PersistentFlags().BoolVar(&options.ignoreCluster, "ignore-cluster", options.ignoreCluster, "Output a service profile through offline generation")
 
 	return cmd
 }
diff --git a/cli/cmd/root.go b/cli/cmd/root.go
index e8ea0b2b3..601c30dc3 100644
--- a/cli/cmd/root.go
+++ b/cli/cmd/root.go
@@ -1,36 +1,29 @@
 package cmd
 
 import (
-	"bytes"
 	"fmt"
 	"os"
 	"regexp"
 	"strings"
-	"time"
 
 	"github.com/fatih/color"
 	"github.com/linkerd/linkerd2/cli/flag"
 	jaeger "github.com/linkerd/linkerd2/jaeger/cmd"
 	multicluster "github.com/linkerd/linkerd2/multicluster/cmd"
+	"github.com/linkerd/linkerd2/pkg/cmd"
 	viz "github.com/linkerd/linkerd2/viz/cmd"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
-	corev1 "k8s.io/api/core/v1"
-	"k8s.io/client-go/tools/clientcmd"
 )
 
 const (
-	defaultLinkerdNamespace    = "linkerd"
-	defaultCNINamespace        = "linkerd-cni"
-	defaultLinkerdVizNamespace = "linkerd-viz"
-	defaultClusterDomain       = "cluster.local"
-	defaultDockerRegistry      = "ghcr.io/linkerd"
+	defaultLinkerdNamespace = "linkerd"
+	defaultCNINamespace     = "linkerd-cni"
+	defaultClusterDomain    = "cluster.local"
+	defaultDockerRegistry   = "ghcr.io/linkerd"
 
 	jsonOutput  = "json"
 	tableOutput = "table"
-	wideOutput  = "wide"
-
-	maxRps = 100.0
 )
 
 var (
@@ -99,7 +92,7 @@ var RootCmd = &cobra.Command{
 }
 
 func init() {
-	defaultNamespace = getDefaultNamespace()
+	defaultNamespace = cmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
 	RootCmd.PersistentFlags().StringVarP(&controlPlaneNamespace, "linkerd-namespace", "L", defaultLinkerdNamespace, "Namespace in which Linkerd is installed ($LINKERD_NAMESPACE)")
 	RootCmd.PersistentFlags().StringVarP(&cniNamespace, "cni-namespace", "", defaultCNINamespace, "Namespace in which the Linkerd CNI plugin is installed")
 	RootCmd.PersistentFlags().StringVar(&kubeconfigPath, "kubeconfig", "", "Path to the kubeconfig file to use for CLI requests")
@@ -111,10 +104,8 @@ func init() {
 	RootCmd.AddCommand(newCmdAlpha())
 	RootCmd.AddCommand(newCmdCheck())
 	RootCmd.AddCommand(newCmdCompletion())
-	RootCmd.AddCommand(newCmdDashboard())
 	RootCmd.AddCommand(newCmdDiagnostics())
 	RootCmd.AddCommand(newCmdDoc())
-	RootCmd.AddCommand(newCmdEdges())
 	RootCmd.AddCommand(newCmdEndpoints())
 	RootCmd.AddCommand(newCmdIdentity())
 	RootCmd.AddCommand(newCmdInject())
@@ -123,10 +114,6 @@ func init() {
 	RootCmd.AddCommand(newCmdInstallSP())
 	RootCmd.AddCommand(newCmdMetrics())
 	RootCmd.AddCommand(newCmdProfile())
-	RootCmd.AddCommand(newCmdRoutes())
-	RootCmd.AddCommand(newCmdStat())
-	RootCmd.AddCommand(newCmdTap())
-	RootCmd.AddCommand(newCmdTop())
 	RootCmd.AddCommand(newCmdUninject())
 	RootCmd.AddCommand(newCmdUpgrade())
 	RootCmd.AddCommand(newCmdVersion())
@@ -138,86 +125,6 @@ func init() {
 	RootCmd.AddCommand(viz.NewCmdViz())
 }
 
-type statOptionsBase struct {
-	namespace    string
-	timeWindow   string
-	outputFormat string
-}
-
-func newStatOptionsBase() *statOptionsBase {
-	return &statOptionsBase{
-		namespace:    defaultNamespace,
-		timeWindow:   "1m",
-		outputFormat: tableOutput,
-	}
-}
-
-func (o *statOptionsBase) validateOutputFormat() error {
-	switch o.outputFormat {
-	case tableOutput, jsonOutput, wideOutput:
-		return nil
-	default:
-		return fmt.Errorf("--output currently only supports %s, %s and %s", tableOutput, jsonOutput, wideOutput)
-	}
-}
-
-func renderStats(buffer bytes.Buffer, options *statOptionsBase) string {
-	var out string
-	switch options.outputFormat {
-	case jsonOutput:
-		out = buffer.String()
-	default:
-		// strip left padding on the first column
-		b := buffer.Bytes()
-		if len(b) > padding {
-			out = string(b[padding:])
-		}
-		out = strings.Replace(out, "\n"+strings.Repeat(" ", padding), "\n", -1)
-	}
-
-	return out
-}
-
-// getRequestRate calculates request rate from Public API BasicStats.
-func getRequestRate(success, failure uint64, timeWindow string) float64 {
-	windowLength, err := time.ParseDuration(timeWindow)
-	if err != nil {
-		log.Error(err.Error())
-		return 0.0
-	}
-	return float64(success+failure) / windowLength.Seconds()
-}
-
-// getSuccessRate calculates success rate from Public API BasicStats.
-func getSuccessRate(success, failure uint64) float64 {
-	if success+failure == 0 {
-		return 0.0
-	}
-	return float64(success) / float64(success+failure)
-}
-
-// getDefaultNamespace fetches the default namespace
-// used in the current KubeConfig context
-func getDefaultNamespace() string {
-	rules := clientcmd.NewDefaultClientConfigLoadingRules()
-
-	if kubeconfigPath != "" {
-		rules.ExplicitPath = kubeconfigPath
-	}
-
-	overrides := &clientcmd.ConfigOverrides{CurrentContext: kubeContext}
-	kubeCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides)
-	ns, _, err := kubeCfg.Namespace()
-
-	if err != nil {
-		log.Warnf(`could not set namespace from kubectl context, using 'default' namespace: %s
-		 ensure the KUBECONFIG path %s is valid`, err, kubeconfigPath)
-		return corev1.NamespaceDefault
-	}
-
-	return ns
-}
-
 // registryOverride replaces the registry-portion of the provided image with the provided registry.
 func registryOverride(image, newRegistry string) string {
 	if image == "" {
diff --git a/cli/cmd/version.go b/cli/cmd/version.go
index a6788a70c..e7873ff90 100644
--- a/cli/cmd/version.go
+++ b/cli/cmd/version.go
@@ -9,6 +9,8 @@ import (
 
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
 	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	"github.com/linkerd/linkerd2/pkg/version"
 	"github.com/spf13/cobra"
 )
@@ -38,8 +40,18 @@ func newCmdVersion() *cobra.Command {
 		Use:   "version",
 		Short: "Print the client and server version information",
 		Args:  cobra.NoArgs,
-		Run: func(cmd *cobra.Command, args []string) {
-			configureAndRunVersion(cmd.Context(), options, os.Stdout, rawPublicAPIClient)
+		RunE: func(cmd *cobra.Command, args []string) error {
+			var k8sAPI *k8s.KubernetesAPI
+			var err error
+			if !options.onlyClientVersion {
+				k8sAPI, err = k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+				if err != nil {
+					return err
+				}
+			}
+
+			configureAndRunVersion(cmd.Context(), k8sAPI, options, os.Stdout, api.RawPublicAPIClient)
+			return nil
 		},
 	}
 
@@ -53,9 +65,10 @@ func newCmdVersion() *cobra.Command {
 
 func configureAndRunVersion(
 	ctx context.Context,
+	k8sAPI *k8s.KubernetesAPI,
 	options *versionOptions,
 	stdout io.Writer,
-	mkClient func(ctx context.Context) (pb.ApiClient, error),
+	mkClient func(ctx context.Context, k8sAPI *k8s.KubernetesAPI, controlPlaneNamespace, apiAddr string) (pb.ApiClient, error),
 ) {
 	clientVersion := version.Version
 	if options.shortVersion {
@@ -66,7 +79,7 @@ func configureAndRunVersion(
 
 	if !options.onlyClientVersion {
 		serverVersion := defaultVersionString
-		client, clientErr := mkClient(ctx)
+		client, clientErr := mkClient(ctx, k8sAPI, controlPlaneNamespace, apiAddr)
 		if clientErr == nil {
 			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 			defer cancel()
diff --git a/controller/api/destination/watcher/ip_watcher.go b/controller/api/destination/watcher/ip_watcher.go
index d3028f577..4451fac43 100644
--- a/controller/api/destination/watcher/ip_watcher.go
+++ b/controller/api/destination/watcher/ip_watcher.go
@@ -154,10 +154,23 @@ func (iw *IPWatcher) GetPod(podIP string) (*corev1.Pod, error) {
 }
 
 func getResource(ip string, informer cache.SharedIndexInformer) (interface{}, error) {
-	objs, err := informer.GetIndexer().ByIndex(podIPIndex, ip)
+	matchingObjs, err := informer.GetIndexer().ByIndex(podIPIndex, ip)
 	if err != nil {
 		return nil, status.Error(codes.Unknown, err.Error())
 	}
+
+	objs := make([]interface{}, 0)
+	for _, obj := range matchingObjs {
+		// Ignore terminated pods,
+		// their IPs can be reused for new Running pods
+		pod, ok := obj.(*corev1.Pod)
+		if ok && podTerminated(pod) {
+			continue
+		}
+
+		objs = append(objs, obj)
+	}
+
 	if len(objs) > 1 {
 		return nil, status.Errorf(codes.FailedPrecondition, "IP address conflict: %v, %v", objs[0], objs[1])
 	}
@@ -167,6 +180,11 @@ func getResource(ip string, informer cache.SharedIndexInformer) (interface{}, er
 	return nil, nil
 }
 
+func podTerminated(pod *corev1.Pod) bool {
+	phase := pod.Status.Phase
+	return phase == corev1.PodSucceeded || phase == corev1.PodFailed
+}
+
 func (iw *IPWatcher) addService(obj interface{}) {
 	service := obj.(*corev1.Service)
 	if service.Namespace == kubeSystem || service.Spec.ClusterIP == "None" {
@@ -285,10 +303,18 @@ func (iw *IPWatcher) getOrNewServiceSubscriptions(clusterIP string) *serviceSubs
 			pods := []*corev1.Pod{}
 			for _, obj := range objs {
 				if pod, ok := obj.(*corev1.Pod); ok {
-					// Skip pods with HostNetwork.
-					if !pod.Spec.HostNetwork {
-						pods = append(pods, pod)
+					// Skip pods with HostNetwork
+					if pod.Spec.HostNetwork {
+						continue
 					}
+
+					// Ignore terminated pods,
+					// their IPs can be reused for new Running pods
+					if podTerminated(pod) {
+						continue
+					}
+
+					pods = append(pods, pod)
 				}
 			}
 			if len(pods) > 1 {
diff --git a/jaeger/charts/jaeger/README.md b/jaeger/charts/jaeger/README.md
index de6bab171..25eebdebb 100644
--- a/jaeger/charts/jaeger/README.md
+++ b/jaeger/charts/jaeger/README.md
@@ -21,6 +21,7 @@ Kubernetes: `>=1.15.0-0`
 | collector.image.name | string | `"omnition/opencensus-collector"` |  |
 | collector.image.pullPolicy | string | `"Always"` |  |
 | collector.image.version | string | `"0.1.11"` |  |
+| collector.jaegerAddr | string | `nil` | address of the jaeger backend to send traces to |
 | jaeger.image.name | string | `"jaegertracing/all-in-one"` |  |
 | jaeger.image.pullPolicy | string | `"Always"` |  |
 | jaeger.image.version | string | `"1.19.2"` |  |
diff --git a/jaeger/charts/jaeger/templates/tracing.yaml b/jaeger/charts/jaeger/templates/tracing.yaml
index 292b7c21e..1b4d6284a 100644
--- a/jaeger/charts/jaeger/templates/tracing.yaml
+++ b/jaeger/charts/jaeger/templates/tracing.yaml
@@ -26,7 +26,7 @@ data:
         retry-on-failure: true
         sender-type: jaeger-thrift-http
         jaeger-thrift-http:
-          collector-endpoint: {{printf "http://%s.%s:14268/api/traces" "jaeger" .Values.namespace }}
+          collector-endpoint: {{.Values.collector.jaegerAddr | default (printf "http://%s.%s:14268/api/traces" "jaeger" .Values.namespace) }}
           timeout: 5s
 ---
 apiVersion: v1
diff --git a/jaeger/charts/jaeger/values.yaml b/jaeger/charts/jaeger/values.yaml
index 017547aaa..31cc0f109 100644
--- a/jaeger/charts/jaeger/values.yaml
+++ b/jaeger/charts/jaeger/values.yaml
@@ -9,6 +9,9 @@ collector:
     pullPolicy: Always
   # resources:
 
+  # -- address of the jaeger backend to send traces to
+  jaegerAddr:
+
 jaeger:
   image: 
     name: jaegertracing/all-in-one
diff --git a/pkg/charts/charts.go b/pkg/charts/charts.go
index 46e2f333f..2dc9a4bce 100644
--- a/pkg/charts/charts.go
+++ b/pkg/charts/charts.go
@@ -22,6 +22,7 @@ var (
 	L5dPartials = []string{
 		"charts/partials/" + chartutil.ChartfileName,
 		"charts/partials/templates/_proxy.tpl",
+		"charts/partials/templates/_proxy-config-ann.tpl",
 		"charts/partials/templates/_proxy-init.tpl",
 		"charts/partials/templates/_volumes.tpl",
 		"charts/partials/templates/_resources.tpl",
@@ -194,7 +195,10 @@ func OverrideFromFile(values map[string]interface{}, fs http.FileSystem, chartNa
 	return MergeMaps(valuesOverrideMap, values), nil
 }
 
-// MergeMaps returns the merge result of two maps
+// MergeMaps returns the resultant map after merging given two maps of type map[string]interface{}
+// The inputs are not mutated and the second map i.e b's values take predence during merge.
+// This gives semantically correct merge compared with `mergo.Merge` (with boolean values).
+// See https://github.com/imdario/mergo/issues/129
 func MergeMaps(a, b map[string]interface{}) map[string]interface{} {
 	out := make(map[string]interface{}, len(a))
 	for k, v := range a {
@@ -202,9 +206,9 @@ func MergeMaps(a, b map[string]interface{}) map[string]interface{} {
 	}
 	for k, v := range b {
 		if v, ok := v.(map[string]interface{}); ok {
-			if bv, ok := out[k]; ok {
-				if bv, ok := bv.(map[string]interface{}); ok {
-					out[k] = MergeMaps(bv, v)
+			if av, ok := out[k]; ok {
+				if av, ok := av.(map[string]interface{}); ok {
+					out[k] = MergeMaps(av, v)
 					continue
 				}
 			}
diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go
new file mode 100644
index 000000000..73aebf48c
--- /dev/null
+++ b/pkg/cmd/cmd.go
@@ -0,0 +1,29 @@
+package cmd
+
+import (
+	"github.com/prometheus/common/log"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/tools/clientcmd"
+)
+
+// GetDefaultNamespace fetches the default namespace
+// used in the current KubeConfig context
+func GetDefaultNamespace(kubeconfigPath, kubeContext string) string {
+	rules := clientcmd.NewDefaultClientConfigLoadingRules()
+
+	if kubeconfigPath != "" {
+		rules.ExplicitPath = kubeconfigPath
+	}
+
+	overrides := &clientcmd.ConfigOverrides{CurrentContext: kubeContext}
+	kubeCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides)
+	ns, _, err := kubeCfg.Namespace()
+
+	if err != nil {
+		log.Warnf(`could not set namespace from kubectl context, using 'default' namespace: %s
+		 ensure the KUBECONFIG path %s is valid`, err, kubeconfigPath)
+		return corev1.NamespaceDefault
+	}
+
+	return ns
+}
diff --git a/pkg/k8s/portforward.go b/pkg/k8s/portforward.go
index e501e909b..3a86cb077 100644
--- a/pkg/k8s/portforward.go
+++ b/pkg/k8s/portforward.go
@@ -25,6 +25,8 @@ type PortForward struct {
 	method     string
 	url        *url.URL
 	host       string
+	namespace  string
+	podName    string
 	localPort  int
 	remotePort int
 	emitLogs   bool
@@ -140,6 +142,8 @@ func newPortForward(
 		method:     "POST",
 		url:        req.URL(),
 		host:       host,
+		namespace:  namespace,
+		podName:    podName,
 		localPort:  localPort,
 		remotePort: remotePort,
 		emitLogs:   emitLogs,
@@ -172,7 +176,12 @@ func (pf *PortForward) run() error {
 		return err
 	}
 
-	return fw.ForwardPorts()
+	err = fw.ForwardPorts()
+	if err != nil {
+		err = fmt.Errorf("%s for %s/%s", err, pf.namespace, pf.podName)
+		return err
+	}
+	return nil
 }
 
 // Init creates and runs a port-forward connection.
diff --git a/cli/cmd/public_api.go b/pkg/public/api.go
similarity index 54%
rename from cli/cmd/public_api.go
rename to pkg/public/api.go
index f74211d86..6a0af9ef6 100644
--- a/cli/cmd/public_api.go
+++ b/pkg/public/api.go
@@ -1,8 +1,4 @@
-package cmd
-
-// TODO: this file should probably move out of `./cli/cmd` into something like
-// `./cli/publicapi` or `./cli/pkg`:
-// https://github.com/linkerd/linkerd2/issues/2735
+package public
 
 import (
 	"context"
@@ -16,32 +12,28 @@ import (
 	"github.com/linkerd/linkerd2/pkg/k8s"
 )
 
-// rawPublicAPIClient creates a raw public API client with no validation.
-func rawPublicAPIClient(ctx context.Context) (pb.ApiClient, error) {
+// RawPublicAPIClient creates a raw public API client with no validation.
+func RawPublicAPIClient(ctx context.Context, kubeAPI *k8s.KubernetesAPI, controlPlaneNamespace string, apiAddr string) (pb.ApiClient, error) {
 	if apiAddr != "" {
 		return public.NewInternalClient(controlPlaneNamespace, apiAddr)
 	}
 
-	kubeAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
-	if err != nil {
-		return nil, err
-	}
-
 	return public.NewExternalClient(ctx, controlPlaneNamespace, kubeAPI)
 }
 
-// checkPublicAPIClientOrExit builds a new public API client and executes default status
+// CheckPublicAPIClientOrExit builds a new public API client and executes default status
 // checks to determine if the client can successfully perform cli commands. If the
 // checks fail, then CLI will print an error and exit.
-func checkPublicAPIClientOrExit() public.APIClient {
-	return checkPublicAPIClientOrRetryOrExit(time.Time{}, false)
+func CheckPublicAPIClientOrExit(hcOptions healthcheck.Options) public.APIClient {
+	hcOptions.RetryDeadline = time.Time{}
+	return CheckPublicAPIClientOrRetryOrExit(hcOptions, false)
 }
 
-// checkPublicAPIClientWithDeadlineOrExit builds a new public API client and executes status
+// CheckPublicAPIClientOrRetryOrExit builds a new public API client and executes status
 // checks to determine if the client can successfully connect to the API. If the
 // checks fail, then CLI will print an error and exit. If the retryDeadline
 // param is specified, then the CLI will print a message to stderr and retry.
-func checkPublicAPIClientOrRetryOrExit(retryDeadline time.Time, apiChecks bool) public.APIClient {
+func CheckPublicAPIClientOrRetryOrExit(hcOptions healthcheck.Options, apiChecks bool) public.APIClient {
 	checks := []healthcheck.CategoryID{
 		healthcheck.KubernetesAPIChecks,
 		healthcheck.LinkerdControlPlaneExistenceChecks,
@@ -51,24 +43,12 @@ func checkPublicAPIClientOrRetryOrExit(retryDeadline time.Time, apiChecks bool)
 		checks = append(checks, healthcheck.LinkerdAPIChecks)
 	}
 
-	hc := newHealthChecker(checks, retryDeadline)
+	hc := healthcheck.NewHealthChecker(checks, &hcOptions)
 
 	hc.RunChecks(exitOnError)
 	return hc.PublicAPIClient()
 }
 
-func newHealthChecker(checks []healthcheck.CategoryID, retryDeadline time.Time) *healthcheck.HealthChecker {
-	return healthcheck.NewHealthChecker(checks, &healthcheck.Options{
-		ControlPlaneNamespace: controlPlaneNamespace,
-		KubeConfig:            kubeconfigPath,
-		KubeContext:           kubeContext,
-		Impersonate:           impersonate,
-		ImpersonateGroup:      impersonateGroup,
-		APIAddr:               apiAddr,
-		RetryDeadline:         retryDeadline,
-	})
-}
-
 func exitOnError(result *healthcheck.CheckResult) {
 	if result.Retry {
 		fmt.Fprintln(os.Stderr, "Waiting for control plane to become available")
@@ -88,9 +68,6 @@ func exitOnError(result *healthcheck.CheckResult) {
 		fmt.Fprintf(os.Stderr, "%s: %s\n", msg, result.Err)
 
 		checkCmd := "linkerd check"
-		if controlPlaneNamespace != defaultLinkerdNamespace {
-			checkCmd += fmt.Sprintf(" --linkerd-namespace %s", controlPlaneNamespace)
-		}
 		fmt.Fprintf(os.Stderr, "Validate the install with: %s\n", checkCmd)
 
 		os.Exit(1)
diff --git a/viz/charts/linkerd-viz/README.md b/viz/charts/linkerd-viz/README.md
index 0a8b0aebd..4231f44d2 100644
--- a/viz/charts/linkerd-viz/README.md
+++ b/viz/charts/linkerd-viz/README.md
@@ -79,6 +79,7 @@ Kubernetes: `>=1.13.0-0`
 | dashboard.image.registry | string | `"ghcr.io/linkerd"` | Docker registry for the web instance |
 | dashboard.image.tag | string | `"linkerdVersionValue"` | Docker image tag for the web instance |
 | dashboard.logLevel | string | `"info"` | log level of the dashboard component |
+| dashboard.proxy | string | `nil` |  |
 | dashboard.replicas | int | `1` | Number of replicas of dashboard |
 | dashboard.resources.cpu.limit | string | `nil` | Maximum amount of CPU units that the web container can use |
 | dashboard.resources.cpu.request | string | `nil` | Amount of CPU units that the web container requests |
@@ -93,12 +94,14 @@ Kubernetes: `>=1.13.0-0`
 | grafana.image.name | string | `"grafana"` | Docker image name for the grafana instance |
 | grafana.image.registry | string | `"ghcr.io/linkerd"` | Docker registry for the grafana instance |
 | grafana.image.tag | string | `"linkerdVersionValue"` | Docker image tag for the grafana instance |
+| grafana.proxy | string | `nil` |  |
 | grafana.resources.cpu.limit | string | `nil` | Maximum amount of CPU units that the grafana container can use |
 | grafana.resources.cpu.request | string | `nil` | Amount of CPU units that the grafana container requests |
 | grafana.resources.memory.limit | string | `nil` | Maximum amount of memory that grafana container can use |
 | grafana.resources.memory.request | string | `nil` | Amount of memory that the grafana container requests |
 | identityTrustDomain | string | `"cluster.local"` | Trust domain used for identity  |
 | imagePullSecrets | list | `[]` | For Private docker registries, authentication is needed.  Registry secrets are applied to the respective service accounts |
+| installNamespace | bool | `true` | Set to false when installing in a custom namespace. |
 | linkerdNamespace | string | `"linkerd"` | Namespace of the Linkerd core control-plane install |
 | linkerdVersion | string | `"linkerdVersionValue"` | control plane version. See Proxy section for proxy version |
 | namespace | string | `"linkerd-viz"` | Namespace in which the Linkerd Viz extension has to be installed |
@@ -112,6 +115,7 @@ Kubernetes: `>=1.13.0-0`
 | prometheus.image.pullPolicy | string | `"Always"` |  |
 | prometheus.image.registry | string | `"prom"` | Docker registry for the prometheus instance |
 | prometheus.image.tag | string | `"v2.19.3"` | Docker image tag for the prometheus instance |
+| prometheus.proxy | string | `nil` |  |
 | prometheus.remoteWrite | string | `nil` | Allows transparently sending samples to an endpoint. Mostly used for long term storage. |
 | prometheus.resources.cpu.limit | string | `nil` | Maximum amount of CPU units that the prometheus container can use |
 | prometheus.resources.cpu.request | string | `nil` | Amount of CPU units that the prometheus container requests |
@@ -130,6 +134,7 @@ Kubernetes: `>=1.13.0-0`
 | tap.image.tag | string | `"linkerdVersionValue"` | Docker image tag for the tap instance |
 | tap.keyPEM | string | `""` | Certificate key for Tap component. If not provided then Helm will generate one.  |
 | tap.logLevel | string | `"info"` | log level of the tap component |
+| tap.proxy | string | `nil` |  |
 | tap.replicas | int | `1` |  |
 | tap.resources.cpu.limit | string | `nil` | Maximum amount of CPU units that the tap container can use |
 | tap.resources.cpu.request | string | `nil` | Amount of CPU units that the tap container requests |
diff --git a/viz/charts/linkerd-viz/templates/grafana.yaml b/viz/charts/linkerd-viz/templates/grafana.yaml
index 521494307..b31d6df2c 100644
--- a/viz/charts/linkerd-viz/templates/grafana.yaml
+++ b/viz/charts/linkerd-viz/templates/grafana.yaml
@@ -108,6 +108,9 @@ spec:
     metadata:
       annotations:
         {{.Values.createdByAnnotation}}: {{default (printf "linkerd/helm %s" .Values.linkerdVersion) .Values.cliVersion}}
+        {{- with .Values.grafana.proxy }}
+        {{- include "partials.proxy.config.annotations" .resources | nindent 8 }}
+        {{- end }}
         {{- with .Values.podAnnotations }}{{ toYaml . | trim | nindent 8 }}{{- end }}
       labels:
         {{.Values.extensionAnnotation}}: linkerd-viz
diff --git a/viz/charts/linkerd-viz/templates/namespace.yaml b/viz/charts/linkerd-viz/templates/namespace.yaml
index f436f5d33..7b29f29c1 100644
--- a/viz/charts/linkerd-viz/templates/namespace.yaml
+++ b/viz/charts/linkerd-viz/templates/namespace.yaml
@@ -1,3 +1,4 @@
+{{- if (.Values.installNamespace) -}}
 ---
 ###
 ### Linkerd Viz Extension Namespace
@@ -11,3 +12,4 @@ metadata:
     {{.Values.extensionAnnotation}}: linkerd-viz
   annotations:
     {{.Values.proxyInjectAnnotation}}: enabled
+{{ end -}}
diff --git a/viz/charts/linkerd-viz/templates/prometheus.yaml b/viz/charts/linkerd-viz/templates/prometheus.yaml
index fc9af7058..17a60bbf6 100644
--- a/viz/charts/linkerd-viz/templates/prometheus.yaml
+++ b/viz/charts/linkerd-viz/templates/prometheus.yaml
@@ -221,6 +221,9 @@ spec:
     metadata:
       annotations:
         {{.Values.createdByAnnotation}}: {{default (printf "linkerd/helm %s" .Values.linkerdVersion) .Values.cliVersion}}
+        {{- with .Values.prometheus.proxy }}
+        {{- include "partials.proxy.config.annotations" .resources | nindent 8 }}
+        {{- end }}
         {{- with .Values.podAnnotations }}{{  . | trim | nindent 8 }}{{- end }}
       labels:
         {{.Values.extensionAnnotation}}: linkerd-viz
diff --git a/viz/charts/linkerd-viz/templates/tap.yaml b/viz/charts/linkerd-viz/templates/tap.yaml
index 6cce931e8..660bcf055 100644
--- a/viz/charts/linkerd-viz/templates/tap.yaml
+++ b/viz/charts/linkerd-viz/templates/tap.yaml
@@ -60,6 +60,9 @@ spec:
         checksum/config: {{ include (print $.Template.BasePath "/tap-rbac.yaml") . | sha256sum }}
         {{- end }}
         {{.Values.createdByAnnotation}}: {{default (printf "linkerd/helm %s" .Values.linkerdVersion) .Values.cliVersion}}
+        {{- with .Values.tap.proxy }}
+        {{- include "partials.proxy.config.annotations" .resources | nindent 8 }}
+        {{- end }}
         {{- with .Values.podAnnotations }}{{ toYaml . | trim | nindent 8 }}{{- end }}
       labels:
         {{.Values.extensionAnnotation}}: linkerd-viz
@@ -72,7 +75,7 @@ spec:
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
       {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "tap" "label" .Values.controllerComponentLabel -}}
+      {{- $local := dict "component" "tap" "label" "component" -}}
       {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
       {{- end }}
       containers:
@@ -101,8 +104,8 @@ spec:
           httpGet:
             path: /ready
             port: 9998
-        {{- if .Values.tapResources -}}
-        {{- include "partials.resources" .Values.tapResources | nindent 8 }}
+        {{- if .Values.tap.resources -}}
+        {{- include "partials.resources" .Values.tap.resources | nindent 8 }}
         {{- end }}
         securityContext:
           runAsUser: {{.Values.tap.UID}}
diff --git a/viz/charts/linkerd-viz/templates/web.yaml b/viz/charts/linkerd-viz/templates/web.yaml
index 038c6013c..b16818a64 100644
--- a/viz/charts/linkerd-viz/templates/web.yaml
+++ b/viz/charts/linkerd-viz/templates/web.yaml
@@ -52,6 +52,9 @@ spec:
     metadata:
       annotations:
         {{.Values.createdByAnnotation}}: {{default (printf "linkerd/helm %s" .Values.linkerdVersion) .Values.cliVersion}}
+        {{- with .Values.dashboard.proxy }}
+        {{- include "partials.proxy.config.annotations" .resources | nindent 8 }}
+        {{- end }}
         {{- with .Values.podAnnotations }}{{ toYaml . | trim | nindent 8 }}{{- end }}
       labels:
         {{.Values.extensionAnnotation}}: linkerd-viz
diff --git a/viz/charts/linkerd-viz/values.yaml b/viz/charts/linkerd-viz/values.yaml
index 2b16cf3c1..b9d57d305 100644
--- a/viz/charts/linkerd-viz/values.yaml
+++ b/viz/charts/linkerd-viz/values.yaml
@@ -30,6 +30,8 @@ createdByAnnotation: linkerd.io/created-by
 proxyInjectAnnotation: linkerd.io/inject
 extensionAnnotation: linkerd.io/extension
 
+# -- Set to false when installing in a custom namespace.
+installNamespace: true
 # -- Namespace in which the Linkerd Viz extension has to be installed
 namespace: linkerd-viz
 
@@ -97,6 +99,11 @@ tap:
       # -- Amount of memory that the tap container requests
       request:
 
+  proxy:
+    # -- If set, overrides default proxy resources for the proxy injected
+    # into the tap component
+    # resources:
+
   UID: *uid
 
 # web dashboard configuration
@@ -135,6 +142,11 @@ dashboard:
       # -- Amount of memory that the web container requests
       request:
 
+  proxy:
+    # -- If set, overrides default proxy resources for the proxy injected
+    # into the dashboard component
+    # resources:
+
 grafana:
   # -- toggle field to enable or disable grafana
   enabled: true
@@ -158,6 +170,11 @@ grafana:
       # -- Amount of memory that the grafana container requests
       request:
 
+  proxy:
+    # -- If set, overrides default proxy resources for the proxy injected
+    # into the grafana component
+    # resources:
+
 prometheus:
   # -- toggle field to enable or disable prometheus
   enabled: true
@@ -277,3 +294,8 @@ prometheus:
       limit:
       # -- Amount of memory that the prometheus container requests
       request:
+
+  proxy:
+    # -- If set, overrides default proxy resources for the proxy injected
+    # into the prometheus component
+    # resources:
diff --git a/cli/cmd/dashboard.go b/viz/cmd/dashboard.go
similarity index 89%
rename from cli/cmd/dashboard.go
rename to viz/cmd/dashboard.go
index eba313c12..9a0bec844 100644
--- a/cli/cmd/dashboard.go
+++ b/viz/cmd/dashboard.go
@@ -6,7 +6,9 @@ import (
 	"os/signal"
 	"time"
 
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	"github.com/pkg/browser"
 	"github.com/spf13/cobra"
 )
@@ -80,13 +82,26 @@ func newCmdDashboard() *cobra.Command {
 			}
 
 			// ensure we can connect to the public API before starting the proxy
-			checkPublicAPIClientOrRetryOrExit(time.Now().Add(options.wait), true)
+			api.CheckPublicAPIClientOrRetryOrExit(healthcheck.Options{
+				ControlPlaneNamespace: controlPlaneNamespace,
+				KubeConfig:            kubeconfigPath,
+				Impersonate:           impersonate,
+				ImpersonateGroup:      impersonateGroup,
+				KubeContext:           kubeContext,
+				APIAddr:               apiAddr,
+				RetryDeadline:         time.Now().Add(options.wait),
+			}, true)
 
 			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
 			if err != nil {
 				return err
 			}
 
+			vizNamespace, err := getVizNamespace(cmd.Context(), k8sAPI)
+			if err != nil {
+				return err
+			}
+
 			signals := make(chan os.Signal, 1)
 			signal.Notify(signals, os.Interrupt)
 			defer signal.Stop(signals)
@@ -94,7 +109,7 @@ func newCmdDashboard() *cobra.Command {
 			portforward, err := k8s.NewPortForward(
 				cmd.Context(),
 				k8sAPI,
-				defaultLinkerdVizNamespace,
+				vizNamespace,
 				webDeployment,
 				options.host,
 				options.port,
diff --git a/cli/cmd/edges.go b/viz/cmd/edges.go
similarity index 94%
rename from cli/cmd/edges.go
rename to viz/cmd/edges.go
index 022db3639..f05276b5c 100644
--- a/cli/cmd/edges.go
+++ b/viz/cmd/edges.go
@@ -10,11 +10,20 @@ import (
 	"strings"
 	"text/tabwriter"
 
+	"github.com/fatih/color"
 	"github.com/linkerd/linkerd2/controller/api/util"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	"github.com/spf13/cobra"
 )
 
+var (
+	okStatus = color.New(color.FgGreen, color.Bold).SprintFunc()("\u221A") // √
+
+)
+
 type edgesOptions struct {
 	namespace     string
 	outputFormat  string
@@ -23,7 +32,6 @@ type edgesOptions struct {
 
 func newEdgesOptions() *edgesOptions {
 	return &edgesOptions{
-		namespace:     defaultNamespace,
 		outputFormat:  tableOutput,
 		allNamespaces: false,
 	}
@@ -75,6 +83,10 @@ func newCmdEdges() *cobra.Command {
 		Args:      cobra.ExactArgs(1),
 		ValidArgs: util.ValidTargets,
 		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
 			reqs, err := buildEdgesRequests(args, options)
 			if err != nil {
 				return fmt.Errorf("Error creating edges request: %s", err)
@@ -82,7 +94,14 @@ func newCmdEdges() *cobra.Command {
 
 			// The gRPC client is concurrency-safe, so we can reuse it in all the following goroutines
 			// https://github.com/grpc/grpc-go/issues/682
-			client := checkPublicAPIClientOrExit()
+			client := api.CheckPublicAPIClientOrExit(healthcheck.Options{
+				ControlPlaneNamespace: controlPlaneNamespace,
+				KubeConfig:            kubeconfigPath,
+				Impersonate:           impersonate,
+				ImpersonateGroup:      impersonateGroup,
+				KubeContext:           kubeContext,
+				APIAddr:               apiAddr,
+			})
 			c := make(chan indexedEdgeResults, len(reqs))
 			for num, req := range reqs {
 				go func(num int, req *pb.EdgesRequest) {
diff --git a/viz/cmd/root.go b/viz/cmd/root.go
index 463318545..645d17ff8 100644
--- a/viz/cmd/root.go
+++ b/viz/cmd/root.go
@@ -1,23 +1,30 @@
 package cmd
 
 import (
+	"context"
+	"errors"
 	"fmt"
 	"regexp"
 
+	"github.com/linkerd/linkerd2/pkg/k8s"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 const (
 	vizChartName            = "linkerd-viz"
 	defaultLinkerdNamespace = "linkerd"
-	defaultVizNamespace     = "linkerd-viz"
+	maxRps                  = 100.0
+
+	jsonOutput  = "json"
+	tableOutput = "table"
+	wideOutput  = "wide"
 )
 
 var (
 	apiAddr               string // An empty value means "use the Kubernetes configuration"
 	controlPlaneNamespace string
-	namespace             string
 	kubeconfigPath        string
 	kubeContext           string
 	impersonate           string
@@ -52,7 +59,6 @@ func NewCmdViz() *cobra.Command {
 	}
 
 	vizCmd.PersistentFlags().StringVarP(&controlPlaneNamespace, "linkerd-namespace", "L", defaultLinkerdNamespace, "Namespace in which Linkerd is installed")
-	vizCmd.PersistentFlags().StringVarP(&namespace, "namespace", "n", defaultVizNamespace, "Namespace in which viz extension is installed")
 	vizCmd.PersistentFlags().StringVar(&kubeconfigPath, "kubeconfig", "", "Path to the kubeconfig file to use for CLI requests")
 	vizCmd.PersistentFlags().StringVar(&kubeContext, "context", "", "Name of the kubeconfig context to use")
 	vizCmd.PersistentFlags().StringVar(&impersonate, "as", "", "Username to impersonate for Kubernetes operations")
@@ -60,7 +66,24 @@ func NewCmdViz() *cobra.Command {
 	vizCmd.PersistentFlags().StringVar(&apiAddr, "api-addr", "", "Override kubeconfig and communicate directly with the control plane at host:port (mostly for testing)")
 	vizCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Turn on debug logging")
 	vizCmd.AddCommand(newCmdInstall())
+	vizCmd.AddCommand(newCmdRoutes())
+	vizCmd.AddCommand(newCmdStat())
+	vizCmd.AddCommand(newCmdTap())
+	vizCmd.AddCommand(newCmdTop())
+	vizCmd.AddCommand(newCmdEdges())
+	vizCmd.AddCommand(newCmdDashboard())
 	vizCmd.AddCommand(newCmdUninstall())
 
 	return vizCmd
 }
+
+func getVizNamespace(ctx context.Context, k8sAPI *k8s.KubernetesAPI) (string, error) {
+	ns, err := k8sAPI.CoreV1().Namespaces().List(ctx, metav1.ListOptions{LabelSelector: "linkerd.io/extension=linkerd-viz"})
+	if err != nil {
+		return "", err
+	}
+	if len(ns.Items) == 0 {
+		return "", errors.New("linkerd-viz extension not found")
+	}
+	return ns.Items[0].Name, nil
+}
diff --git a/cli/cmd/routes.go b/viz/cmd/routes.go
similarity index 91%
rename from cli/cmd/routes.go
rename to viz/cmd/routes.go
index 782e4c152..9b09a564f 100644
--- a/cli/cmd/routes.go
+++ b/viz/cmd/routes.go
@@ -9,10 +9,13 @@ import (
 	"sort"
 	"strings"
 	"text/tabwriter"
+	"time"
 
 	"github.com/linkerd/linkerd2/controller/api/util"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -63,7 +66,18 @@ This command will only display traffic which is sent to a service that has a Ser
 				return fmt.Errorf("error creating metrics request while making routes request: %v", err)
 			}
 
-			output, err := requestRouteStatsFromAPI(checkPublicAPIClientOrExit(), req, options)
+			output, err := requestRouteStatsFromAPI(
+				api.CheckPublicAPIClientOrExit(healthcheck.Options{
+					ControlPlaneNamespace: controlPlaneNamespace,
+					KubeConfig:            kubeconfigPath,
+					Impersonate:           impersonate,
+					ImpersonateGroup:      impersonateGroup,
+					KubeContext:           kubeContext,
+					APIAddr:               apiAddr,
+				}),
+				req,
+				options,
+			)
 			if err != nil {
 				return err
 			}
@@ -244,6 +258,24 @@ func printRouteTable(stats []*routeRowStats, w *tabwriter.Writer, options *route
 	}
 }
 
+// getRequestRate calculates request rate from Public API BasicStats.
+func getRequestRate(success, failure uint64, timeWindow string) float64 {
+	windowLength, err := time.ParseDuration(timeWindow)
+	if err != nil {
+		log.Error(err.Error())
+		return 0.0
+	}
+	return float64(success+failure) / windowLength.Seconds()
+}
+
+// getSuccessRate calculates success rate from Public API BasicStats.
+func getSuccessRate(success, failure uint64) float64 {
+	if success+failure == 0 {
+		return 0.0
+	}
+	return float64(success) / float64(success+failure)
+}
+
 // JSONRouteStats represents the JSON output of the routes command
 // Using pointers there where the value is NA and the corresponding json is null
 type JSONRouteStats struct {
diff --git a/cli/cmd/stat.go b/viz/cmd/stat.go
similarity index 93%
rename from cli/cmd/stat.go
rename to viz/cmd/stat.go
index 84cc5407f..ccc545302 100644
--- a/cli/cmd/stat.go
+++ b/viz/cmd/stat.go
@@ -13,7 +13,11 @@ import (
 
 	"github.com/linkerd/linkerd2/controller/api/util"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
+	"github.com/linkerd/linkerd2/pkg/cmd"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	api "github.com/linkerd/linkerd2/pkg/public"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -29,6 +33,28 @@ type statOptions struct {
 	unmeshed      bool
 }
 
+type statOptionsBase struct {
+	namespace    string
+	timeWindow   string
+	outputFormat string
+}
+
+func newStatOptionsBase() *statOptionsBase {
+	return &statOptionsBase{
+		timeWindow:   "1m",
+		outputFormat: tableOutput,
+	}
+}
+
+func (o *statOptionsBase) validateOutputFormat() error {
+	switch o.outputFormat {
+	case tableOutput, jsonOutput, wideOutput:
+		return nil
+	default:
+		return fmt.Errorf("--output currently only supports %s, %s and %s", tableOutput, jsonOutput, wideOutput)
+	}
+}
+
 type indexedResults struct {
 	ix   int
 	rows []*pb.StatTable_PodGroup_Row
@@ -141,6 +167,10 @@ If no resource name is specified, displays stats about all resources of the spec
 		Args:      cobra.MinimumNArgs(1),
 		ValidArgs: util.ValidTargets,
 		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
 			reqs, err := buildStatSummaryRequests(args, options)
 			if err != nil {
 				return fmt.Errorf("error creating metrics request while making stats request: %v", err)
@@ -148,7 +178,14 @@ If no resource name is specified, displays stats about all resources of the spec
 
 			// The gRPC client is concurrency-safe, so we can reuse it in all the following goroutines
 			// https://github.com/grpc/grpc-go/issues/682
-			client := checkPublicAPIClientOrExit()
+			client := api.CheckPublicAPIClientOrExit(healthcheck.Options{
+				ControlPlaneNamespace: controlPlaneNamespace,
+				KubeConfig:            kubeconfigPath,
+				Impersonate:           impersonate,
+				ImpersonateGroup:      impersonateGroup,
+				KubeContext:           kubeContext,
+				APIAddr:               apiAddr,
+			})
 			c := make(chan indexedResults, len(reqs))
 			for num, req := range reqs {
 				go func(num int, req *pb.StatSummaryRequest) {
@@ -742,7 +779,7 @@ func (o *statOptions) validateConflictingFlags() error {
 		return fmt.Errorf("--to-namespace and --from-namespace flags are mutually exclusive")
 	}
 
-	if o.allNamespaces && o.namespace != defaultNamespace {
+	if o.allNamespaces && o.namespace != cmd.GetDefaultNamespace(kubeconfigPath, kubeContext) {
 		return fmt.Errorf("--all-namespaces and --namespace flags are mutually exclusive")
 	}
 
@@ -762,7 +799,7 @@ func (o *statOptions) validateNamespaceFlags() error {
 
 	// Note: technically, this allows you to say `stat ns --namespace <default-namespace-from-kubectl-context>`, but that
 	// seems like an edge case.
-	if o.namespace != defaultNamespace {
+	if o.namespace != cmd.GetDefaultNamespace(kubeconfigPath, kubeContext) {
 		return fmt.Errorf("--namespace flag is incompatible with namespace resource type")
 	}
 
@@ -778,3 +815,20 @@ func getByteRate(bytes uint64, timeWindow string) float64 {
 	}
 	return float64(bytes) / windowLength.Seconds()
 }
+
+func renderStats(buffer bytes.Buffer, options *statOptionsBase) string {
+	var out string
+	switch options.outputFormat {
+	case jsonOutput:
+		out = buffer.String()
+	default:
+		// strip left padding on the first column
+		b := buffer.Bytes()
+		if len(b) > padding {
+			out = string(b[padding:])
+		}
+		out = strings.Replace(out, "\n"+strings.Repeat(" ", padding), "\n", -1)
+	}
+
+	return out
+}
diff --git a/cli/cmd/tap.go b/viz/cmd/tap.go
similarity index 99%
rename from cli/cmd/tap.go
rename to viz/cmd/tap.go
index 0195a03c1..4b3fca237 100644
--- a/cli/cmd/tap.go
+++ b/viz/cmd/tap.go
@@ -13,6 +13,7 @@ import (
 	"github.com/linkerd/linkerd2/controller/api/util"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
 	"github.com/linkerd/linkerd2/pkg/addr"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/protohttp"
 	"github.com/linkerd/linkerd2/pkg/tap"
@@ -104,7 +105,6 @@ type tapEvent struct {
 
 func newTapOptions() *tapOptions {
 	return &tapOptions{
-		namespace:     defaultNamespace,
 		toResource:    "",
 		toNamespace:   "",
 		maxRps:        maxRps,
@@ -171,6 +171,10 @@ func newCmdTap() *cobra.Command {
 		Args:      cobra.RangeArgs(1, 2),
 		ValidArgs: util.ValidTargets,
 		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
 			requestParams := util.TapRequestParams{
 				Resource:      strings.Join(args, "/"),
 				Namespace:     options.namespace,
diff --git a/cli/cmd/testdata/routes_one_output.golden b/viz/cmd/testdata/routes_one_output.golden
similarity index 100%
rename from cli/cmd/testdata/routes_one_output.golden
rename to viz/cmd/testdata/routes_one_output.golden
diff --git a/cli/cmd/testdata/routes_one_output_json.golden b/viz/cmd/testdata/routes_one_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/routes_one_output_json.golden
rename to viz/cmd/testdata/routes_one_output_json.golden
diff --git a/cli/cmd/testdata/routes_one_output_wide.golden b/viz/cmd/testdata/routes_one_output_wide.golden
similarity index 100%
rename from cli/cmd/testdata/routes_one_output_wide.golden
rename to viz/cmd/testdata/routes_one_output_wide.golden
diff --git a/cli/cmd/testdata/stat_all_output.golden b/viz/cmd/testdata/stat_all_output.golden
similarity index 100%
rename from cli/cmd/testdata/stat_all_output.golden
rename to viz/cmd/testdata/stat_all_output.golden
diff --git a/cli/cmd/testdata/stat_all_output_json.golden b/viz/cmd/testdata/stat_all_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/stat_all_output_json.golden
rename to viz/cmd/testdata/stat_all_output_json.golden
diff --git a/cli/cmd/testdata/stat_one_output.golden b/viz/cmd/testdata/stat_one_output.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_output.golden
rename to viz/cmd/testdata/stat_one_output.golden
diff --git a/cli/cmd/testdata/stat_one_output_json.golden b/viz/cmd/testdata/stat_one_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_output_json.golden
rename to viz/cmd/testdata/stat_one_output_json.golden
diff --git a/cli/cmd/testdata/stat_one_pod_output.golden b/viz/cmd/testdata/stat_one_pod_output.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_pod_output.golden
rename to viz/cmd/testdata/stat_one_pod_output.golden
diff --git a/cli/cmd/testdata/stat_one_tcp_output.golden b/viz/cmd/testdata/stat_one_tcp_output.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_tcp_output.golden
rename to viz/cmd/testdata/stat_one_tcp_output.golden
diff --git a/cli/cmd/testdata/stat_one_ts_output.golden b/viz/cmd/testdata/stat_one_ts_output.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_ts_output.golden
rename to viz/cmd/testdata/stat_one_ts_output.golden
diff --git a/cli/cmd/testdata/stat_one_ts_output_json.golden b/viz/cmd/testdata/stat_one_ts_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/stat_one_ts_output_json.golden
rename to viz/cmd/testdata/stat_one_ts_output_json.golden
diff --git a/cli/cmd/testdata/tap_busy_output.golden b/viz/cmd/testdata/tap_busy_output.golden
similarity index 100%
rename from cli/cmd/testdata/tap_busy_output.golden
rename to viz/cmd/testdata/tap_busy_output.golden
diff --git a/cli/cmd/testdata/tap_busy_output_json.golden b/viz/cmd/testdata/tap_busy_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/tap_busy_output_json.golden
rename to viz/cmd/testdata/tap_busy_output_json.golden
diff --git a/cli/cmd/testdata/tap_busy_output_wide.golden b/viz/cmd/testdata/tap_busy_output_wide.golden
similarity index 100%
rename from cli/cmd/testdata/tap_busy_output_wide.golden
rename to viz/cmd/testdata/tap_busy_output_wide.golden
diff --git a/cli/cmd/testdata/tap_empty_output.golden b/viz/cmd/testdata/tap_empty_output.golden
similarity index 100%
rename from cli/cmd/testdata/tap_empty_output.golden
rename to viz/cmd/testdata/tap_empty_output.golden
diff --git a/cli/cmd/top.go b/viz/cmd/top.go
similarity index 99%
rename from cli/cmd/top.go
rename to viz/cmd/top.go
index d80e1d906..d7d950710 100644
--- a/cli/cmd/top.go
+++ b/viz/cmd/top.go
@@ -15,6 +15,7 @@ import (
 	"github.com/linkerd/linkerd2/controller/api/util"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
 	"github.com/linkerd/linkerd2/pkg/addr"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/protohttp"
 	"github.com/linkerd/linkerd2/pkg/tap"
@@ -260,7 +261,6 @@ const (
 
 func newTopOptions() *topOptions {
 	return &topOptions{
-		namespace:     defaultNamespace,
 		toResource:    "",
 		toNamespace:   "",
 		maxRps:        maxRps,
@@ -319,6 +319,10 @@ func newCmdTop() *cobra.Command {
 		Args:      cobra.RangeArgs(1, 2),
 		ValidArgs: util.ValidTargets,
 		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
 			requestParams := util.TapRequestParams{
 				Resource:      strings.Join(args, "/"),
 				Namespace:     options.namespace,
diff --git a/viz/cmd/uninstall.go b/viz/cmd/uninstall.go
index 5e145ebbd..a0d3ca1a7 100644
--- a/viz/cmd/uninstall.go
+++ b/viz/cmd/uninstall.go
@@ -34,8 +34,13 @@ func uninstallRunE(ctx context.Context) error {
 		return err
 	}
 
+	vizNamespace, err := getVizNamespace(ctx, k8sAPI)
+	if err != nil {
+		return err
+	}
+
 	resources, err := resource.FetchKubernetesResources(ctx, k8sAPI,
-		metav1.ListOptions{LabelSelector: fmt.Sprintf("%s=%s", k8s.LinkerdExtensionLabel, defaultVizNamespace)},
+		metav1.ListOptions{LabelSelector: fmt.Sprintf("%s=%s", k8s.LinkerdExtensionLabel, vizNamespace)},
 	)
 	if err != nil {
 		return err


Test patch:
diff --git a/bin/_test-helpers.sh b/bin/_test-helpers.sh
index 5c802e439..ae9838365 100644
--- a/bin/_test-helpers.sh
+++ b/bin/_test-helpers.sh
@@ -394,11 +394,6 @@ run_upgrade-edge_test() {
 # Run the upgrade-stable test by upgrading the most-recent stable release to the
 # HEAD of this branch.
 run_upgrade-stable_test() {
-  if [ -n "$RUN_ARM_TEST" ]; then
-    echo "Skipped. Linkerd stable version does not support ARM yet"
-    return
-  fi
-
   stable_install_url="https://run.linkerd.io/install"
   upgrade_test "stable" "$stable_install_url"
 }
@@ -430,11 +425,6 @@ helm_cleanup() {
 }
 
 run_helm-upgrade_test() {
-  if [ -n "$RUN_ARM_TEST" ]; then
-    echo "Skipped. Linkerd stable version does not support ARM yet"
-    return
-  fi
-
   local stable_version
   stable_version=$(latest_release_channel "stable")
 
diff --git a/bin/test-cleanup b/bin/test-cleanup
index dd22570cd..0cb9ea460 100755
--- a/bin/test-cleanup
+++ b/bin/test-cleanup
@@ -10,24 +10,6 @@ handle_cleanup_input "$@"
 
 check_linkerd_binary
 
-populate_array() {
-  [ $# -ge 3 ] || {
-    echo "function ${FUNCNAME[0]} expects 3 mandatory inparameters but got $#
-Usage: ${FUNCNAME[0]} TARGET_ARRAY RESOURCE_TYPE LINKERD_LABEL (FRIENDLY_NAME)" >&2
-    return
-  }
-
-  # We are using the variable i even if shellsheck does not see it
-  # shellcheck disable=SC2034
-  local i=0
-  unset -v "$1"
-  while IFS= read -r "$1[i++]"; do :; done <<< "$(kubectl --context="$k8s_context" get -A "$2" -oname -l "linkerd.io/$3")"
-  # ensure that the last element isn't empty
-  eval "[[ \${$1[--i]} ]]" || unset "$1[i]" # ensures last element isn't empty
-  # inform user if no results were returned from command
-  [ -z "$(eval "[[ \${$1[0]} ]]")" ] && echo "no ${4:-$2} found" >&2
-}
-
 echo "cleaning up viz extension resources, if present [${k8s_context}]"
 "$linkerd_path" viz uninstall 2> /dev/null | kubectl --context="$k8s_context" delete -f -
 
@@ -37,69 +19,11 @@ echo "cleaning up multicluster resources, if present [${k8s_context}]"
 echo "cleaning up jaeger extension resources, if present [${k8s_context}]"
 "$linkerd_path" jaeger uninstall 2> /dev/null | kubectl --context="$k8s_context" delete -f -
 
-# Initialize empty arrays
-namespaces_controlplane=()
-namespaces_cni=()
-namespaces_dataplane=()
-clusterrolebindings=()
-clusterroles=()
-webhookconfigs=()
-validatingconfigs=()
-podsecuritypolicies=()
-customresourcedefinitions=()
-apiservices=()
-rolebindings=()
-
-echo "cleaning up control-plane namespaces in k8s-context [${k8s_context}]"
-
-populate_array namespaces_controlplane   ns                              is-control-plane 'control-plane namespaces'
-
-echo "cleaning up CNI namespaces in k8s-context [${k8s_context}]"
-
-populate_array namespaces_cni ns         cni-resource                    'CNI namespaces'
+echo "cleaning up linkerd resources [${k8s_context}]"
+"$linkerd_path" uninstall 2> /dev/null | kubectl --context="$k8s_context" delete -f -
 
-echo "cleaning up data-plane namespaces in k8s-context [${k8s_context}]"
-
-populate_array namespaces_dataplane      ns                              is-test-data-plane 'data-plane namespaces'
-populate_array clusterrolebindings       clusterrolebindings             control-plane-ns
-populate_array clusterroles              clusterroles                    control-plane-ns
-populate_array webhookconfigs            mutatingwebhookconfigurations   control-plane-ns
-populate_array validatingconfigs         validatingwebhookconfigurations control-plane-ns
-populate_array podsecuritypolicies       podsecuritypolicies             control-plane-ns
-populate_array customresourcedefinitions customresourcedefinitions       control-plane-ns
-populate_array apiservices               apiservices                     control-plane-ns
-
-# No action if all arrays are empty
-if [[ ${namespaces_controlplane[*]}   || \
-      ${namespaces_cni[*]}            || \
-      ${namespaces_dataplane[*]}      || \
-      ${clusterrolebindings[*]}       || \
-      ${clusterroles[*]}              || \
-      ${webhookconfigs[*]}            || \
-      ${validatingconfigs[*]}         || \
-      ${podsecuritypolicies[*]}       || \
-      ${customresourcedefinitions[*]} || \
-      ${apiservices[*]} ]]; then
-  kubectl --context="$k8s_context" delete \
-    ${namespaces_controlplane:+"${namespaces_controlplane[@]}"}     \
-    ${namespaces_cni:+"${namespaces_cni[@]}"}                       \
-    ${namespaces_dataplane:+"${namespaces_dataplane[@]}"}           \
-    ${clusterrolebindings:+"${clusterrolebindings[@]}"}             \
-    ${clusterroles:+"${clusterroles[@]}"}                           \
-    ${webhookconfigs:+"${webhookconfigs[@]}"}                       \
-    ${validatingconfigs:+"${validatingconfigs[@]}"}                 \
-    ${podsecuritypolicies:+"${podsecuritypolicies[@]}"}             \
-    ${customresourcedefinitions:+"${customresourcedefinitions[@]}"} \
-    ${apiservices:+"${apiservices[@]}"}
-fi
-
-echo "cleaning up rolebindings in kube-system namespace in k8s-context [${k8s_context}]"
-populate_array rolebindings              rolebindings                    control-plane-ns
-
-# No action if array is empty
-if [[ ${rolebindings[*]} ]]; then
-  kubectl --context="$k8s_context" delete "${rolebindings[@]}" -n kube-system
-fi
+echo "cleaning up the all namespaces labelled with linkerd.io/is-test-data-plane"
+kubectl --context="$k8s_context" delete ns -l linkerd.io/is-test-data-plane
 
 # Helm cleanup. Just the entries in `helm ls` as the resources should have already been cleaned up by the code above.
 releases=$(helm ls -A -q)
diff --git a/cli/cmd/endpoints_test.go b/cli/cmd/endpoints_test.go
index 1c794a505..019d36d28 100644
--- a/cli/cmd/endpoints_test.go
+++ b/cli/cmd/endpoints_test.go
@@ -135,5 +135,5 @@ func testEndpointsCall(exp endpointsExp, t *testing.T) {
 
 	output := renderEndpoints(endpoints, exp.options)
 
-	diffTestdata(t, exp.file, output)
+	testDataDiffer.DiffTestdata(t, exp.file, output)
 }
diff --git a/cli/cmd/inject_test.go b/cli/cmd/inject_test.go
index 9668b143a..c88061a6d 100644
--- a/cli/cmd/inject_test.go
+++ b/cli/cmd/inject_test.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/linkerd/linkerd2/pkg/charts/linkerd2"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/testutil"
 )
 
 type testCase struct {
@@ -52,10 +53,10 @@ func testUninjectAndInject(t *testing.T, tc testCase) {
 	if exitCode := uninjectAndInject([]io.Reader{read}, report, output, transformer); exitCode != 0 {
 		t.Errorf("Unexpected error injecting YAML: %v\n", report)
 	}
-	diffTestdata(t, tc.goldenFileName, output.String())
+	testDataDiffer.DiffTestdata(t, tc.goldenFileName, output.String())
 
 	reportFileName := mkFilename(tc.reportFileName, verbose)
-	diffTestdata(t, reportFileName, report.String())
+	testDataDiffer.DiffTestdata(t, reportFileName, report.String())
 }
 
 func defaultConfig() *linkerd2.Values {
@@ -335,13 +336,13 @@ func testInjectCmd(t *testing.T, tc injectCmd) {
 		t.Fatalf("Expected exit code to be %d but got: %d", tc.exitCode, exitCode)
 	}
 	if tc.stdOutGoldenFileName != "" {
-		diffTestdata(t, tc.stdOutGoldenFileName, outBuffer.String())
+		testDataDiffer.DiffTestdata(t, tc.stdOutGoldenFileName, outBuffer.String())
 	} else if outBuffer.Len() != 0 {
 		t.Fatalf("Expected no standard output, but got: %s", outBuffer)
 	}
 
 	stdErrGoldenFileName := mkFilename(tc.stdErrGoldenFileName, verbose)
-	diffTestdata(t, stdErrGoldenFileName, errBuffer.String())
+	testDataDiffer.DiffTestdata(t, stdErrGoldenFileName, errBuffer.String())
 }
 
 func TestRunInjectCmd(t *testing.T) {
@@ -427,10 +428,10 @@ func testInjectFilePath(t *testing.T, tc injectFilePath) {
 	if exitCode := runInjectCmd(in, errBuf, actual, transformer); exitCode != 0 {
 		t.Fatal("Unexpected error. Exit code from runInjectCmd: ", exitCode)
 	}
-	diffTestdata(t, tc.expectedFile, actual.String())
+	testDataDiffer.DiffTestdata(t, tc.expectedFile, actual.String())
 
 	stdErrFile := mkFilename(tc.stdErrFile, verbose)
-	diffTestdata(t, stdErrFile, errBuf.String())
+	testDataDiffer.DiffTestdata(t, stdErrFile, errBuf.String())
 }
 
 func testReadFromFolder(t *testing.T, resourceFolder string, expectedFolder string) {
@@ -454,10 +455,10 @@ func testReadFromFolder(t *testing.T, resourceFolder string, expectedFolder stri
 	}
 
 	expectedFile := filepath.Join(expectedFolder, "injected_nginx_redis.yaml")
-	diffTestdata(t, expectedFile, actual.String())
+	testDataDiffer.DiffTestdata(t, expectedFile, actual.String())
 
 	stdErrFileName := mkFilename(filepath.Join(expectedFolder, "injected_nginx_redis.stderr"), verbose)
-	diffTestdata(t, stdErrFileName, errBuf.String())
+	testDataDiffer.DiffTestdata(t, stdErrFileName, errBuf.String())
 }
 
 func TestInjectFilePath(t *testing.T) {
@@ -548,7 +549,7 @@ func TestWalk(t *testing.T) {
 	}()
 
 	var (
-		data  = []byte(readTestdata(t, "inject_gettest_deployment.bad.input.yml"))
+		data  = []byte(testutil.ReadTestdata(t, "inject_gettest_deployment.bad.input.yml"))
 		file1 = filepath.Join(tmpFolderRoot, "root.txt")
 		file2 = filepath.Join(tmpFolderData, "data.txt")
 	)
diff --git a/cli/cmd/install-cni-plugin_test.go b/cli/cmd/install-cni-plugin_test.go
index 2f98e6436..a0c004679 100644
--- a/cli/cmd/install-cni-plugin_test.go
+++ b/cli/cmd/install-cni-plugin_test.go
@@ -100,7 +100,7 @@ func TestRenderCNIPlugin(t *testing.T) {
 			if err != nil {
 				t.Fatalf("Unexpected error: %v", err)
 			}
-			diffTestdata(t, tc.goldenFileName, buf.String())
+			testDataDiffer.DiffTestdata(t, tc.goldenFileName, buf.String())
 		})
 	}
 
diff --git a/cli/cmd/install-sp_test.go b/cli/cmd/install-sp_test.go
index 3a8fcb78c..edf94b505 100644
--- a/cli/cmd/install-sp_test.go
+++ b/cli/cmd/install-sp_test.go
@@ -24,7 +24,7 @@ func TestRenderSP(t *testing.T) {
 			if err != nil {
 				t.Fatalf("Unexpected error: %v", err)
 			}
-			diffTestdata(t, tc.goldenFileName, buf.String())
+			testDataDiffer.DiffTestdata(t, tc.goldenFileName, buf.String())
 		})
 	}
 }
diff --git a/cli/cmd/install_cni_helm_test.go b/cli/cmd/install_cni_helm_test.go
index 5dc42728a..cb106535a 100644
--- a/cli/cmd/install_cni_helm_test.go
+++ b/cli/cmd/install_cni_helm_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	cnicharts "github.com/linkerd/linkerd2/pkg/charts/cni"
+	"github.com/linkerd/linkerd2/testutil"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/engine"
@@ -89,7 +90,7 @@ func testRenderCniHelm(t *testing.T, chart *chart.Chart, overrideConfig *chartut
 		buf.WriteString(v)
 	}
 
-	diffTestdata(t, goldenFileName, buf.String())
+	testDataDiffer.DiffTestdata(t, goldenFileName, buf.String())
 }
 
 func chartCniPlugin(t *testing.T) *chart.Chart {
@@ -123,7 +124,7 @@ func chartCniPlugin(t *testing.T) *chart.Chart {
 
 	for _, template := range cniChart.Templates {
 		filepath := filepath.Join(cniChart.Metadata.Sources[0], template.Name)
-		template.Data = []byte(readTestdata(t, filepath))
+		template.Data = []byte(testutil.ReadTestdata(t, filepath))
 	}
 
 	return cniChart
diff --git a/cli/cmd/install_helm_test.go b/cli/cmd/install_helm_test.go
index 32e6c2f63..a0fd1ccee 100644
--- a/cli/cmd/install_helm_test.go
+++ b/cli/cmd/install_helm_test.go
@@ -8,6 +8,7 @@ import (
 
 	l5dcharts "github.com/linkerd/linkerd2/pkg/charts/linkerd2"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/testutil"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/engine"
@@ -177,7 +178,7 @@ func testRenderHelm(t *testing.T, linkerd2Chart *chart.Chart, goldenFileName str
 		}
 	}
 
-	diffTestdata(t, goldenFileName, buf.String())
+	testDataDiffer.DiffTestdata(t, goldenFileName, buf.String())
 }
 
 func chartControlPlane(t *testing.T, ha bool, additionalConfig string, ignoreOutboundPorts string, ignoreInboundPorts string) *chart.Chart {
@@ -243,7 +244,7 @@ func chartControlPlane(t *testing.T, ha bool, additionalConfig string, ignoreOut
 
 	for _, template := range linkerd2Chart.Templates {
 		filepath := filepath.Join(linkerd2Chart.Metadata.Sources[0], template.Name)
-		template.Data = []byte(readTestdata(t, filepath))
+		template.Data = []byte(testutil.ReadTestdata(t, filepath))
 	}
 
 	return linkerd2Chart
@@ -268,7 +269,7 @@ func chartPartials(t *testing.T, paths []string) *chart.Chart {
 	for _, template := range chart.Templates {
 		template := template
 		filepath := filepath.Join(chart.Metadata.Sources[0], template.Name)
-		template.Data = []byte(readTestdata(t, filepath))
+		template.Data = []byte(testutil.ReadTestdata(t, filepath))
 	}
 
 	return chart
diff --git a/cli/cmd/install_test.go b/cli/cmd/install_test.go
index 70dbb1312..2c91b9605 100644
--- a/cli/cmd/install_test.go
+++ b/cli/cmd/install_test.go
@@ -219,7 +219,7 @@ func TestRender(t *testing.T) {
 			if err := render(&buf, tc.values, ""); err != nil {
 				t.Fatalf("Failed to render templates: %v", err)
 			}
-			diffTestdata(t, tc.goldenFileName, buf.String())
+			testDataDiffer.DiffTestdata(t, tc.goldenFileName, buf.String())
 		})
 	}
 }
diff --git a/cli/cmd/main_test.go b/cli/cmd/main_test.go
index cb84d6224..676a7e0af 100644
--- a/cli/cmd/main_test.go
+++ b/cli/cmd/main_test.go
@@ -2,86 +2,22 @@ package cmd
 
 import (
 	"flag"
-	"io/ioutil"
 	"os"
-	"path/filepath"
 	"testing"
 
-	"github.com/sergi/go-diff/diffmatchpatch"
+	"github.com/linkerd/linkerd2/testutil"
 )
 
 var (
-	// updateFixtures is set by the `-update` flag.
-	updateFixtures bool
-
-	// prettyDiff is set by the `-pretty-diff` flag.
-	prettyDiff bool
-
-	// write any rejected test data into this path
-	rejectPath string
+	testDataDiffer testutil.TestDataDiffer
 )
 
 // TestMain parses flags before running tests
 func TestMain(m *testing.M) {
-	flag.BoolVar(&updateFixtures, "update", false, "update text fixtures in place")
-	prettyDiff = os.Getenv("LINKERD_TEST_PRETTY_DIFF") != ""
-	flag.BoolVar(&prettyDiff, "pretty-diff", prettyDiff, "display the full text when diffing")
-	flag.StringVar(&rejectPath, "reject-path", "", "write results for failed tests to this path (path is relative to the test location)")
+	flag.BoolVar(&testDataDiffer.UpdateFixtures, "update", false, "update text fixtures in place")
+	prettyDiff := os.Getenv("LINKERD_TEST_PRETTY_DIFF") != ""
+	flag.BoolVar(&testDataDiffer.PrettyDiff, "pretty-diff", prettyDiff, "display the full text when diffing")
+	flag.StringVar(&testDataDiffer.RejectPath, "reject-path", "", "write results for failed tests to this path (path is relative to the test location)")
 	flag.Parse()
 	os.Exit(m.Run())
 }
-
-// readTestdata reads a file and returns the contents of that file as a string.
-func readTestdata(t *testing.T, fileName string) string {
-	file, err := os.Open(filepath.Join("testdata", fileName))
-	if err != nil {
-		t.Fatalf("Failed to open expected output file: %v", err)
-	}
-
-	fixture, err := ioutil.ReadAll(file)
-	if err != nil {
-		t.Fatalf("Failed to read expected output file: %v", err)
-	}
-
-	return string(fixture)
-}
-
-func writeTestdata(t *testing.T, fileName string, data []byte) {
-	p := filepath.Join("testdata", fileName)
-	if err := ioutil.WriteFile(p, data, 0644); err != nil {
-		t.Fatal(err)
-	}
-}
-
-func writeRejects(t *testing.T, origFileName string, data []byte) {
-	p := filepath.Join(rejectPath, origFileName+".rej")
-	if err := ioutil.WriteFile(p, data, 0644); err != nil {
-		t.Fatal(err)
-	}
-}
-
-// TODO: share this with integration tests
-func diffTestdata(t *testing.T, path, actual string) {
-	expected := readTestdata(t, path)
-	if actual == expected {
-		return
-	}
-	dmp := diffmatchpatch.New()
-	diffs := dmp.DiffMain(expected, actual, true)
-	diffs = dmp.DiffCleanupSemantic(diffs)
-	var diff string
-	if prettyDiff {
-		diff = dmp.DiffPrettyText(diffs)
-	} else {
-		diff = dmp.PatchToText(dmp.PatchMake(diffs))
-	}
-	t.Errorf("mismatch: %s\n%s", path, diff)
-
-	if updateFixtures {
-		writeTestdata(t, path, []byte(actual))
-	}
-
-	if rejectPath != "" {
-		writeRejects(t, path, []byte(actual))
-	}
-}
diff --git a/cli/cmd/uninject_test.go b/cli/cmd/uninject_test.go
index ae1312dfc..49ebe1fd1 100644
--- a/cli/cmd/uninject_test.go
+++ b/cli/cmd/uninject_test.go
@@ -116,8 +116,8 @@ func TestUninjectYAML(t *testing.T) {
 				t.Errorf("Failed to uninject %s\n", tc.inputFileName)
 			}
 
-			diffTestdata(t, tc.goldenFileName, output.String())
-			diffTestdata(t, tc.reportFileName, report.String())
+			testDataDiffer.DiffTestdata(t, tc.goldenFileName, output.String())
+			testDataDiffer.DiffTestdata(t, tc.reportFileName, report.String())
 		})
 	}
 }
diff --git a/cli/cmd/version_test.go b/cli/cmd/version_test.go
index d1322a418..09c4efb38 100644
--- a/cli/cmd/version_test.go
+++ b/cli/cmd/version_test.go
@@ -9,11 +9,12 @@ import (
 
 	"github.com/linkerd/linkerd2/controller/api/public"
 	pb "github.com/linkerd/linkerd2/controller/gen/public"
+	"github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/version"
 )
 
-func mkMockClient(version string, publicAPIErr error, mkClientErr error) func(ctx context.Context) (pb.ApiClient, error) {
-	return func(ctx context.Context) (pb.ApiClient, error) {
+func mkMockClient(version string, publicAPIErr error, mkClientErr error) func(ctx context.Context, k8sAPI *k8s.KubernetesAPI, controlPlaneNamespace, apiAddr string) (pb.ApiClient, error) {
+	return func(ctx context.Context, k8sAPI *k8s.KubernetesAPI, controlPlaneNamespace, apiAddr string) (pb.ApiClient, error) {
 		return &public.MockAPIClient{
 			ErrorToReturn: publicAPIErr,
 			VersionInfoToReturn: &pb.VersionInfo{
@@ -26,7 +27,7 @@ func mkMockClient(version string, publicAPIErr error, mkClientErr error) func(ct
 func TestConfigureAndRunVersion(t *testing.T) {
 	testCases := []struct {
 		options  *versionOptions
-		mkClient func(ctx context.Context) (pb.ApiClient, error)
+		mkClient func(ctx context.Context, k8sAPI *k8s.KubernetesAPI, controlPlaneNamespace, apiAddr string) (pb.ApiClient, error)
 		out      string
 	}{
 		{
@@ -66,7 +67,7 @@ func TestConfigureAndRunVersion(t *testing.T) {
 		t.Run(fmt.Sprintf("test %d TestConfigureAndRunVersion()", i), func(t *testing.T) {
 			wout := bytes.NewBufferString("")
 
-			configureAndRunVersion(context.Background(), tc.options, wout, tc.mkClient)
+			configureAndRunVersion(context.Background(), nil, tc.options, wout, tc.mkClient)
 
 			if tc.out != wout.String() {
 				t.Fatalf("Expected output: \"%s\", got: \"%s\"", tc.out, wout)
diff --git a/controller/api/destination/server_test.go b/controller/api/destination/server_test.go
index 49a701d1e..367f675fc 100644
--- a/controller/api/destination/server_test.go
+++ b/controller/api/destination/server_test.go
@@ -93,6 +93,24 @@ spec:
         value: 0.0.0.0:4143
       name: linkerd-proxy`,
 		`
+apiVersion: v1
+kind: Pod
+metadata:
+  name: name2-2
+  namespace: ns
+status:
+  phase: Succeeded
+  podIP: 172.17.0.13`,
+		`
+apiVersion: v1
+kind: Pod
+metadata:
+  name: name2-3
+  namespace: ns
+status:
+  phase: Failed
+  podIP: 172.17.0.13`,
+		`
 apiVersion: linkerd.io/v1alpha2
 kind: ServiceProfile
 metadata:
diff --git a/test/integration/edges/edges_test.go b/test/integration/edges/edges_test.go
index df7af39f7..61a46cadd 100644
--- a/test/integration/edges/edges_test.go
+++ b/test/integration/edges/edges_test.go
@@ -136,7 +136,7 @@ func TestDirectEdges(t *testing.T) {
 		// check edges
 		timeout := 50 * time.Second
 		err = TestHelper.RetryFor(timeout, func() error {
-			out, err = TestHelper.LinkerdRun("-n", testNamespace, "-o", "json", "edges", "deploy")
+			out, err = TestHelper.LinkerdRun("-n", testNamespace, "-o", "json", "viz", "edges", "deploy")
 			if err != nil {
 				return err
 			}
diff --git a/test/integration/externalissuer/external_issuer_test.go b/test/integration/externalissuer/external_issuer_test.go
index e9123f4c1..23f362a0d 100644
--- a/test/integration/externalissuer/external_issuer_test.go
+++ b/test/integration/externalissuer/external_issuer_test.go
@@ -71,7 +71,7 @@ func verifyInstallApp(ctx context.Context, t *testing.T) {
 
 func checkAppWoks(t *testing.T, timeout time.Duration) error {
 	return TestHelper.RetryFor(timeout, func() error {
-		args := []string{"stat", "deploy", "-n", TestHelper.GetTestNamespace(TestAppNamespaceSuffix), "--from", "deploy/slow-cooker", "-t", "1m"}
+		args := []string{"viz", "stat", "deploy", "-n", TestHelper.GetTestNamespace(TestAppNamespaceSuffix), "--from", "deploy/slow-cooker", "-t", "1m"}
 		out, err := TestHelper.LinkerdRun(args...)
 		if err != nil {
 			return err
diff --git a/test/integration/install_test.go b/test/integration/install_test.go
index 37e4bb2f8..6a5b440f2 100644
--- a/test/integration/install_test.go
+++ b/test/integration/install_test.go
@@ -810,7 +810,7 @@ func TestDashboard(t *testing.T) {
 	dashboardPort := 52237
 	dashboardURL := fmt.Sprintf("http://localhost:%d", dashboardPort)
 
-	outputStream, err := TestHelper.LinkerdRunStream("dashboard", "-p",
+	outputStream, err := TestHelper.LinkerdRunStream("viz", "dashboard", "-p",
 		strconv.Itoa(dashboardPort), "--show", "url")
 	if err != nil {
 		testutil.AnnotatedFatalf(t, "error running command",
diff --git a/test/integration/routes/routes_test.go b/test/integration/routes/routes_test.go
index 503158037..b3aae9294 100644
--- a/test/integration/routes/routes_test.go
+++ b/test/integration/routes/routes_test.go
@@ -29,7 +29,7 @@ func TestMain(m *testing.M) {
 // smoke test.
 func TestRoutes(t *testing.T) {
 	// control-plane routes
-	cmd := []string{"routes", "--namespace", TestHelper.GetLinkerdNamespace(), "deploy"}
+	cmd := []string{"viz", "routes", "--namespace", TestHelper.GetLinkerdNamespace(), "deploy"}
 	out, err := TestHelper.LinkerdRun(cmd...)
 	if err != nil {
 		testutil.AnnotatedFatal(t, "'linkerd routes' command failed", err)
@@ -61,7 +61,7 @@ func TestRoutes(t *testing.T) {
 
 	// smoke test / bb routes
 	prefixedNs := TestHelper.GetTestNamespace("smoke-test")
-	cmd = []string{"routes", "--namespace", prefixedNs, "deploy"}
+	cmd = []string{"viz", "routes", "--namespace", prefixedNs, "deploy"}
 	golden := "routes.smoke.golden"
 
 	out, err = TestHelper.LinkerdRun(cmd...)
diff --git a/test/integration/serviceprofiles/serviceprofiles_test.go b/test/integration/serviceprofiles/serviceprofiles_test.go
index 5fa59615f..301e79fc7 100644
--- a/test/integration/serviceprofiles/serviceprofiles_test.go
+++ b/test/integration/serviceprofiles/serviceprofiles_test.go
@@ -9,9 +9,9 @@ import (
 	"testing"
 	"time"
 
-	cmd2 "github.com/linkerd/linkerd2/cli/cmd"
 	sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
 	"github.com/linkerd/linkerd2/testutil"
+	cmd2 "github.com/linkerd/linkerd2/viz/cmd"
 	"sigs.k8s.io/yaml"
 )
 
@@ -278,7 +278,7 @@ func assertExpectedRoutes(expected []string, actual []*cmd2.JSONRouteStats, t *t
 }
 
 func getRoutes(deployName, namespace string, additionalArgs []string) ([]*cmd2.JSONRouteStats, error) {
-	cmd := []string{"routes", "--namespace", namespace, deployName}
+	cmd := []string{"viz", "routes", "--namespace", namespace, deployName}
 
 	if len(additionalArgs) > 0 {
 		cmd = append(cmd, additionalArgs...)
diff --git a/test/integration/stat/stat_test.go b/test/integration/stat/stat_test.go
index f13b6f1f4..586059b52 100644
--- a/test/integration/stat/stat_test.go
+++ b/test/integration/stat/stat_test.go
@@ -64,7 +64,7 @@ func TestCliStatForLinkerdNamespace(t *testing.T) {
 		status       string
 	}{
 		{
-			args: []string{"stat", "deploy", "-n", TestHelper.GetLinkerdNamespace()},
+			args: []string{"viz", "stat", "deploy", "-n", TestHelper.GetLinkerdNamespace()},
 			expectedRows: map[string]string{
 				"linkerd-controller":     "1/1",
 				"linkerd-destination":    "1/1",
@@ -74,7 +74,7 @@ func TestCliStatForLinkerdNamespace(t *testing.T) {
 			},
 		},
 		{
-			args: []string{"stat", "deploy", "-n", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "deploy", "-n", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				"linkerd-grafana":    "1/1",
 				"linkerd-prometheus": "1/1",
@@ -83,51 +83,51 @@ func TestCliStatForLinkerdNamespace(t *testing.T) {
 			},
 		},
 		{
-			args: []string{"stat", fmt.Sprintf("po/%s", prometheusPod), "-n", TestHelper.GetVizNamespace(), "--from", fmt.Sprintf("po/%s", controllerPod), "--from-namespace", TestHelper.GetLinkerdNamespace()},
+			args: []string{"viz", "stat", fmt.Sprintf("po/%s", prometheusPod), "-n", TestHelper.GetVizNamespace(), "--from", fmt.Sprintf("po/%s", controllerPod), "--from-namespace", TestHelper.GetLinkerdNamespace()},
 			expectedRows: map[string]string{
 				prometheusPod: "1/1",
 			},
 			status: "Running",
 		},
 		{
-			args: []string{"stat", "deploy", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("po/%s", prometheusPod), "--to-namespace", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "deploy", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("po/%s", prometheusPod), "--to-namespace", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				"linkerd-controller": "1/1",
 			},
 		},
 		{
-			args: []string{"stat", "svc", "linkerd-prometheus", "-n", TestHelper.GetVizNamespace(), "--from", "deploy/linkerd-controller", "--from-namespace", TestHelper.GetLinkerdNamespace()},
+			args: []string{"viz", "stat", "svc", "linkerd-prometheus", "-n", TestHelper.GetVizNamespace(), "--from", "deploy/linkerd-controller", "--from-namespace", TestHelper.GetLinkerdNamespace()},
 			expectedRows: map[string]string{
 				"linkerd-prometheus": "1/1",
 			},
 		},
 		{
-			args: []string{"stat", "deploy", "-n", TestHelper.GetLinkerdNamespace(), "--to", "svc/linkerd-prometheus", "--to-namespace", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "deploy", "-n", TestHelper.GetLinkerdNamespace(), "--to", "svc/linkerd-prometheus", "--to-namespace", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				"linkerd-controller": "1/1",
 			},
 		},
 		{
-			args: []string{"stat", "ns", TestHelper.GetLinkerdNamespace()},
+			args: []string{"viz", "stat", "ns", TestHelper.GetLinkerdNamespace()},
 			expectedRows: map[string]string{
 				TestHelper.GetLinkerdNamespace(): "5/5",
 			},
 		},
 		{
-			args: []string{"stat", "ns", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "ns", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				TestHelper.GetVizNamespace(): "4/4",
 			},
 		},
 		{
-			args: []string{"stat", "po", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("au/%s", prometheusAuthority), "--to-namespace", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "po", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("au/%s", prometheusAuthority), "--to-namespace", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				controllerPod: "1/1",
 			},
 			status: "Running",
 		},
 		{
-			args: []string{"stat", "au", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("po/%s", prometheusPod), "--to-namespace", TestHelper.GetVizNamespace()},
+			args: []string{"viz", "stat", "au", "-n", TestHelper.GetLinkerdNamespace(), "--to", fmt.Sprintf("po/%s", prometheusPod), "--to-namespace", TestHelper.GetVizNamespace()},
 			expectedRows: map[string]string{
 				prometheusAuthority: "-",
 			},
diff --git a/test/integration/tap/tap_test.go b/test/integration/tap/tap_test.go
index 6fe9d22d1..95edc0f7c 100644
--- a/test/integration/tap/tap_test.go
+++ b/test/integration/tap/tap_test.go
@@ -133,7 +133,7 @@ func TestCliTap(t *testing.T) {
 		})
 
 		t.Run("tap a disabled deployment", func(t *testing.T) {
-			out, stderr, err := TestHelper.PipeToLinkerdRun("", "tap", "deploy/t4", "--namespace", prefixedNs)
+			out, stderr, err := TestHelper.PipeToLinkerdRun("", "viz", "tap", "deploy/t4", "--namespace", prefixedNs)
 			if out != "" {
 				testutil.AnnotatedFatalf(t, "unexpected output",
 					"unexpected output: %s", out)
diff --git a/test/integration/trafficsplit/trafficsplit_test.go b/test/integration/trafficsplit/trafficsplit_test.go
index dd9a9c74e..0adde2de8 100644
--- a/test/integration/trafficsplit/trafficsplit_test.go
+++ b/test/integration/trafficsplit/trafficsplit_test.go
@@ -68,7 +68,7 @@ func parseStatTsRow(out string, expectedRowCount, expectedColumnCount int) (map[
 }
 
 func statTrafficSplit(from string, ns string) (map[string]*statTsRow, error) {
-	cmd := []string{"stat", "ts", "--from", from, "--namespace", ns, "-t", "30s", "--unmeshed"}
+	cmd := []string{"viz", "stat", "ts", "--from", from, "--namespace", ns, "-t", "30s", "--unmeshed"}
 	out, err := TestHelper.LinkerdRun(cmd...)
 	if err != nil {
 		return nil, err
diff --git a/testutil/tap.go b/testutil/tap.go
index 2fae81c31..fe2b973b9 100644
--- a/testutil/tap.go
+++ b/testutil/tap.go
@@ -20,7 +20,7 @@ type TapEvent struct {
 // Tap executes a tap command and converts the command's streaming output into tap
 // events using each line's "id" field
 func Tap(target string, h *TestHelper, arg ...string) ([]*TapEvent, error) {
-	cmd := append([]string{"tap", target}, arg...)
+	cmd := append([]string{"viz", "tap", target}, arg...)
 	outputStream, err := h.LinkerdRunStream(cmd...)
 	if err != nil {
 		return nil, err
diff --git a/testutil/test_data_diff.go b/testutil/test_data_diff.go
new file mode 100644
index 000000000..ec3be8c6b
--- /dev/null
+++ b/testutil/test_data_diff.go
@@ -0,0 +1,72 @@
+package testutil
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/sergi/go-diff/diffmatchpatch"
+)
+
+// TestDataDiffer holds configuration for generating test diff
+type TestDataDiffer struct {
+	PrettyDiff     bool
+	UpdateFixtures bool
+	RejectPath     string
+}
+
+// DiffTestdata generates the diff for actual w.r.the file in path
+func (td *TestDataDiffer) DiffTestdata(t *testing.T, path, actual string) {
+	expected := ReadTestdata(t, path)
+	if actual == expected {
+		return
+	}
+	dmp := diffmatchpatch.New()
+	diffs := dmp.DiffMain(expected, actual, true)
+	diffs = dmp.DiffCleanupSemantic(diffs)
+	var diff string
+	if td.PrettyDiff {
+		diff = dmp.DiffPrettyText(diffs)
+	} else {
+		diff = dmp.PatchToText(dmp.PatchMake(diffs))
+	}
+	t.Errorf("mismatch: %s\n%s", path, diff)
+
+	if td.UpdateFixtures {
+		writeTestdata(t, path, []byte(actual))
+	}
+
+	if td.RejectPath != "" {
+		writeRejects(t, path, []byte(actual), td.RejectPath)
+	}
+}
+
+// ReadTestdata reads a file and returns the contents of that file as a string.
+func ReadTestdata(t *testing.T, fileName string) string {
+	file, err := os.Open(filepath.Join("testdata", fileName))
+	if err != nil {
+		t.Fatalf("Failed to open expected output file: %v", err)
+	}
+
+	fixture, err := ioutil.ReadAll(file)
+	if err != nil {
+		t.Fatalf("Failed to read expected output file: %v", err)
+	}
+
+	return string(fixture)
+}
+
+func writeTestdata(t *testing.T, fileName string, data []byte) {
+	p := filepath.Join("testdata", fileName)
+	if err := ioutil.WriteFile(p, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func writeRejects(t *testing.T, origFileName string, data []byte, rejectPath string) {
+	p := filepath.Join(rejectPath, origFileName+".rej")
+	if err := ioutil.WriteFile(p, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/cli/cmd/edges_test.go b/viz/cmd/edges_test.go
similarity index 98%
rename from cli/cmd/edges_test.go
rename to viz/cmd/edges_test.go
index 29c302cba..f47c962d4 100644
--- a/cli/cmd/edges_test.go
+++ b/viz/cmd/edges_test.go
@@ -118,5 +118,5 @@ func testEdgesCall(exp edgesParamsExp, t *testing.T) {
 	rows := edgesRespToRows(resp)
 	output := renderEdgeStats(rows, exp.options)
 
-	diffTestdata(t, exp.file, output)
+	testDataDiffer.DiffTestdata(t, exp.file, output)
 }
diff --git a/viz/cmd/install_test.go b/viz/cmd/install_test.go
new file mode 100644
index 000000000..93fd4fa34
--- /dev/null
+++ b/viz/cmd/install_test.go
@@ -0,0 +1,50 @@
+package cmd
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+
+	charts "github.com/linkerd/linkerd2/pkg/charts"
+)
+
+func TestRender(t *testing.T) {
+
+	// pin values that are changed by render functions on each test run
+	defaultValues := map[string]interface{}{
+		"tap": map[string]interface{}{
+			"keyPEM":   "test-tap-key-pem",
+			"crtPEM":   "test-tap-crt-pem",
+			"caBundle": "test-tap-ca-bundle",
+		},
+	}
+
+	testCases := []struct {
+		values         map[string]interface{}
+		goldenFileName string
+	}{
+		{
+			nil,
+			"install_default.golden",
+		},
+		{
+			map[string]interface{}{
+				"prometheus":    map[string]interface{}{"enabled": false},
+				"prometheusUrl": "external-prom.com",
+			},
+			"install_prometheus_disabled.golden",
+		},
+	}
+
+	for i, tc := range testCases {
+		tc := tc // pin
+		t.Run(fmt.Sprintf("%d: %s", i, tc.goldenFileName), func(t *testing.T) {
+			var buf bytes.Buffer
+			// Merge overrides with default
+			if err := render(&buf, charts.MergeMaps(defaultValues, tc.values)); err != nil {
+				t.Fatalf("Failed to render templates: %v", err)
+			}
+			testDataDiffer.DiffTestdata(t, tc.goldenFileName, buf.String())
+		})
+	}
+}
diff --git a/viz/cmd/main_test.go b/viz/cmd/main_test.go
new file mode 100644
index 000000000..676a7e0af
--- /dev/null
+++ b/viz/cmd/main_test.go
@@ -0,0 +1,23 @@
+package cmd
+
+import (
+	"flag"
+	"os"
+	"testing"
+
+	"github.com/linkerd/linkerd2/testutil"
+)
+
+var (
+	testDataDiffer testutil.TestDataDiffer
+)
+
+// TestMain parses flags before running tests
+func TestMain(m *testing.M) {
+	flag.BoolVar(&testDataDiffer.UpdateFixtures, "update", false, "update text fixtures in place")
+	prettyDiff := os.Getenv("LINKERD_TEST_PRETTY_DIFF") != ""
+	flag.BoolVar(&testDataDiffer.PrettyDiff, "pretty-diff", prettyDiff, "display the full text when diffing")
+	flag.StringVar(&testDataDiffer.RejectPath, "reject-path", "", "write results for failed tests to this path (path is relative to the test location)")
+	flag.Parse()
+	os.Exit(m.Run())
+}
diff --git a/cli/cmd/routes_test.go b/viz/cmd/routes_test.go
similarity index 97%
rename from cli/cmd/routes_test.go
rename to viz/cmd/routes_test.go
index 1d26f6b1b..6be49f65c 100644
--- a/cli/cmd/routes_test.go
+++ b/viz/cmd/routes_test.go
@@ -64,5 +64,5 @@ func testRoutesCall(exp routesParamsExp, t *testing.T) {
 		t.Fatalf("Unexpected error: %v", err)
 	}
 
-	diffTestdata(t, exp.file, output)
+	testDataDiffer.DiffTestdata(t, exp.file, output)
 }
diff --git a/cli/cmd/stat_test.go b/viz/cmd/stat_test.go
similarity index 87%
rename from cli/cmd/stat_test.go
rename to viz/cmd/stat_test.go
index 1f56fbbd3..87bcd5dec 100644
--- a/cli/cmd/stat_test.go
+++ b/viz/cmd/stat_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/linkerd/linkerd2/controller/api/public"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 )
 
@@ -120,6 +121,9 @@ func TestStat(t *testing.T) {
 	t.Run("Returns an error for named resource queries with the --all-namespaces flag", func(t *testing.T) {
 		options := newStatOptions()
 		options.allNamespaces = true
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		args := []string{"po", "web"}
 		expectedError := "stats for a resource cannot be retrieved by name across all namespaces"
 
@@ -131,6 +135,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Rejects commands with both --to and --from flags", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.toResource = "deploy/foo"
 		options.fromResource = "deploy/bar"
 		args := []string{"ns", "test"}
@@ -144,6 +151,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Rejects commands with both --to-namespace and --from-namespace flags", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.toNamespace = "foo"
 		options.fromNamespace = "bar"
 		args := []string{"po"}
@@ -157,6 +167,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Rejects commands with both --all-namespaces and --namespace flags", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.allNamespaces = true
 		options.namespace = "ns"
 		args := []string{"po"}
@@ -170,6 +183,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Rejects --to-namespace flag when the target is a namespace", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.toNamespace = "bar"
 		args := []string{"ns", "foo"}
 		expectedError := "--to-namespace flag is incompatible with namespace resource type"
@@ -182,6 +198,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Rejects --from-namespace flag when the target is a namespace", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.fromNamespace = "foo"
 		args := []string{"ns/bar"}
 		expectedError := "--from-namespace flag is incompatible with namespace resource type"
@@ -194,6 +213,9 @@ func TestStat(t *testing.T) {
 
 	t.Run("Returns an error if --time-window is not more than 15s", func(t *testing.T) {
 		options := newStatOptions()
+		if options.namespace == "" {
+			options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+		}
 		options.timeWindow = "10s"
 		args := []string{"ns/bar"}
 		expectedError := "metrics time window needs to be at least 15s"
@@ -218,6 +240,9 @@ func testStatCall(exp paramsExp, resourceType string, t *testing.T) {
 	if resourceType == k8s.TrafficSplit {
 		args = []string{"trafficsplit"}
 	}
+	if exp.options.namespace == "" {
+		exp.options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+	}
 	reqs, err := buildStatSummaryRequests(args, exp.options)
 	if err != nil {
 		t.Fatalf("Unexpected error: %v", err)
@@ -231,5 +256,5 @@ func testStatCall(exp paramsExp, resourceType string, t *testing.T) {
 	rows := respToRows(resp)
 	output := renderStatStats(rows, exp.options)
 
-	diffTestdata(t, exp.file, output)
+	testDataDiffer.DiffTestdata(t, exp.file, output)
 }
diff --git a/cli/cmd/tap_test.go b/viz/cmd/tap_test.go
similarity index 100%
rename from cli/cmd/tap_test.go
rename to viz/cmd/tap_test.go
diff --git a/cli/cmd/testdata/edges_one_output.golden b/viz/cmd/testdata/edges_one_output.golden
similarity index 100%
rename from cli/cmd/testdata/edges_one_output.golden
rename to viz/cmd/testdata/edges_one_output.golden
diff --git a/cli/cmd/testdata/edges_one_output_json.golden b/viz/cmd/testdata/edges_one_output_json.golden
similarity index 100%
rename from cli/cmd/testdata/edges_one_output_json.golden
rename to viz/cmd/testdata/edges_one_output_json.golden
diff --git a/cli/cmd/testdata/edges_wide_output.golden b/viz/cmd/testdata/edges_wide_output.golden
similarity index 100%
rename from cli/cmd/testdata/edges_wide_output.golden
rename to viz/cmd/testdata/edges_wide_output.golden
diff --git a/viz/cmd/testdata/install_default.golden b/viz/cmd/testdata/install_default.golden
new file mode 100644
index 000000000..819bf6aee
--- /dev/null
+++ b/viz/cmd/testdata/install_default.golden
@@ -0,0 +1,934 @@
+---
+###
+### Linkerd Viz Extension Namespace
+###
+---
+kind: Namespace
+apiVersion: v1
+metadata:
+  name: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+  annotations:
+    linkerd.io/inject: enabled
+---
+###
+### Grafana RBAC
+###
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-grafana
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+---
+###
+### Prometheus RBAC
+###
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-prometheus
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+rules:
+- apiGroups: [""]
+  resources: ["nodes", "nodes/proxy", "pods"]
+  verbs: ["get", "list", "watch"]
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-prometheus
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-prometheus
+subjects:
+- kind: ServiceAccount
+  name: linkerd-prometheus
+  namespace: linkerd-viz
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-prometheus
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+    namespace: linkerd-viz
+---
+###
+### Tap RBAC
+###
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+rules:
+- apiGroups: [""]
+  resources: ["pods", "services", "replicationcontrollers", "namespaces", "nodes"]
+  verbs: ["list", "get", "watch"]
+- apiGroups: ["extensions", "apps"]
+  resources: ["daemonsets", "deployments", "replicasets", "statefulsets"]
+  verbs: ["list", "get", "watch"]
+- apiGroups: ["extensions", "batch"]
+  resources: ["cronjobs", "jobs"]
+  verbs: ["list" , "get", "watch"]
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap-admin
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+rules:
+- apiGroups: ["tap.linkerd.io"]
+  resources: ["*"]
+  verbs: ["watch"]
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-tap
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: linkerd-linkerd-viz-tap-auth-delegator
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: system:auth-delegator
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-tap
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-linkerd-viz-tap-auth-reader
+  namespace: kube-system
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: extension-apiserver-authentication-reader
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+kind: Secret
+apiVersion: v1
+metadata:
+  name: linkerd-tap-k8s-tls
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+type: kubernetes.io/tls
+data:
+  tls.crt: dGVzdC10YXAtY3J0LXBlbQ==
+  tls.key: dGVzdC10YXAta2V5LXBlbQ==
+---
+apiVersion: apiregistration.k8s.io/v1
+kind: APIService
+metadata:
+  name: v1alpha1.tap.linkerd.io
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+spec:
+  group: tap.linkerd.io
+  version: v1alpha1
+  groupPriorityMinimum: 1000
+  versionPriority: 100
+  service:
+    name: linkerd-tap
+    namespace: linkerd-viz
+  caBundle: dGVzdC10YXAtY2EtYnVuZGxl
+---
+###
+### Web RBAC
+###
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: linkerd-web
+  namespace: linkerd
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd
+rules:
+- apiGroups: [""]
+  resources: ["configmaps"]
+  verbs: ["get"]
+  resourceNames: ["linkerd-config"]
+- apiGroups: [""]
+  resources: ["namespaces", "configmaps"]
+  verbs: ["get"]
+- apiGroups: [""]
+  resources: ["serviceaccounts", "pods"]
+  verbs: ["list"]
+- apiGroups: ["apps"]
+  resources: ["replicasets"]
+  verbs: ["list"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-web
+  namespace: linkerd
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd
+roleRef:
+  kind: Role
+  name: linkerd-web
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: linkerd-linkerd-viz-web-check
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+rules:
+- apiGroups: ["rbac.authorization.k8s.io"]
+  resources: ["clusterroles", "clusterrolebindings"]
+  verbs: ["list"]
+- apiGroups: ["apiextensions.k8s.io"]
+  resources: ["customresourcedefinitions"]
+  verbs: ["list"]
+- apiGroups: ["admissionregistration.k8s.io"]
+  resources: ["mutatingwebhookconfigurations", "validatingwebhookconfigurations"]
+  verbs: ["list"]
+- apiGroups: ["policy"]
+  resources: ["podsecuritypolicies"]
+  verbs: ["list"]
+- apiGroups: ["linkerd.io"]
+  resources: ["serviceprofiles"]
+  verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: linkerd-linkerd-viz-web-check
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+roleRef:
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-web-check
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-web-admin
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-tap-admin
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-web
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-viz-psp
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    namespace: linkerd-viz
+roleRef:
+  kind: Role
+  name: linkerd-psp
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+- kind: ServiceAccount
+  name: linkerd-grafana
+  namespace: linkerd-viz
+- kind: ServiceAccount
+  name: linkerd-prometheus
+  namespace: linkerd-viz
+---
+###
+### Grafana
+###
+---
+kind: ConfigMap
+apiVersion: v1
+metadata:
+  name: linkerd-grafana-config
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+data:
+  grafana.ini: |-
+    instance_name = linkerd-grafana
+    [server]
+    root_url = %(protocol)s://%(domain)s:/grafana/
+    [auth]
+    disable_login_form = true
+    [auth.anonymous]
+    enabled = true
+    org_role = Editor
+    [auth.basic]
+    enabled = false
+    [analytics]
+    check_for_updates = false
+    [panels]
+    disable_sanitize_html = true
+  datasources.yaml: |-
+    apiVersion: 1
+    datasources:
+    - name: prometheus
+      type: prometheus
+      access: proxy
+      orgId: 1
+      url: http://linkerd-prometheus.linkerd-viz.svc.cluster.local:9090
+      isDefault: true
+      jsonData:
+        timeInterval: "5s"
+      version: 1
+      editable: true
+
+  dashboards.yaml: |-
+    apiVersion: 1
+    providers:
+    - name: 'default'
+      orgId: 1
+      folder: ''
+      type: file
+      disableDeletion: true
+      editable: true
+      options:
+        path: /var/lib/grafana/dashboards
+        homeDashboardId: linkerd-top-line
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-grafana
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+  ports:
+  - name: http
+    port: 3000
+    targetPort: 3000
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: grafana
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: grafana
+    namespace: linkerd-viz
+  name: linkerd-grafana
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: grafana
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: grafana
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - env:
+        - name: GF_PATHS_DATA
+          value: /data
+        # Force using the go-based DNS resolver instead of the OS' to avoid failures in some environments
+        # see https://github.com/grafana/grafana/issues/20096
+        - name: GODEBUG
+          value: netdns=go
+        image: ghcr.io/linkerd/grafana:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /api/health
+            port: 3000
+          initialDelaySeconds: 30
+        name: grafana
+        ports:
+        - containerPort: 3000
+          name: http
+        readinessProbe:
+          httpGet:
+            path: /api/health
+            port: 3000
+        resources:
+        securityContext:
+          runAsUser: 472
+        volumeMounts:
+        - mountPath: /data
+          name: data
+        - mountPath: /etc/grafana
+          name: grafana-config
+          readOnly: true
+      serviceAccountName: linkerd-grafana
+      volumes:
+      - emptyDir: {}
+        name: data
+      - configMap:
+          items:
+          - key: grafana.ini
+            path: grafana.ini
+          - key: datasources.yaml
+            path: provisioning/datasources/datasources.yaml
+          - key: dashboards.yaml
+            path: provisioning/dashboards/dashboards.yaml
+          name: linkerd-grafana-config
+        name: grafana-config
+---
+###
+### Prometheus
+###
+---
+kind: ConfigMap
+apiVersion: v1
+metadata:
+  name: linkerd-prometheus-config
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+data:
+  prometheus.yml: |-
+    global:
+      evaluation_interval: 10s
+      scrape_interval: 10s
+      scrape_timeout: 10s
+
+    rule_files:
+    - /etc/prometheus/*_rules.yml
+    - /etc/prometheus/*_rules.yaml
+
+    scrape_configs:
+    - job_name: 'prometheus'
+      static_configs:
+      - targets: ['localhost:9090']
+
+    - job_name: 'grafana'
+      kubernetes_sd_configs:
+      - role: pod
+        namespaces:
+          names: ['linkerd-viz']
+      relabel_configs:
+      - source_labels:
+        - __meta_kubernetes_pod_container_name
+        action: keep
+        regex: ^grafana$
+
+    #  Required for: https://grafana.com/grafana/dashboards/315
+    - job_name: 'kubernetes-nodes-cadvisor'
+      scheme: https
+      tls_config:
+        ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
+        insecure_skip_verify: true
+      bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
+      kubernetes_sd_configs:
+      - role: node
+      relabel_configs:
+      - action: labelmap
+        regex: __meta_kubernetes_node_label_(.+)
+      - target_label: __address__
+        replacement: kubernetes.default.svc:443
+      - source_labels: [__meta_kubernetes_node_name]
+        regex: (.+)
+        target_label: __metrics_path__
+        replacement: /api/v1/nodes/$1/proxy/metrics/cadvisor
+      metric_relabel_configs:
+      - source_labels: [__name__]
+        regex: '(container|machine)_(cpu|memory|network|fs)_(.+)'
+        action: keep
+      - source_labels: [__name__]
+        regex: 'container_memory_failures_total' # unneeded large metric
+        action: drop
+
+    - job_name: 'linkerd-controller'
+      kubernetes_sd_configs:
+      - role: pod
+        namespaces:
+          names: ['linkerd']
+      relabel_configs:
+      - source_labels:
+        - __meta_kubernetes_pod_label_linkerd_io_control_plane_component
+        - __meta_kubernetes_pod_container_port_name
+        action: keep
+        regex: (.*);admin-http$
+      - source_labels: [__meta_kubernetes_pod_container_name]
+        action: replace
+        target_label: component
+
+    - job_name: 'linkerd-service-mirror'
+      kubernetes_sd_configs:
+      - role: pod
+      relabel_configs:
+      - source_labels:
+        - __meta_kubernetes_pod_label_linkerd_io_control_plane_component
+        - __meta_kubernetes_pod_container_port_name
+        action: keep
+        regex: linkerd-service-mirror;admin-http$
+      - source_labels: [__meta_kubernetes_pod_container_name]
+        action: replace
+        target_label: component
+
+    - job_name: 'linkerd-proxy'
+      kubernetes_sd_configs:
+      - role: pod
+      relabel_configs:
+      - source_labels:
+        - __meta_kubernetes_pod_container_name
+        - __meta_kubernetes_pod_container_port_name
+        - __meta_kubernetes_pod_label_linkerd_io_control_plane_ns
+        action: keep
+        regex: ^linkerd-proxy;linkerd-admin;linkerd$
+      - source_labels: [__meta_kubernetes_namespace]
+        action: replace
+        target_label: namespace
+      - source_labels: [__meta_kubernetes_pod_name]
+        action: replace
+        target_label: pod
+      # special case k8s' "job" label, to not interfere with prometheus' "job"
+      # label
+      # __meta_kubernetes_pod_label_linkerd_io_proxy_job=foo =>
+      # k8s_job=foo
+      - source_labels: [__meta_kubernetes_pod_label_linkerd_io_proxy_job]
+        action: replace
+        target_label: k8s_job
+      # drop __meta_kubernetes_pod_label_linkerd_io_proxy_job
+      - action: labeldrop
+        regex: __meta_kubernetes_pod_label_linkerd_io_proxy_job
+      # __meta_kubernetes_pod_label_linkerd_io_proxy_deployment=foo =>
+      # deployment=foo
+      - action: labelmap
+        regex: __meta_kubernetes_pod_label_linkerd_io_proxy_(.+)
+      # drop all labels that we just made copies of in the previous labelmap
+      - action: labeldrop
+        regex: __meta_kubernetes_pod_label_linkerd_io_proxy_(.+)
+      # __meta_kubernetes_pod_label_linkerd_io_foo=bar =>
+      # foo=bar
+      - action: labelmap
+        regex: __meta_kubernetes_pod_label_linkerd_io_(.+)
+      # Copy all pod labels to tmp labels
+      - action: labelmap
+        regex: __meta_kubernetes_pod_label_(.+)
+        replacement: __tmp_pod_label_$1
+      # Take `linkerd_io_` prefixed labels and copy them without the prefix
+      - action: labelmap
+        regex: __tmp_pod_label_linkerd_io_(.+)
+        replacement:  __tmp_pod_label_$1
+      # Drop the `linkerd_io_` originals
+      - action: labeldrop
+        regex: __tmp_pod_label_linkerd_io_(.+)
+      # Copy tmp labels into real labels
+      - action: labelmap
+        regex: __tmp_pod_label_(.+)
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-prometheus
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: prometheus
+  ports:
+  - name: admin-http
+    port: 9090
+    targetPort: 9090
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: prometheus
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: prometheus
+    namespace: linkerd-viz
+  name: linkerd-prometheus
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: prometheus
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: prometheus
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      securityContext:
+        fsGroup: 65534
+      containers:
+      - args:
+        - --config.file=/etc/prometheus/prometheus.yml
+        - --log.level=info
+        - --storage.tsdb.path=/data
+        - --storage.tsdb.retention.time=6h
+        image: prom/prometheus:v2.19.3
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /-/healthy
+            port: 9090
+          initialDelaySeconds: 30
+          timeoutSeconds: 30
+        name: prometheus
+        ports:
+        - containerPort: 9090
+          name: admin-http
+        readinessProbe:
+          httpGet:
+            path: /-/ready
+            port: 9090
+          initialDelaySeconds: 30
+          timeoutSeconds: 30
+        resources:
+        securityContext:
+          runAsNonRoot: true
+          runAsUser: 65534
+          runAsGroup: 65534
+        volumeMounts:
+        - mountPath: /data
+          name: data
+        - mountPath: /etc/prometheus/prometheus.yml
+          name: prometheus-config
+          subPath: prometheus.yml
+          readOnly: true
+      serviceAccountName: linkerd-prometheus
+      volumes:
+      - name: data
+        emptyDir: {}
+      - configMap:
+          name: linkerd-prometheus-config
+        name: prometheus-config
+---
+###
+### Tap
+###
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-tap
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+  ports:
+  - name: grpc
+    port: 8088
+    targetPort: 8088
+  - name: apiserver
+    port: 443
+    targetPort: apiserver
+---
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: tap
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: tap
+    namespace: linkerd-viz
+  name: linkerd-tap
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: tap
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        checksum/config: 1396a8de42cb50dca5caf85bdb2b7ae7ad08152d0c107ec3586c366912d36aff
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: tap
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - args:
+        - tap
+        - -controller-namespace=linkerd
+        - -log-level=info
+        - -identity-trust-domain=cluster.local
+        image: ghcr.io/linkerd/controller:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /ping
+            port: 9998
+          initialDelaySeconds: 10
+        name: tap
+        ports:
+        - containerPort: 8088
+          name: grpc
+        - containerPort: 8089
+          name: apiserver
+        - containerPort: 9998
+          name: admin-http
+        readinessProbe:
+          failureThreshold: 7
+          httpGet:
+            path: /ready
+            port: 9998
+        resources:
+        securityContext:
+          runAsUser: 2103
+        volumeMounts:
+        - mountPath: /var/run/linkerd/tls
+          name: tls
+          readOnly: true
+      serviceAccountName: linkerd-tap
+      volumes:
+      - name: tls
+        secret:
+          secretName: linkerd-tap-k8s-tls
+
+---
+###
+### Web
+###
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-web
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: web
+  ports:
+  - name: http
+    port: 8084
+    targetPort: 8084
+  - name: admin-http
+    port: 9994
+    targetPort: 9994
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: web
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: web
+    namespace: linkerd-viz
+  name: linkerd-web
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: web
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: web
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - args:
+        - -api-addr=linkerd-controller-api.linkerd.svc.cluster.local:8085
+        - -cluster-domain=cluster.local
+        - -grafana-addr=linkerd-grafana.linkerd-viz.svc.cluster.local:3000
+        - -controller-namespace=linkerd
+        - -log-level=info
+        - -enforced-host=^(localhost|127\.0\.0\.1|linkerd-web\.linkerd-viz\.svc\.cluster\.local|linkerd-web\.linkerd-viz\.svc|\[::1\])(:\d+)?$
+        image: ghcr.io/linkerd/web:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /ping
+            port: 9994
+          initialDelaySeconds: 10
+        name: web
+        ports:
+        - containerPort: 8084
+          name: http
+        - containerPort: 9994
+          name: admin-http
+        readinessProbe:
+          failureThreshold: 7
+          httpGet:
+            path: /ready
+            port: 9994
+        resources:
+        securityContext:
+          runAsUser: 2103
+      serviceAccountName: linkerd-web
diff --git a/viz/cmd/testdata/install_prometheus_disabled.golden b/viz/cmd/testdata/install_prometheus_disabled.golden
new file mode 100644
index 000000000..cd5522bd1
--- /dev/null
+++ b/viz/cmd/testdata/install_prometheus_disabled.golden
@@ -0,0 +1,642 @@
+---
+###
+### Linkerd Viz Extension Namespace
+###
+---
+kind: Namespace
+apiVersion: v1
+metadata:
+  name: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+  annotations:
+    linkerd.io/inject: enabled
+---
+###
+### Grafana RBAC
+###
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-grafana
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+---
+###
+### Tap RBAC
+###
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+rules:
+- apiGroups: [""]
+  resources: ["pods", "services", "replicationcontrollers", "namespaces", "nodes"]
+  verbs: ["list", "get", "watch"]
+- apiGroups: ["extensions", "apps"]
+  resources: ["daemonsets", "deployments", "replicasets", "statefulsets"]
+  verbs: ["list", "get", "watch"]
+- apiGroups: ["extensions", "batch"]
+  resources: ["cronjobs", "jobs"]
+  verbs: ["list" , "get", "watch"]
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap-admin
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+rules:
+- apiGroups: ["tap.linkerd.io"]
+  resources: ["*"]
+  verbs: ["watch"]
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-tap
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-tap
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: linkerd-linkerd-viz-tap-auth-delegator
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: system:auth-delegator
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-tap
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-linkerd-viz-tap-auth-reader
+  namespace: kube-system
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: extension-apiserver-authentication-reader
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+---
+kind: Secret
+apiVersion: v1
+metadata:
+  name: linkerd-tap-k8s-tls
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+type: kubernetes.io/tls
+data:
+  tls.crt: dGVzdC10YXAtY3J0LXBlbQ==
+  tls.key: dGVzdC10YXAta2V5LXBlbQ==
+---
+apiVersion: apiregistration.k8s.io/v1
+kind: APIService
+metadata:
+  name: v1alpha1.tap.linkerd.io
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+spec:
+  group: tap.linkerd.io
+  version: v1alpha1
+  groupPriorityMinimum: 1000
+  versionPriority: 100
+  service:
+    name: linkerd-tap
+    namespace: linkerd-viz
+  caBundle: dGVzdC10YXAtY2EtYnVuZGxl
+---
+###
+### Web RBAC
+###
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: linkerd-web
+  namespace: linkerd
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd
+rules:
+- apiGroups: [""]
+  resources: ["configmaps"]
+  verbs: ["get"]
+  resourceNames: ["linkerd-config"]
+- apiGroups: [""]
+  resources: ["namespaces", "configmaps"]
+  verbs: ["get"]
+- apiGroups: [""]
+  resources: ["serviceaccounts", "pods"]
+  verbs: ["list"]
+- apiGroups: ["apps"]
+  resources: ["replicasets"]
+  verbs: ["list"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-web
+  namespace: linkerd
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd
+roleRef:
+  kind: Role
+  name: linkerd-web
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: linkerd-linkerd-viz-web-check
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+rules:
+- apiGroups: ["rbac.authorization.k8s.io"]
+  resources: ["clusterroles", "clusterrolebindings"]
+  verbs: ["list"]
+- apiGroups: ["apiextensions.k8s.io"]
+  resources: ["customresourcedefinitions"]
+  verbs: ["list"]
+- apiGroups: ["admissionregistration.k8s.io"]
+  resources: ["mutatingwebhookconfigurations", "validatingwebhookconfigurations"]
+  verbs: ["list"]
+- apiGroups: ["policy"]
+  resources: ["podsecuritypolicies"]
+  verbs: ["list"]
+- apiGroups: ["linkerd.io"]
+  resources: ["serviceprofiles"]
+  verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: linkerd-linkerd-viz-web-check
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+roleRef:
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-web-check
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: linkerd-linkerd-viz-web-admin
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: linkerd-linkerd-viz-tap-admin
+subjects:
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: linkerd-web
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd-viz
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: linkerd-viz-psp
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    namespace: linkerd-viz
+roleRef:
+  kind: Role
+  name: linkerd-psp
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+- kind: ServiceAccount
+  name: linkerd-tap
+  namespace: linkerd-viz
+- kind: ServiceAccount
+  name: linkerd-web
+  namespace: linkerd-viz
+- kind: ServiceAccount
+  name: linkerd-grafana
+  namespace: linkerd-viz
+---
+###
+### Grafana
+###
+---
+kind: ConfigMap
+apiVersion: v1
+metadata:
+  name: linkerd-grafana-config
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+data:
+  grafana.ini: |-
+    instance_name = linkerd-grafana
+    [server]
+    root_url = %(protocol)s://%(domain)s:/grafana/
+    [auth]
+    disable_login_form = true
+    [auth.anonymous]
+    enabled = true
+    org_role = Editor
+    [auth.basic]
+    enabled = false
+    [analytics]
+    check_for_updates = false
+    [panels]
+    disable_sanitize_html = true
+  datasources.yaml: |-
+    apiVersion: 1
+    datasources:
+    - name: prometheus
+      type: prometheus
+      access: proxy
+      orgId: 1
+      url: external-prom.com
+      isDefault: true
+      jsonData:
+        timeInterval: "5s"
+      version: 1
+      editable: true
+
+  dashboards.yaml: |-
+    apiVersion: 1
+    providers:
+    - name: 'default'
+      orgId: 1
+      folder: ''
+      type: file
+      disableDeletion: true
+      editable: true
+      options:
+        path: /var/lib/grafana/dashboards
+        homeDashboardId: linkerd-top-line
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-grafana
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: grafana
+  ports:
+  - name: http
+    port: 3000
+    targetPort: 3000
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: grafana
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: grafana
+    namespace: linkerd-viz
+  name: linkerd-grafana
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: grafana
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: grafana
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - env:
+        - name: GF_PATHS_DATA
+          value: /data
+        # Force using the go-based DNS resolver instead of the OS' to avoid failures in some environments
+        # see https://github.com/grafana/grafana/issues/20096
+        - name: GODEBUG
+          value: netdns=go
+        image: ghcr.io/linkerd/grafana:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /api/health
+            port: 3000
+          initialDelaySeconds: 30
+        name: grafana
+        ports:
+        - containerPort: 3000
+          name: http
+        readinessProbe:
+          httpGet:
+            path: /api/health
+            port: 3000
+        resources:
+        securityContext:
+          runAsUser: 472
+        volumeMounts:
+        - mountPath: /data
+          name: data
+        - mountPath: /etc/grafana
+          name: grafana-config
+          readOnly: true
+      serviceAccountName: linkerd-grafana
+      volumes:
+      - emptyDir: {}
+        name: data
+      - configMap:
+          items:
+          - key: grafana.ini
+            path: grafana.ini
+          - key: datasources.yaml
+            path: provisioning/datasources/datasources.yaml
+          - key: dashboards.yaml
+            path: provisioning/dashboards/dashboards.yaml
+          name: linkerd-grafana-config
+        name: grafana-config
+---
+###
+### Tap
+###
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-tap
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: tap
+  ports:
+  - name: grpc
+    port: 8088
+    targetPort: 8088
+  - name: apiserver
+    port: 443
+    targetPort: apiserver
+---
+kind: Deployment
+apiVersion: apps/v1
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: tap
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: tap
+    namespace: linkerd-viz
+  name: linkerd-tap
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: tap
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        checksum/config: 1396a8de42cb50dca5caf85bdb2b7ae7ad08152d0c107ec3586c366912d36aff
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: tap
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - args:
+        - tap
+        - -controller-namespace=linkerd
+        - -log-level=info
+        - -identity-trust-domain=cluster.local
+        image: ghcr.io/linkerd/controller:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /ping
+            port: 9998
+          initialDelaySeconds: 10
+        name: tap
+        ports:
+        - containerPort: 8088
+          name: grpc
+        - containerPort: 8089
+          name: apiserver
+        - containerPort: 9998
+          name: admin-http
+        readinessProbe:
+          failureThreshold: 7
+          httpGet:
+            path: /ready
+            port: 9998
+        resources:
+        securityContext:
+          runAsUser: 2103
+        volumeMounts:
+        - mountPath: /var/run/linkerd/tls
+          name: tls
+          readOnly: true
+      serviceAccountName: linkerd-tap
+      volumes:
+      - name: tls
+        secret:
+          secretName: linkerd-tap-k8s-tls
+
+---
+###
+### Web
+###
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: linkerd-web
+  namespace: linkerd-viz
+  labels:
+    linkerd.io/extension: linkerd-viz
+    component: web
+    namespace: linkerd-viz
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+spec:
+  type: ClusterIP
+  selector:
+    linkerd.io/extension: linkerd-viz
+    component: web
+  ports:
+  - name: http
+    port: 8084
+    targetPort: 8084
+  - name: admin-http
+    port: 9994
+    targetPort: 9994
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+    linkerd.io/created-by: linkerd/helm dev-undefined
+  labels:
+    linkerd.io/extension: linkerd-viz
+    app.kubernetes.io/name: web
+    app.kubernetes.io/part-of: Linkerd
+    app.kubernetes.io/version: dev-undefined
+    component: web
+    namespace: linkerd-viz
+  name: linkerd-web
+  namespace: linkerd-viz
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      linkerd.io/extension: linkerd-viz
+      component: web
+      namespace: linkerd-viz
+  template:
+    metadata:
+      annotations:
+        linkerd.io/created-by: linkerd/helm dev-undefined
+      labels:
+        linkerd.io/extension: linkerd-viz
+        component: web
+        namespace: linkerd-viz
+    spec:
+      nodeSelector:
+        beta.kubernetes.io/os: linux
+      containers:
+      - args:
+        - -api-addr=linkerd-controller-api.linkerd.svc.cluster.local:8085
+        - -cluster-domain=cluster.local
+        - -grafana-addr=linkerd-grafana.linkerd-viz.svc.cluster.local:3000
+        - -controller-namespace=linkerd
+        - -log-level=info
+        - -enforced-host=^(localhost|127\.0\.0\.1|linkerd-web\.linkerd-viz\.svc\.cluster\.local|linkerd-web\.linkerd-viz\.svc|\[::1\])(:\d+)?$
+        image: ghcr.io/linkerd/web:dev-undefined
+        imagePullPolicy: 
+        livenessProbe:
+          httpGet:
+            path: /ping
+            port: 9994
+          initialDelaySeconds: 10
+        name: web
+        ports:
+        - containerPort: 8084
+          name: http
+        - containerPort: 9994
+          name: admin-http
+        readinessProbe:
+          failureThreshold: 7
+          httpGet:
+            path: /ready
+            port: 9994
+        resources:
+        securityContext:
+          runAsUser: 2103
+      serviceAccountName: linkerd-web