Repository: linkerd/linkerd2
Pull request #7934 (2022-02-21 16:50:51)

Move `upgrade` integration tests in their own files
This change hoists all of the upgrade logic into dedicated files.

Upgrade tests are now done programatically (at the expense of repeating a lot of code between `upgrade-edge` and `upgrade-stable`). This (in theory) will remove the dependency on an external test-runner and special arguments for the `TestHelper`. Upgrade tests can now be run through go's runner, provided a Kubernetes cluster has been provisioned: `go test ./test/integration/upgrade-<release-channel>/...`

To implement this change, all of the upgrade code has been moved from `install_test.go`. We also create a few additional helper functions:

* we create a helper function to get us the latest linkerd version from both release channels;
* we create a helper function to download the latest binary for a specific release channel;
* and finally, we create a helper function to exec other processes (so we can use the linkerd cli to install the control plane before an upgrade).


<!--  Thanks for sending a pull request!

If you already have a well-structured git commit message, chances are GitHub
set the title and description of this PR to the git commit message subject and
body, respectively. If so, you may delete these instructions and submit your PR.

If this is your first time, please read our contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md

The title and description of your Pull Request should match the git commit
subject and body, respectively. Git commit messages are structured as follows:

```
Subject

Problem

Solution

Validation

Fixes #[GitHub issue ID]

DCO Sign off
```

Example git commit message:

```
Introduce Pull Request Template

GitHub's community guidelines recommend a pull request template, the repo was
lacking one.

Introduce a `PULL_REQUEST_TEMPLATE.md` file.

Once merged, the
[Community profile checklist](https://github.com/linkerd/linkerd2/community)
should indicate the repo now provides a pull request template.

Fixes #3321

Signed-off-by: Jane Smith <jane.smith@example.com>
```

Note the git commit message subject becomes the pull request title.

For more details around git commits, see the section on Committing in our
contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md#committing
-->



Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 2e09e4537..002d55012 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -9,9 +9,7 @@
         "matklad.rust-analyzer",
         "NathanRidley.autotrim",
         "samverschueren.final-newline",
-        "streetsidesoftware.code-spell-checker",
         "tamasfe.even-better-toml",
