Repository: linkerd/linkerd2
Pull request #7951 (2022-02-23 21:04:12)

Fix the if condition in namespace-metadata Job
This fixes the if condition in the namespace-metadata Job which is part of the linkerd-viz chart.

I found this while using ArgoCD to sync both the linkerd2 chart and linkerd-viz chart (versions 21.12.x). The namespace-metadata Job overwrites the namespace labels and annotions of the namespace object from the linkerd2 chart, which prevents the linkerd2 application from getting in sync.

Tested by manually making the changes to the chart and verifying the namespace exists with expected values.

Signed-off-by: Naveen Nalam <nnalam@gmail.com>

@alpeb 


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 2877a29b2..857aa7122 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,20 @@
 # Changes
 
+## edge-22.2.4
+
+This edge release continues to address several security related lints and
+ensures they are checked by CI.
+
+* Add `linkerd check` warning for clusters that cannot verify their
+  `clusterNetworks` due to Nodes missing the `podCIDR` field
+* Changed `Server` CRD to allow having an empty `PodSelector`
+* Modified `linkerd inject` to only support `https` URLs to mitigate security
+  risks
+* Fixed potential goroutine leak in the port forwarding used by several CLI
+  commands and control plane components
+* Fixed timeouts in the policiy validator which could lead to failures if
+  `failurePolicy` was set to `Fail`
+
 ## edge-22.2.3
 
 This edge release fixes some `Instant`-related proxy panics that occur on Amazon
