Repository: linkerd/linkerd2
Pull request #4567 (2020-06-08 10:50:56)

Use buster for base and web images too
Requires setting iptables-legacy as the iptables provider.

Cred should go to @erikwilson and @codeman9 for suggesting the use of iptables-legacy.
https://github.com/rancher/k3s/issues/116#issuecomment-526272383
https://github.com/linkerd/linkerd2/pull/3486#issuecomment-536110931

olix0r: We still have https://github.com/linkerd/linkerd2-proxy-init/pull/3 open -- it should be updated.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 151a1b349..320075594 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,58 @@
 # Changes
 
+## stable-2.8.0
+
+This release introduces new a multi-cluster extension to Linkerd, allowing it
+to establish connections across Kubernetes clusters that are secure,
+transparent to the application, and work with any network topology.
+
+* The CLI has a new set of `linkerd multicluster` sub-commands that provide
+  tooling to create the resources needed to discover services across
+  Kubernetes clusters.
+* The `linkerd multicluster gateways` command exposes gateway-specific
+  telemetry to supplement the existing `stat` and `tap` commands.
+* The Linkerd-provided Grafana instance remains enabled by default, but it can
+  now be disabled. When it is disabled, the Linkerd dashboard can be
+  configured to link to an alternate, externally-managed Grafana instance.
+* Jaeger & OpenCensus are configurable as an [add-on][addon-2.8.0]; and the
+  proxy has been improved to emit spans with labels that reflect its pod's
+  metadata.
+* The `linkerd-cni` component has been promoted from _experimental_ to
+  _stable_.
+* `linkerd profile --open-api` now honors the `x-linkerd-retryable` and
+  `x-linkerd-timeout` OpenAPI annotations.
+* The Helm chart continues to become more flexible and modular, with new
+  Prometheus configuration options. More information is available in the
+  [Helm chart README][helm-2.8.0].
+* gRPC stream error handling has been improved so that transport errors
+  are indicated to the client with a `grpc-status: UNAVAILABLE` trailer.
+* The proxy's memory footprint could grow significantly when
+  server-speaks-first-protocol connections hit the proxy. Now, a timeout is
+  in place to prevent these connections from consuming resources.
+* After benchmarking the proxy in high-concurrency situations, the inbound
+  proxy has been improved to reduce contention, improving latency and
+  reducing spurious timeouts.
+* The proxy could fail requests to services that had only 1 request every 60
+  seconds. This race condition has been eliminated.
+* Finally, users reported that ingress misconfigurations could cause the proxy
+  to consume an entire CPU which could lead to timeouts. The proxy now
+  attempts to prevent the most common traffic-loop scenarios to protect against
+  this.
+
+***NOTE***: Linkerd's `multicluster` extension does not yet work on Amazon
+EKS. We expect to follow this release with a stable-2.8.1 to address this
+issue. Follow [#4582](https://github.com/linkerd/linkerd2/pull/4582) for updates.
+
+This release includes changes from a massive list of contributors. A special
+thank-you to everyone who helped make this release possible: @aliariff,
+@amariampolskiy, @arminbuerkle, @arthursens, @christianhuening,
+@christyjacob4, @cypherfox, @daxmc99, @dr0pdb, @drholmie, @hydeenoble,
+@joakimr-axis, @jpresky, @kohsheen1234, @lewiscowper, @lundbird, @matei207,
+@mayankshah1607, @mmiller1, @naseemkullah, @sannimichaelse, & @supra08.
+
+[addon-2.8.0]: https://github.com/linkerd/linkerd2/blob/4219955bdb5441c5fce192328d3760da13fb7ba1/charts/linkerd2/README.md#add-ons-configuration
+[helm-2.8.0]: https://github.com/linkerd/linkerd2/blob/4219955bdb5441c5fce192328d3760da13fb7ba1/charts/linkerd2/README.md
+
 ## edge-20.6.2
 
 This edge release is our second release candidate for `stable-2.8`, including
diff --git a/Dockerfile-base b/Dockerfile-base
index 326704520..80deecde3 100644
--- a/Dockerfile-base
+++ b/Dockerfile-base
@@ -3,7 +3,7 @@
 # This means that all Linkerd containers share a common set of tools, and furthermore, they
 # are highly cacheable at runtime.
 
-FROM debian:stretch-20190812-slim
+FROM debian:buster-20200514-slim
 
 RUN apt-get update \
     && apt-get install -y --no-install-recommends \
@@ -13,3 +13,8 @@ RUN apt-get update \
         jq \
         nghttp2 \
     && rm -rf /var/lib/apt/lists/*
+
+# Switching base image from stretch to buster, we need to use iptables-legacy
+# if no changes to how iptables is invoked are done.
+RUN update-alternatives --set iptables /usr/sbin/iptables-legacy \
+    && update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
diff --git a/Dockerfile-debug b/Dockerfile-debug
index 4192cba7a..6d7e9276e 100644
--- a/Dockerfile-debug
+++ b/Dockerfile-debug
@@ -1,4 +1,4 @@
-FROM gcr.io/linkerd-io/base:2019-09-04.01
+FROM gcr.io/linkerd-io/base:2020-06-08.01
 RUN apt-get update && apt-get install -y --no-install-recommends \
     tcpdump \
     iproute2 \
diff --git a/bin/docker-build-base b/bin/docker-build-base
index 3ab68bfcb..46f7be11f 100755
--- a/bin/docker-build-base
+++ b/bin/docker-build-base
@@ -15,7 +15,7 @@ rootdir=$( cd "$bindir"/.. && pwd )
 # shellcheck source=_docker.sh
 . "$bindir"/_docker.sh
 
-tag=2019-09-04.01
+tag=2020-06-08.01
 
 if (docker_pull base $tag); then
     echo "$(docker_repo base):$tag"
diff --git a/bin/docker-pull-deps b/bin/docker-pull-deps
index d64d3113a..9824fc9b4 100755
--- a/bin/docker-pull-deps
+++ b/bin/docker-pull-deps
@@ -9,5 +9,5 @@ bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
 # shellcheck source=_tag.sh
 . "$bindir"/_tag.sh
 
-docker_pull base       2019-09-04.01       || true
+docker_pull base       2020-06-08.01       || true
 docker_pull go-deps    "$(go_deps_sha)"    || true
diff --git a/bin/docker-push-deps b/bin/docker-push-deps
index 108215efc..770ac4844 100755
--- a/bin/docker-push-deps
+++ b/bin/docker-push-deps
@@ -9,5 +9,5 @@ bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
 # shellcheck source=_tag.sh
 . "$bindir"/_tag.sh
 
-docker_push base         2019-09-04.01
+docker_push base         2020-06-08.01
 docker_push go-deps      "$(go_deps_sha)"
diff --git a/charts/add-ons/grafana/templates/grafana.yaml b/charts/add-ons/grafana/templates/grafana.yaml
index 721502447..ac80b6e36 100644
--- a/charts/add-ons/grafana/templates/grafana.yaml
+++ b/charts/add-ons/grafana/templates/grafana.yaml
@@ -121,7 +121,7 @@ spec:
       - env:
         - name: GF_PATHS_DATA
           value: /data
-        image: {{.Values.image.name}}:{{default .Values.global.controllerImageVersion .Values.image.version}}
+        image: {{.Values.image.name}}:{{default .Values.global.linkerdVersion .Values.global.controllerImageVersion}}
         imagePullPolicy: {{.Values.global.imagePullPolicy}}
         livenessProbe:
           httpGet:
diff --git a/charts/linkerd2-multicluster/templates/gateway.yaml b/charts/linkerd2-multicluster/templates/gateway.yaml
index 5f89e133b..1a85c3fb2 100644
--- a/charts/linkerd2-multicluster/templates/gateway.yaml
+++ b/charts/linkerd2-multicluster/templates/gateway.yaml
@@ -76,6 +76,8 @@ spec:
         - name: config
           configMap:
             name: {{.Values.gatewayName}}-config
+        - name: tmp
+          emptyDir: {}
       containers:
         - name: nginx
           readinessProbe:
@@ -101,6 +103,8 @@ spec:
           volumeMounts:
             - name: config
               mountPath: /etc/nginx
+            - name: tmp
+              mountPath: /tmp
       serviceAccountName: {{.Values.gatewayName}}
 ---
 apiVersion: v1
diff --git a/charts/linkerd2/README.md b/charts/linkerd2/README.md
index a665106d4..9393488fe 100644
--- a/charts/linkerd2/README.md
+++ b/charts/linkerd2/README.md
@@ -185,7 +185,6 @@ The following table lists the configurable parameters for the Grafana Add-On.
 | `grafana.enabled`                     | Flag to enable grafana instance to be installed                                                                                                                                                | `true`
 | `grafana.name`                | Name of the grafana instance Service                                                                                                                                                 | `linkerd-grafana`                             |
 | `grafana.image.name`                | Docker image name for the grafana instance                                                                                                                                                 | `gcr.io/linkerd-io/grafana`                             |
-| `grafana.image.version`                | Tag for the grafana container Docker image                                                                                                                                                 | latest version                             |
 | `grafana.resources.cpu.limit`       | Maximum amount of CPU units that the grafana container can use                                                                                                                     ||
 | `grafana.resources.cpu.request`     | Amount of CPU units that the gafana container requests                                                                                                                            ||
 | `grafana.resources.memory.limit`    | Maximum amount of memory that grafana container can use                                                                                                                        ||
diff --git a/charts/linkerd2/templates/linkerd-config-addons.yaml b/charts/linkerd2/templates/linkerd-config-addons.yaml
index f23ee692f..50b5847ca 100644
--- a/charts/linkerd2/templates/linkerd-config-addons.yaml
+++ b/charts/linkerd2/templates/linkerd-config-addons.yaml
@@ -21,7 +21,7 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:{{.Values.global.grafanaUrl}}
+      grafanaUrl: "{{.Values.global.grafanaUrl}}"
     grafana:
       {{- include "linkerd.addons.sanitize-config" .Values.grafana}}
     tracing:
diff --git a/charts/linkerd2/templates/web-rbac.yaml b/charts/linkerd2/templates/web-rbac.yaml
index e7cc2a2c4..666053579 100644
--- a/charts/linkerd2/templates/web-rbac.yaml
+++ b/charts/linkerd2/templates/web-rbac.yaml
@@ -69,6 +69,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/charts/linkerd2/values.yaml b/charts/linkerd2/values.yaml
index 7e686ccc7..ca15942b7 100644
--- a/charts/linkerd2/values.yaml
+++ b/charts/linkerd2/values.yaml
@@ -217,7 +217,6 @@ grafana:
   name: linkerd-grafana
   image:
     name: gcr.io/linkerd-io/grafana
-    version: *linkerd_version
   # resources:
 
 tracing:
diff --git a/cli/cmd/check.go b/cli/cmd/check.go
index b2a4ab1ce..3c16eed02 100644
--- a/cli/cmd/check.go
+++ b/cli/cmd/check.go
@@ -188,26 +188,28 @@ func configureAndRunChecks(wout io.Writer, werr io.Writer, stage string, options
 			}
 			checks = append(checks, healthcheck.LinkerdCNIPluginChecks)
 			checks = append(checks, healthcheck.LinkerdHAChecks)
-			checks = append(checks, healthcheck.LinkerdMulticlusterChecks)
+			checks = append(checks, healthcheck.LinkerdMulticlusterSourceChecks)
+			checks = append(checks, healthcheck.LinkerdMulticlusterTargetChecks)
 
 			checks = append(checks, healthcheck.AddOnCategories...)
 		}
 	}
 
 	hc := healthcheck.NewHealthChecker(checks, &healthcheck.Options{
-		ControlPlaneNamespace:   controlPlaneNamespace,
-		CNINamespace:            cniNamespace,
-		DataPlaneNamespace:      options.namespace,
-		KubeConfig:              kubeconfigPath,
-		KubeContext:             kubeContext,
-		Impersonate:             impersonate,
-		ImpersonateGroup:        impersonateGroup,
-		APIAddr:                 apiAddr,
-		VersionOverride:         options.versionOverride,
-		RetryDeadline:           time.Now().Add(options.wait),
-		CNIEnabled:              options.cniEnabled,
-		InstallManifest:         installManifest,
-		ShouldCheckMulticluster: options.multicluster,
+		ControlPlaneNamespace: controlPlaneNamespace,
+		CNINamespace:          cniNamespace,
+		DataPlaneNamespace:    options.namespace,
+		KubeConfig:            kubeconfigPath,
+		KubeContext:           kubeContext,
+		Impersonate:           impersonate,
+		ImpersonateGroup:      impersonateGroup,
+		APIAddr:               apiAddr,
+		VersionOverride:       options.versionOverride,
+		RetryDeadline:         time.Now().Add(options.wait),
+		CNIEnabled:            options.cniEnabled,
+		InstallManifest:       installManifest,
+		SourceCluster:         options.multicluster,
+		TargetCluster:         options.multicluster,
 	})
 
 	success := runChecks(wout, werr, hc, options.output)
diff --git a/cli/cmd/install.go b/cli/cmd/install.go
index 4b529cb4a..c03179013 100644
--- a/cli/cmd/install.go
+++ b/cli/cmd/install.go
@@ -763,7 +763,6 @@ func (options *installOptions) buildValuesWithoutIdentity(configs *pb.All) (*l5d
 	installValues.Global.HighAvailability = options.highAvailability
 	installValues.Global.ImagePullPolicy = options.imagePullPolicy
 	installValues.Grafana["image"].(map[string]interface{})["name"] = fmt.Sprintf("%s/grafana", options.dockerRegistry)
-	installValues.Grafana["image"].(map[string]interface{})["version"] = options.controlPlaneVersion
 	if options.prometheusImage != "" {
 		installValues.PrometheusImage = options.prometheusImage
 	}
diff --git a/cni-plugin/Dockerfile b/cni-plugin/Dockerfile
index 1fd771d7a..e1b270a22 100644
--- a/cni-plugin/Dockerfile
+++ b/cni-plugin/Dockerfile
@@ -6,7 +6,7 @@ COPY controller controller
 COPY cni-plugin cni-plugin
 RUN CGO_ENABLED=0 GOOS=linux go build -o /go/bin/linkerd-cni -v -mod=readonly ./cni-plugin/
 
-FROM gcr.io/linkerd-io/base:2019-09-04.01
+FROM gcr.io/linkerd-io/base:2020-06-08.01
 WORKDIR /linkerd
 RUN curl -kL -o $(which jq) https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
 COPY --from=golang /go/bin/linkerd-cni /opt/cni/bin/
diff --git a/controller/cmd/service-mirror/cluster_watcher.go b/controller/cmd/service-mirror/cluster_watcher.go
index 12f0b33f6..883ab190e 100644
--- a/controller/cmd/service-mirror/cluster_watcher.go
+++ b/controller/cmd/service-mirror/cluster_watcher.go
@@ -3,11 +3,14 @@ package servicemirror
 import (
 	"errors"
 	"fmt"
+	"net"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/linkerd/linkerd2/controller/k8s"
 	consts "github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/prometheus/client_golang/prometheus"
 	logging "github.com/sirupsen/logrus"
 	corev1 "k8s.io/api/core/v1"
 	kerrors "k8s.io/apimachinery/pkg/api/errors"
@@ -36,6 +39,7 @@ type (
 		log                    *logging.Entry
 		eventsQueue            workqueue.RateLimitingInterface
 		requeueLimit           int
+		repairPeriod           time.Duration
 	}
 
 	// ProbeConfig describes the configured probe on particular gateway (if presents)
@@ -136,6 +140,10 @@ type (
 		svc *corev1.Service
 	}
 
+	// RepairEndpoints is issued when the service mirror and mirror gateway
+	// endpoints should be resolved based on the remote gateway and updated.
+	RepairEndpoints struct{}
+
 	gatewayMetadata struct {
 		Name      string
 		Namespace string
@@ -171,6 +179,7 @@ func NewRemoteClusterServiceWatcher(
 	cfg *rest.Config,
 	clusterName string,
 	requeueLimit int,
+	repairPeriod time.Duration,
 	clusterDomain string,
 ) (*RemoteClusterServiceWatcher, error) {
 	remoteAPI, err := k8s.InitializeAPIForConfig(cfg, false, k8s.Svc)
@@ -191,6 +200,7 @@ func NewRemoteClusterServiceWatcher(
 		}),
 		eventsQueue:  workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()),
 		requeueLimit: requeueLimit,
+		repairPeriod: repairPeriod,
 	}, nil
 }
 
@@ -990,6 +1000,8 @@ func (rcsw *RemoteClusterServiceWatcher) processNextEvent() (bool, interface{},
 		err = rcsw.cleanupMirroredResources()
 	case *OprhanedServicesGcTriggered:
 		err = rcsw.cleanupOrphanedServices()
+	case *RepairEndpoints:
+		rcsw.repairEndpoints()
 	default:
 		if ev != nil || !done { // we get a nil in case we are shutting down...
 			rcsw.log.Warnf("Received unknown event: %v", ev)
@@ -1005,6 +1017,7 @@ func (rcsw *RemoteClusterServiceWatcher) processNextEvent() (bool, interface{},
 func (rcsw *RemoteClusterServiceWatcher) processEvents() {
 	for {
 		done, event, err := rcsw.processNextEvent()
+		rcsw.eventsQueue.Done(event)
 		// the logic here is that there might have been an API
 		// connectivity glitch or something. So its not a bad idea to requeue
 		// the event and try again up to a number of limits, just to ensure
@@ -1066,6 +1079,20 @@ func (rcsw *RemoteClusterServiceWatcher) Start() error {
 		},
 	)
 	go rcsw.processEvents()
+
+	go func() {
+		ticker := time.NewTicker(rcsw.repairPeriod)
+		for {
+			select {
+			case <-ticker.C:
+				ev := RepairEndpoints{}
+				rcsw.eventsQueue.Add(&ev)
+			case <-rcsw.stopper:
+				return
+			}
+		}
+	}()
+
 	return nil
 }
 
@@ -1121,9 +1148,16 @@ func (rcsw *RemoteClusterServiceWatcher) extractGatewaySpec(gateway *corev1.Serv
 
 	var gatewayEndpoints []corev1.EndpointAddress
 	for _, ingress := range gateway.Status.LoadBalancer.Ingress {
+		ip := ingress.IP
+		if ip == "" {
+			ipAddr, err := net.ResolveIPAddr("ip", ingress.Hostname)
+			if err != nil {
+				return nil, err
+			}
+			ip = ipAddr.String()
+		}
 		gatewayEndpoints = append(gatewayEndpoints, corev1.EndpointAddress{
-			IP:       ingress.IP,
-			Hostname: ingress.Hostname,
+			IP: ip,
 		})
 	}
 
@@ -1144,3 +1178,53 @@ func (rcsw *RemoteClusterServiceWatcher) extractGatewaySpec(gateway *corev1.Serv
 		ProbeConfig:      probeConfig,
 	}, nil
 }
+
+// repairEndpoints will look up all remote gateways and update the endpoints
+// of all local mirror services for those gateways. Note that we ignore resource
+// version and update ALL affected endpoints objects. This is because the
+// remote gateway may be exposed as a DNS hostname and we want to re-resolve
+// this DNS name in case its IP address has changed. By invoking repairEndpoints
+// frequently, we can pick up any DNS changes fairly quickly.
+// TODO: Replace this with a more robust solution that does not rely on
+// frequently repairing endpoints to pick up DNS updates.
+func (rcsw *RemoteClusterServiceWatcher) repairEndpoints() {
+	svcs, err := rcsw.remoteAPIClient.Svc().Lister().Services(metav1.NamespaceAll).List(labels.Everything())
+	if err != nil {
+		rcsw.log.Errorf("failed to list remote gateways: %s", err)
+		return
+	}
+	rcsw.log.Errorf("During repair, found %d remote services", len(svcs))
+	for _, svc := range svcs {
+		if isGateway(svc.Annotations) {
+
+			// We omit a resource version here because we want to get ALL mirror
+			// services for this gateway.
+			affectedServices, err := rcsw.affectedMirroredServicesForGatewayUpdate(&gatewayMetadata{
+				Name:      svc.Name,
+				Namespace: svc.Namespace,
+			}, "")
+			if err != nil {
+				rcsw.log.Errorf("failed to determine mirror services for gateway %s.%s: %s", svc.Name, svc.Namespace, err)
+				continue
+			}
+
+			spec, err := rcsw.extractGatewaySpec(svc)
+			if err != nil {
+				rcsw.log.Errorf("failed to extract spec for gateway %s.%s: %s", svc.Name, svc.Namespace, err)
+				continue
+			}
+
+			endpointRepairCounter.With(prometheus.Labels{
+				gatewayNameLabel:      svc.Name,
+				gatewayNamespaceLabel: svc.Namespace,
+				gatewayClusterName:    rcsw.clusterName,
+			}).Inc()
+
+			rcsw.log.Errorf("adding gateway update event %s with %d mirrro services", svc.Name, len(affectedServices))
+			rcsw.eventsQueue.Add(&RemoteGatewayUpdated{
+				gatewaySpec:      *spec,
+				affectedServices: affectedServices,
+			})
+		}
+	}
+}
diff --git a/controller/cmd/service-mirror/config_watcher.go b/controller/cmd/service-mirror/config_watcher.go
index 3d9f9283b..c71543aca 100644
--- a/controller/cmd/service-mirror/config_watcher.go
+++ b/controller/cmd/service-mirror/config_watcher.go
@@ -3,6 +3,7 @@ package servicemirror
 import (
 	"fmt"
 	"sync"
+	"time"
 
 	"github.com/linkerd/linkerd2/controller/k8s"
 	log "github.com/sirupsen/logrus"
@@ -21,16 +22,18 @@ type RemoteClusterConfigWatcher struct {
 	k8sAPI                 *k8s.API
 	clusterWatchers        map[string]*RemoteClusterServiceWatcher
 	requeueLimit           int
+	repairPeriod           time.Duration
 	sync.RWMutex
 }
 
 // NewRemoteClusterConfigWatcher Creates a new config watcher
-func NewRemoteClusterConfigWatcher(serviceMirrorNamespace string, secretsInformer cache.SharedIndexInformer, k8sAPI *k8s.API, requeueLimit int) *RemoteClusterConfigWatcher {
+func NewRemoteClusterConfigWatcher(serviceMirrorNamespace string, secretsInformer cache.SharedIndexInformer, k8sAPI *k8s.API, requeueLimit int, repairPeriod time.Duration) *RemoteClusterConfigWatcher {
 	rcw := &RemoteClusterConfigWatcher{
 		serviceMirrorNamespace: serviceMirrorNamespace,
 		k8sAPI:                 k8sAPI,
 		clusterWatchers:        map[string]*RemoteClusterServiceWatcher{},
 		requeueLimit:           requeueLimit,
+		repairPeriod:           repairPeriod,
 	}
 	secretsInformer.AddEventHandler(
 		cache.FilteringResourceEventHandler{
@@ -126,7 +129,7 @@ func (rcw *RemoteClusterConfigWatcher) registerRemoteCluster(secret *corev1.Secr
 		return fmt.Errorf("there is already a cluster with name %s being watcher. Please delete its config before attempting to register a new one", config.ClusterName)
 	}
 
-	watcher, err := NewRemoteClusterServiceWatcher(rcw.serviceMirrorNamespace, rcw.k8sAPI, clientConfig, config.ClusterName, rcw.requeueLimit, config.ClusterDomain)
+	watcher, err := NewRemoteClusterServiceWatcher(rcw.serviceMirrorNamespace, rcw.k8sAPI, clientConfig, config.ClusterName, rcw.requeueLimit, rcw.repairPeriod, config.ClusterDomain)
 	if err != nil {
 		return err
 	}
diff --git a/controller/cmd/service-mirror/events_formatting.go b/controller/cmd/service-mirror/events_formatting.go
index 6a2cba1bf..02058024c 100644
--- a/controller/cmd/service-mirror/events_formatting.go
+++ b/controller/cmd/service-mirror/events_formatting.go
@@ -113,6 +113,10 @@ func (od OnDeleteCalled) String() string {
 	return fmt.Sprintf("OnDeleteCalled: {svc: %s}", formatService(od.svc))
 }
 
+func (re RepairEndpoints) String() string {
+	return "RepairEndpoints"
+}
+
 //Events for probe manager
 
 func (ps probeSpec) String() string {
diff --git a/controller/cmd/service-mirror/main.go b/controller/cmd/service-mirror/main.go
index 831d8cf66..3fc6476e3 100644
--- a/controller/cmd/service-mirror/main.go
+++ b/controller/cmd/service-mirror/main.go
@@ -55,6 +55,7 @@ func Main(args []string) {
 	requeueLimit := cmd.Int("event-requeue-limit", 3, "requeue limit for events")
 	metricsAddr := cmd.String("metrics-addr", ":9999", "address to serve scrapable metrics on")
 	namespace := cmd.String("namespace", "", "address to serve scrapable metrics on")
+	repairPeriod := cmd.Duration("endpoint-refresh-period", 1*time.Minute, "frequency to refresh endpoint resolution")
 
 	flags.ConfigureAndParse(cmd, args)
 
@@ -88,7 +89,7 @@ func Main(args []string) {
 	probeManager.Start()
 
 	k8sAPI.Sync(nil)
-	watcher := NewRemoteClusterConfigWatcher(*namespace, secretsInformer, k8sAPI, *requeueLimit)
+	watcher := NewRemoteClusterConfigWatcher(*namespace, secretsInformer, k8sAPI, *requeueLimit, *repairPeriod)
 	log.Info("Started cluster config watcher")
 
 	go admin.StartServer(*metricsAddr)
diff --git a/controller/cmd/service-mirror/metrics.go b/controller/cmd/service-mirror/metrics.go
index a5b644678..97f027b5a 100644
--- a/controller/cmd/service-mirror/metrics.go
+++ b/controller/cmd/service-mirror/metrics.go
@@ -29,6 +29,18 @@ type probeMetrics struct {
 	unregister func()
 }
 
+var endpointRepairCounter *prometheus.CounterVec
+
+func init() {
+	endpointRepairCounter = promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "service_mirror_endpoint_repairs",
+			Help: "Increments when the service mirror controller attempts to repair mirror endpoints",
+		},
+		[]string{gatewayNameLabel, gatewayNamespaceLabel, gatewayClusterName},
+	)
+}
+
 func newProbeMetricVecs() probeMetricVecs {
 	labelNames := []string{gatewayNameLabel, gatewayNamespaceLabel, gatewayClusterName}
 
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index 3e4ebb665..8d282da1a 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -333,19 +333,21 @@ type category struct {
 
 // Options specifies configuration for a HealthChecker.
 type Options struct {
-	ControlPlaneNamespace   string
-	CNINamespace            string
-	DataPlaneNamespace      string
-	KubeConfig              string
-	KubeContext             string
-	Impersonate             string
-	ImpersonateGroup        []string
-	APIAddr                 string
-	VersionOverride         string
-	RetryDeadline           time.Time
-	CNIEnabled              bool
-	InstallManifest         string
-	ShouldCheckMulticluster bool
+	ControlPlaneNamespace string
+	CNINamespace          string
+	DataPlaneNamespace    string
+	KubeConfig            string
+	KubeContext           string
+	Impersonate           string
+	ImpersonateGroup      []string
+	APIAddr               string
+	VersionOverride       string
+	RetryDeadline         time.Time
+	CNIEnabled            bool
+	InstallManifest       string
+	SourceCluster         bool
+	TargetCluster         bool
+	MultiCluster          bool
 }
 
 // HealthChecker encapsulates all health check checkers, and clients required to
@@ -378,7 +380,7 @@ func NewHealthChecker(categoryIDs []CategoryID, options *Options) *HealthChecker
 	}
 
 	hc.categories = append(hc.allCategories(), hc.addOnCategories()...)
-	hc.categories = append(hc.categories, hc.multiClusterCategory())
+	hc.categories = append(hc.categories, hc.multiClusterCategory()...)
 
 	checkMap := map[CategoryID]struct{}{}
 	for _, category := range categoryIDs {
diff --git a/pkg/healthcheck/healthcheck_multicluster.go b/pkg/healthcheck/healthcheck_multicluster.go
index c005cbdfa..d42f80441 100644
--- a/pkg/healthcheck/healthcheck_multicluster.go
+++ b/pkg/healthcheck/healthcheck_multicluster.go
@@ -21,9 +21,13 @@ import (
 )
 
 const (
-	// LinkerdMulticlusterChecks adds a series of checks to validate
-	// that the multicluster setup is working as expected
-	LinkerdMulticlusterChecks CategoryID = "linkerd-multicluster"
+	// LinkerdMulticlusterSourceChecks adds a series of checks to validate
+	// the source side of a multicluster setup
+	LinkerdMulticlusterSourceChecks CategoryID = "linkerd-multicluster-source"
+
+	// LinkerdMulticlusterTargetChecks add a series of checks to validate the
+	// targetside of a multicluster setup
+	LinkerdMulticlusterTargetChecks CategoryID = "linkerd-multicluster-target"
 
 	linkerdServiceMirrorComponentName   = "linkerd-service-mirror"
 	linkerdServiceMirrorClusterRoleName = "linkerd-service-mirror-access-local-resources"
@@ -54,102 +58,152 @@ var expectedServiceMirrorRemoteClusterPolicyVerbs = []string{
 	"watch",
 }
 
-func (hc *HealthChecker) multiClusterCategory() category {
-	return category{
-		id: LinkerdMulticlusterChecks,
-		checkers: []checker{
-			{
-				description: "service mirror controller is running",
-				hintAnchor:  "l5d-multicluster-service-mirror-running",
-				fatal:       true,
-				check: func(context.Context) error {
-					return hc.checkServiceMirrorController()
+func (hc *HealthChecker) multiClusterCategory() []category {
+	return []category{
+		{
+			id: LinkerdMulticlusterSourceChecks,
+			checkers: []checker{
+				{
+					description: "service mirror controller is running",
+					hintAnchor:  "l5d-multicluster-service-mirror-running",
+					fatal:       true,
+					check: func(context.Context) error {
+						return hc.checkServiceMirrorController()
+					},
 				},
-			},
-			{
-				description: "service mirror controller ClusterRoles exist",
-				hintAnchor:  "l5d-multicluster-cluster-role-exist",
-				check: func(context.Context) error {
-					if hc.Options.ShouldCheckMulticluster {
-						return hc.checkClusterRoles(true, []string{linkerdServiceMirrorClusterRoleName}, hc.serviceMirrorComponentsSelector())
-					}
-					return &SkipError{Reason: "not checking muticluster"}
+				{
+					description: "service mirror controller ClusterRoles exist",
+					hintAnchor:  "l5d-multicluster-cluster-role-exist",
+					check: func(context.Context) error {
+						if hc.Options.SourceCluster {
+							return hc.checkClusterRoles(true, []string{linkerdServiceMirrorClusterRoleName}, hc.serviceMirrorComponentsSelector())
+						}
+						return &SkipError{Reason: "not checking muticluster"}
+					},
 				},
-			},
-			{
-				description: "service mirror controller ClusterRoleBindings exist",
-				hintAnchor:  "l5d-multicluster-cluster-role-binding-exist",
-				check: func(context.Context) error {
-					if hc.Options.ShouldCheckMulticluster {
-						return hc.checkClusterRoleBindings(true, []string{linkerdServiceMirrorClusterRoleName}, hc.serviceMirrorComponentsSelector())
-					}
-					return &SkipError{Reason: "not checking muticluster"}
+				{
+					description: "service mirror controller ClusterRoleBindings exist",
+					hintAnchor:  "l5d-multicluster-cluster-role-binding-exist",
+					check: func(context.Context) error {
+						if hc.Options.SourceCluster {
+							return hc.checkClusterRoleBindings(true, []string{linkerdServiceMirrorClusterRoleName}, hc.serviceMirrorComponentsSelector())
+						}
+						return &SkipError{Reason: "not checking muticluster"}
+					},
 				},
-			},
-			{
-				description: "service mirror controller Roles exist",
-				hintAnchor:  "l5d-multicluster-role-exist",
-				check: func(context.Context) error {
-					if hc.Options.ShouldCheckMulticluster {
-						return hc.checkRoles(true, hc.serviceMirrorNs, []string{linkerdServiceMirrorRoleName}, hc.serviceMirrorComponentsSelector())
-					}
-					return &SkipError{Reason: "not checking muticluster"}
+				{
+					description: "service mirror controller Roles exist",
+					hintAnchor:  "l5d-multicluster-role-exist",
+					check: func(context.Context) error {
+						if hc.Options.SourceCluster {
+							return hc.checkRoles(true, hc.serviceMirrorNs, []string{linkerdServiceMirrorRoleName}, hc.serviceMirrorComponentsSelector())
+						}
+						return &SkipError{Reason: "not checking muticluster"}
+					},
 				},
-			},
-			{
-				description: "service mirror controller RoleBindings exist",
-				hintAnchor:  "l5d-multicluster-role-binding-exist",
-				check: func(context.Context) error {
-					if hc.Options.ShouldCheckMulticluster {
-						return hc.checkRoleBindings(true, hc.serviceMirrorNs, []string{linkerdServiceMirrorRoleName}, hc.serviceMirrorComponentsSelector())
-					}
-					return &SkipError{Reason: "not checking muticluster"}
+				{
+					description: "service mirror controller RoleBindings exist",
+					hintAnchor:  "l5d-multicluster-role-binding-exist",
+					check: func(context.Context) error {
+						if hc.Options.SourceCluster {
+							return hc.checkRoleBindings(true, hc.serviceMirrorNs, []string{linkerdServiceMirrorRoleName}, hc.serviceMirrorComponentsSelector())
+						}
+						return &SkipError{Reason: "not checking muticluster"}
+					},
 				},
-			},
-			{
-				description: "service mirror controller ServiceAccounts exist",
-				hintAnchor:  "l5d-multicluster-service-account-exist",
-				check: func(context.Context) error {
-					if hc.Options.ShouldCheckMulticluster {
-						return hc.checkServiceAccounts([]string{linkerdServiceMirrorComponentName}, hc.serviceMirrorNs, hc.serviceMirrorComponentsSelector())
-					}
-					return &SkipError{Reason: "not checking muticluster"}
+				{
+					description: "service mirror controller ServiceAccounts exist",
+					hintAnchor:  "l5d-multicluster-service-account-exist",
+					check: func(context.Context) error {
+						if hc.Options.SourceCluster {
+							return hc.checkServiceAccounts([]string{linkerdServiceMirrorComponentName}, hc.serviceMirrorNs, hc.serviceMirrorComponentsSelector())
+						}
+						return &SkipError{Reason: "not checking muticluster"}
+					},
 				},
-			},
-			{
-				description: "service mirror controller has required permissions",
-				hintAnchor:  "l5d-multicluster-source-rbac-correct",
-				check: func(context.Context) error {
-					return hc.checkServiceMirrorLocalRBAC()
+				{
+					description: "service mirror controller has required permissions",
+					hintAnchor:  "l5d-multicluster-source-rbac-correct",
+					check: func(context.Context) error {
+						return hc.checkServiceMirrorLocalRBAC()
+					},
 				},
-			},
-			{
-				description: "service mirror controller can access target clusters",
-				hintAnchor:  "l5d-smc-target-clusters-access",
-				check: func(context.Context) error {
-					return hc.checkRemoteClusterConnectivity()
+				{
+					description: "service mirror controller can access target clusters",
+					hintAnchor:  "l5d-smc-target-clusters-access",
+					check: func(context.Context) error {
+						return hc.checkRemoteClusterConnectivity()
+					},
 				},
-			},
-			{
-				description: "all target cluster gateways are alive",
-				hintAnchor:  "l5d-multicluster-target-gateways-alive",
-				check: func(ctx context.Context) error {
-					return hc.checkRemoteClusterGatewaysHealth(ctx)
+				{
+					description: "all target cluster gateways are alive",
+					hintAnchor:  "l5d-multicluster-target-gateways-alive",
+					check: func(ctx context.Context) error {
+						return hc.checkRemoteClusterGatewaysHealth(ctx)
+					},
 				},
-			},
-			{
-				description: "clusters share trust anchors",
-				hintAnchor:  "l5d-multicluster-clusters-share-anchors",
-				check: func(ctx context.Context) error {
-					return hc.checkRemoteClusterAnchors()
+				{
+					description: "clusters share trust anchors",
+					hintAnchor:  "l5d-multicluster-clusters-share-anchors",
+					check: func(ctx context.Context) error {
+						return hc.checkRemoteClusterAnchors()
+					},
+				},
+				{
+					description: "multicluster daisy chaining is avoided",
+					hintAnchor:  "l5d-multicluster-daisy-chaining",
+					warning:     true,
+					check: func(ctx context.Context) error {
+						return hc.checkDaisyChains()
+					},
+				},
+				{
+					description: "all mirror services have endpoints",
+					hintAnchor:  "l5d-multicluster-services-endpoints",
+					warning:     true,
+					check: func(ctx context.Context) error {
+						return hc.checkIfMirrorServicesHaveEndpoints()
+					},
+				},
+				{
+					description: "all gateway mirrors have endpoints",
+					hintAnchor:  "l5d-multicluster-gateways-endpoints",
+					warning:     true,
+					check: func(ctx context.Context) error {
+						return hc.checkIfGatewayMirrorsHaveEndpoints()
+					},
+				},
+				{
+					description: "remote: all referenced gateways are valid",
+					hintAnchor:  "l5d-multicluster-gateways-exist",
+					warning:     true,
+					check: func(ctx context.Context) error {
+						return hc.checkRemoteGateways()
+					},
 				},
 			},
-			{
-				description: "multicluster daisy chaining is avoided",
-				hintAnchor:  "l5d-multicluster-daisy-chaining",
-				warning:     true,
-				check: func(ctx context.Context) error {
-					return hc.checkDaisyChains()
+		},
+		{
+			id: LinkerdMulticlusterTargetChecks,
+			checkers: []checker{
+				{
+					description: "all cluster gateways are valid",
+					hintAnchor:  "l5d-multicluster-gateways-exist",
+					warning:     true,
+					check: func(ctx context.Context) error {
+						targetCluster, err := hc.isTargetCluster()
+						if err != nil {
+							return err
+						}
+						if targetCluster || hc.TargetCluster {
+							err := hc.checkLocalGateways()
+							if err != nil {
+								return err
+							}
+							return hc.checkIfGatewaysHaveEndpoints()
+						}
+						return &SkipError{Reason: "not checking target cluster"}
+					},
 				},
 			},
 		},
@@ -170,19 +224,19 @@ func (hc *HealthChecker) checkServiceMirrorController() error {
 	}
 
 	// if we have explicitly requested for multicluster to be checked, error out
-	if len(result.Items) == 0 && hc.Options.ShouldCheckMulticluster {
+	if len(result.Items) == 0 && hc.Options.SourceCluster {
 		return errors.New("Service mirror controller is not present")
 	}
 
 	if len(result.Items) > 0 {
-		hc.Options.ShouldCheckMulticluster = true
+		hc.Options.SourceCluster = true
 
 		if len(result.Items) > 1 {
-			var errors []string
+			var errors []error
 			for _, smc := range result.Items {
-				errors = append(errors, fmt.Sprintf("%s/%s", smc.Namespace, smc.Name))
+				errors = append(errors, fmt.Errorf("%s/%s", smc.Namespace, smc.Name))
 			}
-			return fmt.Errorf("There are more than one service mirror controllers:\n\t%s", strings.Join(errors, "\n\t"))
+			return fmt.Errorf("There are more than one service mirror controllers:\n%s", joinErrors(errors, 1))
 		}
 
 		controller := result.Items[0]
@@ -223,7 +277,7 @@ func verifyRule(expected expectedPolicy, actual []v1.PolicyRule) error {
 }
 
 func (hc *HealthChecker) checkServiceMirrorLocalRBAC() error {
-	if hc.Options.ShouldCheckMulticluster {
+	if hc.Options.SourceCluster {
 		var errors []string
 
 		clusterRole, err := hc.kubeAPI.RbacV1().ClusterRoles().Get(linkerdServiceMirrorClusterRoleName, metav1.GetOptions{})
@@ -324,7 +378,7 @@ func (hc *HealthChecker) checkRemoteClusterAnchors() error {
 	}
 
 	if len(offendingClusters) > 0 {
-		return fmt.Errorf("Problematic clusters:\n\t%s", strings.Join(offendingClusters, "\n\t"))
+		return fmt.Errorf("Problematic clusters:\n    %s", strings.Join(offendingClusters, "\n    "))
 	}
 
 	return nil
@@ -337,7 +391,7 @@ func serviceExported(svc corev1.Service) bool {
 }
 
 func (hc *HealthChecker) checkDaisyChains() error {
-	if hc.Options.ShouldCheckMulticluster {
+	if hc.Options.SourceCluster {
 		errs := []error{}
 
 		svcs, err := hc.kubeAPI.CoreV1().Services(metav1.NamespaceAll).List(metav1.ListOptions{})
@@ -394,8 +448,110 @@ func (hc *HealthChecker) checkDaisyChains() error {
 	return &SkipError{Reason: "not checking muticluster"}
 }
 
+func (hc *HealthChecker) checkLocalGateways() error {
+
+	errs := checkGateways(hc.kubeAPI)
+	if len(errs) > 0 {
+		return joinErrors(errs, 1)
+	}
+	return nil
+}
+
+func (hc *HealthChecker) checkRemoteGateways() error {
+
+	if len(hc.remoteClusterConfigs) == 0 {
+		return &SkipError{Reason: "no target cluster configs"}
+	}
+
+	var offendingClusters []error
+	for _, cfg := range hc.remoteClusterConfigs {
+		clientConfig, err := clientcmd.RESTConfigFromKubeConfig(cfg.APIConfig)
+		if err != nil {
+			offendingClusters = append(offendingClusters, fmt.Errorf("* %s: unable to parse api config", cfg.ClusterName))
+			continue
+		}
+
+		remoteAPI, err := k8s.NewAPIForConfig(clientConfig, "", []string{}, requestTimeout)
+		if err != nil {
+			offendingClusters = append(offendingClusters, fmt.Errorf("* %s: unable to instantiate api", cfg.ClusterName))
+			continue
+		}
+
+		errs := checkGateways(remoteAPI)
+		if len(errs) > 0 {
+			offendingClusters = append(offendingClusters, fmt.Errorf("* %s: remote cluster has invalid gateways:\n%s", cfg.ClusterName, joinErrors(errs, 2).Error()))
+			continue
+		}
+	}
+	if len(offendingClusters) > 0 {
+		return joinErrors(offendingClusters, 1)
+	}
+	return nil
+}
+
+func checkGateways(api *k8s.KubernetesAPI) []error {
+	errs := []error{}
+	services, err := api.CoreV1().Services(metav1.NamespaceAll).List(metav1.ListOptions{})
+	if err != nil {
+		return []error{err}
+	}
+
+	for _, svc := range services.Items {
+		if serviceExported(svc) {
+			// Check if there is a relevant gateway
+			gatewayName := svc.Annotations[k8s.GatewayNameAnnotation]
+			gatewayNamespace := svc.Annotations[k8s.GatewayNsAnnotation]
+			gateway, err := api.CoreV1().Services(gatewayNamespace).Get(gatewayName, metav1.GetOptions{})
+			if err != nil {
+				errs = append(errs, fmt.Errorf("Exported service %s.%s references a gateway that does not exist: %s.%s", svc.Name, svc.Namespace, gatewayName, gatewayNamespace))
+				continue
+			}
+
+			// check if there is an external IP for the gateway service
+			if len(gateway.Status.LoadBalancer.Ingress) <= 0 {
+				errs = append(errs, fmt.Errorf("Exported service %s.%s references a gateway with no external IP: %s.%s", svc.Name, svc.Namespace, gatewayName, gatewayNamespace))
+			}
+
+			// check if the gateway service has relevant ports
+			portNames := []string{k8s.GatewayPortName, k8s.ProbePortName}
+			for _, portName := range portNames {
+				if !ifPortExists(gateway.Spec.Ports, portName) {
+					errs = append(errs, fmt.Errorf("Exported service %s.%s references a gateway that is missing port %s: %s.%s", svc.Name, svc.Namespace, portName, gatewayName, gatewayNamespace))
+				}
+			}
+
+		}
+	}
+	return errs
+}
+
+func ifPortExists(ports []corev1.ServicePort, portName string) bool {
+	for _, port := range ports {
+		if port.Name == portName {
+			return true
+		}
+	}
+	return false
+}
+
+func (hc *HealthChecker) isTargetCluster() (bool, error) {
+
+	services, err := hc.kubeAPI.CoreV1().Services(metav1.NamespaceAll).List(metav1.ListOptions{})
+	if err != nil {
+		return false, err
+	}
+
+	for _, service := range services.Items {
+		if serviceExported(service) {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
 func (hc *HealthChecker) checkRemoteClusterConnectivity() error {
-	if hc.Options.ShouldCheckMulticluster {
+	if hc.Options.SourceCluster {
 		options := metav1.ListOptions{
 			FieldSelector: fmt.Sprintf("%s=%s", "type", k8s.MirrorSecretType),
 		}
@@ -408,24 +564,24 @@ func (hc *HealthChecker) checkRemoteClusterConnectivity() error {
 			return &SkipError{Reason: "no target cluster configs"}
 		}
 
-		var errors []string
+		var errors []error
 		for _, s := range secrets.Items {
 			secret := s
 			config, err := sm.ParseRemoteClusterSecret(&secret)
 			if err != nil {
-				errors = append(errors, fmt.Sprintf("*  secret: [%s/%s]: could not parse config secret: %s", secret.Namespace, secret.Name, err))
+				errors = append(errors, fmt.Errorf("* secret: [%s/%s]: could not parse config secret: %s", secret.Namespace, secret.Name, err))
 				continue
 			}
 
 			clientConfig, err := clientcmd.RESTConfigFromKubeConfig(config.APIConfig)
 			if err != nil {
-				errors = append(errors, fmt.Sprintf("* secret: [%s/%s] cluster: [%s]: unable to parse api config: %s", secret.Namespace, secret.Name, config.ClusterName, err))
+				errors = append(errors, fmt.Errorf("* secret: [%s/%s] cluster: [%s]: unable to parse api config: %s", secret.Namespace, secret.Name, config.ClusterName, err))
 				continue
 			}
 
 			remoteAPI, err := k8s.NewAPIForConfig(clientConfig, "", []string{}, requestTimeout)
 			if err != nil {
-				errors = append(errors, fmt.Sprintf("* secret: [%s/%s] cluster: [%s]: could not instantiate api for target cluster: %s", secret.Namespace, secret.Name, config.ClusterName, err))
+				errors = append(errors, fmt.Errorf("* secret: [%s/%s] cluster: [%s]: could not instantiate api for target cluster: %s", secret.Namespace, secret.Name, config.ClusterName, err))
 				continue
 			}
 
@@ -443,7 +599,7 @@ func (hc *HealthChecker) checkRemoteClusterConnectivity() error {
 			}
 
 			if err := comparePermissions(expectedServiceMirrorRemoteClusterPolicyVerbs, verbs); err != nil {
-				errors = append(errors, fmt.Sprintf("* cluster: [%s]: Insufficient Service permissions: %s", config.ClusterName, err))
+				errors = append(errors, fmt.Errorf("* cluster: [%s]: Insufficient Service permissions: %s", config.ClusterName, err))
 			}
 
 			hc.remoteClusterConfigs = append(hc.remoteClusterConfigs, config)
@@ -451,7 +607,7 @@ func (hc *HealthChecker) checkRemoteClusterConnectivity() error {
 		}
 
 		if len(errors) > 0 {
-			return fmt.Errorf("Problematic clusters:\n\t%s", strings.Join(errors, "\n\t"))
+			return joinErrors(errors, 2)
 		}
 		return nil
 	}
@@ -459,7 +615,7 @@ func (hc *HealthChecker) checkRemoteClusterConnectivity() error {
 }
 
 func (hc *HealthChecker) checkRemoteClusterGatewaysHealth(ctx context.Context) error {
-	if hc.Options.ShouldCheckMulticluster {
+	if hc.Options.SourceCluster {
 		if hc.apiClient == nil {
 			return errors.New("public api client uninitialized")
 		}
@@ -478,16 +634,107 @@ func (hc *HealthChecker) checkRemoteClusterGatewaysHealth(ctx context.Context) e
 		}
 		for _, gtw := range rsp.GetOk().GatewaysTable.Rows {
 			if gtw.Alive {
-				aliveGateways = append(aliveGateways, fmt.Sprintf("\t* cluster: [%s], gateway: [%s/%s]", gtw.ClusterName, gtw.Namespace, gtw.Name))
+				aliveGateways = append(aliveGateways, fmt.Sprintf("    * cluster: [%s], gateway: [%s/%s]", gtw.ClusterName, gtw.Namespace, gtw.Name))
 			} else {
 				deadGateways = append(deadGateways, fmt.Sprintf("* cluster: [%s], gateway: [%s/%s]", gtw.ClusterName, gtw.Namespace, gtw.Name))
 			}
 		}
 
 		if len(deadGateways) > 0 {
-			return fmt.Errorf("Some gateways are not alive:\n\t%s", strings.Join(deadGateways, "\n\t"))
+			return fmt.Errorf("Some gateways are not alive:\n    %s", strings.Join(deadGateways, "\n    "))
 		}
 		return &VerboseSuccess{Message: strings.Join(aliveGateways, "\n")}
 	}
 	return &SkipError{Reason: "not checking muticluster"}
 }
+
+func (hc *HealthChecker) checkIfMirrorServicesHaveEndpoints() error {
+	if hc.Options.SourceCluster {
+
+		var servicesWithNoEndpoints []string
+		selector := fmt.Sprintf("%s, !%s", k8s.MirroredResourceLabel, k8s.MirroredGatewayLabel)
+		mirrorServices, err := hc.kubeAPI.CoreV1().Services(metav1.NamespaceAll).List(metav1.ListOptions{LabelSelector: selector})
+		if err != nil {
+			return err
+		}
+
+		for _, svc := range mirrorServices.Items {
+			// Check if there is a relevant end-point
+			endpoint, err := hc.kubeAPI.CoreV1().Endpoints(svc.Namespace).Get(svc.Name, metav1.GetOptions{})
+			if err != nil || len(endpoint.Subsets) == 0 {
+				servicesWithNoEndpoints = append(servicesWithNoEndpoints, fmt.Sprintf("%s.%s mirrored from cluster [%s] (gateway: [%s/%s])", svc.Name, svc.Namespace, svc.Labels[k8s.RemoteClusterNameLabel], svc.Labels[k8s.RemoteGatewayNsLabel], svc.Labels[k8s.RemoteGatewayNameLabel]))
+			}
+		}
+
+		if len(servicesWithNoEndpoints) > 0 {
+			return fmt.Errorf("Some mirror services do not have endpoints:\n    %s", strings.Join(servicesWithNoEndpoints, "\n    "))
+		}
+		return nil
+	}
+	return &SkipError{Reason: "not checking muticluster"}
+}
+
+func (hc *HealthChecker) checkIfGatewayMirrorsHaveEndpoints() error {
+	if hc.Options.SourceCluster {
+
+		var gatewayMirrorsWithNoEndpoints []string
+		gatewayServices, err := hc.kubeAPI.CoreV1().Services(metav1.NamespaceAll).List(metav1.ListOptions{LabelSelector: k8s.MirroredGatewayLabel})
+		if err != nil {
+			return err
+		}
+
+		for _, svc := range gatewayServices.Items {
+			// Check if there is a relevant end-point
+			endpoints, err := hc.kubeAPI.CoreV1().Endpoints(svc.Namespace).Get(svc.Name, metav1.GetOptions{})
+			if err != nil || len(endpoints.Subsets) == 0 {
+				gatewayMirrorsWithNoEndpoints = append(gatewayMirrorsWithNoEndpoints, fmt.Sprintf("%s.%s mirrored from cluster [%s]", svc.Name, svc.Namespace, svc.Labels[k8s.RemoteClusterNameLabel]))
+			}
+		}
+
+		if len(gatewayMirrorsWithNoEndpoints) > 0 {
+			return fmt.Errorf("Some gateway mirrors do not have endpoints:\n    %s", strings.Join(gatewayMirrorsWithNoEndpoints, "\n    "))
+		}
+		return nil
+	}
+	return &SkipError{Reason: "not checking muticluster"}
+}
+
+func (hc *HealthChecker) checkIfGatewaysHaveEndpoints() error {
+
+	var gatewaysWithNoEndpoints []string
+	services, err := hc.kubeAPI.CoreV1().Services(corev1.NamespaceAll).List(metav1.ListOptions{})
+	if err != nil {
+		return err
+	}
+
+	for _, service := range services.Items {
+		if gatewayService(service) {
+			// Check if there is a relevant end-point
+			endpoints, err := hc.kubeAPI.CoreV1().Endpoints(service.Namespace).Get(service.Name, metav1.GetOptions{})
+			if err != nil || len(endpoints.Subsets) == 0 {
+				gatewaysWithNoEndpoints = append(gatewaysWithNoEndpoints, fmt.Sprintf("%s.%s", service.Name, service.Namespace))
+			}
+		}
+
+	}
+
+	if len(gatewaysWithNoEndpoints) > 0 {
+		return fmt.Errorf("Some gateway services do not have endpoints:\n    %s", strings.Join(gatewaysWithNoEndpoints, "\n    "))
+	}
+	return nil
+
+}
+
+func gatewayService(svc corev1.Service) bool {
+	_, isGtw := svc.Annotations[k8s.MulticlusterGatewayAnnotation]
+	return isGtw
+}
+
+func joinErrors(errs []error, tabDepth int) error {
+	indent := strings.Repeat("    ", tabDepth)
+	errStrings := []string{}
+	for _, err := range errs {
+		errStrings = append(errStrings, indent+err.Error())
+	}
+	return errors.New(strings.Join(errStrings, "\n"))
+}
diff --git a/web/Dockerfile b/web/Dockerfile
index e442dafad..3e4335a92 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,5 +1,5 @@
 ## bundle web assets
-FROM node:14-stretch as webpack-bundle
+FROM node:14-buster as webpack-bundle
 RUN curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.21.1 --network-concurrency 1
 
 ENV PATH /root/.yarn/bin:$PATH
@@ -32,7 +32,7 @@ COPY pkg pkg
 RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -o web/web -ldflags "-s -w" ./web
 
 ## package it all up
-FROM debian:stretch-20190812-slim
+FROM debian:buster-20200514-slim
 WORKDIR /linkerd
 
 COPY LICENSE .
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 72a9e9ae3..c0423fb65 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -1824,11 +1824,6 @@ abab@^2.0.0:
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
   integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==
 
-abbrev@1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
-  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
-
 accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@@ -2002,7 +1997,7 @@ anymatch@^3.0.3, anymatch@~3.1.1:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
-aproba@^1.0.3, aproba@^1.1.1:
+aproba@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
   integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
@@ -2036,14 +2031,6 @@ archiver@^3.0.0:
     tar-stream "^2.1.0"
     zip-stream "^2.1.2"
 
-are-we-there-yet@~1.1.2:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
-  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
-  dependencies:
-    delegates "^1.0.0"
-    readable-stream "^2.0.6"
-
 argparse@^1.0.7:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@@ -3187,11 +3174,6 @@ console-browserify@^1.1.0:
   resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
   integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==
 
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
-  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
-
 constants-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@@ -3523,7 +3505,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
-debug@3.2.6, debug@^3.0.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6:
+debug@3.2.6, debug@^3.0.0, debug@^3.1.1, debug@^3.2.5:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
   integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@@ -3594,11 +3576,6 @@ deep-equal@^1.0.1:
     object-keys "^1.1.1"
     regexp.prototype.flags "^1.2.0"
 
-deep-extend@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
-  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-
 deep-is@^0.1.3, deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
@@ -3683,11 +3660,6 @@ delayed-stream@~1.0.0:
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
-delegates@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
-
 depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@@ -3711,7 +3683,7 @@ detect-file@^1.0.0:
   resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
   integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
 
-detect-libc@^1.0.2, detect-libc@^1.0.3:
+detect-libc@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
   integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
@@ -4894,13 +4866,6 @@ fs-extra@^9.0.0:
     jsonfile "^6.0.1"
     universalify "^1.0.0"
 
-fs-minipass@^1.2.5:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
-  integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==
-  dependencies:
-    minipass "^2.6.0"
-
 fs-write-stream-atomic@^1.0.8:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
@@ -4953,20 +4918,6 @@ functions-have-names@^1.2.0:
   resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.1.tgz#a981ac397fa0c9964551402cdc5533d7a4d52f91"
   integrity sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA==
 
-gauge@~2.7.3:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
-  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
-  dependencies:
-    aproba "^1.0.3"
-    console-control-strings "^1.0.0"
-    has-unicode "^2.0.0"
-    object-assign "^4.1.0"
-    signal-exit "^3.0.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
-    wide-align "^1.1.0"
-
 gaze@^1.1.2:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a"
@@ -5235,11 +5186,6 @@ has-symbols@^1.0.0, has-symbols@^1.0.1:
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
   integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
 
-has-unicode@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
-
 has-value@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
@@ -5516,7 +5462,7 @@ hyphenate-style-name@^1.0.3:
   resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48"
   integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ==
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
+iconv-lite@0.4.24, iconv-lite@^0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -5540,13 +5486,6 @@ iferr@^0.1.5:
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
   integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE=
 
-ignore-walk@^3.0.1:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"
-  integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==
-  dependencies:
-    minimatch "^3.0.4"
-
 ignore@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@@ -5629,7 +5568,7 @@ inherits@2.0.3:
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@^1.3.5, ini@~1.3.0:
+ini@^1.3.4, ini@^1.3.5:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
   integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
@@ -7409,21 +7348,6 @@ minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
-minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
-  version "2.9.0"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6"
-  integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==
-  dependencies:
-    safe-buffer "^5.1.2"
-    yallist "^3.0.0"
-
-minizlib@^1.2.1:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d"
-  integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==
-  dependencies:
-    minipass "^2.9.0"
-
 mississippi@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022"
@@ -7460,7 +7384,7 @@ mkdirp@0.5.3:
   dependencies:
     minimist "^1.2.5"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3:
+mkdirp@^0.5.1, mkdirp@^0.5.3:
   version "0.5.5"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
   integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@@ -7595,15 +7519,6 @@ nearley@^2.7.10:
     randexp "0.4.6"
     semver "^5.4.1"
 
-needle@^2.2.1:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.1.tgz#14af48732463d7475696f937626b1b993247a56a"
-  integrity sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g==
-  dependencies:
-    debug "^3.2.6"
-    iconv-lite "^0.4.4"
-    sax "^1.2.4"
-
 negotiator@0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -7701,35 +7616,11 @@ node-notifier@^6.0.0:
     shellwords "^0.1.1"
     which "^1.3.1"
 
-node-pre-gyp@*:
-  version "0.14.0"
-  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83"
-  integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==
-  dependencies:
-    detect-libc "^1.0.2"
-    mkdirp "^0.5.1"
-    needle "^2.2.1"
-    nopt "^4.0.1"
-    npm-packlist "^1.1.6"
-    npmlog "^4.0.2"
-    rc "^1.2.7"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar "^4.4.2"
-
 node-releases@^1.1.53:
   version "1.1.53"
   resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4"
   integrity sha512-wp8zyQVwef2hpZ/dJH7SfSrIPD6YoJz6BDQDpGEkcA0s3LpAQoxBIYmfIq6QAhC1DhwsyCgTaTTcONwX8qzCuQ==
 
-nopt@^4.0.1:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48"
-  integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==
-  dependencies:
-    abbrev "1"
-    osenv "^0.1.4"
-
 normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@@ -7757,27 +7648,6 @@ normalize-url@^4.1.0:
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
   integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
 
-npm-bundled@^1.0.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"
-  integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==
-  dependencies:
-    npm-normalize-package-bin "^1.0.1"
-
-npm-normalize-package-bin@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2"
-  integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==
-
-npm-packlist@^1.1.6:
-  version "1.4.8"
-  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
-  integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
-  dependencies:
-    ignore-walk "^3.0.1"
-    npm-bundled "^1.0.1"
-    npm-normalize-package-bin "^1.0.1"
-
 npm-run-path@^2.0.0:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
@@ -7792,16 +7662,6 @@ npm-run-path@^4.0.0:
   dependencies:
     path-key "^3.0.0"
 
-npmlog@^4.0.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
-  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
-  dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.7.3"
-    set-blocking "~2.0.0"
-
 nth-check@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
@@ -7824,7 +7684,7 @@ oauth-sign@~0.9.0:
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+object-assign@^4.0.1, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -7990,11 +7850,6 @@ os-browserify@^0.3.0:
   resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
   integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
 
-os-homedir@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
 os-locale@^3.0.0, os-locale@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
@@ -8004,19 +7859,11 @@ os-locale@^3.0.0, os-locale@^3.1.0:
     lcid "^2.0.0"
     mem "^4.0.0"
 
-os-tmpdir@^1.0.0, os-tmpdir@~1.0.2:
+os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
-osenv@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
 p-cancelable@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.0.0.tgz#4a3740f5bdaf5ed5d7c3e34882c6fb5d6b266a6e"
@@ -8767,16 +8614,6 @@ raw-body@2.4.0:
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
-rc@^1.2.7:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
-  dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
 react-dom@16.13.1:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
@@ -8943,7 +8780,7 @@ read-pkg@^5.2.0:
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@@ -9276,7 +9113,7 @@ rimraf@2.6.3:
   dependencies:
     glob "^7.1.3"
 
-rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3:
+rimraf@^2.5.4, rimraf@^2.6.3:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -9423,7 +9260,7 @@ selfsigned@^1.10.7:
   dependencies:
     node-forge "0.9.0"
 
-"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
+"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -9497,7 +9334,7 @@ serve-static@1.14.1:
     parseurl "~1.3.3"
     send "0.17.1"
 
-set-blocking@^2.0.0, set-blocking@~2.0.0:
+set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
   integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
@@ -10042,7 +9879,7 @@ strip-final-newline@^2.0.0:
   resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
   integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
 
-strip-json-comments@2.0.1, strip-json-comments@~2.0.1:
+strip-json-comments@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
@@ -10147,19 +9984,6 @@ tar-stream@^2.0.0, tar-stream@^2.1.0:
     inherits "^2.0.3"
     readable-stream "^3.1.1"
 
-tar@^4.4.2:
-  version "4.4.13"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
-  integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==
-  dependencies:
-    chownr "^1.1.1"
-    fs-minipass "^1.2.5"
-    minipass "^2.8.6"
-    minizlib "^1.2.1"
-    mkdirp "^0.5.0"
-    safe-buffer "^5.1.2"
-    yallist "^3.0.3"
-
 tcp-port-used@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70"
@@ -10922,9 +10746,9 @@ websocket-driver@>=0.5.1:
     websocket-extensions ">=0.1.1"
 
 websocket-extensions@>=0.1.1:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29"
-  integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
+  integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
 
 whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5:
   version "1.0.5"
@@ -10980,7 +10804,7 @@ which@^2.0.1, which@^2.0.2:
   dependencies:
     isexe "^2.0.0"
 
-wide-align@1.1.3, wide-align@^1.1.0:
+wide-align@1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
   integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
@@ -11107,7 +10931,7 @@ yallist@^2.1.2:
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
-yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3:
+yallist@^3.0.2:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
   integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==


Test patch:
diff --git a/cli/cmd/testdata/inject_tap_deployment.input.yml b/cli/cmd/testdata/inject_tap_deployment.input.yml
index ce06ad797..b330a9271 100644
--- a/cli/cmd/testdata/inject_tap_deployment.input.yml
+++ b/cli/cmd/testdata/inject_tap_deployment.input.yml
@@ -205,7 +205,7 @@ spec:
         - 4190,4191
         - --outbound-ports-to-ignore
         - "443"
-        image: gcr.io/linkerd-io/proxy-init:v1.3.2
+        image: gcr.io/linkerd-io/proxy-init:v1.3.3
         imagePullPolicy: IfNotPresent
         name: linkerd-init
         resources:
diff --git a/cli/cmd/testdata/install_addon_config.golden b/cli/cmd/testdata/install_addon_config.golden
index 619b80c93..d426cf315 100644
--- a/cli/cmd/testdata/install_addon_config.golden
+++ b/cli/cmd/testdata/install_addon_config.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/cli/cmd/testdata/install_addon_control-plane.golden b/cli/cmd/testdata/install_addon_control-plane.golden
index a287e2d07..3ef4fa7ee 100644
--- a/cli/cmd/testdata/install_addon_control-plane.golden
+++ b/cli/cmd/testdata/install_addon_control-plane.golden
@@ -2010,12 +2010,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/install_config.golden b/cli/cmd/testdata/install_config.golden
index daa3258fe..b33dfcb6f 100644
--- a/cli/cmd/testdata/install_config.golden
+++ b/cli/cmd/testdata/install_config.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/cli/cmd/testdata/install_control-plane.golden b/cli/cmd/testdata/install_control-plane.golden
index 8b669bb31..abd95a1ce 100644
--- a/cli/cmd/testdata/install_control-plane.golden
+++ b/cli/cmd/testdata/install_control-plane.golden
@@ -2009,12 +2009,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index ec7b0e05c..b634e059e 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2953,12 +2956,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 76ba816e5..c11930743 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2836,12 +2839,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: my.custom.registry/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 19cbcfed4..a1e93eb3b 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2836,12 +2839,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_grafana_existing.golden b/cli/cmd/testdata/install_grafana_existing.golden
index d2aecb399..6f92f2083 100644
--- a/cli/cmd/testdata/install_grafana_existing.golden
+++ b/cli/cmd/testdata/install_grafana_existing.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2824,7 +2827,7 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:somegrafana.xyz
+      grafanaUrl: "somegrafana.xyz"
     grafana:
       enabled: false
     tracing:
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 554cb54f1..41f8874f9 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -3085,12 +3088,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
       resources:
         cpu:
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index 76eb1d297..0ffcaf205 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -3085,12 +3088,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
       resources:
         cpu:
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 95a29afef..5243a7beb 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -235,6 +235,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2747,12 +2750,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_helm_output.golden b/cli/cmd/testdata/install_helm_output.golden
index 35e359304..822071723 100644
--- a/cli/cmd/testdata/install_helm_output.golden
+++ b/cli/cmd/testdata/install_helm_output.golden
@@ -288,6 +288,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2877,12 +2880,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: linkerdVersionValue
       name: linkerd-grafana
     tracing:
       enabled: false
@@ -3025,7 +3027,7 @@ spec:
       - env:
         - name: GF_PATHS_DATA
           value: /data
-        image: gcr.io/linkerd-io/grafana:linkerdVersionValue
+        image: gcr.io/linkerd-io/grafana:linkerd-version
         imagePullPolicy: IfNotPresent
         livenessProbe:
           httpGet:
diff --git a/cli/cmd/testdata/install_helm_output_addons.golden b/cli/cmd/testdata/install_helm_output_addons.golden
index d907cb8af..ec4c48c78 100644
--- a/cli/cmd/testdata/install_helm_output_addons.golden
+++ b/cli/cmd/testdata/install_helm_output_addons.golden
@@ -288,6 +288,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2878,12 +2881,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: linkerdVersionValue
       name: linkerd-grafana
     tracing:
       collector:
@@ -3495,7 +3497,7 @@ spec:
       - env:
         - name: GF_PATHS_DATA
           value: /data
-        image: gcr.io/linkerd-io/grafana:linkerdVersionValue
+        image: gcr.io/linkerd-io/grafana:linkerd-version
         imagePullPolicy: IfNotPresent
         livenessProbe:
           httpGet:
diff --git a/cli/cmd/testdata/install_helm_output_ha.golden b/cli/cmd/testdata/install_helm_output_ha.golden
index fd0603251..a2071f2ad 100644
--- a/cli/cmd/testdata/install_helm_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_output_ha.golden
@@ -288,6 +288,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -3126,12 +3129,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: linkerdVersionValue
       name: linkerd-grafana
       resources:
         cpu:
@@ -3281,7 +3283,7 @@ spec:
       - env:
         - name: GF_PATHS_DATA
           value: /data
-        image: gcr.io/linkerd-io/grafana:linkerdVersionValue
+        image: gcr.io/linkerd-io/grafana:linkerd-version
         imagePullPolicy: IfNotPresent
         livenessProbe:
           httpGet:
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 6efb9f752..8974fca9a 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2569,12 +2572,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 34d1d1d16..7c6e5cdeb 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2828,12 +2831,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
@@ -2972,7 +2974,7 @@ spec:
       - env:
         - name: GF_PATHS_DATA
           value: /data
-        image: gcr.io/linkerd-io/grafana:install-control-plane-version
+        image: gcr.io/linkerd-io/grafana:ControllerImageVersion
         imagePullPolicy: ImagePullPolicy
         livenessProbe:
           httpGet:
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index aa2f2094f..8707a8966 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2836,12 +2839,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_restricted_dashboard.golden b/cli/cmd/testdata/install_restricted_dashboard.golden
index 5c6a0a746..2943fe251 100644
--- a/cli/cmd/testdata/install_restricted_dashboard.golden
+++ b/cli/cmd/testdata/install_restricted_dashboard.golden
@@ -2771,12 +2771,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/install_tracing.golden b/cli/cmd/testdata/install_tracing.golden
index 55f4dfa98..12569fe91 100644
--- a/cli/cmd/testdata/install_tracing.golden
+++ b/cli/cmd/testdata/install_tracing.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2837,12 +2840,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/install_tracing_overwrite.golden b/cli/cmd/testdata/install_tracing_overwrite.golden
index d79e256bb..26b36a4ce 100644
--- a/cli/cmd/testdata/install_tracing_overwrite.golden
+++ b/cli/cmd/testdata/install_tracing_overwrite.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2837,12 +2840,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: install-control-plane-version
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/upgrade_add-on_config.golden b/cli/cmd/testdata/upgrade_add-on_config.golden
index d6268abf3..f3011ac37 100644
--- a/cli/cmd/testdata/upgrade_add-on_config.golden
+++ b/cli/cmd/testdata/upgrade_add-on_config.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/cli/cmd/testdata/upgrade_add-on_controlplane.golden b/cli/cmd/testdata/upgrade_add-on_controlplane.golden
index 239cf3450..613e11118 100644
--- a/cli/cmd/testdata/upgrade_add-on_controlplane.golden
+++ b/cli/cmd/testdata/upgrade_add-on_controlplane.golden
@@ -2026,12 +2026,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/upgrade_add-on_overwrite.golden b/cli/cmd/testdata/upgrade_add-on_overwrite.golden
index a9931a1c3..f62cabfb0 100644
--- a/cli/cmd/testdata/upgrade_add-on_overwrite.golden
+++ b/cli/cmd/testdata/upgrade_add-on_overwrite.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2853,12 +2856,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/upgrade_add_add-on.golden b/cli/cmd/testdata/upgrade_add_add-on.golden
index bd3749825..4af364191 100644
--- a/cli/cmd/testdata/upgrade_add_add-on.golden
+++ b/cli/cmd/testdata/upgrade_add_add-on.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2853,12 +2856,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       collector:
diff --git a/cli/cmd/testdata/upgrade_default.golden b/cli/cmd/testdata/upgrade_default.golden
index d4b31cc47..191ea71d5 100644
--- a/cli/cmd/testdata/upgrade_default.golden
+++ b/cli/cmd/testdata/upgrade_default.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_external_issuer.golden b/cli/cmd/testdata/upgrade_external_issuer.golden
index a71af28d0..00324188b 100644
--- a/cli/cmd/testdata/upgrade_external_issuer.golden
+++ b/cli/cmd/testdata/upgrade_external_issuer.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2838,12 +2841,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_grafana_addon_overwrite.yaml b/cli/cmd/testdata/upgrade_grafana_addon_overwrite.yaml
index bc1af7165..ad24c8adc 100644
--- a/cli/cmd/testdata/upgrade_grafana_addon_overwrite.yaml
+++ b/cli/cmd/testdata/upgrade_grafana_addon_overwrite.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_grafana_disabled.yaml b/cli/cmd/testdata/upgrade_grafana_disabled.yaml
index 634b1ed64..329fbd6c8 100644
--- a/cli/cmd/testdata/upgrade_grafana_disabled.yaml
+++ b/cli/cmd/testdata/upgrade_grafana_disabled.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2839,7 +2842,7 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: false
     tracing:
diff --git a/cli/cmd/testdata/upgrade_grafana_enabled.yaml b/cli/cmd/testdata/upgrade_grafana_enabled.yaml
index bc1af7165..ad24c8adc 100644
--- a/cli/cmd/testdata/upgrade_grafana_enabled.yaml
+++ b/cli/cmd/testdata/upgrade_grafana_enabled.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_grafana_enabled_disabled.yaml b/cli/cmd/testdata/upgrade_grafana_enabled_disabled.yaml
index 634b1ed64..329fbd6c8 100644
--- a/cli/cmd/testdata/upgrade_grafana_enabled_disabled.yaml
+++ b/cli/cmd/testdata/upgrade_grafana_enabled_disabled.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2839,7 +2842,7 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: false
     tracing:
diff --git a/cli/cmd/testdata/upgrade_grafana_overwrite.yaml b/cli/cmd/testdata/upgrade_grafana_overwrite.yaml
index 5ecff49d2..bfebbeb6a 100644
--- a/cli/cmd/testdata/upgrade_grafana_overwrite.yaml
+++ b/cli/cmd/testdata/upgrade_grafana_overwrite.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: linkerd-image-overwrite
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana-overwrite
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_ha.golden b/cli/cmd/testdata/upgrade_ha.golden
index 72a60a706..99aabc523 100644
--- a/cli/cmd/testdata/upgrade_ha.golden
+++ b/cli/cmd/testdata/upgrade_ha.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -3101,12 +3104,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
       resources:
         cpu:
diff --git a/cli/cmd/testdata/upgrade_ha_config.golden b/cli/cmd/testdata/upgrade_ha_config.golden
index 945b33b58..d0d1f39be 100644
--- a/cli/cmd/testdata/upgrade_ha_config.golden
+++ b/cli/cmd/testdata/upgrade_ha_config.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/cli/cmd/testdata/upgrade_keep_webhook_cabundle.golden b/cli/cmd/testdata/upgrade_keep_webhook_cabundle.golden
index 232066a47..e8ae1a04f 100644
--- a/cli/cmd/testdata/upgrade_keep_webhook_cabundle.golden
+++ b/cli/cmd/testdata/upgrade_keep_webhook_cabundle.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_nothing_addon.yaml b/cli/cmd/testdata/upgrade_nothing_addon.yaml
index bc1af7165..ad24c8adc 100644
--- a/cli/cmd/testdata/upgrade_nothing_addon.yaml
+++ b/cli/cmd/testdata/upgrade_nothing_addon.yaml
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_overwrite_issuer.golden b/cli/cmd/testdata/upgrade_overwrite_issuer.golden
index cccbd6f10..c30f3c87a 100644
--- a/cli/cmd/testdata/upgrade_overwrite_issuer.golden
+++ b/cli/cmd/testdata/upgrade_overwrite_issuer.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2836,12 +2839,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_overwrite_trust_anchors-external-issuer.golden b/cli/cmd/testdata/upgrade_overwrite_trust_anchors-external-issuer.golden
index 4c353f5f4..53f187fde 100644
--- a/cli/cmd/testdata/upgrade_overwrite_trust_anchors-external-issuer.golden
+++ b/cli/cmd/testdata/upgrade_overwrite_trust_anchors-external-issuer.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2822,12 +2825,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_overwrite_trust_anchors.golden b/cli/cmd/testdata/upgrade_overwrite_trust_anchors.golden
index cccbd6f10..c30f3c87a 100644
--- a/cli/cmd/testdata/upgrade_overwrite_trust_anchors.golden
+++ b/cli/cmd/testdata/upgrade_overwrite_trust_anchors.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2836,12 +2839,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/cli/cmd/testdata/upgrade_two_level_webhook_cert.golden b/cli/cmd/testdata/upgrade_two_level_webhook_cert.golden
index b0d05ed9d..d2a7974b4 100644
--- a/cli/cmd/testdata/upgrade_two_level_webhook_cert.golden
+++ b/cli/cmd/testdata/upgrade_two_level_webhook_cert.golden
@@ -276,6 +276,9 @@ rules:
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
   verbs: ["list"]
+- apiGroups: ["apiregistration.k8s.io"]
+  resources: ["apiservices"]
+  verbs: ["get"]
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -2852,12 +2855,11 @@ metadata:
 data:
   values: |-
     global:
-      grafanaUrl:
+      grafanaUrl: ""
     grafana:
       enabled: true
       image:
         name: gcr.io/linkerd-io/grafana
-        version: UPGRADE-CONTROL-PLANE-VERSION
       name: linkerd-grafana
     tracing:
       enabled: false
diff --git a/controller/cmd/service-mirror/cluster_watcher_mirroring_test.go b/controller/cmd/service-mirror/cluster_watcher_mirroring_test.go
index 468e18e0f..ce434f8e2 100644
--- a/controller/cmd/service-mirror/cluster_watcher_mirroring_test.go
+++ b/controller/cmd/service-mirror/cluster_watcher_mirroring_test.go
@@ -2,10 +2,12 @@ package servicemirror
 
 import (
 	"fmt"
+	"net"
 	"reflect"
 	"testing"
 
 	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/util/workqueue"
 )
@@ -83,7 +85,7 @@ func (tc *mirroringTestCase) run(t *testing.T) {
 		for _, ev := range tc.expectedEventsInQueue {
 			evInQueue, _ := q.Get()
 			if !reflect.DeepEqual(ev, evInQueue) {
-				t.Fatalf("was expecting to see event %T but got %T", ev, evInQueue)
+				t.Fatalf("was expecting to see event %s but got %s", ev, evInQueue)
 			}
 		}
 	})
@@ -262,6 +264,12 @@ func TestRemoteServiceUpdatedMirroring(t *testing.T) {
 }
 
 func TestRemoteGatewayUpdatedMirroring(t *testing.T) {
+
+	localhostIP, err := net.ResolveIPAddr("ip", "localhost")
+	if err != nil {
+		t.Fatal(err)
+	}
+
 	for _, tt := range []mirroringTestCase{
 		{
 			description: "endpoints ports are updated on gateway change",
@@ -377,6 +385,28 @@ func TestRemoteGatewayUpdatedMirroring(t *testing.T) {
 						}}),
 			},
 		},
+		{
+			description: "gateway uses hostname address",
+			environment: remoteGatewayUpdatedWithHostnameAddress,
+			expectedEventsInQueue: []interface{}{
+				&RemoteGatewayUpdated{
+					gatewaySpec: GatewaySpec{
+						gatewayName:      "gateway",
+						gatewayNamespace: "gateway-ns",
+						clusterName:      "remote",
+						addresses:        []corev1.EndpointAddress{{IP: localhostIP.String()}},
+						incomingPort:     999,
+						resourceVersion:  "currentGatewayResVersion",
+						ProbeConfig: &ProbeConfig{
+							path:            defaultProbePath,
+							port:            defaultProbePort,
+							periodInSeconds: defaultProbePeriod,
+						},
+					},
+					affectedServices: []*v1.Service{},
+				},
+			},
+		},
 	} {
 		tc := tt // pin
 		tc.run(t)
diff --git a/controller/cmd/service-mirror/cluster_watcher_test_util.go b/controller/cmd/service-mirror/cluster_watcher_test_util.go
index c2605163f..b59dc0b61 100644
--- a/controller/cmd/service-mirror/cluster_watcher_test_util.go
+++ b/controller/cmd/service-mirror/cluster_watcher_test_util.go
@@ -104,7 +104,7 @@ var createServiceWrongGatewaySpec = &testEnvironment{
 		},
 	},
 	remoteResources: []string{
-		gatewayAsYaml("existing-gateway", "existing-namespace", "222", "192.0.2.127", "mc-wrong", 888, "", 111, "/path", 666),
+		gatewayAsYaml("existing-gateway", "existing-namespace", "222", "192.0.2.127", "", "mc-wrong", 888, "", 111, "/path", 666),
 	},
 }
 
@@ -130,7 +130,7 @@ var createServiceOkeGatewaySpec = &testEnvironment{
 		},
 	},
 	remoteResources: []string{
-		gatewayAsYaml("existing-gateway", "existing-namespace", "222", "192.0.2.127", "mc-gateway", 888, "gateway-identity", defaultProbePort, defaultProbePath, defaultProbePeriod),
+		gatewayAsYaml("existing-gateway", "existing-namespace", "222", "192.0.2.127", "", "mc-gateway", 888, "gateway-identity", defaultProbePort, defaultProbePath, defaultProbePeriod),
 	},
 }
 
@@ -193,7 +193,7 @@ var updateServiceToNewGateway = &testEnvironment{
 		},
 	},
 	remoteResources: []string{
-		gatewayAsYaml("gateway-new", "gateway-ns", "currentGatewayResVersion", "0.0.0.0", "mc-gateway", 999, "", defaultProbePort, defaultProbePath, defaultProbePeriod),
+		gatewayAsYaml("gateway-new", "gateway-ns", "currentGatewayResVersion", "0.0.0.0", "", "mc-gateway", 999, "", defaultProbePort, defaultProbePath, defaultProbePeriod),
 	},
 	localResources: []string{
 		mirroredServiceAsYaml("test-service-remote", "test-namespace", "gateway", "gateway-ns", "past", "pastGatewayResVersion", []corev1.ServicePort{
@@ -269,7 +269,7 @@ var updateServiceWithChangedPorts = &testEnvironment{
 		},
 	},
 	remoteResources: []string{
-		gatewayAsYaml("gateway", "gateway-ns", "currentGatewayResVersion", "192.0.2.127", "mc-gateway", 888, "", defaultProbePort, defaultProbePath, defaultProbePeriod),
+		gatewayAsYaml("gateway", "gateway-ns", "currentGatewayResVersion", "192.0.2.127", "", "mc-gateway", 888, "", defaultProbePort, defaultProbePath, defaultProbePeriod),
 	},
 	localResources: []string{
 		mirroredServiceAsYaml("test-service-remote", "test-namespace", "gateway", "gateway-ns", "past", "pastGatewayResVersion", []corev1.ServicePort{
@@ -378,6 +378,15 @@ var remoteGatewayUpdated = &testEnvironment{
 	},
 }
 
+var remoteGatewayUpdatedWithHostnameAddress = &testEnvironment{
+	events: []interface{}{
+		&RepairEndpoints{},
+	},
+	remoteResources: []string{
+		gatewayAsYaml("gateway", "gateway-ns", "currentGatewayResVersion", "", "localhost", "mc-gateway", 999, "", defaultProbePort, defaultProbePath, defaultProbePeriod),
+	},
+}
+
 var gatewayAddressChanged = &testEnvironment{
 	events: []interface{}{
 		&RemoteGatewayUpdated{
@@ -774,7 +783,7 @@ func mirroredServiceAsYaml(name, namespace, gtwName, gtwNs, resourceVersion, gat
 	return string(bytes)
 }
 
-func gateway(name, namespace, resourceVersion, ip, portName string, port int32, identity string, probePort int32, probePath string, probePeriod int) *corev1.Service {
+func gateway(name, namespace, resourceVersion, ip, hostname, portName string, port int32, identity string, probePort int32, probePath string, probePeriod int) *corev1.Service {
 	svc := corev1.Service{
 		TypeMeta: metav1.TypeMeta{
 			Kind:       "Service",
@@ -785,9 +794,10 @@ func gateway(name, namespace, resourceVersion, ip, portName string, port int32,
 			Namespace:       namespace,
 			ResourceVersion: resourceVersion,
 			Annotations: map[string]string{
-				consts.GatewayIdentity:    identity,
-				consts.GatewayProbePath:   probePath,
-				consts.GatewayProbePeriod: fmt.Sprint(probePeriod),
+				consts.GatewayIdentity:               identity,
+				consts.GatewayProbePath:              probePath,
+				consts.GatewayProbePeriod:            fmt.Sprint(probePeriod),
+				consts.MulticlusterGatewayAnnotation: "true",
 			},
 		},
 		Spec: corev1.ServiceSpec{
@@ -809,11 +819,14 @@ func gateway(name, namespace, resourceVersion, ip, portName string, port int32,
 	if ip != "" {
 		svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, corev1.LoadBalancerIngress{IP: ip})
 	}
+	if hostname != "" {
+		svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, corev1.LoadBalancerIngress{Hostname: hostname})
+	}
 	return &svc
 }
 
-func gatewayAsYaml(name, namespace, resourceVersion, ip, portName string, port int32, identity string, probePort int32, probePath string, probePeriod int) string {
-	gtw := gateway(name, namespace, resourceVersion, ip, portName, port, identity, probePort, probePath, probePeriod)
+func gatewayAsYaml(name, namespace, resourceVersion, ip, hostname, portName string, port int32, identity string, probePort int32, probePath string, probePeriod int) string {
+	gtw := gateway(name, namespace, resourceVersion, ip, hostname, portName, port, identity, probePort, probePath, probePeriod)
 
 	bytes, err := yaml.Marshal(gtw)
 	if err != nil {
diff --git a/pkg/charts/linkerd2/values_test.go b/pkg/charts/linkerd2/values_test.go
index 85367aa15..e00d6a516 100644
--- a/pkg/charts/linkerd2/values_test.go
+++ b/pkg/charts/linkerd2/values_test.go
@@ -135,8 +135,7 @@ func TestNewValues(t *testing.T) {
 			"enabled": true,
 			"name":    "linkerd-grafana",
 			"image": map[string]interface{}{
-				"name":    "gcr.io/linkerd-io/grafana",
-				"version": testVersion,
+				"name": "gcr.io/linkerd-io/grafana",
 			},
 		},
 	}
@@ -148,7 +147,6 @@ func TestNewValues(t *testing.T) {
 	actual.Global.Proxy.Image.Version = testVersion
 	actual.Global.ProxyInit.Image.Version = testVersion
 	actual.DebugContainer.Image.Version = testVersion
-	actual.Grafana["image"].(map[string]interface{})["version"] = testVersion
 
 	// Make Add-On Values nil to not have to check for their defaults
 	actual.Tracing = nil
@@ -195,8 +193,7 @@ func TestNewValues(t *testing.T) {
 			"enabled": true,
 			"name":    "linkerd-grafana",
 			"image": map[string]interface{}{
-				"name":    "gcr.io/linkerd-io/grafana",
-				"version": testVersion,
+				"name": "gcr.io/linkerd-io/grafana",
 			},
 			"resources": map[string]interface{}{
 				"cpu": map[string]interface{}{
@@ -250,7 +247,6 @@ func TestNewValues(t *testing.T) {
 		actual.Global.Proxy.Image.Version = testVersion
 		actual.Global.ProxyInit.Image.Version = testVersion
 		actual.DebugContainer.Image.Version = testVersion
-		actual.Grafana["image"].(map[string]interface{})["version"] = testVersion
 		// Make Add-On Values nil to not have to check for their defaults
 		actual.Tracing = nil
 
diff --git a/testutil/test_helper.go b/testutil/test_helper.go
index a6613415c..a6aacec44 100644
--- a/testutil/test_helper.go
+++ b/testutil/test_helper.go
@@ -61,6 +61,39 @@ var LinkerdDeployReplicas = map[string]deploySpec{
 	"linkerd-proxy-injector": {1, []string{"proxy-injector"}},
 }
 
+// NewGenericTestHelper returns a new *TestHelper from the options provided as function parameters.
+// This helper was created to be able to reuse this package without hard restrictions
+// as seen in `NewTestHelper()` which is primarily used with integration tests
+// See - https://github.com/linkerd/linkerd2/issues/4530
+func NewGenericTestHelper(
+	linkerd,
+	namespace,
+	upgradeFromVersion,
+	clusterDomain,
+	helmPath,
+	helmChart,
+	helmStableChart,
+	helmReleaseName string,
+	externalIssuer,
+	uninstall bool,
+) *TestHelper {
+	return &TestHelper{
+		linkerd:            linkerd,
+		namespace:          namespace,
+		upgradeFromVersion: upgradeFromVersion,
+		helm: helm{
+			path:               helmPath,
+			chart:              helmChart,
+			stableChart:        helmStableChart,
+			releaseName:        helmReleaseName,
+			upgradeFromVersion: upgradeFromVersion,
+		},
+		clusterDomain:  clusterDomain,
+		externalIssuer: externalIssuer,
+		uninstall:      uninstall,
+	}
+}
+
 // NewTestHelper creates a new instance of TestHelper for the current test run.
 // The new TestHelper can be configured via command line flags.
 func NewTestHelper() *TestHelper {