-        "vadimcn.vscode-lldb",
         "zxh404.vscode-proto3"
     ],
     "settings": {
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/Cargo.lock b/Cargo.lock
index b1d60bac8..9b7e9b778 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -24,9 +24,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.54"
+version = "1.0.55"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a99269dff3bc004caa411f38845c20303f1e393ca2bd6581576fa3a7f59577d"
+checksum = "159bb86af3a200e19a068f4224eae4c8bb2d0fa054c7e5d1cacd5cef95e684cd"
 
 [[package]]
 name = "async-stream"
@@ -128,9 +128,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "3.1.0"
+version = "3.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5f1fea81f183005ced9e59cdb01737ef2423956dac5a6d731b06b2ecfaa3467"
+checksum = "5177fac1ab67102d8989464efd043c6ff44191b1557ec1ddd489b4f7e1447e77"
 dependencies = [
  "bitflags",
  "clap_derive",
@@ -401,9 +401,9 @@ dependencies = [
 
 [[package]]
 name = "getrandom"
-version = "0.2.4"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c"
+checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77"
 dependencies = [
  "cfg-if",
  "libc",
@@ -790,9 +790,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.118"
+version = "0.2.119"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06e509672465a0504304aa87f9f176f2b2b716ed8fb105ebe5c02dc6dce96a94"
+checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
 
 [[package]]
 name = "linked-hash-map"
@@ -1388,9 +1388,9 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.20.3"
+version = "0.20.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b323592e3164322f5b193dc4302e4e36cd8d37158a712d664efae1a5c2791700"
+checksum = "4fbfeb8d0ddb84706bc597a5574ab8912817c52a397f819e5b614e2265206921"
 dependencies = [
  "log",
  "ring",
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/templates/policy/server.yaml b/charts/linkerd-crds/templates/policy/server.yaml
index ba8530f74..d9fb155f6 100644
--- a/charts/linkerd-crds/templates/policy/server.yaml
+++ b/charts/linkerd-crds/templates/policy/server.yaml
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
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/inject.go b/cli/cmd/inject.go
index a3a05cf3c..10dadbe70 100644
--- a/cli/cmd/inject.go
+++ b/cli/cmd/inject.go
@@ -70,7 +70,7 @@ sub-folders, or coming from stdin.`,
   kubectl get deploy -o yaml | linkerd inject - | kubectl apply -f -
 
   # Injecting a file from a remote URL
-  linkerd inject http://url.to/yml | kubectl apply -f -
+  linkerd inject https://url.to/yml | kubectl apply -f -
 
   # Inject all the resources inside a folder and its sub-folders.
   linkerd inject <folder> | kubectl apply -f -`,
diff --git a/cli/cmd/inject_util.go b/cli/cmd/inject_util.go
index 60175f80e..2c4508a3f 100644
--- a/cli/cmd/inject_util.go
+++ b/cli/cmd/inject_util.go
@@ -10,6 +10,7 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/linkerd/linkerd2/pkg/inject"
 	corev1 "k8s.io/api/core/v1"
@@ -142,17 +143,19 @@ func processList(bytes []byte, rt resourceTransformer) ([]byte, []inject.Report,
 // Read all the resource files found in path into a slice of readers.
 // path can be either a file, directory or stdin.
 func read(path string) ([]io.Reader, error) {
-	var (
-		in  []io.Reader
-		err error
-	)
 	if path == "-" {
-		in = append(in, os.Stdin)
-	} else if isValidURL(path) {
-		resp, err := http.Get(path)
+		return []io.Reader{os.Stdin}, nil
+	}
+
+	if url, ok := toURL(path); ok {
+		if strings.ToLower(url.Scheme) != "https" {
+			return nil, fmt.Errorf("only HTTPS URLs are allowed")
+		}
+		resp, err := http.Get(url.String())
 		if err != nil {
 			return nil, err
 		}
+		defer resp.Body.Close()
 
 		if resp.StatusCode != http.StatusOK {
 			return nil, fmt.Errorf("unable to read URL %q, server reported %s, status code=%d", path, resp.Status, resp.StatusCode)
@@ -164,26 +167,21 @@ func read(path string) ([]io.Reader, error) {
 		if err != nil {
 			return nil, err
 		}
-		resp.Body.Close()
-		in = append(in, buf)
-	} else {
-		in, err = walk(path)
-		if err != nil {
-			return nil, err
-		}
+
+		return []io.Reader{buf}, nil
 	}
 
-	return in, nil
+	return walk(path)
 }
 
 // checks if the given string is a valid URL
-func isValidURL(path string) bool {
+func toURL(path string) (*url.URL, bool) {
 	u, err := url.ParseRequestURI(path)
-	if err != nil {
-		return false
+	if err == nil && u.Host != "" && u.Scheme != "" {
+		return u, true
 	}
 
-	return u.Host != "" && u.Scheme != ""
+	return nil, false
 }
 
 // walk walks the file tree rooted at path. path may be a file or a directory.
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/controller/gen/apis/server/v1beta1/types.go b/controller/gen/apis/server/v1beta1/types.go
index 099fa929c..5b5211deb 100644
--- a/controller/gen/apis/server/v1beta1/types.go
+++ b/controller/gen/apis/server/v1beta1/types.go
@@ -29,7 +29,7 @@ type Server struct {
 
 // ServerSpec specifies a Server resource.
 type ServerSpec struct {
-	PodSelector   *metav1.LabelSelector `json:"podSelector,omitempty"`
+	PodSelector   *metav1.LabelSelector `json:"podSelector"`
 	Port          intstr.IntOrString    `json:"port,omitempty"`
 	ProxyProtocol string                `json:"proxyProtocol,omitempty"`
 }
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/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/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/policy-controller/k8s/api/src/labels.rs b/policy-controller/k8s/api/src/labels.rs
index cfdd17321..3c5bbb433 100644
--- a/policy-controller/k8s/api/src/labels.rs
+++ b/policy-controller/k8s/api/src/labels.rs
@@ -27,7 +27,8 @@ pub enum Operator {
     DoesNotExist,
 }
 
-/// Selects a set of pods that expose a server.
+/// Selects a set of pods that expose a server. The result of `match_labels` and
+/// `match_expressions` are ANDed.
 #[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Serialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct Selector {
@@ -38,14 +39,22 @@ pub struct Selector {
 // === Selector ===
 
 impl Selector {
-    pub fn from_expressions(exprs: Expressions) -> Self {
+    #[cfg(test)]
+    fn new(labels: Map, exprs: Expressions) -> Self {
+        Self {
+            match_labels: Some(labels),
+            match_expressions: Some(exprs),
+        }
+    }
+
+    fn from_expressions(exprs: Expressions) -> Self {
         Self {
             match_labels: None,
             match_expressions: Some(exprs),
         }
     }
 
-    pub fn from_map(map: Map) -> Self {
+    fn from_map(map: Map) -> Self {
         Self {
             match_labels: Some(map),
             match_expressions: None,
@@ -201,6 +210,32 @@ mod tests {
                 true,
                 "expression match",
             ),
+            (
+                Selector::new(
+                    Map::from([("foo".to_string(), "bar".to_string())]),
+                    vec![Expression {
+                        key: "bah".into(),
+                        operator: Operator::In,
+                        values: Some(Some("bar".to_string()).into_iter().collect()),
+                    }],
+                ),
+                Labels::from_iter(vec![("foo", "bar"), ("bah", "baz")]),
+                false,
+                "matches labels but not expressions",
+            ),
+            (
+                Selector::new(
+                    Map::from([("foo".to_string(), "bar".to_string())]),
+                    vec![Expression {
+                        key: "bah".into(),
+                        operator: Operator::In,
+                        values: Some(Some("bar".to_string()).into_iter().collect()),
+                    }],
+                ),
+                Labels::from_iter(vec![("foo", "bar"), ("bah", "bar")]),
+                true,
+                "matches both labels and expressions",
+            ),
         ] {
             assert_eq!(selector.matches(labels), *matches, "{}", msg);
         }
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/web/app/package.json b/web/app/package.json
index 192695e40..42455a286 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -16,7 +16,7 @@
     "@material-ui/icons": "4.11.2",
     "@material-ui/lab": "^4.0.0-alpha.60",
     "classnames": "2.3.1",
-    "core-js": "^3.21.0",
+    "core-js": "^3.21.1",
     "css-mediaquery": "^0.1.2",
     "d3-drag": "3.0.0",
     "d3-force": "3.0.0",
@@ -41,7 +41,7 @@
     "whatwg-fetch": "3.6.2"
   },
   "devDependencies": {
-    "@babel/core": "^7.17.2",
+    "@babel/core": "^7.17.5",
     "@babel/eslint-parser": "^7.17.0",
     "@babel/plugin-proposal-class-properties": "^7.16.7",
     "@babel/preset-env": "^7.16.11",
@@ -77,7 +77,7 @@
     "sinon-stub-promise": "4.0.0",
     "style-loader": "^3.3.1",
     "url-loader": "^4.1.1",
-    "webpack": "^5.68.0",
+    "webpack": "^5.69.1",
     "webpack-bundle-analyzer": "4.5.0",
     "webpack-cli": "4.9.2",
     "webpack-dev-server": "4.7.4"
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 24bcc44bf..05fc79cba 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -2,10 +2,10 @@
 # yarn lockfile v1
 
 
-"@ampproject/remapping@^2.0.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.0.tgz#72becdf17ee44b2d1ac5651fb12f1952c336fe23"
-  integrity sha512-d5RysTlJ7hmw5Tw4UxgxcY3lkMe92n8sXCcuLPAyIAHK6j8DefDwtGnVVDgOnv+RnEosulDJ9NPKQL27bDId0g==
+"@ampproject/remapping@^2.1.0":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.2.tgz#4edca94973ded9630d20101cd8559cedb8d8bd34"
+  integrity sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.0"
 
@@ -21,20 +21,20 @@
   resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.16.8.tgz#31560f9f29fdf1868de8cb55049538a1b9732a60"
   integrity sha512-m7OkX0IdKLKPpBlJtF561YJal5y/jyI5fNfWbPxh2D/nbzzGI4qRyrD8xO2jB24u7l+5I2a43scCG2IrfjC50Q==
 
-"@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.17.2", "@babel/core@^7.7.2", "@babel/core@^7.8.0":
-  version "7.17.2"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.2.tgz#2c77fc430e95139d816d39b113b31bf40fb22337"
-  integrity sha512-R3VH5G42VSDolRHyUO4V2cfag8WHcZyxdq5Z/m8Xyb92lW/Erm/6kM+XtRFGf3Mulre3mveni2NHfEUws8wSvw==
+"@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.17.5", "@babel/core@^7.7.2", "@babel/core@^7.8.0":
+  version "7.17.5"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.5.tgz#6cd2e836058c28f06a4ca8ee7ed955bbf37c8225"
+  integrity sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==
   dependencies:
-    "@ampproject/remapping" "^2.0.0"
+    "@ampproject/remapping" "^2.1.0"
     "@babel/code-frame" "^7.16.7"
-    "@babel/generator" "^7.17.0"
+    "@babel/generator" "^7.17.3"
     "@babel/helper-compilation-targets" "^7.16.7"
     "@babel/helper-module-transforms" "^7.16.7"
     "@babel/helpers" "^7.17.2"
-    "@babel/parser" "^7.17.0"
+    "@babel/parser" "^7.17.3"
     "@babel/template" "^7.16.7"
-    "@babel/traverse" "^7.17.0"
+    "@babel/traverse" "^7.17.3"
     "@babel/types" "^7.17.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
@@ -58,10 +58,10 @@
   dependencies:
     eslint-rule-composer "^0.3.0"
 
-"@babel/generator@^7.11.6", "@babel/generator@^7.17.0", "@babel/generator@^7.7.2":
-  version "7.17.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.0.tgz#7bd890ba706cd86d3e2f727322346ffdbf98f65e"
-  integrity sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw==
+"@babel/generator@^7.11.6", "@babel/generator@^7.17.3", "@babel/generator@^7.7.2":
+  version "7.17.3"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.3.tgz#a2c30b0c4f89858cb87050c3ffdfd36bdf443200"
+  integrity sha512-+R6Dctil/MgUsZsZAkYgK+ADNSZzJRRy0TvY65T71z/CR854xHQ1EweBYXdfT+HNeN7w0cSJJEzgxZMv40pxsg==
   dependencies:
     "@babel/types" "^7.17.0"
     jsesc "^2.5.1"
@@ -317,10 +317,10 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.11.5", "@babel/parser@^7.14.7", "@babel/parser@^7.16.7", "@babel/parser@^7.17.0":
-  version "7.17.0"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.0.tgz#f0ac33eddbe214e4105363bb17c3341c5ffcc43c"
-  integrity sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.11.5", "@babel/parser@^7.14.7", "@babel/parser@^7.16.7", "@babel/parser@^7.17.3":
+  version "7.17.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.3.tgz#b07702b982990bf6fdc1da5049a23fece4c5c3d0"
+  integrity sha512-7yJPvPV+ESz2IUTPbOL+YkIGyCqOyNIzdguKQuJGnH7bg1WTIifuM21YqokFt/THWh1AkCRn9IgoykTRCBVpzA==
 
 "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.7":
   version "7.16.7"
@@ -1002,18 +1002,18 @@
     "@babel/parser" "^7.16.7"
     "@babel/types" "^7.16.7"
 
-"@babel/traverse@^7.13.0", "@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8", "@babel/traverse@^7.17.0", "@babel/traverse@^7.7.2":
-  version "7.17.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.0.tgz#3143e5066796408ccc880a33ecd3184f3e75cd30"
-  integrity sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg==
+"@babel/traverse@^7.13.0", "@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8", "@babel/traverse@^7.17.0", "@babel/traverse@^7.17.3", "@babel/traverse@^7.7.2":
+  version "7.17.3"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.3.tgz#0ae0f15b27d9a92ba1f2263358ea7c4e7db47b57"
+  integrity sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==
   dependencies:
     "@babel/code-frame" "^7.16.7"
-    "@babel/generator" "^7.17.0"
+    "@babel/generator" "^7.17.3"
     "@babel/helper-environment-visitor" "^7.16.7"
     "@babel/helper-function-name" "^7.16.7"
     "@babel/helper-hoist-variables" "^7.16.7"
     "@babel/helper-split-export-declaration" "^7.16.7"
-    "@babel/parser" "^7.17.0"
+    "@babel/parser" "^7.17.3"
     "@babel/types" "^7.17.0"
     debug "^4.1.0"
     globals "^11.1.0"
@@ -1628,10 +1628,10 @@
   dependencies:
     "@types/node" "*"
 
-"@types/eslint-scope@^3.7.0":
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
-  integrity sha512-SCFeogqiptms4Fg29WpOTk5nHIzfpKCemSN63ksBQYKTcXoJEmJagV+DhVmbapZzY4/5YaOV1nZwrsU79fFm1g==
+"@types/eslint-scope@^3.7.3":
+  version "3.7.3"
+  resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
+  integrity sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==
   dependencies:
     "@types/eslint" "*"
     "@types/estree" "*"
@@ -1644,10 +1644,10 @@
     "@types/estree" "*"
     "@types/json-schema" "*"
 
-"@types/estree@*", "@types/estree@^0.0.50":
-  version "0.0.50"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
-  integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
+"@types/estree@*", "@types/estree@^0.0.51":
+  version "0.0.51"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
+  integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==
 
 "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18":
   version "4.17.27"
@@ -2955,10 +2955,10 @@ core-js-pure@^3.16.0:
   resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.18.0.tgz#e5187347bae66448c9e2d67c01c34c4df3261dc5"
   integrity sha512-ZnK+9vyuMhKulIGqT/7RHGRok8RtkHMEX/BGPHkHx+ouDkq+MUvf9mfIgdqhpmPDu8+V5UtRn/CbCRc9I4lX4w==
 
-core-js@^3.21.0:
-  version "3.21.0"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.0.tgz#f479dbfc3dffb035a0827602dd056839a774aa71"
-  integrity sha512-YUdI3fFu4TF/2WykQ2xzSiTQdldLB4KVuL9WeAy5XONZYt5Cun/fpQvctoKbCgvPhmzADeesTk/j2Rdx77AcKQ==
+core-js@^3.21.1:
+  version "3.21.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94"
+  integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==
 
 core-util-is@~1.0.0:
   version "1.0.3"
@@ -7967,13 +7967,13 @@ webpack-sources@^3.2.3:
   resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
   integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
 
-webpack@^5.68.0:
-  version "5.68.0"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.68.0.tgz#a653a58ed44280062e47257f260117e4be90d560"
-  integrity sha512-zUcqaUO0772UuuW2bzaES2Zjlm/y3kRBQDVFVCge+s2Y8mwuUTdperGaAv65/NtRL/1zanpSJOq/MD8u61vo6g==
+webpack@^5.69.1:
+  version "5.69.1"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.69.1.tgz#8cfd92c192c6a52c99ab00529b5a0d33aa848dc5"
+  integrity sha512-+VyvOSJXZMT2V5vLzOnDuMz5GxEqLk7hKWQ56YxPW/PQRUuKimPqmEIJOx8jHYeyo65pKbapbW464mvsKbaj4A==
   dependencies:
-    "@types/eslint-scope" "^3.7.0"
-    "@types/estree" "^0.0.50"
+    "@types/eslint-scope" "^3.7.3"
+    "@types/estree" "^0.0.51"
     "@webassemblyjs/ast" "1.11.1"
     "@webassemblyjs/wasm-edit" "1.11.1"
     "@webassemblyjs/wasm-parser" "1.11.1"


Test patch:
diff --git a/bin/_test-helpers.sh b/bin/_test-helpers.sh
index dd1b26001..a50628c2f 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() {
diff --git a/cli/cmd/inject_test.go b/cli/cmd/inject_test.go
index 997d53c85..f3b56372d 100644
--- a/cli/cmd/inject_test.go
+++ b/cli/cmd/inject_test.go
@@ -577,7 +577,7 @@ func TestInjectFilePath(t *testing.T) {
 	})
 }
 
-func TestValidURL(t *testing.T) {
+func TestToURL(t *testing.T) {
 	// if the string follows a URL pattern, true has to be returned
 	// if not false is returned
 
@@ -593,9 +593,9 @@ func TestValidURL(t *testing.T) {
 	}
 
 	for url, expectedValue := range tests {
-		value := isValidURL(url)
-		if value != expectedValue {
-			t.Errorf("Result mismatch for %s. expected %v, but got %v", url, expectedValue, value)
+		_, ok := toURL(url)
+		if ok != expectedValue {
+			t.Errorf("Result mismatch for %s. expected %v, but got %v", url, expectedValue, ok)
 		}
 	}
 
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index 8e0409c21..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 8bc44caef..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 8e431d0ef..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 8bc44caef..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 e992de988..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 24639c539..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 a82a5f56a..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 2ff1506d9..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 f20376f76..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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_helm_crds_output.golden b/cli/cmd/testdata/install_helm_crds_output.golden
index aea4795f0..986154154 100644
--- a/cli/cmd/testdata/install_helm_crds_output.golden
+++ b/cli/cmd/testdata/install_helm_crds_output.golden
@@ -97,9 +97,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
diff --git a/cli/cmd/testdata/install_helm_crds_output_ha.golden b/cli/cmd/testdata/install_helm_crds_output_ha.golden
index aea4795f0..986154154 100644
--- a/cli/cmd/testdata/install_helm_crds_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_crds_output_ha.golden
@@ -97,9 +97,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 8c61af4e0..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 4c6749ec8..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 e3c65b3f2..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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 bc04879c6..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
@@ -95,9 +95,9 @@ spec:
                   type: object
                   description: >-
                     Selects pods in the same namespace.
-                  oneOf:
-                    - required: [matchExpressions]
-                    - required: [matchLabels]
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
                   properties:
                     matchLabels:
                       type: object
@@ -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/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)