@@ -3142,7 +3157,7 @@ resolutions](https://github.com/linkerd/linkerd2/pull/3848) are released when
 the associated balancer becomes idle.
 
 Finally, an update to follow best practices in the Helm charts has caused a
-*breaking change*. Users who have installed Linkerd using Helm must be certain
+_breaking change_. Users who have installed Linkerd using Helm must be certain
 to read the details of
 [#3822](https://github.com/linkerd/linkerd2/issues/3822)
 
@@ -5207,7 +5222,7 @@ you are upgrading from the `stable-2.0.0` release.
 **Full release notes**:
 
 * CLI
-  * `linkerd routes` command displays per-route stats for *any resource*
+  * `linkerd routes` command displays per-route stats for _any resource_
   * Service profiles are now supported for external authorities
   * `linkerd routes --open-api` flag generates a service profile based on an
     OpenAPI specification (swagger) file
diff --git a/charts/linkerd-control-plane/Chart.yaml b/charts/linkerd-control-plane/Chart.yaml
index 8a0846f3b..42126f91f 100644
--- a/charts/linkerd-control-plane/Chart.yaml
+++ b/charts/linkerd-control-plane/Chart.yaml
@@ -16,7 +16,7 @@ dependencies:
 - name: partials
   version: 0.1.0
   repository: file://../partials
-version: 1.1.6-edge
+version: 1.1.7-edge
 icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
diff --git a/charts/linkerd-control-plane/README.md b/charts/linkerd-control-plane/README.md
index 142c46f55..eb4145398 100644
--- a/charts/linkerd-control-plane/README.md
+++ b/charts/linkerd-control-plane/README.md
@@ -3,7 +3,7 @@
 Linkerd gives you observability, reliability, and security
 for your microservices — with no code change required.
 
-![Version: 1.1.6-edge](https://img.shields.io/badge/Version-1.1.6--edge-informational?style=flat-square)
+![Version: 1.1.7-edge](https://img.shields.io/badge/Version-1.1.7--edge-informational?style=flat-square)
 ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
 ![AppVersion: edge-XX.X.X](https://img.shields.io/badge/AppVersion-edge--XX.X.X-informational?style=flat-square)
 
diff --git a/charts/linkerd-crds/Chart.yaml b/charts/linkerd-crds/Chart.yaml
index 87c2c2278..6449f4676 100644
--- a/charts/linkerd-crds/Chart.yaml
+++ b/charts/linkerd-crds/Chart.yaml
@@ -10,11 +10,11 @@ kubeVersion: ">=1.20.0-0"
 name: "linkerd-crds"
 sources:
 - https://github.com/linkerd/linkerd2/
-dependencies: 
+dependencies:
 - name: partials
   version: 0.1.0
   repository: file://../partials
-version: 1.0.1-edge
+version: 1.0.2-edge
 icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
diff --git a/charts/linkerd-crds/README.md b/charts/linkerd-crds/README.md
index d1786af6e..93f6205cb 100644
--- a/charts/linkerd-crds/README.md
+++ b/charts/linkerd-crds/README.md
@@ -3,7 +3,7 @@
 Linkerd gives you observability, reliability, and security
 for your microservices — with no code change required.
 
-![Version: 1.0.1-edge](https://img.shields.io/badge/Version-1.0.1--edge-informational?style=flat-square)
+![Version: 1.0.2-edge](https://img.shields.io/badge/Version-1.0.2--edge-informational?style=flat-square)
 ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
 
 **Homepage:** <https://linkerd.io>
diff --git a/charts/linkerd2-cni/Chart.yaml b/charts/linkerd2-cni/Chart.yaml
index ae01806e5..369085541 100644
--- a/charts/linkerd2-cni/Chart.yaml
+++ b/charts/linkerd2-cni/Chart.yaml
@@ -9,5 +9,5 @@ description: |
 kubeVersion: ">=1.20.0-0"
 icon: https://linkerd.io/images/logo-only-200h.png
 name: "linkerd2-cni"
-version: 30.0.6-edge
+version: 30.0.7-edge
 
diff --git a/charts/linkerd2-cni/README.md b/charts/linkerd2-cni/README.md
index 28f8abfa9..cb0d6c3d3 100644
--- a/charts/linkerd2-cni/README.md
+++ b/charts/linkerd2-cni/README.md
@@ -6,7 +6,7 @@ Linkerd [CNI plugin](https://linkerd.io/2/features/cni/) takes care of setting
 up your pod's network so  incoming and outgoing traffic is proxied through the
 data plane.
 
-![Version: 30.0.6-edge](https://img.shields.io/badge/Version-30.0.6--edge-informational?style=flat-square)
+![Version: 30.0.7-edge](https://img.shields.io/badge/Version-30.0.7--edge-informational?style=flat-square)
 
 ![AppVersion: edge-XX.X.X](https://img.shields.io/badge/AppVersion-edge--XX.X.X-informational?style=flat-square)
 
diff --git a/cli/cmd/metrics_diagnostics_util.go b/cli/cmd/metrics_diagnostics_util.go
index bb4f6c8bb..e4c7aca7d 100644
--- a/cli/cmd/metrics_diagnostics_util.go
+++ b/cli/cmd/metrics_diagnostics_util.go
@@ -128,12 +128,14 @@ func getMetrics(
 		}(pod)
 	}
 
+	timeout := time.NewTimer(waitingTime)
+	defer timeout.Stop()
 wait:
 	for {
 		select {
 		case result := <-resultChan:
 			results = append(results, result)
-		case <-time.After(waitingTime):
+		case <-timeout.C:
 			break wait // timed out
 		}
 		if atomic.LoadInt32(&activeRoutines) == 0 {
diff --git a/jaeger/charts/linkerd-jaeger/Chart.yaml b/jaeger/charts/linkerd-jaeger/Chart.yaml
index 702fdd6fc..d67e47d9b 100644
--- a/jaeger/charts/linkerd-jaeger/Chart.yaml
+++ b/jaeger/charts/linkerd-jaeger/Chart.yaml
@@ -11,7 +11,7 @@ kubeVersion: ">=1.20.0-0"
 name: linkerd-jaeger
 sources:
 - https://github.com/linkerd/linkerd2/
-version: 30.2.2-edge
+version: 30.2.3-edge
 icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
diff --git a/jaeger/charts/linkerd-jaeger/README.md b/jaeger/charts/linkerd-jaeger/README.md
index 7c6b9a653..8e783d668 100644
--- a/jaeger/charts/linkerd-jaeger/README.md
+++ b/jaeger/charts/linkerd-jaeger/README.md
@@ -3,7 +3,7 @@
 The Linkerd-Jaeger extension adds distributed tracing to Linkerd using
 OpenCensus and Jaeger.
 
-![Version: 30.2.2-edge](https://img.shields.io/badge/Version-30.2.2--edge-informational?style=flat-square)
+![Version: 30.2.3-edge](https://img.shields.io/badge/Version-30.2.3--edge-informational?style=flat-square)
 
 ![AppVersion: edge-XX.X.X](https://img.shields.io/badge/AppVersion-edge--XX.X.X-informational?style=flat-square)
 
diff --git a/jaeger/charts/linkerd-jaeger/templates/namespace-metadata.yaml b/jaeger/charts/linkerd-jaeger/templates/namespace-metadata.yaml
index 37be31f68..c29f1e2dc 100644
--- a/jaeger/charts/linkerd-jaeger/templates/namespace-metadata.yaml
+++ b/jaeger/charts/linkerd-jaeger/templates/namespace-metadata.yaml
@@ -35,7 +35,7 @@ spec:
           ns=$(curl -kfv -H "Authorization: Bearer $token" \
             "https://kubernetes.default.svc/api/v1/namespaces/{{.Release.Namespace}}")
 
-          if echo "$ns" | grep -vq 'labels'; then
+          if ! echo "$ns" | grep -q 'labels'; then
             ops="$ops{\"op\": \"add\",\"path\": \"/metadata/labels\",\"value\": {}},"
           fi
 
diff --git a/multicluster/charts/linkerd-multicluster/Chart.yaml b/multicluster/charts/linkerd-multicluster/Chart.yaml
index 7549be6cc..02e3ffe69 100644
--- a/multicluster/charts/linkerd-multicluster/Chart.yaml
+++ b/multicluster/charts/linkerd-multicluster/Chart.yaml
@@ -11,7 +11,7 @@ kubeVersion: ">=1.20.0-0"
 name: "linkerd-multicluster"
 sources:
 - https://github.com/linkerd/linkerd2/
-version: 30.0.5-edge
+version: 30.0.6-edge
 icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
diff --git a/multicluster/charts/linkerd-multicluster/README.md b/multicluster/charts/linkerd-multicluster/README.md
index 8230e085d..15215a88f 100644
--- a/multicluster/charts/linkerd-multicluster/README.md
+++ b/multicluster/charts/linkerd-multicluster/README.md
@@ -3,7 +3,7 @@
 The Linkerd-Multicluster extension contains resources to support multicluster
 linking to remote clusters
 
-![Version: 30.0.5-edge](https://img.shields.io/badge/Version-30.0.5--edge-informational?style=flat-square)
+![Version: 30.0.6-edge](https://img.shields.io/badge/Version-30.0.6--edge-informational?style=flat-square)
 
 ![AppVersion: edge-XX.X.X](https://img.shields.io/badge/AppVersion-edge--XX.X.X-informational?style=flat-square)
 
diff --git a/multicluster/charts/linkerd-multicluster/templates/namespace-metadata.yaml b/multicluster/charts/linkerd-multicluster/templates/namespace-metadata.yaml
index de3cfa511..0d85e76e9 100644
--- a/multicluster/charts/linkerd-multicluster/templates/namespace-metadata.yaml
+++ b/multicluster/charts/linkerd-multicluster/templates/namespace-metadata.yaml
@@ -35,7 +35,7 @@ spec:
           ns=$(curl -kfv -H "Authorization: Bearer $token" \
             "https://kubernetes.default.svc/api/v1/namespaces/{{.Release.Namespace}}")
 
-          if echo "$ns" | grep -vq 'labels'; then
+          if ! echo "$ns" | grep -q 'labels'; then
             ops="$ops{\"op\": \"add\",\"path\": \"/metadata/labels\",\"value\": {}},"
           fi
 
diff --git a/multicluster/service-mirror/cluster_watcher.go b/multicluster/service-mirror/cluster_watcher.go
index 546971c2b..3a2b8d52a 100644
--- a/multicluster/service-mirror/cluster_watcher.go
+++ b/multicluster/service-mirror/cluster_watcher.go
@@ -950,14 +950,21 @@ func (rcsw *RemoteClusterServiceWatcher) repairEndpoints(ctx context.Context) er
 	for _, svc := range mirrorServices {
 		updatedService := svc.DeepCopy()
 
-		// If the Service is headless we should skip repairing its Endpoints.
-		// Headless Services that are mirrored on a remote cluster will have
-		// their Endpoints created with hostnames and nested clusterIP services,
-		// we should avoid replacing these with the gateway address.
+		// Mirrors for headless services are also headless, and their
+		// Endpoints point to auxiliary services instead of pointing to
+		// the gateway, so they're skipped.
 		if svc.Spec.ClusterIP == corev1.ClusterIPNone {
 			rcsw.log.Debugf("Skipped repairing endpoints for %s/%s", svc.Namespace, svc.Name)
 			continue
 		}
+		// And these auxiliary services have Endpoints that point to
+		// the gateway but aren't mirroring per se any Endpoints on the
+		// target cluster, so they're also skipped.
+		if _, found := svc.Labels[consts.MirroredHeadlessSvcNameLabel]; found {
+			rcsw.log.Debugf("Skipped repairing endpoints for %s/%s", svc.Namespace, svc.Name)
+			continue
+		}
+
 		endpoints, err := rcsw.localAPIClient.Endpoint().Lister().Endpoints(svc.Namespace).Get(svc.Name)
 		if err != nil {
 			rcsw.log.Errorf("Could not get local endpoints: %s", err)
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index 953f64dbe..80c633579 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -1636,8 +1636,8 @@ func (hc *HealthChecker) LinkerdConfig() *l5dcharts.Values {
 func (hc *HealthChecker) runCheck(category *Category, c *Checker, observer CheckObserver) bool {
 	for {
 		ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)
-		defer cancel()
 		err := c.check(ctx)
+		cancel()
 		var se SkipError
 		if errors.As(err, &se) {
 			log.Debugf("Skipping check: %s. Reason: %s", c.description, se.Reason)
diff --git a/pkg/k8s/portforward.go b/pkg/k8s/portforward.go
index f670de69e..6ba6a4272 100644
--- a/pkg/k8s/portforward.go
+++ b/pkg/k8s/portforward.go
@@ -204,7 +204,7 @@ func (pf *PortForward) run() error {
 func (pf *PortForward) Init() error {
 	log.Debugf("Starting port forward to %s %d:%d", pf.url, pf.localPort, pf.remotePort)
 
-	failure := make(chan error)
+	failure := make(chan error, 1)
 
 	go func() {
 		if err := pf.run(); err != nil {
diff --git a/viz/charts/linkerd-viz/Chart.yaml b/viz/charts/linkerd-viz/Chart.yaml
index be951e39b..522e26640 100644
--- a/viz/charts/linkerd-viz/Chart.yaml
+++ b/viz/charts/linkerd-viz/Chart.yaml
@@ -11,7 +11,7 @@ kubeVersion: ">=1.20.0-0"
 name: "linkerd-viz"
 sources:
 - https://github.com/linkerd/linkerd2/
-version: 30.0.5-edge
+version: 30.0.6-edge
 icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
diff --git a/viz/charts/linkerd-viz/README.md b/viz/charts/linkerd-viz/README.md
index 15988920b..5e2ee049a 100644
--- a/viz/charts/linkerd-viz/README.md
+++ b/viz/charts/linkerd-viz/README.md
@@ -3,7 +3,7 @@
 The Linkerd-Viz extension contains observability and visualization
 components for Linkerd.
 
-![Version: 30.0.5-edge](https://img.shields.io/badge/Version-30.0.5--edge-informational?style=flat-square)
+![Version: 30.0.6-edge](https://img.shields.io/badge/Version-30.0.6--edge-informational?style=flat-square)
 
 ![AppVersion: edge-XX.X.X](https://img.shields.io/badge/AppVersion-edge--XX.X.X-informational?style=flat-square)
 
diff --git a/viz/charts/linkerd-viz/templates/namespace-metadata.yaml b/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
index 7613a9d3a..87620bcfc 100644
--- a/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
+++ b/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
@@ -36,10 +36,10 @@ spec:
           ns=$(curl -kfv -H "Authorization: Bearer $token" \
             "https://kubernetes.default.svc/api/v1/namespaces/{{.Release.Namespace}}")
 
-          if echo "$ns" | grep -vq 'labels'; then
+          if ! echo "$ns" | grep -q 'labels'; then
             ops="$ops{\"op\": \"add\",\"path\": \"/metadata/labels\",\"value\": {}},"
           fi
-          if echo "$ns" | grep -vq 'annotations'; then
+          if ! echo "$ns" | grep -q 'annotations'; then
             ops="$ops{\"op\": \"add\", \"path\": \"/metadata/annotations\", \"value\": {}},"
           fi
 


Test patch:
diff --git a/bin/_test-helpers.sh b/bin/_test-helpers.sh
index dd1b26001..d1a5969cf 100644
--- a/bin/_test-helpers.sh
+++ b/bin/_test-helpers.sh
@@ -390,90 +390,20 @@ latest_release_channel() {
     "$bindir"/scurl https://versioncheck.linkerd.io/version.json | grep -o "$1-[0-9]*.[0-9]*.[0-9]*"
 }
 
-# Install a specific Linkerd version.
-# $1 - URL to use to download specific Linkerd version
-# $2 - Linkerd version
-install_version() {
-    tmp=$(mktemp -d -t l5dbin.XXX)
-
-    local install_url=$1
-    local version=$2
-
-    "$bindir"/scurl "$install_url" | HOME=$tmp sh > /dev/null 2>&1
-
-    local linkerd_path=$tmp/.linkerd2/bin/linkerd
-    local test_app_namespace=upgrade-test
-
-    (
-        set -x
-        # TODO: Use a mix of helm override flags and CLI flags and remove this condition
-        # once stable-2.10 is out
-        edge_regex='(edge)-([0-9]+\.[0-9]+\.[0-9]+)'
-        if [[ "$version" =~ $edge_regex ]]; then
-          "$linkerd_path" install --set proxyInit.ignoreInboundPorts="1234\,5678" --controller-log-level debug | kubectl --context="$context" apply -f - 2>&1
-        else
-          "$linkerd_path" install --skip-inbound-ports '1234,5678' --controller-log-level debug | kubectl --context="$context" apply -f - 2>&1
-        fi
-    )
-    exit_on_err "install_version() - installing $version failed"
-
-    (
-        set -x
-        "$linkerd_path" check --wait 60m 2>&1
-    )
-    exit_on_err 'install_version() - linkerd check failed'
-
-    #Now we need to install the app that will be used to verify that upgrade does not break anything
-    kubectl --context="$context" create namespace "$test_app_namespace" > /dev/null 2>&1
-    kubectl --context="$context" label namespaces "$test_app_namespace" 'test.linkerd.io/is-test-data-plane'='true' > /dev/null 2>&1
-    (
-        set -x
-        "$linkerd_path" inject "$test_directory/install/testdata/upgrade_test.yaml" | kubectl --context="$context" apply --namespace="$test_app_namespace" -f - 2>&1
-    )
-    exit_on_err 'install_version() - linkerd inject failed'
-}
-
-upgrade_test() {
-  local release_channel=$1
-  local install_url=$2
-
-  local upgrade_version
-  upgrade_version=$(latest_release_channel "$release_channel")
-
-  if [ -z "$upgrade_version" ]; then
-    echo 'error getting upgrade_version'
-    exit 1
-  fi
-
-  install_version "$install_url" "$upgrade_version"
-
-  # Install viz extension
-  local tmp_linkerd_path=$tmp/.linkerd2/bin/linkerd
-  (
-      set -x
-      "$tmp_linkerd_path" viz install | kubectl --context="$context" apply -f - 2>&1
-  )
-  exit_on_err "upgrade_test() - installing viz extension in $upgrade_version failed"
-
-  run_test "$test_directory/install/install_test.go" --upgrade-from-version="$upgrade_version"
-}
-
 # Run the upgrade-edge test by upgrading the most-recent edge release to the
 # HEAD of this branch.
 run_upgrade-edge_test() {
-  edge_install_url="https://run.linkerd.io/install-edge"
-  upgrade_test "edge" "$edge_install_url"
-}
-
-run_viz_test() {
-  run_test "$test_directory/viz/..."
+  run_test "$test_directory/upgrade-edge/..." 
 }
 
 # Run the upgrade-stable test by upgrading the most-recent stable release to the
 # HEAD of this branch.
 run_upgrade-stable_test() {
-  stable_install_url="https://run.linkerd.io/install"
-  upgrade_test "stable" "$stable_install_url"
+  run_test "$test_directory/upgrade-stable/..." 
+}
+
+run_viz_test() {
+  run_test "$test_directory/viz/..."
 }
 
 setup_helm() {
@@ -536,16 +466,15 @@ run_multicluster_test() {
   link=$(multicluster_link target)
 
   export context="k3d-source"
-  # Create the emojivoto namespace in the source cluster so that mirror services
-  # can be created there.
+  # Create the emojivoto and multicluster-statefulset namespaces in the source
+  # cluster so that mirror services can be created there.
   kubectl --context="$context" create namespace emojivoto
+  kubectl --context="$context" create namespace multicluster-statefulset
   run_test "$test_directory/multicluster/install_test.go" --certs-path "$tmp"
   echo "$link" | kubectl --context="$context" apply -f -
   run_test "$test_directory/multicluster/source" 
   export context="k3d-target"
   run_test "$test_directory/multicluster/target2" 
-  export context="k3d-target"
-  run_test "$test_directory/multicluster/target-statefulset" 
 }
 
 run_deep_test() {
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index fa5e09083..992f361c3 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 378cc2d95..c6b15ff9f 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 785183255..6ab8dec5e 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 378cc2d95..c6b15ff9f 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index c7cd89d51..c46118241 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index 91ee17444..a81a767aa 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index f6d1d1254..c73579780 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index 489edcf46..a23f05517 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index fe7526249..dc6040da2 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 3cedeadf8..6da6a5d56 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 3037eb2b7..99ce526f3 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: CliVersion
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: CliVersion
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: CliVersion
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index 5e50687d1..e0d54d34d 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 2e30c0d2c..badc5f06c 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -6,7 +6,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -152,7 +152,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: policy.linkerd.io
@@ -441,7 +441,7 @@ metadata:
   annotations:
     linkerd.io/created-by: linkerd/cli dev-undefined
   labels:
-    helm.sh/chart: linkerd-control-plane-1.1.6-edge
+    helm.sh/chart: linkerd-control-plane-1.1.7-edge
     linkerd.io/control-plane-ns: linkerd
 spec:
   group: linkerd.io
diff --git a/test/integration/install/install_test.go b/test/integration/install/install_test.go
index 98c95e0af..ef03ee38d 100644
--- a/test/integration/install/install_test.go
+++ b/test/integration/install/install_test.go
@@ -7,7 +7,6 @@ import (
 	"os"
 	"reflect"
 	"regexp"
-	"strconv"
 	"strings"
 	"testing"
 	"text/template"
@@ -865,43 +864,6 @@ func TestUpgradeTestAppWorksAfterUpgrade(t *testing.T) {
 	}
 }
 
-func TestDashboard(t *testing.T) {
-	dashboardPort := 52237
-	dashboardURL := fmt.Sprintf("http://localhost:%d", dashboardPort)
-
-	outputStream, err := TestHelper.LinkerdRunStream("viz", "dashboard", "-p",
-		strconv.Itoa(dashboardPort), "--show", "url")
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "error running command",
-			"error running command:\n%s", err)
-	}
-	defer outputStream.Stop()
-
-	outputLines, err := outputStream.ReadUntil(4, 1*time.Minute)
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "error running command",
-			"error running command:\n%s", err)
-	}
-
-	output := strings.Join(outputLines, "")
-	if !strings.Contains(output, dashboardURL) {
-		testutil.AnnotatedFatalf(t,
-			"dashboard command failed. Expected url [%s] not present", dashboardURL)
-	}
-
-	resp, err := TestHelper.HTTPGetURL(dashboardURL + "/api/version")
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "unexpected error",
-			"unexpected error: %v", err)
-	}
-
-	if !strings.Contains(resp, TestHelper.GetVersion()) {
-		testutil.AnnotatedFatalf(t, "dashboard command failed; response doesn't contain expected version",
-			"dashboard command failed. Expected response [%s] to contain version [%s]",
-			resp, TestHelper.GetVersion())
-	}
-}
-
 func TestRestarts(t *testing.T) {
 	expectedDeployments := testutil.LinkerdDeployReplicasEdge
 	if !TestHelper.ExternalPrometheus() {
diff --git a/test/integration/multicluster/install_test.go b/test/integration/multicluster/install_test.go
index 3132171e5..bb98e0ee0 100644
--- a/test/integration/multicluster/install_test.go
+++ b/test/integration/multicluster/install_test.go
@@ -103,25 +103,21 @@ func TestInstallViz(t *testing.T) {
 }
 
 func TestCheckMulticluster(t *testing.T) {
-	if TestHelper.GetMulticlusterHelmReleaseName() != "" || TestHelper.Multicluster() {
-		cmd := []string{"multicluster", "check", "--wait=60m"}
-		golden := "check.multicluster.golden"
-		timeout := time.Minute
-		err := TestHelper.RetryFor(timeout, func() error {
-			out, err := TestHelper.LinkerdRun(cmd...)
-			if err != nil {
-				return fmt.Errorf("'linkerd multicluster check' command failed\n%w", err)
-			}
-			err = TestHelper.ValidateOutput(out, golden)
-			if err != nil {
-				return fmt.Errorf("received unexpected output\n%w", err)
-			}
-			return nil
-		})
+	cmd := []string{"multicluster", "check", "--wait=10s"}
+	golden := "check.multicluster.golden"
+	timeout := time.Minute
+	err := TestHelper.RetryFor(timeout, func() error {
+		out, err := TestHelper.LinkerdRun(cmd...)
 		if err != nil {
-			testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd multicluster check' command timed-out (%s)", timeout), err)
+			return fmt.Errorf("'linkerd multicluster check' command failed\n%s", out)
 		}
-	} else {
-		t.Skip("Skipping for non multicluster test")
+		err = TestHelper.ValidateOutput(out, golden)
+		if err != nil {
+			return fmt.Errorf("received unexpected output: %w", err)
+		}
+		return nil
+	})
+	if err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd multicluster check' command timed-out (%s)", timeout), err)
 	}
 }
diff --git a/test/integration/multicluster/source/source_test.go b/test/integration/multicluster/source/source_test.go
index 0532c2b33..8a8a3c976 100644
--- a/test/integration/multicluster/source/source_test.go
+++ b/test/integration/multicluster/source/source_test.go
@@ -1,14 +1,18 @@
 package source
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
 	"os"
 	"strings"
 	"testing"
+	"text/template"
 	"time"
 
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/version"
 	"github.com/linkerd/linkerd2/testutil"
 )
 
@@ -40,7 +44,7 @@ func TestGateways(t *testing.T) {
 		if fields[1] != "True" {
 			return errors.New("target cluster is not alive")
 		}
-		if fields[2] != "1" {
+		if fields[2] != "3" {
 			return fmt.Errorf("invalid NUM_SVC: %s", fields[2])
 		}
 
@@ -83,3 +87,59 @@ func TestInstallSlowCooker(t *testing.T) {
 	}
 
 }
+
+func TestCheck(t *testing.T) {
+	check(t)
+}
+
+// TestCheckAfterRepairEndpoints calls `linkerd mc check` again after 1 minute,
+// so that the RepairEndpoints event has already been processed, making sure
+// that resyncing didn't break things
+func TestCheckAfterRepairEndpoints(t *testing.T) {
+	time.Sleep(time.Minute + 5*time.Second)
+	check(t)
+}
+
+func check(t *testing.T) {
+	cmd := []string{"multicluster", "check", "--wait=10s"}
+	timeout := 20 * time.Second
+	err := TestHelper.RetryFor(timeout, func() error {
+		out, err := TestHelper.LinkerdRun(cmd...)
+		if err != nil {
+			return fmt.Errorf("'linkerd multicluster check' command failed\n%s", out)
+		}
+
+		pods, err := TestHelper.KubernetesHelper.GetPods(context.Background(), "linkerd-multicluster", nil)
+		if err != nil {
+			testutil.AnnotatedFatal(t, fmt.Sprintf("failed to retrieve pods: %s", err), err)
+		}
+
+		tpl := template.Must(template.ParseFiles("testdata/check.multicluster.golden"))
+		versionErr := healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{})
+		versionErrMsg := ""
+		if versionErr != nil {
+			versionErrMsg = versionErr.Error()
+		}
+		vars := struct {
+			ProxyVersionErr string
+			HintURL         string
+		}{
+			versionErrMsg,
+			healthcheck.HintBaseURL(TestHelper.GetVersion()),
+		}
+
+		var expected bytes.Buffer
+		if err := tpl.Execute(&expected, vars); err != nil {
+			testutil.AnnotatedFatal(t, fmt.Sprintf("failed to parse check.multicluster.golden template: %s", err), err)
+		}
+
+		if out != expected.String() {
+			return fmt.Errorf(
+				"Expected:\n%s\nActual:\n%s", expected.String(), out)
+		}
+		return nil
+	})
+	if err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd multicluster check' command timed-out (%s)", timeout), err)
+	}
+}
diff --git a/test/integration/multicluster/source/testdata/check.multicluster.golden b/test/integration/multicluster/source/testdata/check.multicluster.golden
new file mode 100644
index 000000000..ca9e5e283
--- /dev/null
+++ b/test/integration/multicluster/source/testdata/check.multicluster.golden
@@ -0,0 +1,24 @@
+linkerd-multicluster
+--------------------
+√ Link CRD exists
+√ Link resources are valid
+	* target
+√ remote cluster access credentials are valid
+	* target
+√ clusters share trust anchors
+	* target
+√ service mirror controller has required permissions
+	* target
+√ service mirror controllers are running
+	* target
+√ all gateway mirrors are healthy
+	* target
+√ all mirror services have endpoints
+√ all mirror services are part of a Link
+√ multicluster extension proxies are healthy
+‼ multicluster extension proxies are up-to-date
+    {{.ProxyVersionErr}}
+    see {{.HintURL}}l5d-multicluster-proxy-cp-version for hints
+√ multicluster extension proxies and cli versions match
+
+Status check results are √
diff --git a/test/integration/multicluster/target-statefulset/target_statefulset_test.go b/test/integration/multicluster/target-statefulset/target_statefulset_test.go
deleted file mode 100644
index 37e01a852..000000000
--- a/test/integration/multicluster/target-statefulset/target_statefulset_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package target_statefulset_test
-
-import (
-	"context"
-	"fmt"
-	"os"
-	"regexp"
-	"testing"
-	"time"
-
-	"github.com/linkerd/linkerd2/testutil"
-)
-
-var TestHelper *testutil.TestHelper
-
-var (
-	tcpConnRE = regexp.MustCompile(
-		`tcp_open_total\{direction="outbound",peer="dst",target_addr="[0-9\.]+:[0-9]+",target_ip="[0-9\.]+",target_port="[0-9]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
-	)
-	httpReqRE = regexp.MustCompile(
-		`request_total\{direction="outbound",target_addr="[0-9\.]+:8080",target_ip="[0-9\.]+",target_port="[0-9\.]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
-	)
-	dgCmd = []string{"diagnostics", "proxy-metrics", "--namespace",
-		"linkerd-multicluster", "deploy/linkerd-gateway"}
-)
-
-func TestMain(m *testing.M) {
-	TestHelper = testutil.NewTestHelper()
-	os.Exit(m.Run())
-}
-
-/////////////////////
-//  TEST EXECUTION //
-/////////////////////
-
-// TestMulticlusterStatefulSetTargetTraffic will test that a statefulset can be
-// mirrored from a target cluster to a source cluster. The test deploys two
-// workloads: a slow cooker (as a client) in the src, and an nginx statefulset in
-// (as a server) in the tgt. The slow-cooker is configured to send traffic to an
-// nginx endpoint mirror (nginx-statefulset-0). The traffic should be received
-// by the nginx pod in the tgt. To assert this, we get proxy metrics from the
-// gateway to make sure our connections from the source cluster were routed
-// correctly.
-func TestMulticlusterStatefulSetTargetTraffic(t *testing.T) {
-	err := TestHelper.CreateDataPlaneNamespaceIfNotExists(context.Background(),
-		"multicluster-statefulset", nil)
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "failed to create multicluster-statefulset namespace", "failed to create multicluster-statefulset namespace: %s", err)
-	}
-
-	nginx, err := TestHelper.LinkerdRun("inject", "testdata/nginx.yaml")
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "failed to inject nginx manifest", "failed to inject nginx manifest: %s", err)
-	}
-
-	out, err := TestHelper.KubectlApply(nginx, "")
-	if err != nil {
-		testutil.AnnotatedFatalf(t, "failed to install nginx", "failed to install nginx: %s\ngot: %s", err, out)
-	}
-
-	t.Run("expect open outbound TCP connection from gateway to nginx", func(t *testing.T) {
-		// Use a short time window so that slow-cooker can warm-up and send
-		// requests.
-		err := TestHelper.RetryFor(30*time.Second, func() error {
-			// Check gateway metrics
-			metrics, err := TestHelper.LinkerdRun(dgCmd...)
-			if err != nil {
-				return fmt.Errorf("failed to get metrics for gateway deployment: %w", err)
-			}
-
-			// If no match, it means there are no open tcp conns from gateway to
-			// nginx pod.
-			if !tcpConnRE.MatchString(metrics) {
-				return fmt.Errorf("failed to find expected TCP connection open outbound metric from gateway to nginx\nexpected: %s, got: %s", tcpConnRE, metrics)
-			}
-
-			return nil
-		})
-
-		if err != nil {
-			testutil.AnnotatedFatalf(t, "unexpected error", "unexpected error: %v", err)
-		}
-
-	})
-
-	t.Run("expect non-empty HTTP request metric from gateway to nginx", func(t *testing.T) {
-		// Use a short time window so that slow-cooker can warm-up and send
-		// requests.
-		err := TestHelper.RetryFor(30*time.Second, func() error {
-			// Check gateway metrics
-			metrics, err := TestHelper.LinkerdRun(dgCmd...)
-			if err != nil {
-				return fmt.Errorf("failed to get metrics for gateway deployment: %w", err)
-			}
-
-			// If no match, it means there are no outbound HTTP requests from
-			// gateway to nginx pod.
-			if !httpReqRE.MatchString(metrics) {
-				return fmt.Errorf("failed to find expected outbound HTTP request metric from gateway to nginx\nexpected: %s, got: %s", httpReqRE, metrics)
-			}
-			return nil
-		})
-
-		if err != nil {
-			testutil.AnnotatedFatalf(t, "unexpected error", "unexpected error: %v", err)
-		}
-	})
-}
diff --git a/test/integration/multicluster/target1/target_test.go b/test/integration/multicluster/target1/target_test.go
index 78b048ccf..0020b25fa 100644
--- a/test/integration/multicluster/target1/target_test.go
+++ b/test/integration/multicluster/target1/target_test.go
@@ -29,3 +29,18 @@ func TestInstallEmojivoto(t *testing.T) {
 		testutil.AnnotatedFatalf(t, "failed to install emojivoto", "failed to install emojivoto: %s\n%s", err, out)
 	}
 }
+
+func TestInstallNginxSS(t *testing.T) {
+	if err := TestHelper.CreateDataPlaneNamespaceIfNotExists(context.Background(), "multicluster-statefulset", nil); err != nil {
+		testutil.AnnotatedFatalf(t, "failed to create multicluster-statefulset namespace",
+			"failed to create multicluster-statefulset namespace: %s", err)
+	}
+	yaml, err := testutil.ReadFile("testdata/nginx-ss.yml")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to read 'nginx-ss.yml'", "failed to read 'nginx-ss.yml': %s", err)
+	}
+	out, err := TestHelper.KubectlApply(yaml, "multicluster-statefulset")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to install nginx-ss", "failed to install nginx-ss: %s\n%s", err, out)
+	}
+}
diff --git a/test/integration/multicluster/target-statefulset/testdata/nginx.yaml b/test/integration/multicluster/target1/testdata/nginx-ss.yml
similarity index 93%
rename from test/integration/multicluster/target-statefulset/testdata/nginx.yaml
rename to test/integration/multicluster/target1/testdata/nginx-ss.yml
index a7df2731d..fcc7c7de8 100644
--- a/test/integration/multicluster/target-statefulset/testdata/nginx.yaml
+++ b/test/integration/multicluster/target1/testdata/nginx-ss.yml
@@ -35,6 +35,8 @@ spec:
   replicas: 1
   template:
     metadata:
+      annotations:
+        linkerd.io/inject: enabled
       labels:
         app: nginx-set
     spec:
diff --git a/test/integration/multicluster/target2/target_test.go b/test/integration/multicluster/target2/target_test.go
index 9e82364e8..0887edbf8 100644
--- a/test/integration/multicluster/target2/target_test.go
+++ b/test/integration/multicluster/target2/target_test.go
@@ -3,6 +3,7 @@ package target2
 import (
 	"fmt"
 	"os"
+	"regexp"
 	"strings"
 	"testing"
 	"time"
@@ -12,6 +13,17 @@ import (
 
 var TestHelper *testutil.TestHelper
 
+var (
+	tcpConnRE = regexp.MustCompile(
+		`tcp_open_total\{direction="outbound",peer="dst",target_addr="[0-9\.]+:[0-9]+",target_ip="[0-9\.]+",target_port="[0-9]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
+	)
+	httpReqRE = regexp.MustCompile(
+		`request_total\{direction="outbound",target_addr="[0-9\.]+:8080",target_ip="[0-9\.]+",target_port="[0-9\.]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
+	)
+	dgCmd = []string{"diagnostics", "proxy-metrics", "--namespace",
+		"linkerd-multicluster", "deploy/linkerd-gateway"}
+)
+
 func TestMain(m *testing.M) {
 	TestHelper = testutil.NewTestHelper()
 	os.Exit(m.Run())
@@ -48,3 +60,61 @@ func TestTargetTraffic(t *testing.T) {
 		testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd multicluster gateways' command timed-out (%s)", timeout), err)
 	}
 }
+
+// TestMulticlusterStatefulSetTargetTraffic will test that a statefulset can be
+// mirrored from a target cluster to a source cluster. The test deploys two
+// workloads: a slow cooker (as a client) in the src, and an nginx statefulset in
+// (as a server) in the tgt. The slow-cooker is configured to send traffic to an
+// nginx endpoint mirror (nginx-statefulset-0). The traffic should be received
+// by the nginx pod in the tgt. To assert this, we get proxy metrics from the
+// gateway to make sure our connections from the source cluster were routed
+// correctly.
+func TestMulticlusterStatefulSetTargetTraffic(t *testing.T) {
+	t.Run("expect open outbound TCP connection from gateway to nginx", func(t *testing.T) {
+		// Use a short time window so that slow-cooker can warm-up and send
+		// requests.
+		err := TestHelper.RetryFor(30*time.Second, func() error {
+			// Check gateway metrics
+			metrics, err := TestHelper.LinkerdRun(dgCmd...)
+			if err != nil {
+				return fmt.Errorf("failed to get metrics for gateway deployment: %w", err)
+			}
+
+			// If no match, it means there are no open tcp conns from gateway to
+			// nginx pod.
+			if !tcpConnRE.MatchString(metrics) {
+				return fmt.Errorf("failed to find expected TCP connection open outbound metric from gateway to nginx")
+			}
+
+			return nil
+		})
+
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "unexpected error", "unexpected error: %v", err)
+		}
+
+	})
+
+	t.Run("expect non-empty HTTP request metric from gateway to nginx", func(t *testing.T) {
+		// Use a short time window so that slow-cooker can warm-up and send
+		// requests.
+		err := TestHelper.RetryFor(30*time.Second, func() error {
+			// Check gateway metrics
+			metrics, err := TestHelper.LinkerdRun(dgCmd...)
+			if err != nil {
+				return fmt.Errorf("failed to get metrics for gateway deployment: %w", err)
+			}
+
+			// If no match, it means there are no outbound HTTP requests from
+			// gateway to nginx pod.
+			if !httpReqRE.MatchString(metrics) {
+				return fmt.Errorf("failed to find expected outbound HTTP request metric from gateway to nginx\nexpected: %s, got: %s", httpReqRE, metrics)
+			}
+			return nil
+		})
+
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "unexpected error", "unexpected error: %v", err)
+		}
+	})
+}
diff --git a/test/integration/upgrade-edge/testdata/check.upgrade.golden b/test/integration/upgrade-edge/testdata/check.upgrade.golden
new file mode 100644
index 000000000..f3bbfb38f
--- /dev/null
+++ b/test/integration/upgrade-edge/testdata/check.upgrade.golden
@@ -0,0 +1,108 @@
+Linkerd core checks
+===================
+
+kubernetes-api
+--------------
+√ can initialize the client
+√ can query the Kubernetes API
+
+kubernetes-version
+------------------
+√ is running the minimum Kubernetes API version
+√ is running the minimum kubectl version
+
+linkerd-existence
+-----------------
+√ 'linkerd-config' config map exists
+√ heartbeat ServiceAccount exist
+√ control plane replica sets are ready
+√ no unschedulable pods
+√ control plane pods are ready
+√ cluster networks can be verified
+√ cluster networks contains all node podCIDRs
+
+linkerd-config
+--------------
+√ control plane Namespace exists
+√ control plane ClusterRoles exist
+√ control plane ClusterRoleBindings exist
+√ control plane ServiceAccounts exist
+√ control plane CustomResourceDefinitions exist
+√ control plane MutatingWebhookConfigurations exist
+√ control plane ValidatingWebhookConfigurations exist
+√ proxy-init container runs as root user if docker container runtime is used
+
+linkerd-identity
+----------------
+√ certificate config is valid
+√ trust anchors are using supported crypto algorithm
+√ trust anchors are within their validity period
+√ trust anchors are valid for at least 60 days
+√ issuer cert is using supported crypto algorithm
+√ issuer cert is within its validity period
+√ issuer cert is valid for at least 60 days
+√ issuer cert is issued by the trust anchor
+
+linkerd-webhooks-and-apisvc-tls
+-------------------------------
+√ proxy-injector webhook has valid cert
+√ proxy-injector cert is valid for at least 60 days
+√ sp-validator webhook has valid cert
+√ sp-validator cert is valid for at least 60 days
+√ policy-validator webhook has valid cert
+√ policy-validator cert is valid for at least 60 days
+
+linkerd-identity-data-plane
+---------------------------
+√ data plane proxies certificate match CA
+
+linkerd-version
+---------------
+√ can determine the latest version
+√ cli is up-to-date
+
+linkerd-control-plane-proxy
+---------------------------
+√ control plane proxies are healthy
+√ control plane proxies are up-to-date
+√ control plane proxies and cli versions match
+
+linkerd-data-plane
+------------------
+√ data plane namespace exists
+√ data plane proxies are ready
+√ data plane is up-to-date
+√ data plane and cli versions match
+√ data plane pod labels are configured correctly
+√ data plane service labels are configured correctly
+√ data plane service annotations are configured correctly
+√ opaque ports are properly annotated
+
+Linkerd extensions checks
+=========================
+
+linkerd-viz
+-----------
+√ linkerd-viz Namespace exists
+√ linkerd-viz ClusterRoles exist
+√ linkerd-viz ClusterRoleBindings exist
+√ tap API server has valid cert
+√ tap API server cert is valid for at least 60 days
+√ tap API service is running
+√ linkerd-viz pods are injected
+√ viz extension pods are running
+√ viz extension proxies are healthy
+‼ viz extension proxies are up-to-date
+    {{.ProxyVersionErr}}
+    see {{.HintURL}}l5d-viz-proxy-cp-version for hints
+√ viz extension proxies and cli versions match
+√ prometheus is installed and configured correctly
+√ can initialize the client
+√ viz extension self-check
+
+linkerd-viz-data-plane
+----------------------
+√ data plane namespace exists
+√ data plane proxy metrics are present in Prometheus
+
+Status check results are √
diff --git a/test/integration/upgrade-edge/testdata/emoji.yaml b/test/integration/upgrade-edge/testdata/emoji.yaml
new file mode 100644
index 000000000..eefad5156
--- /dev/null
+++ b/test/integration/upgrade-edge/testdata/emoji.yaml
@@ -0,0 +1,157 @@
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: emoji
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: voting
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: web
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: emoji
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: emoji-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: emoji-svc
+    spec:
+      serviceAccountName: emoji
+      containers:
+      - env:
+        - name: GRPC_PORT
+          value: "8080"
+        image: buoyantio/emojivoto-emoji-svc:v10
+        name: emoji-svc
+        ports:
+        - containerPort: 8080
+          name: grpc
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: emoji-svc
+spec:
+  selector:
+    app: emoji-svc
+  clusterIP: None
+  ports:
+  - name: grpc
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: voting
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: voting-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: voting-svc
+    spec:
+      serviceAccountName: voting
+      containers:
+      - env:
+        - name: GRPC_PORT
+          value: "8080"
+        image: buoyantio/emojivoto-voting-svc:v10
+        name: voting-svc
+        ports:
+        - containerPort: 8080
+          name: grpc
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: voting-svc
+spec:
+  selector:
+    app: voting-svc
+  clusterIP: None
+  ports:
+  - name: grpc
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: web
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: web-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: web-svc
+    spec:
+      serviceAccountName: web
+      containers:
+      - env:
+        - name: WEB_PORT
+          value: "8080"
+        - name: EMOJISVC_HOST
+          value: emoji-svc:8080
+        - name: VOTINGSVC_HOST
+          value: voting-svc:8080
+        - name: INDEX_BUNDLE
+          value: dist/index_bundle.js
+        image: buoyantio/emojivoto-web:v10
+        name: web-svc
+        ports:
+        - containerPort: 8080
+          name: http
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: web-svc
+spec:
+  type: LoadBalancer
+  selector:
+    app: web-svc
+  ports:
+  - name: http
+    port: 80
+    targetPort: 8080
+---
diff --git a/test/integration/upgrade-edge/upgrade_edge_test.go b/test/integration/upgrade-edge/upgrade_edge_test.go
new file mode 100644
index 000000000..845bc516e
--- /dev/null
+++ b/test/integration/upgrade-edge/upgrade_edge_test.go
@@ -0,0 +1,540 @@
+package edgeupgradetest
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"reflect"
+	"runtime"
+	"strings"
+	"testing"
+	"text/template"
+	"time"
+
+	"github.com/linkerd/linkerd2/pkg/flags"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/tree"
+	"github.com/linkerd/linkerd2/pkg/version"
+	"github.com/linkerd/linkerd2/testutil"
+)
+
+//////////////////////
+///   TEST SETUP   ///
+//////////////////////
+var (
+	TestHelper *testutil.TestHelper
+
+	configMapUID string
+
+	linkerdSvcEdge = []testutil.Service{
+		{Namespace: "linkerd", Name: "linkerd-dst"},
+		{Namespace: "linkerd", Name: "linkerd-identity"},
+
+		{Namespace: "linkerd", Name: "linkerd-dst-headless"},
+		{Namespace: "linkerd", Name: "linkerd-identity-headless"},
+	}
+
+	// skippedInboundPorts lists some ports to be marked as skipped, which will
+	// be verified in test/integration/inject
+	skippedInboundPorts    = "1234,5678"
+	skippedOutboundPorts   = "1234,5678"
+	linkerdBaseEdgeVersion string
+)
+
+func TestMain(m *testing.M) {
+	TestHelper = testutil.NewTestHelper()
+	os.Exit(m.Run())
+}
+
+//////////////////////
+/// TEST EXECUTION ///
+//////////////////////
+func TestInstallResourcesPreUpgrade(t *testing.T) {
+	versions, err := TestHelper.GetReleaseChannelVersions()
+	if err != nil {
+		testutil.AnnotatedFatal(t, "failed to get the latest release channels versions", err)
+	}
+	linkerdBaseEdgeVersion = versions["edge"]
+
+	tmpDir, err := os.MkdirTemp("", "upgrade-cli")
+	if err != nil {
+		testutil.AnnotatedFatal(t, "failed to create temp dir", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	cliPath := fmt.Sprintf("%s/linkerd2-cli-%s-%s-%s", tmpDir, linkerdBaseEdgeVersion, runtime.GOOS, runtime.GOARCH)
+	if err := TestHelper.DownloadCLIBinary(cliPath, linkerdBaseEdgeVersion); err != nil {
+		testutil.AnnotatedFatal(t, "failed to fetch cli executable", err)
+	}
+
+	// Nest all pre-upgrade tests here so they can install and check resources
+	// using the latest edge CLI
+	t.Run(fmt.Sprintf("installing Linkerd %s control plane", linkerdBaseEdgeVersion), func(t *testing.T) {
+		args := []string{
+			"install",
+			"--controller-log-level", "debug",
+			"--set", "proxyInit.ignoreInboundPorts=1234\\,5678",
+		}
+
+		// Pipe cmd & args to `linkerd`
+		out, err := TestHelper.CmdRun(cliPath, args...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'linkerd install' command failed", "'linkerd install' command failed:\n%v", err)
+		}
+
+		out, err = TestHelper.KubectlApply(out, "")
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+				"'kubectl apply' command failed\n%s", out)
+		}
+
+		TestHelper.WaitRollout(t, testutil.LinkerdDeployReplicasEdge)
+	})
+
+	// TestInstallViz will install the viz extension to be used by the rest of the
+	// tests in the viz suite
+	t.Run(fmt.Sprintf("installing Linkerd %s viz extension", linkerdBaseEdgeVersion), func(t *testing.T) {
+		args := []string{
+			"viz",
+			"install",
+			"--set", fmt.Sprintf("namespace=%s", TestHelper.GetVizNamespace()),
+		}
+
+		out, err := TestHelper.CmdRun(cliPath, args...)
+		if err != nil {
+			testutil.AnnotatedFatal(t, "'linkerd viz install' command failed", err)
+		}
+
+		out, err = TestHelper.KubectlApplyWithArgs(out)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+				"'kubectl apply' command failed\n%s", out)
+		}
+
+		TestHelper.WaitRollout(t, testutil.LinkerdVizDeployReplicas)
+		TestHelper.AddInstalledExtension("viz")
+
+	})
+
+	// Check client and server versions are what we expect them to be
+	t.Run(fmt.Sprintf("check version is %s pre-upgrade", linkerdBaseEdgeVersion), func(t *testing.T) {
+		out, err := TestHelper.CmdRun(cliPath, "version")
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\n%s", err.Error())
+		}
+
+		if !strings.Contains(out, fmt.Sprintf("Client version: %s", linkerdBaseEdgeVersion)) {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\nexpected client version: %s, got: %s", linkerdBaseEdgeVersion, out)
+		}
+		if !strings.Contains(out, fmt.Sprintf("Server version: %s", linkerdBaseEdgeVersion)) {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\nexpected server version: %s, got: %s", linkerdBaseEdgeVersion, out)
+		}
+	})
+}
+
+func TestUpgradeTestAppWorksBeforeUpgrade(t *testing.T) {
+	ctx := context.Background()
+	testAppNamespace := "upgrade-test"
+
+	// create namespace, and install test app
+	if err := TestHelper.CreateDataPlaneNamespaceIfNotExists(ctx, testAppNamespace, map[string]string{k8s.ProxyInjectAnnotation: "enabled"}); err != nil {
+		testutil.AnnotatedFatalf(t, "failed to create namespace", "failed to create namespace %s: %s", testAppNamespace, err)
+	}
+
+	if _, err := TestHelper.Kubectl("", "apply", "-f", "./testdata/emoji.yaml", "-n", testAppNamespace); err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' failed", "'kubectl apply' failed: %s", err)
+	}
+
+	// make sure app is running
+	for _, deploy := range []string{"emoji", "voting", "web"} {
+		if err := TestHelper.CheckPods(ctx, testAppNamespace, deploy, 1); err != nil {
+			var rce *testutil.RestartCountError
+			if errors.As(err, &rce) {
+				testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+			} else {
+				testutil.AnnotatedError(t, "CheckPods timed-out", err)
+			}
+		}
+	}
+
+	if err := testutil.ExerciseTestAppEndpoint("/api/list", testAppNamespace, TestHelper); err != nil {
+		testutil.AnnotatedFatalf(t, "error exercising test app endpoint before upgrade",
+			"error exercising test app endpoint before upgrade %s", err)
+	}
+}
+
+func TestRetrieveUidPreUpgrade(t *testing.T) {
+	var err error
+	configMapUID, err = TestHelper.KubernetesHelper.GetConfigUID(context.Background(), TestHelper.GetLinkerdNamespace())
+	if err != nil || configMapUID == "" {
+		testutil.AnnotatedFatalf(t, "error retrieving linkerd-config's uid",
+			"error retrieving linkerd-config's uid: %s", err)
+	}
+}
+
+func TestUpgradeCli(t *testing.T) {
+	cmd := "upgrade"
+	args := []string{
+		"--controller-log-level", "debug",
+		"--set", "proxyInit.ignoreInboundPorts=1234\\,5678",
+		"--set", "heartbeatSchedule=1 2 3 4 5",
+	}
+
+	// test 2-stage install during upgrade
+	out, err := TestHelper.LinkerdRun(cmd, "config")
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd upgrade config' command failed", err)
+	}
+
+	// apply stage 1
+	// Limit the pruning only to known resources
+	// that we intend to be delete in this stage to prevent it
+	// from deleting other resources that have the
+	// label
+	out, err = TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/control-plane-ns=linkerd",
+		"--prune-whitelist", "rbac.authorization.k8s.io/v1/clusterrole",
+		"--prune-whitelist", "rbac.authorization.k8s.io/v1/clusterrolebinding",
+		"--prune-whitelist", "apiregistration.k8s.io/v1/apiservice",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"kubectl apply command failed\n%s", out)
+	}
+
+	// prepare for stage 2
+	args = append([]string{"control-plane"}, args...)
+	args = append(args, "--set", "proxyInit.ignoreOutboundPorts=1234\\,5678")
+	exec := append([]string{cmd}, args...)
+	out, err = TestHelper.LinkerdRun(exec...)
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd upgrade' command failed", err)
+	}
+
+	// test `linkerd upgrade --from-manifests`
+	kubeArgs := append([]string{"--namespace", TestHelper.GetLinkerdNamespace(), "get"}, "configmaps", "-oyaml")
+	configManifests, err := TestHelper.Kubectl("", kubeArgs...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl get' command failed",
+			"'kubectl get' command failed with %s\n%s\n%s", err, configManifests, kubeArgs)
+	}
+
+	kubeArgs = append([]string{"--namespace", TestHelper.GetLinkerdNamespace(), "get"}, "secrets", "-oyaml")
+	secretManifests, err := TestHelper.Kubectl("", kubeArgs...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl get' command failed",
+			"'kubectl get' command failed with %s\n%s\n%s", err, secretManifests, kubeArgs)
+	}
+
+	manifests := configManifests + "---\n" + secretManifests
+
+	exec = append(exec, "--from-manifests", "-")
+	upgradeFromManifests, stderr, err := TestHelper.PipeToLinkerdRun(manifests, exec...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'linkerd upgrade --from-manifests' command failed",
+			"'linkerd upgrade --from-manifests' command failed with %s\n%s\n%s\n%s", err, stderr, upgradeFromManifests, manifests)
+	}
+
+	if out != upgradeFromManifests {
+		// retry in case it's just a discrepancy in the heartbeat cron schedule
+		exec := append([]string{cmd}, args...)
+		out, err := TestHelper.LinkerdRun(exec...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, fmt.Sprintf("command failed: %v", exec),
+				"command failed: %v\n%s\n%s", exec, out, stderr)
+		}
+
+		if out != upgradeFromManifests {
+			testutil.AnnotatedFatalf(t, "manifest upgrade differs from k8s upgrade",
+				"manifest upgrade differs from k8s upgrade.\nk8s upgrade:\n%s\nmanifest upgrade:\n%s", out, upgradeFromManifests)
+		}
+	}
+
+	// Limit the pruning only to known resources
+	// that we intend to be delete in this stage to prevent it
+	// from deleting other resources that have the
+	// label
+	cmdOut, err := TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/control-plane-ns=linkerd",
+		"--prune-whitelist", "apps/v1/deployment",
+		"--prune-whitelist", "core/v1/service",
+		"--prune-whitelist", "core/v1/configmap",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"'kubectl apply' command failed\n%s", cmdOut)
+	}
+
+	TestHelper.WaitRollout(t, testutil.LinkerdDeployReplicasEdge)
+
+	// It is necessary to clone LinkerdVizDeployReplicas so that we do not
+	// mutate its original value.
+	expectedDeployments := make(map[string]testutil.DeploySpec)
+	for k, v := range testutil.LinkerdVizDeployReplicas {
+		expectedDeployments[k] = v
+	}
+
+	// Install Linkerd Viz Extension
+	vizCmd := []string{
+		"viz",
+		"install",
+		"--set", fmt.Sprintf("namespace=%s", TestHelper.GetVizNamespace()),
+	}
+	out, err = TestHelper.LinkerdRun(vizCmd...)
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd viz install' command failed", err)
+	}
+
+	out, err = TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/extension=viz",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"'kubectl apply' command failed\n%s", out)
+	}
+
+	TestHelper.WaitRollout(t, expectedDeployments)
+
+}
+
+func TestControlPlaneResourcesPostInstall(t *testing.T) {
+	expectedDeployments := testutil.LinkerdDeployReplicasEdge
+	expectedServices := linkerdSvcEdge
+	vizServices := []testutil.Service{
+		{Namespace: "linkerd-viz", Name: "web"},
+		{Namespace: "linkerd-viz", Name: "tap"},
+		{Namespace: "linkerd-viz", Name: "prometheus"},
+	}
+	expectedServices = append(expectedServices, vizServices...)
+	expectedDeployments["prometheus"] = testutil.DeploySpec{Namespace: "linkerd-viz", Replicas: 1}
+
+	testutil.TestResourcesPostInstall(TestHelper.GetLinkerdNamespace(), expectedServices, expectedDeployments, TestHelper, t)
+}
+
+func TestRetrieveUidPostUpgrade(t *testing.T) {
+	newConfigMapUID, err := TestHelper.KubernetesHelper.GetConfigUID(context.Background(), TestHelper.GetLinkerdNamespace())
+	if err != nil || newConfigMapUID == "" {
+		testutil.AnnotatedFatalf(t, "error retrieving linkerd-config's uid",
+			"error retrieving linkerd-config's uid: %s", err)
+	}
+	if configMapUID != newConfigMapUID {
+		testutil.AnnotatedFatalf(t, "linkerd-config's uid after upgrade doesn't match its value before the upgrade",
+			"linkerd-config's uid after upgrade [%s] doesn't match its value before the upgrade [%s]",
+			newConfigMapUID, configMapUID,
+		)
+	}
+}
+
+func TestOverridesSecret(t *testing.T) {
+	configOverridesSecret, err := TestHelper.KubernetesHelper.GetSecret(context.Background(), TestHelper.GetLinkerdNamespace(), "linkerd-config-overrides")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "could not retrieve linkerd-config-overrides",
+			"could not retrieve linkerd-config-overrides\n%s", err)
+	}
+
+	overrides := configOverridesSecret.Data["linkerd-config-overrides"]
+	overridesTree, err := tree.BytesToTree(overrides)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "could not retrieve linkerd-config-overrides",
+			"could not retrieve linkerd-config-overrides\n%s", err)
+	}
+
+	// Check for fields that were added during install
+	testCases := []struct {
+		path  []string
+		value string
+	}{
+		{
+			[]string{"controllerLogLevel"},
+			"debug",
+		},
+		{
+			[]string{"proxyInit", "ignoreInboundPorts"},
+			skippedInboundPorts,
+		},
+		{
+			[]string{"proxyInit", "ignoreOutboundPorts"},
+			skippedOutboundPorts,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc // pin
+		t.Run(fmt.Sprintf("%s: %s", strings.Join(tc.path, "/"), tc.value), func(t *testing.T) {
+			finalValue, err := overridesTree.GetString(tc.path...)
+			if err != nil {
+				testutil.AnnotatedFatalf(t, "could not perform tree.GetString",
+					"could not perform tree.GetString\n%s", err)
+			}
+
+			if tc.value != finalValue {
+				testutil.AnnotatedFatalf(t, fmt.Sprintf("Values at path %s do not match", strings.Join(tc.path, "/")),
+					"Expected value at [%s] to be [%s] but received [%s]",
+					strings.Join(tc.path, "/"), tc.value, finalValue)
+			}
+		})
+	}
+
+	extractValue := func(t *testing.T, path ...string) string {
+		val, err := overridesTree.GetString(path...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error calling overridesTree.GetString()",
+				"error calling overridesTree.GetString(): %s", err)
+			return ""
+
+		}
+		return val
+	}
+
+	t.Run("Check if any unknown fields snuck in", func(t *testing.T) {
+		knownKeys := tree.Tree{
+			"controllerLogLevel": "debug",
+			"heartbeatSchedule":  "1 2 3 4 5",
+			"identity": tree.Tree{
+				"issuer": tree.Tree{
+					"tls": tree.Tree{
+						"crtPEM": extractValue(t, "identity", "issuer", "tls", "crtPEM"),
+						"keyPEM": extractValue(t, "identity", "issuer", "tls", "keyPEM"),
+					},
+				},
+			},
+			"identityTrustAnchorsPEM": extractValue(t, "identityTrustAnchorsPEM"),
+			"proxyInit": tree.Tree{
+				"ignoreInboundPorts":  skippedInboundPorts,
+				"ignoreOutboundPorts": skippedOutboundPorts,
+			},
+		}
+
+		if reg := os.Getenv(flags.EnvOverrideDockerRegistry); reg != "" {
+			knownKeys["controllerImage"] = reg + "/controller"
+			knownKeys["debugContainer"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/debug",
+				},
+			}
+			knownKeys["policyController"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/policy-controller",
+				},
+			}
+			knownKeys["proxy"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/proxy",
+				},
+			}
+			knownKeys["proxyInit"].(tree.Tree)["image"] = tree.Tree{
+				"name": reg + "/proxy-init",
+			}
+
+		}
+
+		// Check if the keys in overridesTree match with knownKeys
+		if !reflect.DeepEqual(overridesTree.String(), knownKeys.String()) {
+			testutil.AnnotatedFatalf(t, "Overrides and knownKeys are different",
+				"Expected overrides to be [%s] but found [%s]",
+				knownKeys.String(), overridesTree.String(),
+			)
+		}
+	})
+}
+
+func TestVersionPostInstall(t *testing.T) {
+	err := TestHelper.CheckVersion(TestHelper.GetVersion())
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "Version command failed",
+			"Version command failed\n%s", err.Error())
+	}
+}
+
+func TestCheckProxyPostUpgrade(t *testing.T) {
+	cmd := []string{
+		"check", "--proxy", "-n", TestHelper.GetLinkerdNamespace(),
+		"--expected-version", TestHelper.GetVersion(),
+		"--wait=60m",
+	}
+
+	expected := getCheckOutput(t, "check.upgrade.golden", TestHelper.GetVizNamespace())
+	// Check output is non-deterministic for proxies that are not running the
+	// current version. This tends to cause a mismatch between the expected
+	// output (which is templated) and the actual output. We add a retry to "eventually"
+	// get a match
+	err := TestHelper.RetryFor(5*time.Minute, func() error {
+		out, err := TestHelper.LinkerdRun(cmd...)
+		if err != nil {
+			return fmt.Errorf("%w\n%s", err, out)
+		}
+
+		if !strings.Contains(out, expected) {
+			return fmt.Errorf("expected: %s\nactual: %s", expected, out)
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'linkerd check' command timed-out", "'linkerd check' command timed-out\n%v", err)
+	}
+}
+
+func TestUpgradeTestAppWorksAfterUpgrade(t *testing.T) {
+	testAppNamespace := "upgrade-test"
+
+	// Restart pods after upgrade to make sure they're re-injected with the
+	// latest proxy
+	if _, err := TestHelper.Kubectl("", "rollout", "restart", "deploy", "-n", testAppNamespace); err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl rollout' failed", "'kubectl rollout' failed: %s", err)
+	}
+
+	// make sure app is running before proceeding
+	ctx := context.Background()
+	for _, deploy := range []string{"emoji", "voting", "web"} {
+		if err := TestHelper.CheckPods(ctx, testAppNamespace, deploy, 1); err != nil {
+			var rce *testutil.RestartCountError
+			if errors.As(err, &rce) {
+				testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+			} else {
+				testutil.AnnotatedError(t, "CheckPods timed-out", err)
+			}
+		}
+	}
+
+	if err := testutil.ExerciseTestAppEndpoint("/api/vote?choice=:policeman:", testAppNamespace, TestHelper); err != nil {
+		testutil.AnnotatedFatalf(t, "error exercising test app endpoint after upgrade",
+			"error exercising test app endpoint after upgrade %s", err)
+	}
+}
+
+func getCheckOutput(t *testing.T, goldenFile string, namespace string) string {
+	pods, err := TestHelper.KubernetesHelper.GetPods(context.Background(), namespace, nil)
+	if err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("failed to retrieve pods: %s", err), err)
+	}
+
+	proxyVersionErr := ""
+	err = healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{})
+	if err != nil {
+		proxyVersionErr = err.Error()
+	}
+
+	tpl := template.Must(template.ParseFiles("testdata" + "/" + goldenFile))
+	vars := struct {
+		ProxyVersionErr string
+		HintURL         string
+	}{
+		proxyVersionErr,
+		healthcheck.HintBaseURL(TestHelper.GetVersion()),
+	}
+
+	var expected bytes.Buffer
+	if err := tpl.Execute(&expected, vars); err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("failed to parse %s template: %s", goldenFile, err), err)
+	}
+
+	return expected.String()
+}
diff --git a/test/integration/upgrade-stable/testdata/check.upgrade.golden b/test/integration/upgrade-stable/testdata/check.upgrade.golden
new file mode 100644
index 000000000..f3bbfb38f
--- /dev/null
+++ b/test/integration/upgrade-stable/testdata/check.upgrade.golden
@@ -0,0 +1,108 @@
+Linkerd core checks
+===================
+
+kubernetes-api
+--------------
+√ can initialize the client
+√ can query the Kubernetes API
+
+kubernetes-version
+------------------
+√ is running the minimum Kubernetes API version
+√ is running the minimum kubectl version
+
+linkerd-existence
+-----------------
+√ 'linkerd-config' config map exists
+√ heartbeat ServiceAccount exist
+√ control plane replica sets are ready
+√ no unschedulable pods
+√ control plane pods are ready
+√ cluster networks can be verified
+√ cluster networks contains all node podCIDRs
+
+linkerd-config
+--------------
+√ control plane Namespace exists
+√ control plane ClusterRoles exist
+√ control plane ClusterRoleBindings exist
+√ control plane ServiceAccounts exist
+√ control plane CustomResourceDefinitions exist
+√ control plane MutatingWebhookConfigurations exist
+√ control plane ValidatingWebhookConfigurations exist
+√ proxy-init container runs as root user if docker container runtime is used
+
+linkerd-identity
+----------------
+√ certificate config is valid
+√ trust anchors are using supported crypto algorithm
+√ trust anchors are within their validity period
+√ trust anchors are valid for at least 60 days
+√ issuer cert is using supported crypto algorithm
+√ issuer cert is within its validity period
+√ issuer cert is valid for at least 60 days
+√ issuer cert is issued by the trust anchor
+
+linkerd-webhooks-and-apisvc-tls
+-------------------------------
+√ proxy-injector webhook has valid cert
+√ proxy-injector cert is valid for at least 60 days
+√ sp-validator webhook has valid cert
+√ sp-validator cert is valid for at least 60 days
+√ policy-validator webhook has valid cert
+√ policy-validator cert is valid for at least 60 days
+
+linkerd-identity-data-plane
+---------------------------
+√ data plane proxies certificate match CA
+
+linkerd-version
+---------------
+√ can determine the latest version
+√ cli is up-to-date
+
+linkerd-control-plane-proxy
+---------------------------
+√ control plane proxies are healthy
+√ control plane proxies are up-to-date
+√ control plane proxies and cli versions match
+
+linkerd-data-plane
+------------------
+√ data plane namespace exists
+√ data plane proxies are ready
+√ data plane is up-to-date
+√ data plane and cli versions match
+√ data plane pod labels are configured correctly
+√ data plane service labels are configured correctly
+√ data plane service annotations are configured correctly
+√ opaque ports are properly annotated
+
+Linkerd extensions checks
+=========================
+
+linkerd-viz
+-----------
+√ linkerd-viz Namespace exists
+√ linkerd-viz ClusterRoles exist
+√ linkerd-viz ClusterRoleBindings exist
+√ tap API server has valid cert
+√ tap API server cert is valid for at least 60 days
+√ tap API service is running
+√ linkerd-viz pods are injected
+√ viz extension pods are running
+√ viz extension proxies are healthy
+‼ viz extension proxies are up-to-date
+    {{.ProxyVersionErr}}
+    see {{.HintURL}}l5d-viz-proxy-cp-version for hints
+√ viz extension proxies and cli versions match
+√ prometheus is installed and configured correctly
+√ can initialize the client
+√ viz extension self-check
+
+linkerd-viz-data-plane
+----------------------
+√ data plane namespace exists
+√ data plane proxy metrics are present in Prometheus
+
+Status check results are √
diff --git a/test/integration/upgrade-stable/testdata/emoji.yaml b/test/integration/upgrade-stable/testdata/emoji.yaml
new file mode 100644
index 000000000..eefad5156
--- /dev/null
+++ b/test/integration/upgrade-stable/testdata/emoji.yaml
@@ -0,0 +1,157 @@
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: emoji
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: voting
+---
+kind: ServiceAccount
+apiVersion: v1
+metadata:
+  name: web
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: emoji
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: emoji-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: emoji-svc
+    spec:
+      serviceAccountName: emoji
+      containers:
+      - env:
+        - name: GRPC_PORT
+          value: "8080"
+        image: buoyantio/emojivoto-emoji-svc:v10
+        name: emoji-svc
+        ports:
+        - containerPort: 8080
+          name: grpc
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: emoji-svc
+spec:
+  selector:
+    app: emoji-svc
+  clusterIP: None
+  ports:
+  - name: grpc
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: voting
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: voting-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: voting-svc
+    spec:
+      serviceAccountName: voting
+      containers:
+      - env:
+        - name: GRPC_PORT
+          value: "8080"
+        image: buoyantio/emojivoto-voting-svc:v10
+        name: voting-svc
+        ports:
+        - containerPort: 8080
+          name: grpc
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: voting-svc
+spec:
+  selector:
+    app: voting-svc
+  clusterIP: None
+  ports:
+  - name: grpc
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  name: web
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: web-svc
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        app: web-svc
+    spec:
+      serviceAccountName: web
+      containers:
+      - env:
+        - name: WEB_PORT
+          value: "8080"
+        - name: EMOJISVC_HOST
+          value: emoji-svc:8080
+        - name: VOTINGSVC_HOST
+          value: voting-svc:8080
+        - name: INDEX_BUNDLE
+          value: dist/index_bundle.js
+        image: buoyantio/emojivoto-web:v10
+        name: web-svc
+        ports:
+        - containerPort: 8080
+          name: http
+        resources:
+          requests:
+            cpu: 100m
+status: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: web-svc
+spec:
+  type: LoadBalancer
+  selector:
+    app: web-svc
+  ports:
+  - name: http
+    port: 80
+    targetPort: 8080
+---
diff --git a/test/integration/upgrade-stable/upgrade_stable_test.go b/test/integration/upgrade-stable/upgrade_stable_test.go
new file mode 100644
index 000000000..960f98a8c
--- /dev/null
+++ b/test/integration/upgrade-stable/upgrade_stable_test.go
@@ -0,0 +1,540 @@
+package stableupgradetest
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"reflect"
+	"runtime"
+	"strings"
+	"testing"
+	"text/template"
+	"time"
+
+	"github.com/linkerd/linkerd2/pkg/flags"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/tree"
+	"github.com/linkerd/linkerd2/pkg/version"
+	"github.com/linkerd/linkerd2/testutil"
+)
+
+//////////////////////
+///   TEST SETUP   ///
+//////////////////////
+var (
+	TestHelper *testutil.TestHelper
+
+	configMapUID string
+
+	linkerdSvcStable = []testutil.Service{
+		{Namespace: "linkerd", Name: "linkerd-dst"},
+		{Namespace: "linkerd", Name: "linkerd-identity"},
+
+		{Namespace: "linkerd", Name: "linkerd-dst-headless"},
+		{Namespace: "linkerd", Name: "linkerd-identity-headless"},
+	}
+
+	// skippedInboundPorts lists some ports to be marked as skipped, which will
+	// be verified in test/integration/inject
+	skippedInboundPorts      = "1234,5678"
+	skippedOutboundPorts     = "1234,5678"
+	linkerdBaseStableVersion string
+)
+
+func TestMain(m *testing.M) {
+	TestHelper = testutil.NewTestHelper()
+	os.Exit(m.Run())
+}
+
+//////////////////////
+/// TEST EXECUTION ///
+//////////////////////
+func TestInstallResourcesPreUpgrade(t *testing.T) {
+	versions, err := TestHelper.GetReleaseChannelVersions()
+	if err != nil {
+		testutil.AnnotatedFatal(t, "failed to get the latest release channels versions", err)
+	}
+	linkerdBaseStableVersion = versions["stable"]
+
+	tmpDir, err := os.MkdirTemp("", "upgrade-cli")
+	if err != nil {
+		testutil.AnnotatedFatal(t, "failed to create temp dir", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	cliPath := fmt.Sprintf("%s/linkerd2-cli-%s-%s-%s", tmpDir, linkerdBaseStableVersion, runtime.GOOS, runtime.GOARCH)
+	if err := TestHelper.DownloadCLIBinary(cliPath, linkerdBaseStableVersion); err != nil {
+		testutil.AnnotatedFatal(t, "failed to fetch cli executable", err)
+	}
+
+	// Nest all pre-upgrade tests here so they can install and check resources
+	// using the latest stable CLI
+	t.Run(fmt.Sprintf("installing Linkerd %s control plane", linkerdBaseStableVersion), func(t *testing.T) {
+		args := []string{
+			"install",
+			"--controller-log-level", "debug",
+			"--set", "proxyInit.ignoreInboundPorts=1234\\,5678",
+		}
+
+		// Pipe cmd & args to `linkerd`
+		out, err := TestHelper.CmdRun(cliPath, args...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'linkerd install' command failed", "'linkerd install' command failed:\n%v", err)
+		}
+
+		out, err = TestHelper.KubectlApply(out, "")
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+				"'kubectl apply' command failed\n%s", out)
+		}
+
+		TestHelper.WaitRollout(t, testutil.LinkerdDeployReplicasStable)
+	})
+
+	// TestInstallViz will install the viz extension to be used by the rest of the
+	// tests in the viz suite
+	t.Run(fmt.Sprintf("installing Linkerd %s viz extension", linkerdBaseStableVersion), func(t *testing.T) {
+		args := []string{
+			"viz",
+			"install",
+			"--set", fmt.Sprintf("namespace=%s", TestHelper.GetVizNamespace()),
+		}
+
+		out, err := TestHelper.CmdRun(cliPath, args...)
+		if err != nil {
+			testutil.AnnotatedFatal(t, "'linkerd viz install' command failed", err)
+		}
+
+		out, err = TestHelper.KubectlApplyWithArgs(out)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+				"'kubectl apply' command failed\n%s", out)
+		}
+
+		TestHelper.WaitRollout(t, testutil.LinkerdVizDeployReplicas)
+		TestHelper.AddInstalledExtension("viz")
+
+	})
+
+	// Check client and server versions are what we expect them to be
+	t.Run(fmt.Sprintf("check version is %s pre-upgrade", linkerdBaseStableVersion), func(t *testing.T) {
+		out, err := TestHelper.CmdRun(cliPath, "version")
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\n%s", err.Error())
+		}
+
+		if !strings.Contains(out, fmt.Sprintf("Client version: %s", linkerdBaseStableVersion)) {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\nexpected client version: %s, got: %s", linkerdBaseStableVersion, out)
+		}
+		if !strings.Contains(out, fmt.Sprintf("Server version: %s", linkerdBaseStableVersion)) {
+			testutil.AnnotatedFatalf(t, "'linkerd version' command failed", "'linkerd version' command failed\nexpected server version: %s, got: %s", linkerdBaseStableVersion, out)
+		}
+	})
+}
+
+func TestUpgradeTestAppWorksBeforeUpgrade(t *testing.T) {
+	ctx := context.Background()
+	testAppNamespace := "upgrade-test"
+
+	// create namespace and install test app
+	if err := TestHelper.CreateDataPlaneNamespaceIfNotExists(ctx, testAppNamespace, map[string]string{k8s.ProxyInjectAnnotation: "enabled"}); err != nil {
+		testutil.AnnotatedFatalf(t, "failed to create namespace", "failed to create namespace %s: %s", testAppNamespace, err)
+	}
+
+	if _, err := TestHelper.Kubectl("", "apply", "-f", "./testdata/emoji.yaml", "-n", testAppNamespace); err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl' apply failed", "'kubectl apply' failed: %s", err)
+	}
+
+	// make sure app is running
+	for _, deploy := range []string{"emoji", "voting", "web"} {
+		if err := TestHelper.CheckPods(ctx, testAppNamespace, deploy, 1); err != nil {
+			var rce *testutil.RestartCountError
+			if errors.As(err, &rce) {
+				testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+			} else {
+				testutil.AnnotatedError(t, "CheckPods timed-out", err)
+			}
+		}
+	}
+
+	if err := testutil.ExerciseTestAppEndpoint("/api/list", testAppNamespace, TestHelper); err != nil {
+		testutil.AnnotatedFatalf(t, "error exercising test app endpoint before upgrade",
+			"error exercising test app endpoint before upgrade %s", err)
+	}
+}
+
+func TestRetrieveUidPreUpgrade(t *testing.T) {
+	var err error
+	configMapUID, err = TestHelper.KubernetesHelper.GetConfigUID(context.Background(), TestHelper.GetLinkerdNamespace())
+	if err != nil || configMapUID == "" {
+		testutil.AnnotatedFatalf(t, "error retrieving linkerd-config's uid",
+			"error retrieving linkerd-config's uid: %s", err)
+	}
+}
+
+func TestUpgradeCli(t *testing.T) {
+	cmd := "upgrade"
+	args := []string{
+		"--controller-log-level", "debug",
+		"--set", "proxyInit.ignoreInboundPorts=1234\\,5678",
+		"--set", "heartbeatSchedule=1 2 3 4 5",
+	}
+
+	// test 2-stage install during upgrade
+	out, err := TestHelper.LinkerdRun(cmd, "config")
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd upgrade config' command failed", err)
+	}
+
+	// apply stage 1
+	// Limit the pruning only to known resources
+	// that we intend to be delete in this stage to prevent it
+	// from deleting other resources that have the
+	// label
+	out, err = TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/control-plane-ns=linkerd",
+		"--prune-whitelist", "rbac.authorization.k8s.io/v1/clusterrole",
+		"--prune-whitelist", "rbac.authorization.k8s.io/v1/clusterrolebinding",
+		"--prune-whitelist", "apiregistration.k8s.io/v1/apiservice",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"kubectl apply command failed\n%s", out)
+	}
+
+	// prepare for stage 2
+	args = append([]string{"control-plane"}, args...)
+	args = append(args, "--set", "proxyInit.ignoreOutboundPorts=1234\\,5678")
+	exec := append([]string{cmd}, args...)
+	out, err = TestHelper.LinkerdRun(exec...)
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd upgrade' command failed", err)
+	}
+
+	// test `linkerd upgrade --from-manifests`
+	kubeArgs := append([]string{"--namespace", TestHelper.GetLinkerdNamespace(), "get"}, "configmaps", "-oyaml")
+	configManifests, err := TestHelper.Kubectl("", kubeArgs...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl get' command failed",
+			"'kubectl get' command failed with %s\n%s\n%s", err, configManifests, kubeArgs)
+	}
+
+	kubeArgs = append([]string{"--namespace", TestHelper.GetLinkerdNamespace(), "get"}, "secrets", "-oyaml")
+	secretManifests, err := TestHelper.Kubectl("", kubeArgs...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl get' command failed",
+			"'kubectl get' command failed with %s\n%s\n%s", err, secretManifests, kubeArgs)
+	}
+
+	manifests := configManifests + "---\n" + secretManifests
+
+	exec = append(exec, "--from-manifests", "-")
+	upgradeFromManifests, stderr, err := TestHelper.PipeToLinkerdRun(manifests, exec...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'linkerd upgrade --from-manifests' command failed",
+			"'linkerd upgrade --from-manifests' command failed with %s\n%s\n%s\n%s", err, stderr, upgradeFromManifests, manifests)
+	}
+
+	if out != upgradeFromManifests {
+		// retry in case it's just a discrepancy in the heartbeat cron schedule
+		exec := append([]string{cmd}, args...)
+		out, err := TestHelper.LinkerdRun(exec...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, fmt.Sprintf("command failed: %v", exec),
+				"command failed: %v\n%s\n%s", exec, out, stderr)
+		}
+
+		if out != upgradeFromManifests {
+			testutil.AnnotatedFatalf(t, "manifest upgrade differs from k8s upgrade",
+				"manifest upgrade differs from k8s upgrade.\nk8s upgrade:\n%s\nmanifest upgrade:\n%s", out, upgradeFromManifests)
+		}
+	}
+
+	// Limit the pruning only to known resources
+	// that we intend to be delete in this stage to prevent it
+	// from deleting other resources that have the
+	// label
+	cmdOut, err := TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/control-plane-ns=linkerd",
+		"--prune-whitelist", "apps/v1/deployment",
+		"--prune-whitelist", "core/v1/service",
+		"--prune-whitelist", "core/v1/configmap",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"'kubectl apply' command failed\n%s", cmdOut)
+	}
+
+	TestHelper.WaitRollout(t, testutil.LinkerdDeployReplicasStable)
+
+	// It is necessary to clone LinkerdVizDeployReplicas so that we do not
+	// mutate its original value.
+	expectedDeployments := make(map[string]testutil.DeploySpec)
+	for k, v := range testutil.LinkerdVizDeployReplicas {
+		expectedDeployments[k] = v
+	}
+
+	// Install Linkerd Viz Extension
+	vizCmd := []string{
+		"viz",
+		"install",
+		"--set", fmt.Sprintf("namespace=%s", TestHelper.GetVizNamespace()),
+	}
+	out, err = TestHelper.LinkerdRun(vizCmd...)
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd viz install' command failed", err)
+	}
+
+	out, err = TestHelper.KubectlApplyWithArgs(out, []string{
+		"--prune",
+		"-l", "linkerd.io/extension=viz",
+	}...)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"'kubectl apply' command failed\n%s", out)
+	}
+
+	TestHelper.WaitRollout(t, expectedDeployments)
+
+}
+
+func TestControlPlaneResourcesPostInstall(t *testing.T) {
+	expectedDeployments := testutil.LinkerdDeployReplicasStable
+	expectedServices := linkerdSvcStable
+	vizServices := []testutil.Service{
+		{Namespace: "linkerd-viz", Name: "web"},
+		{Namespace: "linkerd-viz", Name: "tap"},
+		{Namespace: "linkerd-viz", Name: "prometheus"},
+	}
+	expectedServices = append(expectedServices, vizServices...)
+	expectedDeployments["prometheus"] = testutil.DeploySpec{Namespace: "linkerd-viz", Replicas: 1}
+
+	testutil.TestResourcesPostInstall(TestHelper.GetLinkerdNamespace(), expectedServices, expectedDeployments, TestHelper, t)
+}
+
+func TestRetrieveUidPostUpgrade(t *testing.T) {
+	newConfigMapUID, err := TestHelper.KubernetesHelper.GetConfigUID(context.Background(), TestHelper.GetLinkerdNamespace())
+	if err != nil || newConfigMapUID == "" {
+		testutil.AnnotatedFatalf(t, "error retrieving linkerd-config's uid",
+			"error retrieving linkerd-config's uid: %s", err)
+	}
+	if configMapUID != newConfigMapUID {
+		testutil.AnnotatedFatalf(t, "linkerd-config's uid after upgrade doesn't match its value before the upgrade",
+			"linkerd-config's uid after upgrade [%s] doesn't match its value before the upgrade [%s]",
+			newConfigMapUID, configMapUID,
+		)
+	}
+}
+
+func TestOverridesSecret(t *testing.T) {
+	configOverridesSecret, err := TestHelper.KubernetesHelper.GetSecret(context.Background(), TestHelper.GetLinkerdNamespace(), "linkerd-config-overrides")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "could not retrieve linkerd-config-overrides",
+			"could not retrieve linkerd-config-overrides\n%s", err)
+	}
+
+	overrides := configOverridesSecret.Data["linkerd-config-overrides"]
+	overridesTree, err := tree.BytesToTree(overrides)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "could not retrieve linkerd-config-overrides",
+			"could not retrieve linkerd-config-overrides\n%s", err)
+	}
+
+	// Check for fields that were added during install
+	testCases := []struct {
+		path  []string
+		value string
+	}{
+		{
+			[]string{"controllerLogLevel"},
+			"debug",
+		},
+		{
+			[]string{"proxyInit", "ignoreInboundPorts"},
+			skippedInboundPorts,
+		},
+		{
+			[]string{"proxyInit", "ignoreOutboundPorts"},
+			skippedOutboundPorts,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc // pin
+		t.Run(fmt.Sprintf("%s: %s", strings.Join(tc.path, "/"), tc.value), func(t *testing.T) {
+			finalValue, err := overridesTree.GetString(tc.path...)
+			if err != nil {
+				testutil.AnnotatedFatalf(t, "could not perform tree.GetString",
+					"could not perform tree.GetString\n%s", err)
+			}
+
+			if tc.value != finalValue {
+				testutil.AnnotatedFatalf(t, fmt.Sprintf("Values at path %s do not match", strings.Join(tc.path, "/")),
+					"Expected value at [%s] to be [%s] but received [%s]",
+					strings.Join(tc.path, "/"), tc.value, finalValue)
+			}
+		})
+	}
+
+	extractValue := func(t *testing.T, path ...string) string {
+		val, err := overridesTree.GetString(path...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error calling overridesTree.GetString()",
+				"error calling overridesTree.GetString(): %s", err)
+			return ""
+
+		}
+		return val
+	}
+
+	t.Run("Check if any unknown fields snuck in", func(t *testing.T) {
+		knownKeys := tree.Tree{
+			"controllerLogLevel": "debug",
+			"heartbeatSchedule":  "1 2 3 4 5",
+			"identity": tree.Tree{
+				"issuer": tree.Tree{
+					"tls": tree.Tree{
+						"crtPEM": extractValue(t, "identity", "issuer", "tls", "crtPEM"),
+						"keyPEM": extractValue(t, "identity", "issuer", "tls", "keyPEM"),
+					},
+				},
+			},
+			"identityTrustAnchorsPEM": extractValue(t, "identityTrustAnchorsPEM"),
+			"proxyInit": tree.Tree{
+				"ignoreInboundPorts":  skippedInboundPorts,
+				"ignoreOutboundPorts": skippedOutboundPorts,
+			},
+		}
+
+		if reg := os.Getenv(flags.EnvOverrideDockerRegistry); reg != "" {
+			knownKeys["controllerImage"] = reg + "/controller"
+			knownKeys["debugContainer"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/debug",
+				},
+			}
+			knownKeys["policyController"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/policy-controller",
+				},
+			}
+			knownKeys["proxy"] = tree.Tree{
+				"image": tree.Tree{
+					"name": reg + "/proxy",
+				},
+			}
+			knownKeys["proxyInit"].(tree.Tree)["image"] = tree.Tree{
+				"name": reg + "/proxy-init",
+			}
+
+		}
+
+		// Check if the keys in overridesTree match with knownKeys
+		if !reflect.DeepEqual(overridesTree.String(), knownKeys.String()) {
+			testutil.AnnotatedFatalf(t, "Overrides and knownKeys are different",
+				"Expected overrides to be [%s] but found [%s]",
+				knownKeys.String(), overridesTree.String(),
+			)
+		}
+	})
+}
+
+func TestVersionPostInstall(t *testing.T) {
+	err := TestHelper.CheckVersion(TestHelper.GetVersion())
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "Version command failed",
+			"Version command failed\n%s", err.Error())
+	}
+}
+
+func TestCheckProxyPostUpgrade(t *testing.T) {
+	cmd := []string{
+		"check", "--proxy", "-n", TestHelper.GetLinkerdNamespace(),
+		"--expected-version", TestHelper.GetVersion(),
+		"--wait=60m",
+	}
+
+	expected := getCheckOutput(t, "check.upgrade.golden", TestHelper.GetVizNamespace())
+	// Check output is non-deterministic for proxies that are not running the
+	// current version. This tends to cause a mismatch between the expected
+	// output (which is templated) and the actual output. We add a retry to "eventually"
+	// get a match
+	err := TestHelper.RetryFor(5*time.Minute, func() error {
+		out, err := TestHelper.LinkerdRun(cmd...)
+		if err != nil {
+			return fmt.Errorf("%w\n%s", err, out)
+		}
+
+		if !strings.Contains(out, expected) {
+			return fmt.Errorf("expected: %s\nactual: %s", expected, out)
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'linkerd check' command timed-out", "'linkerd check' command timed-out\n%v", err)
+	}
+}
+
+func TestUpgradeTestAppWorksAfterUpgrade(t *testing.T) {
+	testAppNamespace := "upgrade-test"
+
+	// Restart pods after upgrade to make sure they're re-injected with the
+	// latest proxy
+	if _, err := TestHelper.Kubectl("", "rollout", "restart", "deploy", "-n", testAppNamespace); err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl rollout' failed", "'kubectl rollout' failed: %s", err)
+	}
+
+	// make sure app is running before proceeding
+	ctx := context.Background()
+	for _, deploy := range []string{"emoji", "voting", "web"} {
+		if err := TestHelper.CheckPods(ctx, testAppNamespace, deploy, 1); err != nil {
+			var rce *testutil.RestartCountError
+			if errors.As(err, &rce) {
+				testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+			} else {
+				testutil.AnnotatedError(t, "CheckPods timed-out", err)
+			}
+		}
+	}
+
+	if err := testutil.ExerciseTestAppEndpoint("/api/vote?choice=:policeman:", testAppNamespace, TestHelper); err != nil {
+		testutil.AnnotatedFatalf(t, "error exercising test app endpoint after upgrade",
+			"error exercising test app endpoint after upgrade %s", err)
+	}
+}
+
+func getCheckOutput(t *testing.T, goldenFile string, namespace string) string {
+	pods, err := TestHelper.KubernetesHelper.GetPods(context.Background(), namespace, nil)
+	if err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("failed to retrieve pods: %s", err), err)
+	}
+
+	proxyVersionErr := ""
+	err = healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{})
+	if err != nil {
+		proxyVersionErr = err.Error()
+	}
+
+	tpl := template.Must(template.ParseFiles("testdata" + "/" + goldenFile))
+	vars := struct {
+		ProxyVersionErr string
+		HintURL         string
+	}{
+		proxyVersionErr,
+		healthcheck.HintBaseURL(TestHelper.GetVersion()),
+	}
+
+	var expected bytes.Buffer
+	if err := tpl.Execute(&expected, vars); err != nil {
+		testutil.AnnotatedFatal(t, fmt.Sprintf("failed to parse %s template: %s", goldenFile, err), err)
+	}
+
+	return expected.String()
+}
diff --git a/test/integration/viz/install_test.go b/test/integration/viz/install_test.go
index eb9599bf6..ddba3d813 100644
--- a/test/integration/viz/install_test.go
+++ b/test/integration/viz/install_test.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"html/template"
 	"os"
+	"strconv"
+	"strings"
 	"testing"
 	"time"
 
@@ -120,3 +122,40 @@ func TestCheckViz(t *testing.T) {
 		testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd viz check' command timed-out (%s)", timeout), err)
 	}
 }
+
+func TestDashboard(t *testing.T) {
+	dashboardPort := 52237
+	dashboardURL := fmt.Sprintf("http://localhost:%d", dashboardPort)
+
+	outputStream, err := TestHelper.LinkerdRunStream("viz", "dashboard", "-p",
+		strconv.Itoa(dashboardPort), "--show", "url")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "error running command",
+			"error running command:\n%s", err)
+	}
+	defer outputStream.Stop()
+
+	outputLines, err := outputStream.ReadUntil(4, 1*time.Minute)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "error running command",
+			"error running command:\n%s", err)
+	}
+
+	output := strings.Join(outputLines, "")
+	if !strings.Contains(output, dashboardURL) {
+		testutil.AnnotatedFatalf(t,
+			"dashboard command failed. Expected url [%s] not present", dashboardURL)
+	}
+
+	resp, err := TestHelper.HTTPGetURL(dashboardURL + "/api/version")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "unexpected error",
+			"unexpected error: %v", err)
+	}
+
+	if !strings.Contains(resp, TestHelper.GetVersion()) {
+		testutil.AnnotatedFatalf(t, "dashboard command failed; response doesn't contain expected version",
+			"dashboard command failed. Expected response [%s] to contain version [%s]",
+			resp, TestHelper.GetVersion())
+	}
+}
diff --git a/testutil/stream.go b/testutil/stream.go
index b207a8354..d2a9ec736 100644
--- a/testutil/stream.go
+++ b/testutil/stream.go
@@ -27,7 +27,8 @@ func (s *Stream) Stop() {
 func (s *Stream) ReadUntil(lineCount int, timeout time.Duration) ([]string, error) {
 	output := make([]string, 0)
 	lines := make(chan string)
-	timeoutAfter := time.After(timeout)
+	timeoutAfter := time.NewTimer(timeout)
+	defer timeoutAfter.Stop()
 	scanner := bufio.NewScanner(s.out)
 	stopSignal := false
 
@@ -44,7 +45,7 @@ func (s *Stream) ReadUntil(lineCount int, timeout time.Duration) ([]string, erro
 
 	for {
 		select {
-		case <-timeoutAfter:
+		case <-timeoutAfter.C:
 			stopSignal = true
 			return output, fmt.Errorf("cmd [%s] Timed out trying to read %d lines", strings.Join(s.cmd.Args, " "), lineCount)
 		case line := <-lines:
diff --git a/testutil/test_helper.go b/testutil/test_helper.go
index d7b52dbf6..01c5d79fd 100644
--- a/testutil/test_helper.go
+++ b/testutil/test_helper.go
@@ -8,11 +8,13 @@ import (
 	"errors"
 	"flag"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"testing"
 	"time"
@@ -417,6 +419,16 @@ type: kubernetes.io/tls`, base64.StdEncoding.EncodeToString([]byte(root)), base6
 	return err
 }
 
+// CmdRun executes an arbitrary command by calling the binary and return its
+// output
+func (h *TestHelper) CmdRun(cmd string, arg ...string) (string, error) {
+	out, stderr, err := combinedOutput("", cmd, arg...)
+	if err != nil {
+		return out, fmt.Errorf("command failed: '%s %s'\n%w\n%s", cmd, strings.Join(arg, " "), err, stderr)
+	}
+	return out, nil
+}
+
 // LinkerdRun executes a linkerd command returning its stdout.
 func (h *TestHelper) LinkerdRun(arg ...string) (string, error) {
 	out, stderr, err := h.PipeToLinkerdRun("", arg...)
@@ -603,14 +615,16 @@ func (h *TestHelper) RetryFor(timeout time.Duration, fn func() error) error {
 		return nil
 	}
 
-	timeoutAfter := time.After(timeout)
-	retryAfter := time.Tick(time.Second)
+	timeoutAfter := time.NewTimer(timeout)
+	defer timeoutAfter.Stop()
+	retryAfter := time.NewTicker(time.Second)
+	defer retryAfter.Stop()
 
 	for {
 		select {
-		case <-timeoutAfter:
+		case <-timeoutAfter.C:
 			return err
-		case <-retryAfter:
+		case <-retryAfter.C:
 			err = fn()
 			if err == nil {
 				return nil
@@ -662,6 +676,46 @@ func (h *TestHelper) WithDataPlaneNamespace(ctx context.Context, testName string
 	}
 }
 
+// GetReleaseChannelVersions is used to fetch the latest versions for Linkerd's
+// release channels: edge and stable
+func (h *TestHelper) GetReleaseChannelVersions() (map[string]string, error) {
+	url := "https://versioncheck.linkerd.io/version.json"
+	resp, err := h.httpClient.Get(url)
+	if err != nil {
+		return map[string]string{}, err
+	}
+	defer resp.Body.Close()
+
+	var versions map[string]string
+	if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
+		return map[string]string{}, err
+	}
+
+	return versions, nil
+}
+
+// DownloadCLIBinary is used to download the Linkerd CLI from GitHub Releases
+// page. The method takes the version to download and a filepath where to save
+// the binary.
+func (h *TestHelper) DownloadCLIBinary(filepath, version string) error {
+	url := fmt.Sprintf("https://github.com/linkerd/linkerd2/releases/download/%[1]s/linkerd2-cli-%[1]s-%s-%s", version, runtime.GOOS, runtime.GOARCH)
+	resp, err := h.httpClient.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	// Create if it doesn't already exist
+	out, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE, 755)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	_, err = io.Copy(out, resp.Body)
+	return err
+}
+
 // ReadFile reads a file from disk and returns the contents as a string.
 func ReadFile(file string) (string, error) {
 	b, err := ioutil.ReadFile(file)