Repository: linkerd/linkerd2
Pull request #3987 (2020-01-28 23:07:57)

Remove dependency on httpbin in egress integration test
The egress integration test works by running a set of `bb` pods in `egress` mode which each are configured to send requests to a different combination of method and url in the `httpbin.org` domain.  We then verify certain properties of the response.

This is problematic because our integration tests rely on the availability and specific behavior of a service outside of our control.  Furthermore, using `bb` as part of this test can be problematic because `bb` sends a body with each request (including GETs) which is not handled well by all webservers.

We simplify this test by running a single `linkerd-debug` pod instead of a set of `bb` pods.  Our test can then run `curl` commands from inside this container using `kubectl exec`.  We use `linkerd.io` instead of `httpbin.org` as our egress target and we relax the check to simply verify that a non-error response is received.

Fixes https://github.com/linkerd/linkerd2/issues/2316


Patch:
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 169e8be49..e595201b9 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -17,19 +17,23 @@ on:
 # go_lint
 # js_unit_tests
 
-# Docker build and integration tests for every master/tag push and linkerd org PR
+# master/tag push and linkerd org PR: Docker build and integration tests
 #
 # docker_pull
 # docker_build
 # kind_setup
-#   -> kind_integration
+#   -> kind_integration_host
 #     -> kind_cleanup
 
+# Forked repository PR: Docker build and integration tests
+#
+# docker_build
+#   -> kind_integration_github
+
 # Docker deploy and cloud integration tests for every master/tag push
 #
 #       -> docker_deploy
 #         -> cloud_integration
-#           -> cloud_cleanup
 
 jobs:
 
@@ -135,18 +139,17 @@ jobs:
   # Docker build and kind integration tests run for:
   # - every master push
   # - every tag push
-  # - every PR from a linkerd org member
+  # - every PR
   #
 
   docker_pull:
     name: Docker pull
+    if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
     runs-on: ubuntu-18.04
     steps:
     - name: Checkout code
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       uses: actions/checkout@v2
     - name: Docker SSH setup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       run: |
         mkdir -p ~/.ssh/
         touch ~/.ssh/id && chmod 600 ~/.ssh/id
@@ -155,7 +158,6 @@ jobs:
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
     - name: Docker pull
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       env:
         DOCKER_HOST: ssh://linkerd-docker
       run: |
@@ -165,9 +167,10 @@ jobs:
   docker_build:
     name: Docker build
     runs-on: ubuntu-18.04
+    env:
+      IMAGE_ARCHIVES_DIR: /home/runner/archives
     steps:
     - name: Checkout code
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       uses: actions/checkout@v2
     - name: Docker SSH setup
       if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
@@ -178,27 +181,43 @@ jobs:
         echo "${{ secrets.DOCKER_PRIVATE_KEY }}" > ~/.ssh/id
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
-    - name: Docker build
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
-      env:
-        DOCKER_HOST: ssh://linkerd-docker
+        echo ::set-env name=DOCKER_HOST::ssh://linkerd-docker
+    - name: Build docker images
       run: |
         export PATH="`pwd`/bin:$PATH"
         DOCKER_TRACE=1 bin/docker-build
+    - name: Create artifact with CLI and image archives
+      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
+      run: |
+        mkdir -p $IMAGE_ARCHIVES_DIR
+        cp target/cli/linux/linkerd $IMAGE_ARCHIVES_DIR/
+        TAG="$(CI_FORCE_CLEAN=1 bin/root-tag)"
+        for img in controller grafana proxy web ; do
+          docker save "gcr.io/linkerd-io/$img:$TAG" > $IMAGE_ARCHIVES_DIR/$img.tar
+        done
+    # `with.path` values do not support environment variables yet, so an
+    # absolute path is used here.
+    #
+    # https://github.com/actions/upload-artifact/issues/8
+    - name: Upload artifact
+      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
+      uses: actions/upload-artifact@v1
+      with:
+        name: image-archives
+        path: /home/runner/archives
 
   kind_setup:
     strategy:
       max-parallel: 3
       matrix:
-        integration_test: [deep, upgrade, helm, custom_domain, external_issuer]
+        integration_test: [deep, upgrade, helm, helm_upgrade, custom_domain, external_issuer]
     name: Cluster setup (${{ matrix.integration_test }})
+    if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
     runs-on: ubuntu-18.04
     steps:
     - name: Checkout code
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       uses: actions/checkout@v2
     - name: Docker SSH setup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       run: |
         mkdir -p ~/.ssh/
         touch ~/.ssh/id && chmod 600 ~/.ssh/id
@@ -207,13 +226,12 @@ jobs:
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
     - name: Kind cluster setup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       env:
         DOCKER_HOST: ssh://linkerd-docker
       run: |
         TAG="$(CI_FORCE_CLEAN=1 bin/root-tag)"
         export KIND_CLUSTER=github-$TAG-${{ matrix.integration_test }}
-        export KUBECONFIG=/tmp/kind-config-$KIND_CLUSTER 
+        export KUBECONFIG=/tmp/kind-config-$KIND_CLUSTER
         export CUSTOM_DOMAIN_CONFIG="test/testdata/custom_cluster_domain_config.yaml"
         # retry cluster creation once in case of port conflict or kubeadm failure
         if [ "${{ matrix.integration_test }}" == "custom_domain" ]
@@ -225,20 +243,27 @@ jobs:
             bin/kind create cluster --name=$KIND_CLUSTER --wait=2m --verbosity 3
         fi
 
-  kind_integration:
+  #
+  # Integration tests that run on the docker host are limited by the fact that
+  # while they all run in separate KinD clusters, they are on the same
+  # machine. The `strategy` context cannot be conditionally set, so there are
+  # separate `kind_integration_*` jobs. This allows the `kind_integration_github`
+  # job to run it's entire matrix in parallel.
+  #
+
+  kind_integration_host:
     strategy:
       max-parallel: 3
       matrix:
-        integration_test: [deep, upgrade, helm, custom_domain, external_issuer]
+        integration_test: [deep, upgrade, helm, helm_upgrade, custom_domain, external_issuer]
     needs: [docker_pull, docker_build, kind_setup]
-    name: Integration tests (${{ matrix.integration_test }})
+    name: Host int. tests (${{ matrix.integration_test }})
+    if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
     runs-on: ubuntu-18.04
     steps:
     - name: Checkout code
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       uses: actions/checkout@v2
     - name: Docker SSH setup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       run: |
         mkdir -p ~/.ssh/
         touch ~/.ssh/id && chmod 600 ~/.ssh/id
@@ -246,8 +271,14 @@ jobs:
         echo "${{ secrets.DOCKER_PRIVATE_KEY }}" > ~/.ssh/id
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
+    - name: Try to load cached Go modules
+      uses: actions/cache@v1
+      with:
+        path: ~/go/pkg/mod
+        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+        restore-keys: |
+          ${{ runner.os }}-go-
     - name: Kind load docker images
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       run: |
         TAG="$(CI_FORCE_CLEAN=1 bin/root-tag)"
         export KIND_CLUSTER=github-$TAG-${{ matrix.integration_test }}
@@ -262,7 +293,6 @@ jobs:
           done
         EOF
     - name: Install linkerd CLI
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       env:
         DOCKER_HOST: ssh://linkerd-docker
       run: |
@@ -275,7 +305,6 @@ jobs:
         [[ "$TAG" == "$($HOME/.linkerd version --short --client)" ]]
         echo "Installed Linkerd CLI version: $TAG"
     - name: Run integration tests
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       env:
         DOCKER_HOST: ssh://linkerd-docker
         GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
@@ -302,18 +331,65 @@ jobs:
           ${{ matrix.integration_test }}_integration_tests
         )
 
+  kind_integration_github:
+    strategy:
+      matrix:
+        integration_test: [deep, upgrade, helm, helm_upgrade, custom_domain, external_issuer]
+    needs: [docker_build]
+    name: GitHub int. tests (${{ matrix.integration_test }})
+    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
+    runs-on: ubuntu-18.04
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v2
+    - name: Download image archives
+      uses: actions/download-artifact@v1
+      with:
+        name: image-archives
+    - name: Try to load cached Go modules
+      uses: actions/cache@v1
+      with:
+        path: ~/go/pkg/mod
+        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+        restore-keys: |
+          ${{ runner.os }}-go-
+    - name: Setup KinD (default)
+      if: matrix.integration_test != 'custom_domain'
+      uses: engineerd/setup-kind@v0.3.0
+      with:
+        version: "v0.6.1"
+    - name: Setup KinD (custom_domain)
+      if: matrix.integration_test == 'custom_domain'
+      uses: engineerd/setup-kind@v0.3.0
+      with:
+        config: test/testdata/custom_cluster_domain_config.yaml
+        version: "v0.6.1"
+    - name: Load image archives into KinD
+      run: |
+        for img in controller grafana proxy web; do
+          kind load image-archive image-archives/$img.tar
+        done
+    - name: Install linkerd CLI
+      run: |
+        cp image-archives/linkerd $HOME/.linkerd
+        chmod +x $HOME/.linkerd
+    - name: Run integration tests
+      run: |
+        . bin/_test-run.sh
+        init_test_run $HOME/.linkerd
+        ${{ matrix.integration_test }}_integration_tests
+
   kind_cleanup:
-    if: always()
     strategy:
       fail-fast: false # always attempt to cleanup all clusters
       matrix:
-        integration_test: [deep, upgrade, helm, custom_domain, external_issuer]
-    needs: [kind_integration]
+        integration_test: [deep, upgrade, helm, helm_upgrade, custom_domain, external_issuer]
+    needs: [kind_integration_host]
     name: Cluster cleanup (${{ matrix.integration_test }})
+    if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
     runs-on: ubuntu-18.04
     steps:
     - name: Checkout code
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       uses: actions/checkout@v2
     # for debugging
     - name: Dump env
@@ -328,7 +404,6 @@ jobs:
         JOB_CONTEXT: ${{ toJson(job) }}
       run: echo "$JOB_CONTEXT"
     - name: Docker SSH setup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       run: |
         mkdir -p ~/.ssh/
         touch ~/.ssh/id && chmod 600 ~/.ssh/id
@@ -337,7 +412,6 @@ jobs:
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
     - name: Kind cluster cleanup
-      if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork
       env:
         DOCKER_HOST: ssh://linkerd-docker
       run: |
@@ -353,38 +427,19 @@ jobs:
 
   docker_deploy:
     name: Docker deploy
+    if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
     runs-on: ubuntu-18.04
-    needs: [validate_go_deps, go_unit_tests, go_lint, js_unit_tests, kind_integration, kind_cleanup]
+    needs: [validate_go_deps, go_unit_tests, go_lint, js_unit_tests, kind_integration_host, kind_cleanup]
     steps:
     - name: Checkout code
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       uses: actions/checkout@v2
     - name: Configure gcloud
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
-      env:
-        CLOUDSDK_CORE_DISABLE_PROMPTS: "1"
-        CLOUD_SDK_SERVICE_ACCOUNT_KEY: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
-        GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
-        GCP_ZONE: ${{ secrets.GCP_ZONE }}
-        GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }}
-      run: |
-        # Install gcloud and kubectl.
-        echo "$CLOUD_SDK_SERVICE_ACCOUNT_KEY" > .gcp.json
-        dir="${CLOUDSDK_INSTALL_DIR:-${HOME}}/google-cloud-sdk"
-        (
-          . bin/_gcp.sh ;
-          install_gcloud "$dir"
-          gcloud components install kubectl
-          # Configure gcloud with a service account.
-          set_gcloud_config "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-          # Get a kubernetes context.
-          get_k8s_ctx "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-        )
-        . "$dir/path.bash.inc"
-        gcloud auth configure-docker
-        bin/kubectl version --short
+      uses: linkerd/linkerd2-action-gcloud@v1.0.0
+      with:
+        cloud_sdk_service_account_key: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
+        gcp_project: ${{ secrets.GCP_PROJECT }}
+        gcp_zone: ${{ secrets.GCP_ZONE }}
     - name: Docker SSH setup
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       run: |
         mkdir -p ~/.ssh/
         touch ~/.ssh/id && chmod 600 ~/.ssh/id
@@ -393,7 +448,6 @@ jobs:
         echo "${{ secrets.DOCKER_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
         ssh linkerd-docker docker version
     - name: Docker push
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       env:
         DOCKER_HOST: ssh://linkerd-docker
       run: |
@@ -406,38 +460,14 @@ jobs:
 
   cloud_integration:
     name: Cloud integration tests
+    if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
     runs-on: ubuntu-18.04
     needs: [docker_deploy]
     steps:
     - name: Checkout code
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       uses: actions/checkout@v2
-    - name: Configure gcloud
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
-      env:
-        CLOUDSDK_CORE_DISABLE_PROMPTS: "1"
-        CLOUD_SDK_SERVICE_ACCOUNT_KEY: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
-        GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
-        GCP_ZONE: ${{ secrets.GCP_ZONE }}
-        GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }}
-      run: |
-        # Install gcloud and kubectl.
-        echo "$CLOUD_SDK_SERVICE_ACCOUNT_KEY" > .gcp.json
-        dir="${CLOUDSDK_INSTALL_DIR:-${HOME}}/google-cloud-sdk"
-        (
-          . bin/_gcp.sh ;
-          install_gcloud "$dir"
-          gcloud components install kubectl
-          # Configure gcloud with a service account.
-          set_gcloud_config "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-          # Get a kubernetes context.
-          get_k8s_ctx "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-        )
-        . "$dir/path.bash.inc"
-        gcloud auth configure-docker
-        bin/kubectl version --short
     - name: Install linkerd CLI
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
+      id: install_cli
       run: |
         TAG="$(CI_FORCE_CLEAN=1 bin/root-tag)"
         image="gcr.io/linkerd-io/cli-bin:$TAG"
@@ -447,8 +477,16 @@ jobs:
         # validate CLI version matches the repo
         [[ "$TAG" == "$($HOME/.linkerd version --short --client)" ]]
         echo "Installed Linkerd CLI version: $TAG"
+        echo "::set-output name=tag::$TAG"
+    - name: Create GKE cluster
+      uses: linkerd/linkerd2-action-gcloud@v1.0.0
+      with:
+        cloud_sdk_service_account_key: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
+        gcp_project: ${{ secrets.GCP_PROJECT }}
+        gcp_zone: ${{ secrets.GCP_ZONE }}
+        create: true
+        name: testing-${{ steps.install_cli.outputs.tag }}-${{ github.run_id }}
     - name: Run integration tests
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       env:
         GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
       run: |
@@ -457,92 +495,39 @@ jobs:
         version="$($HOME/.linkerd version --client --short | tr -cd '[:alnum:]-')"
         bin/test-run $HOME/.linkerd linkerd-$version
     - name: CNI tests
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
       run: |
         export TAG="$($HOME/.linkerd version --client --short)"
         go test -cover -race -v -mod=readonly ./cni-plugin/test -integration-tests
 
-  cloud_cleanup:
-    if: always()
-    name: Cloud cleanup
-    runs-on: ubuntu-18.04
-    needs: [cloud_integration]
-    steps:
-    - name: Checkout code
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
-      uses: actions/checkout@v2
-    - name: Configure gcloud
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
-      env:
-        CLOUDSDK_CORE_DISABLE_PROMPTS: "1"
-        CLOUD_SDK_SERVICE_ACCOUNT_KEY: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
-        GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
-        GCP_ZONE: ${{ secrets.GCP_ZONE }}
-        GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }}
-      run: |
-        # Install gcloud and kubectl.
-        echo "$CLOUD_SDK_SERVICE_ACCOUNT_KEY" > .gcp.json
-        dir="${CLOUDSDK_INSTALL_DIR:-${HOME}}/google-cloud-sdk"
-        (. bin/_gcp.sh ;
-          install_gcloud "$dir"
-          gcloud components install kubectl
-          # Configure gcloud with a service account.
-          set_gcloud_config "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-          # Get a kubernetes context.
-          get_k8s_ctx "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-        )
-        . "$dir/path.bash.inc"
-        gcloud auth configure-docker
-        bin/kubectl version --short
-    - name: Cleanup
-      if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags')
-      run: |
-        export PATH="`pwd`/bin:$PATH"
-        bin/test-cleanup
-
-  #
   # Helm chart artifact deploy run for:
   # - every tag push
   #
 
   chart_deploy:
     name: Helm chart deploy
+    if: startsWith(github.ref, 'refs/tags')
     runs-on: ubuntu-18.04
     needs: [cloud_integration]
     steps:
     - name: Checkout code
-      if: startsWith(github.ref, 'refs/tags')
       uses: actions/checkout@v2
     - name: Configure gsutils
-      if: startsWith(github.ref, 'refs/tags')
-      env:
-        CLOUDSDK_CORE_DISABLE_PROMPTS: "1"
-        CLOUD_SDK_SERVICE_ACCOUNT_KEY: ${{ secrets.LINKERD_SITE_TOKEN }}
-        GCP_PROJECT: ${{ secrets.LINKERD_SITE_PROJECT }}
-        GCP_ZONE: ${{ secrets.LINKERD_SITE_ZONE }}
-        GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }}
-      run: |
-        # Install gcloud and gsutil.
-        echo "$CLOUD_SDK_SERVICE_ACCOUNT_KEY" > .gcp.json
-        dir="${CLOUDSDK_INSTALL_DIR:-${HOME}}/google-cloud-sdk"
-        (
-          . bin/_gcp.sh ;
-          install_gcloud "$dir"
-          gcloud components install gsutil
-          # Configure gcloud with a service account.
-          set_gcloud_config "$GCP_PROJECT" "$GCP_ZONE" "$GKE_CLUSTER"
-        )
-        . "$dir/path.bash.inc"
-        mkdir -p target/helm
+      uses: linkerd/linkerd2-action-gcloud@v1.0.0
+      with:
+        cloud_sdk_service_account_key: ${{ secrets.CLOUD_SDK_SERVICE_ACCOUNT_KEY }}
+        gcp_project: ${{ secrets.GCP_PROJECT }}
+        gcp_zone: ${{ secrets.GCP_ZONE }}
     - name: Edge Helm chart creation and upload
       if: startsWith(github.ref, 'refs/tags/edge')
       run: |
+        mkdir -p target/helm
         gsutil cp gs://helm.linkerd.io/edge/index.yaml target/helm/index-pre.yaml
         bin/helm-build package
         gsutil rsync target/helm gs://helm.linkerd.io/edge
     - name: Stable Helm chart creation and upload
       if: startsWith(github.ref, 'refs/tags/stable')
       run: |
+        mkdir -p target/helm
         gsutil cp gs://helm.linkerd.io/stable/index.yaml target/helm/index-pre.yaml
         bin/helm-build package
         gsutil rsync target/helm gs://helm.linkerd.io/stable
diff --git a/.gitignore b/.gitignore
index 449714e41..1ed553166 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,7 @@ tmp.discovery
 **/CMakeLists.txt
 *.iml
 web/web
-web/app/node_modules
+**/node_modules
 web/app/dist
 web/app/yarn-error.log
 .gorun
diff --git a/.proxy-version b/.proxy-version
index 769b20e12..6a6af7403 100644
--- a/.proxy-version
+++ b/.proxy-version
@@ -1,1 +1,1 @@
-v2.84.0
+v2.85.0
diff --git a/ADOPTERS.md b/ADOPTERS.md
index c270f6b40..1bf0615ba 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -15,10 +15,12 @@
 - [Kurio](https://kurio.id)
 - [LeadIQ](https://leadiq.com)
 - [Mulligan Funding](https://www.mulliganfunding.com/)
+- [National Information Solutions Cooperative (NISC)](https://nisc.coop/)
 - [Nordstrom](https://nordstrom.com/)
 - [Paybase](https://paybase.io/)
 - [PlayStudios Asia](https://www.playstudios.asia)
 - [Studyo](https://studyo.co)
+- [Vernacular.ai](https://vernacular.ai/)
 
 If you're using Linkerd 2.x and aren't on this list, please [submit a pull
 request](https://github.com/linkerd/linkerd2/pulls)!
diff --git a/CHANGES.md b/CHANGES.md
index 4e764b8e0..0900b5a66 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,182 @@
+## stable-2.7.0
+
+This release adds support for integrating Linkerd's PKI with an external
+certificate issuer such as [`cert-manager`] as well as streamlining the
+certificate rotation process in general. For more details about cert-manager
+and certificate rotation, see the
+[docs](https://linkerd.io/2/tasks/use_external_certs/). This release also
+includes performance improvements to the dashboard, reduced memory usage of the
+proxy, various improvements to the Helm chart, and much much more.
+
+To install this release, run: `curl https://run.linkerd.io/install | sh`
+
+**Upgrade notes**: This release includes breaking changes to our Helm charts.
+Please see the [upgrade instructions](https://linkerd.io/2/tasks/upgrade/#upgrade-notice-stable-270).
+
+**Special thanks to**: @alenkacz, @bmcstdio, @daxmc99, @droidnoob, @ereslibre,
+@javaducky, @joakimr-axis, @JohannesEH, @KIVagant, @mayankshah1607,
+@Pothulapati, and @StupidScience!
+
+**Full release notes**:
+
+* CLI
+  * Updated the mTLS trust anchor checks to eliminate false positives caused by
+    extra trailing spaces
+  * Reduced the severity level of the Linkerd version checks, so that they
+    don't fail when the external version endpoint is unreachable
+    (thanks @mayankshah1607!)
+  * Added a new `tap` APIService check to aid with uncovering Kubernetes API
+    aggregatation layer issues (thanks @droidnoob!)
+  * Introduced CNI checks to confirm the CNI plugin is installed and ready;
+    this is done through `linkerd check --pre --linkerd-cni-enabled` before
+    installation and `linkerd check` after installation if the CNI plugin is
+    present
+  * Added support for the `--as-group` flag so that users can impersonate
+    groups for Kubernetes operations (thanks @mayankshah1607!)
+  * Added HA specific checks to `linkerd check` to ensure that the `kube-system`
+    namespace has the `config.linkerd.io/admission-webhooks:disabled`
+    label set
+  * Fixed a problem causing the presence of unnecessary empty fields in
+    generated resource definitions (thanks @mayankshah1607)
+  * Added the ability to pass both port numbers and port ranges to
+    `--skip-inbound-ports` and `--skip-outbound-ports` (thanks to @javaducky!)
+  * Increased the comprehensiveness of `linkerd check --pre`
+  * Added TLS certificate validation to `check` and `upgrade` commands
+  * Added support for injecting CronJobs and ReplicaSets, as well as the ability
+    to use them as targets in the CLI subcommands
+  * Introduced the new flags `--identity-issuer-certificate-file`,
+    `--identity-issuer-key-file` and `identity-trust-anchors-file` to `linkerd
+    upgrade` to support trust anchor and issuer certificate rotation
+  * Added a check that ensures using `--namespace` and `--all-namespaces`
+    results in an error as they are mutually exclusive
+  * Added a `Dashboard.Replicas` parameter to the Linkerd Helm chart to allow
+    configuring the number of dashboard replicas (thanks @KIVagant!)
+  * Removed redundant service profile check (thanks @alenkacz!)
+  * Updated `uninject` command to work with namespace resources
+    (thanks @mayankshah1607!)
+  * Added a new `--identity-external-issuer` flag to `linkerd install` that
+    configures Linkerd to use certificates issued by an external certificate
+    issuer (such as `cert-manager`)
+  * Added support for injecting a namespace to `linkerd inject` (thanks
+    @mayankshah1607!)
+  * Added checks to `linkerd check --preinstall` ensuring Kubernetes Secrets
+    can be created and accessed
+  * Fixed `linkerd tap` sometimes displaying incorrect pod names for unmeshed
+    IPs that match multiple running pods
+  * Made `linkerd install --ignore-cluster` and `--skip-checks` faster
+  * Fixed a bug causing `linkerd upgrade` to fail when used with
+  `--from-manifest`
+  * Made `--cluster-domain` an install-only flag (thanks @bmcstdio!)
+  * Updated `check` to ensure that proxy trust anchors match configuration
+       (thanks @ereslibre!)
+  * Added condition to the `linkerd stat` command that requires a window size
+    of at least 15 seconds to work properly with Prometheus
+* Controller
+  * Fixed an issue where an override of the Docker registry was not being
+    applied to debug containers (thanks @javaducky!)
+  * Added check for the Subject Alternate Name attributes to the API server
+    when access restrictions have been enabled (thanks @javaducky!)
+  * Added support for arbitrary pod labels so that users can leverage the
+    Linkerd provided Prometheus instance to scrape for their own labels
+    (thanks @daxmc99!)
+  * Fixed an issue with CNI config parsing
+  * Fixed a race condition in the `linkerd-web` service
+  * Updated Prometheus to 2.15.2 (thanks @Pothulapati)
+  * Increased minimum kubernetes version to 1.13.0
+  * Added support for pod ip and service cluster ip lookups in the destination 
+    service
+  * Added recommended kubernetes labels to control-plane
+  * Added the `--wait-before-exit-seconds` flag to linkerd inject for the proxy 
+    sidecar to delay the start of its shutdown process (a huge commit from 
+    @KIVagant, thanks!)
+  * Added a pre-sign check to the identity service 
+  * Fixed inject failures for pods with security context capabilities
+  * Added `conntrack` to the `debug` container to help with connection tracking
+    debugging
+  * Fixed a bug in `tap` where mismatch cluster domain and trust domain caused
+    `tap` to hang
+  * Fixed an issue in the `identity` RBAC resource which caused start up errors
+    in k8s 1.6 (thanks @Pothulapati!)
+  * Added support for using trust anchors from an external certificate issuer
+    (such as `cert-mananger`) to the `linkerd-identity` service
+  * Added support for headless services (thanks @JohannesEH!)
+* Helm
+  * **Breaking change**: Renamed `noInitContainer` parameter to `cniEnabled`
+  * **Breaking Change** Updated Helm charts to follow best practices (thanks
+    @Pothulapati and @javaducky!)
+  * Fixed an issue with `helm install` where the lists of ignored inbound and
+    outbound ports would not be reflected
+  * Fixed the `linkerd-cni` Helm chart not setting proper namespace annotations
+    and labels
+  * Fixed certificate issuance lifetime not being set when installing through
+    Helm
+  * Updated the helm build to retain previous releases
+  * Moved CNI template into its own Helm chart
+* Proxy
+  * Fixed an issue that could cause the OpenCensus exporter to stall
+  * Improved error classification and error responses for gRPC services
+  * Fixed a bug where the proxy could stop receiving service discovery updates,
+    resulting in 503 errors
+  * Improved debug/error logging to include detailed contextual information
+  * Fixed a bug in the proxy's logging subsystem that could cause the proxy to
+    consume memory until the process is OOM killed, especially when the proxy was
+    configured to log diagnostic information
+  * Updated proxy dependencies to address RUSTSEC-2019-0033, RUSTSEC-2019-0034,
+    and RUSTSEC-2020-02
+* Web UI
+  * Fixed an error when refreshing an already open dashboard when the Linkerd
+    version has changed
+  * Increased the speed of the dashboard by pausing network activity when the 
+    dashboard is not visible to the user
+  * Added support for CronJobs and ReplicaSets, including new Grafana dashboards
+    for them
+  * Added `linkerd check` to the dashboard in the `/controlplane` view
+  * Added request and response headers to the `tap` expanded view in the
+    dashboard
+  * Added filter to namespace select button
+  * Improved how empty tables are displayed
+  * Added `Host:` header validation to the `linkerd-web` service, to protect
+    against DNS rebinding attacks
+  * Made the dashboard sidebar component responsive
+  * Changed the navigation bar color to the one used on the [Linkerd](https://linkerd.io/) website
+* Internal
+  * Added validation to incoming sidecar injection requests that ensures
+    the value of `linkerd.io/inject` is either `enabled` or `disabled`
+    (thanks @mayankshah1607)
+  * Upgraded the Prometheus Go client library to v1.2.1 (thanks @daxmc99!)
+  * Fixed an issue causing `tap`, `injector` and `sp-validator` to use 
+    old certificates after `helm upgrade` due to not being restarted
+  * Fixed incomplete Swagger definition of the tap api, causing benign
+    error logging in the kube-apiserver
+  * Removed the destination container from the linkerd-controller deployment as
+    it now runs in the linkerd-destination deployment
+  * Allowed the control plane to be injected with the `debug` container
+  * Updated proxy image build script to support HTTP proxy options
+    (thanks @joakimr-axis!)
+  * Updated the CLI `doc` command to auto-generate documentation for the proxy
+    configuration annotations (thanks @StupidScience!)
+  * Added new `--trace-collector` and `--trace-collector-svc-account` flags to
+    `linkerd inject` that configures the OpenCensus trace collector used by
+    proxies in the injected workload (thanks @Pothulapati!)
+  * Added a new `--control-plane-tracing` flag to `linkerd install` that enables
+    distributed tracing in the control plane (thanks @Pothulapati!)
+  * Added distributed tracing support to the control plane (thanks
+    @Pothulapati!)
+
+## edge-20.2.1
+
+This edge release is a release candidate for `stable-2.7` and fixes an issue
+where the proxy could consume inappropriate amounts of memory.
+
+* Proxy
+  * Fixed a bug in the proxy's logging subsystem that could cause the proxy to
+    consume memory until the process is OOM killed, especially when the proxy was
+    configured to log diagnostic information
+  * Fixed properly emitting `grpc-status` headers when signaling proxy errors to
+    gRPC clients
+  * Updated certain proxy dependencies to address RUSTSEC-2019-0033,
+    RUSTSEC-2019-0034, and RUSTSEC-2020-02
+
 ## edge-20.1.4
 
 This edge release is a release candidate for `stable-2.7`.
diff --git a/Dockerfile-proxy b/Dockerfile-proxy
index 0ae139182..7d0da11f4 100644
--- a/Dockerfile-proxy
+++ b/Dockerfile-proxy
@@ -9,7 +9,7 @@ RUN (proxy=$(bin/fetch-proxy $(cat proxy-version)) && \
     mv "$proxy" linkerd2-proxy)
 
 ## compile proxy-identity agent
-FROM gcr.io/linkerd-io/go-deps:8d2ce4bf as golang
+FROM gcr.io/linkerd-io/go-deps:93ea34a8 as golang
 WORKDIR /linkerd-build
 COPY pkg/flags pkg/flags
 COPY pkg/tls pkg/tls
diff --git a/bin/_gcp.sh b/bin/_gcp.sh
deleted file mode 100644
index 56fd29515..000000000
--- a/bin/_gcp.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-set -eu
-
-install_gcloud() {
-    dir="$1"
-
-    export CLOUDSDK_CORE_DISABLE_PROMPTS=1
-    if [ -d "$dir/bin" ]; then
-        . "$dir/path.bash.inc"
-        gcloud components update
-    else
-        rm -rf "$dir"
-        curl https://sdk.cloud.google.com | bash
-        . "$dir/path.bash.inc"
-    fi
-}
-
-set_gcloud_config() {
-    project="$1"
-    zone="$2"
-    cluster="$3"
-
-    gcloud auth activate-service-account --key-file .gcp.json
-    gcloud config set core/project "$project"
-    gcloud config set compute/zone "$zone"
-    gcloud config set container/cluster "$cluster"
-}
-
-get_k8s_ctx() {
-    project="$1"
-    zone="$2"
-    cluster="$3"
-
-    for c in $(kubectl config get-clusters |sed 1d) ; do
-        if [ "$c" = "gke_${project}_${zone}_${cluster}" ]; then
-            return 0
-        fi
-    done
-
-    gcloud container clusters get-credentials "$cluster"
-}
diff --git a/charts/linkerd2-cni/values.yaml b/charts/linkerd2-cni/values.yaml
index d63377b89..3a3c502f9 100644
--- a/charts/linkerd2-cni/values.yaml
+++ b/charts/linkerd2-cni/values.yaml
@@ -6,7 +6,7 @@ ignoreInboundPorts: ""
 ignoreOutboundPorts: ""
 createdByAnnotation: linkerd.io/created-by
 cniPluginImage:   "gcr.io/linkerd-io/cni-plugin"
-cniPluginVersion: edge-20.1.4
+cniPluginVersion: stable-2.7.0
 logLevel:         info
 portsToRedirect:  ""
 proxyUID:         2102
diff --git a/charts/linkerd2/values.yaml b/charts/linkerd2/values.yaml
index af59e0966..f8b609205 100644
--- a/charts/linkerd2/values.yaml
+++ b/charts/linkerd2/values.yaml
@@ -6,9 +6,12 @@
 global:
   clusterDomain: &cluster_domain cluster.local
   imagePullPolicy: &image_pull_policy IfNotPresent
+  
+  # Control Plane Trace Configuration
+  controlPlaneTracing: false
 
   # control plane version. See Proxy section for proxy version
-  linkerdVersion: &linkerd_version edge-20.1.4
+  linkerdVersion: &linkerd_version stable-2.7.0
 
   namespace: linkerd
 
@@ -128,9 +131,6 @@ heartbeatSchedule: "0 0 * * *"
 prometheusImage: prom/prometheus:v2.15.2
 prometheusLogLevel: *controller_log_level
 
-# Control Plane Trace Configuration
-controlPlaneTracing: false
-
 # proxy injector configuration
 proxyInjector:
   # if empty, Helm will auto-generate these fields
diff --git a/cli/Dockerfile-bin b/cli/Dockerfile-bin
index ced183955..c465d05df 100644
--- a/cli/Dockerfile-bin
+++ b/cli/Dockerfile-bin
@@ -1,5 +1,5 @@
 ## compile binaries
-FROM gcr.io/linkerd-io/go-deps:8d2ce4bf as golang
+FROM gcr.io/linkerd-io/go-deps:93ea34a8 as golang
 WORKDIR /linkerd-build
 COPY cli cli
 COPY charts charts
diff --git a/cli/cmd/check.go b/cli/cmd/check.go
index 75148b875..8ecc883e0 100644
--- a/cli/cmd/check.go
+++ b/cli/cmd/check.go
@@ -9,6 +9,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/linkerd/linkerd2/pkg/version"
+
 	"github.com/briandowns/spinner"
 	"github.com/linkerd/linkerd2/pkg/healthcheck"
 	"github.com/mattn/go-isatty"
@@ -17,24 +19,26 @@ import (
 )
 
 type checkOptions struct {
-	versionOverride string
-	preInstallOnly  bool
-	dataPlaneOnly   bool
-	wait            time.Duration
-	namespace       string
-	cniEnabled      bool
-	output          string
+	versionOverride    string
+	preInstallOnly     bool
+	dataPlaneOnly      bool
+	wait               time.Duration
+	namespace          string
+	cniEnabled         bool
+	output             string
+	cliVersionOverride string
 }
 
 func newCheckOptions() *checkOptions {
 	return &checkOptions{
-		versionOverride: "",
-		preInstallOnly:  false,
-		dataPlaneOnly:   false,
-		wait:            300 * time.Second,
-		namespace:       "",
-		cniEnabled:      false,
-		output:          tableOutput,
+		versionOverride:    "",
+		preInstallOnly:     false,
+		dataPlaneOnly:      false,
+		wait:               300 * time.Second,
+		namespace:          "",
+		cniEnabled:         false,
+		output:             tableOutput,
+		cliVersionOverride: "",
 	}
 }
 
@@ -55,6 +59,7 @@ func (options *checkOptions) checkFlagSet() *pflag.FlagSet {
 	flags := pflag.NewFlagSet("check", pflag.ExitOnError)
 
 	flags.StringVar(&options.versionOverride, "expected-version", options.versionOverride, "Overrides the version used when checking if Linkerd is running the latest version (mostly for testing)")
+	flags.StringVar(&options.cliVersionOverride, "cli-version-override", "", "Used to override the version of the cli (mostly for testing)")
 	flags.StringVarP(&options.output, "output", "o", options.output, "Output format. One of: basic, json")
 	flags.DurationVar(&options.wait, "wait", options.wait, "Maximum allowed time for all tests to pass")
 
@@ -141,6 +146,11 @@ func configureAndRunChecks(wout io.Writer, werr io.Writer, stage string, options
 	if err != nil {
 		return fmt.Errorf("Validation error when executing check command: %v", err)
 	}
+
+	if options.cliVersionOverride != "" {
+		version.Version = options.cliVersionOverride
+	}
+
 	checks := []healthcheck.CategoryID{
 		healthcheck.KubernetesAPIChecks,
 		healthcheck.KubernetesVersionChecks,
diff --git a/cli/cmd/root.go b/cli/cmd/root.go
index 55645240d..f54c868e4 100644
--- a/cli/cmd/root.go
+++ b/cli/cmd/root.go
@@ -100,7 +100,6 @@ func init() {
 	RootCmd.PersistentFlags().StringArrayVar(&impersonateGroup, "as-group", []string{}, "Group to impersonate for Kubernetes operations")
 	RootCmd.PersistentFlags().StringVar(&apiAddr, "api-addr", "", "Override kubeconfig and communicate directly with the control plane at host:port (mostly for testing)")
 	RootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Turn on debug logging")
-
 	RootCmd.AddCommand(newCmdCheck())
 	RootCmd.AddCommand(newCmdCompletion())
 	RootCmd.AddCommand(newCmdDashboard())
diff --git a/cni-plugin/Dockerfile b/cni-plugin/Dockerfile
index b229039b6..9ae99bcb2 100644
--- a/cni-plugin/Dockerfile
+++ b/cni-plugin/Dockerfile
@@ -1,5 +1,5 @@
 ## compile cni-plugin utility
-FROM gcr.io/linkerd-io/go-deps:8d2ce4bf as golang
+FROM gcr.io/linkerd-io/go-deps:93ea34a8 as golang
 WORKDIR /linkerd-build
 COPY pkg pkg
 COPY controller controller
diff --git a/controller/Dockerfile b/controller/Dockerfile
index 1c9dd7f65..b4cc08afc 100644
--- a/controller/Dockerfile
+++ b/controller/Dockerfile
@@ -1,5 +1,5 @@
 ## compile controller service
-FROM gcr.io/linkerd-io/go-deps:8d2ce4bf as golang
+FROM gcr.io/linkerd-io/go-deps:93ea34a8 as golang
 WORKDIR /linkerd-build
 COPY controller/gen controller/gen
 COPY pkg pkg
diff --git a/go.mod b/go.mod
index 3d5d222ee..dd73d025b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/linkerd/linkerd2
 
-go 1.13.4
+go 1.13
 
 require (
 	contrib.go.opencensus.io/exporter/ocagent v0.6.0
diff --git a/go.sum b/go.sum
index 48a0f60aa..518fec565 100644
--- a/go.sum
+++ b/go.sum
@@ -211,10 +211,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/linkerd/linkerd2-proxy-api v0.1.10 h1:zGwz2Fek7fdfCxyZW7jq8IE6sexeVFW5UFBQFL6MrP4=
 github.com/linkerd/linkerd2-proxy-api v0.1.10/go.mod h1:2WJHEYXoww5ALM6c1QspRFiROGZg08tGxCO1js0tTVA=
-github.com/linkerd/linkerd2-proxy-init v1.3.0 h1:kyrMpaNKGPfd7FA8O/6VGxsYXx++TxxZVgRL6knWSa8=
-github.com/linkerd/linkerd2-proxy-init v1.3.0/go.mod h1:lK/sORPZDP6aFhPZps+AHMKphX5v9bKVDNESllz18C0=
-github.com/linkerd/linkerd2-proxy-init v1.3.1-0.20200117170322-a7441ab79b25 h1:RVz/okrTQugk9GKUDY9mJbl8mKUh90jfxTu97IvBvjg=
-github.com/linkerd/linkerd2-proxy-init v1.3.1-0.20200117170322-a7441ab79b25/go.mod h1:lK/sORPZDP6aFhPZps+AHMKphX5v9bKVDNESllz18C0=
 github.com/linkerd/linkerd2-proxy-init v1.3.1 h1:KTvUvPlSKUkpzZM0L/EIk+EJaKk7dwygu0CP95fOyGI=
 github.com/linkerd/linkerd2-proxy-init v1.3.1/go.mod h1:lK/sORPZDP6aFhPZps+AHMKphX5v9bKVDNESllz18C0=
 github.com/linkerd/stern v0.0.0-20190907020106-201e8ccdff9c h1:v8RmXOGO65HHLqcDpVNGGe3krA8FptfxcYS3wSS8bJU=
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index 2bba7aa45..27cdd4d38 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -166,6 +166,15 @@ const HintBaseURL = "https://linkerd.io/checks/#"
 // TODO: Make this default value overridiable, e.g. by CLI flag
 const AllowedClockSkew = time.Minute + tls.DefaultClockSkewAllowance
 
+var linkerdHAControlPlaneComponents = []string{
+	"linkerd-controller",
+	"linkerd-destination",
+	"linkerd-identity",
+	"linkerd-proxy-injector",
+	"linkerd-sp-validator",
+	"linkerd-tap",
+}
+
 var (
 	retryWindow    = 5 * time.Second
 	requestTimeout = 30 * time.Second
@@ -1167,11 +1176,43 @@ func (hc *HealthChecker) allCategories() []category {
 						return &SkipError{Reason: "not run for non HA installs"}
 					},
 				},
+				{
+					description:   "multiple replicas of control plane pods",
+					hintAnchor:    "l5d-control-plane-replicas",
+					retryDeadline: hc.RetryDeadline,
+					warning:       true,
+					check: func(ctx context.Context) error {
+						if hc.isHA() {
+							return hc.checkMinReplicasAvailable()
+						}
+						return &SkipError{Reason: "not run for non HA installs"}
+					},
+				},
 			},
 		},
 	}
 }
 
+func (hc *HealthChecker) checkMinReplicasAvailable() error {
+	faulty := []string{}
+
+	for _, component := range linkerdHAControlPlaneComponents {
+		conf, err := hc.kubeAPI.AppsV1().Deployments(hc.ControlPlaneNamespace).Get(component, metav1.GetOptions{})
+		if err != nil {
+			return err
+		}
+
+		if conf.Status.AvailableReplicas <= 1 {
+			faulty = append(faulty, component)
+		}
+	}
+
+	if len(faulty) > 0 {
+		return fmt.Errorf("not enough replicas available for %v", faulty)
+	}
+	return nil
+}
+
 func (hc *HealthChecker) issuerIdentity() string {
 	return fmt.Sprintf("identity.%s.%s", hc.ControlPlaneNamespace, hc.linkerdConfig.Global.IdentityContext.TrustDomain)
 }
diff --git a/web/Dockerfile b/web/Dockerfile
index 6de090f67..439831140 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -21,7 +21,7 @@ COPY web/app ./web/app
 RUN ./bin/web build
 
 ## compile go server
-FROM gcr.io/linkerd-io/go-deps:8d2ce4bf as golang
+FROM gcr.io/linkerd-io/go-deps:93ea34a8 as golang
 WORKDIR /linkerd-build
 RUN mkdir -p web
 COPY web/main.go web
diff --git a/web/app/.eslintrc b/web/app/.eslintrc
index c578089c6..852a1cabd 100644
--- a/web/app/.eslintrc
+++ b/web/app/.eslintrc
@@ -84,7 +84,6 @@
 
     // These rules represent best practices that we're not adhering to, and should be enabled
     "jsx-a11y/click-events-have-key-events": 0,
-    "no-param-reassign": 0,
-    "react/no-did-update-set-state": 0
+    "no-param-reassign": 0
   }
 }
diff --git a/web/app/js/components/Namespace.jsx b/web/app/js/components/Namespace.jsx
index 5532f7f77..65f32e77c 100644
--- a/web/app/js/components/Namespace.jsx
+++ b/web/app/js/components/Namespace.jsx
@@ -48,7 +48,7 @@ class Namespaces extends React.Component {
     if (!_isEqual(prevProps.match.params.namespace, params.namespace)) {
       // React won't unmount this component when switching resource pages so we need to clear state
       this.api.cancelCurrentRequests();
-      this.setState(this.getInitialState(params));
+      this.resetState(params);
     }
 
     handlePageVisibility({
@@ -59,6 +59,10 @@ class Namespaces extends React.Component {
     });
   }
 
+  resetState(params) {
+    this.setState(this.getInitialState(params));
+  }
+
   componentWillUnmount() {
     this.stopServerPolling();
   }
diff --git a/web/app/js/components/ResourceDetail.jsx b/web/app/js/components/ResourceDetail.jsx
index 0bb51c718..d868fde6c 100644
--- a/web/app/js/components/ResourceDetail.jsx
+++ b/web/app/js/components/ResourceDetail.jsx
@@ -93,7 +93,7 @@ export class ResourceDetailBase extends React.Component {
       // React won't unmount this component when switching resource pages so we need to clear state
       this.api.cancelCurrentRequests();
       this.unmeshedSources = {};
-      this.setState(this.getInitialState(match, pathPrefix));
+      this.resetState(match, pathPrefix);
     }
 
     handlePageVisibility({
@@ -104,6 +104,10 @@ export class ResourceDetailBase extends React.Component {
     });
   }
 
+  resetState(match, pathPrefix) {
+    this.setState(this.getInitialState(match, pathPrefix));
+  }
+
   componentWillUnmount() {
     this.stopServerPolling();
   }
diff --git a/web/app/js/components/util/withREST.jsx b/web/app/js/components/util/withREST.jsx
index 7f5ac7ec6..211497f13 100644
--- a/web/app/js/components/util/withREST.jsx
+++ b/web/app/js/components/util/withREST.jsx
@@ -56,10 +56,14 @@ const withREST = (WrappedComponent, componentPromises, options = {}) => {
 
       // React won't unmount this component when switching resource pages so we need to clear state
       this.stopServerPolling();
-      this.setState(this.getInitialState());
+      this.resetState();
       this.startServerPolling(this.props);
     }
 
+    resetState() {
+      this.setState(this.getInitialState());
+    }
+
     componentWillUnmount() {
       this.stopServerPolling();
     }


Test patch:
diff --git a/bin/_test-run.sh b/bin/_test-run.sh
index 92af0723a..53ba6e761 100755
--- a/bin/_test-run.sh
+++ b/bin/_test-run.sh
@@ -3,6 +3,11 @@
 # This file is a collection of helper functions for running integration tests.
 # It is used primarily by `bin/test-run` and ci.
 
+# Returns the latest stable verson
+latest_stable() {
+  curl -s https://versioncheck.linkerd.io/version.json | grep -o "stable-[0-9]*.[0-9]*.[0-9]*"
+}
+
 # init_test_run parses input params, initializes global vars, and checks for
 # linkerd and kubectl. Call this prior to calling any of the
 # *_integration_tests() functions.
@@ -42,6 +47,20 @@ upgrade_integration_tests() {
     cleanup
 }
 
+helm_upgrade_integration_tests() {
+    helm_path=$bindir/helm
+    helm_chart="$( cd "$bindir"/.. && pwd )"/charts/linkerd2
+    helm_release_name=$linkerd_namespace-test
+    tiller_namespace=$linkerd_namespace-tiller
+
+    run_helm_upgrade_test
+    exit_on_err 'error testing Helm upgrade'
+    helm_cleanup
+    exit_on_err 'error cleaning up Helm upgrade'
+    # clean the data plane test resources
+    cleanup
+}
+
 helm_integration_tests() {
     helm_path=$bindir/helm
     helm_chart="$( cd "$bindir"/.. && pwd )"/charts/linkerd2
@@ -158,14 +177,14 @@ install_stable() {
 # $1 - namespace to use for the stable release
 run_upgrade_test() {
     local stable_namespace=$1
-    local stable_version=$(curl -s https://versioncheck.linkerd.io/version.json | grep -o "stable-[0-9]*.[0-9]*.[0-9]*")
+    local stable_version=$(latest_stable)
 
     install_stable $stable_namespace
     run_test "$test_directory/install_test.go" --upgrade-from-version=$stable_version --linkerd-namespace=$stable_namespace
 }
 
-run_helm_test() {
-    (
+setup_helm() {
+      (
         set -e
         kubectl --context=$k8s_context create ns $tiller_namespace
         kubectl --context=$k8s_context label ns $tiller_namespace linkerd.io/is-test-helm=true
@@ -173,8 +192,20 @@ run_helm_test() {
         kubectl --context=$k8s_context label clusterrolebinding ${tiller_namespace}:tiller-cluster-admin linkerd.io/is-test-helm=true
         "$helm_path" --kube-context=$k8s_context --tiller-namespace=$tiller_namespace init --wait
         "$helm_path" --kube-context=$k8s_context --tiller-namespace=$tiller_namespace dependency update "$helm_chart"
+        "$helm_path" --kube-context=$k8s_context --tiller-namespace=$tiller_namespace repo add linkerd https://helm.linkerd.io/stable
     )
     exit_on_err 'error setting up Helm'
+}
+
+run_helm_upgrade_test() {
+    setup_helm
+    local stable_version=$(latest_stable)
+    run_test "$test_directory/install_test.go" --linkerd-namespace=$linkerd_namespace-helm \
+        --helm-path="$helm_path" --helm-chart="$helm_chart" --helm-stable-chart="linkerd/linkerd2" --helm-release=$helm_release_name --tiller-ns=$tiller_namespace --upgrade-helm-from-version="$stable_version"
+}
+
+run_helm_test() {
+    setup_helm
     run_test "$test_directory/install_test.go" --linkerd-namespace=$linkerd_namespace-helm \
         --helm-path="$helm_path" --helm-chart="$helm_chart" --helm-release=$helm_release_name --tiller-ns=$tiller_namespace
 }
diff --git a/bin/test-run b/bin/test-run
index 0c75b5d41..582c86cb0 100755
--- a/bin/test-run
+++ b/bin/test-run
@@ -3,8 +3,9 @@
 # This script executes the full suite of integration tests, in series:
 # 1. upgrade_integration_tests
 # 2. helm_integration_tests
-# 3. deep_integration_tests
-# 4. external_issuer_integration_tests
+# 3. helm_upgrade_integration_tests
+# 4. deep_integration_tests
+# 5. external_issuer_integration_tests
 
 bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
 
@@ -17,6 +18,7 @@ printf 'Testing Linkerd version [%s] namespace [%s] k8s-context [%s]\n' "$linker
 
 upgrade_integration_tests
 helm_integration_tests
+helm_upgrade_integration_tests
 deep_integration_tests
 external_issuer_integration_tests
 
diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go
index 15e591717..0ce06776e 100644
--- a/pkg/healthcheck/healthcheck_test.go
+++ b/pkg/healthcheck/healthcheck_test.go
@@ -2395,13 +2395,24 @@ data:
 	}
 }
 
-func getFakeConfig(secretScheme string, schemeInConfig string, issuerCerts *issuercerts.IssuerCertData, configAnchorsModifier func(string) string) []string {
-
-	var resources []string
-	base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerCrt))
+func getFakeConfigMap(scheme string, issuerCerts *issuercerts.IssuerCertData) string {
+	anchors, _ := json.Marshal(issuerCerts.TrustAnchors)
+	return fmt.Sprintf(`
+kind: ConfigMap
+apiVersion: v1
+metadata:
+  name: linkerd-config
+  namespace: linkerd
+data:
+  global: |
+    {"linkerdNamespace": "linkerd", "identityContext":{"trustAnchorsPem": %s, "trustDomain": "cluster.local", "scheme": "%s"}}
+---
+`, anchors, scheme)
+}
 
-	if secretScheme == k8s.IdentityIssuerSchemeLinkerd {
-		resources = append(resources, fmt.Sprintf(`
+func getFakeSecret(scheme string, issuerCerts *issuercerts.IssuerCertData) string {
+	if scheme == k8s.IdentityIssuerSchemeLinkerd {
+		return fmt.Sprintf(`
 kind: Secret
 apiVersion: v1
 metadata:
@@ -2411,10 +2422,10 @@ data:
   crt.pem: %s
   key.pem: %s
 ---
-`, base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerCrt)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerKey))))
-	} else {
-		resources = append(resources, fmt.Sprintf(
-			`
+`, base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerCrt)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerKey)))
+	}
+	return fmt.Sprintf(
+		`
 kind: Secret
 apiVersion: v1
 metadata:
@@ -2425,31 +2436,10 @@ data:
   tls.crt: %s
   tls.key: %s
 ---
-`, base64.StdEncoding.EncodeToString([]byte(issuerCerts.TrustAnchors)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerCrt)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerKey))))
-	}
-	anchorsToEncode := issuerCerts.TrustAnchors
-	if configAnchorsModifier != nil {
-		anchorsToEncode = configAnchorsModifier(anchorsToEncode)
-	}
-	anchors, _ := json.Marshal(anchorsToEncode)
-
-	resources = append(resources, fmt.Sprintf(`
-kind: ConfigMap
-apiVersion: v1
-metadata:
-  name: linkerd-config
-  namespace: linkerd
-data:
-  global: |
-    {"linkerdNamespace": "linkerd", "identityContext":{"trustAnchorsPem": %s, "trustDomain": "cluster.local", "scheme": "%s"}}
----
-`, anchors, schemeInConfig))
-
-	return resources
+`, base64.StdEncoding.EncodeToString([]byte(issuerCerts.TrustAnchors)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerCrt)), base64.StdEncoding.EncodeToString([]byte(issuerCerts.IssuerKey)))
 }
 
 func createIssuerData(dnsName string, notBefore, notAfter time.Time) *issuercerts.IssuerCertData {
-
 	// Generate a new root key.
 	key, _ := tls.GenerateKey()
 
@@ -2465,89 +2455,157 @@ func createIssuerData(dnsName string, notBefore, notAfter time.Time) *issuercert
 	}
 }
 
-func TestLinkerdIdentityCheck(t *testing.T) {
-	type lifeSpan struct {
-		starts time.Time
-		ends   time.Time
-	}
+type lifeSpan struct {
+	starts time.Time
+	ends   time.Time
+}
+
+func runIdentityCheckTestCase(t *testing.T, testID int, testDescription string, checkerToTest string, fakeConfigMap string, fakeSecret string, expectedOutput []string) {
+	t.Run(fmt.Sprintf("%d/%s", testID, testDescription), func(t *testing.T) {
+		hc := NewHealthChecker(
+			[]CategoryID{},
+			&Options{
+				DataPlaneNamespace: "linkerd",
+			},
+		)
+		hc.addCheckAsCategory("linkerd-identity-test-cat", LinkerdIdentity, checkerToTest)
+		var err error
+		hc.ControlPlaneNamespace = "linkerd"
+		hc.kubeAPI, err = k8s.NewFakeAPI(fakeConfigMap, fakeSecret)
+		_, hc.linkerdConfig, _ = hc.checkLinkerdConfigConfigMap()
+
+		if testDescription != "certificate config is valid" {
+			hc.issuerCert, hc.roots, _ = hc.checkCertificatesConfig()
+		}
+
+		if err != nil {
+			t.Fatalf("Unexpected error: %s", err)
+		}
+
+		obs := newObserver()
+		hc.RunChecks(obs.resultFn)
+		if !reflect.DeepEqual(obs.results, expectedOutput) {
+			t.Fatalf("Expected results %v, but got %v", expectedOutput, obs.results)
+		}
+	})
+}
+
+func TestLinkerdIdentityCheckCertConfig(t *testing.T) {
 	var testCases = []struct {
-		checkDescription      string
-		certificateDNSName    string
-		lifespan              *lifeSpan
-		tlsSecretScheme       string
-		schemeInConfig        string
-		expectedOutput        []string
-		checkerToTest         string
-		configAnchorsModifier func(string) string
+		checkDescription            string
+		tlsSecretScheme             string
+		schemeInConfig              string
+		expectedOutput              []string
+		configMapIssuerDataModifier func(issuercerts.IssuerCertData) issuercerts.IssuerCertData
+		tlsSecretIssuerDataModifier func(issuercerts.IssuerCertData) issuercerts.IssuerCertData
 	}{
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "works with valid cert and linkerd.io/tls secret",
 			tlsSecretScheme:  k8s.IdentityIssuerSchemeLinkerd,
 			schemeInConfig:   k8s.IdentityIssuerSchemeLinkerd,
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "works with valid cert and kubernetes.io/tls secret",
 			tlsSecretScheme:  string(corev1.SecretTypeTLS),
 			schemeInConfig:   string(corev1.SecretTypeTLS),
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "works if config scheme is empty and secret scheme is linkerd.io/tls (pre 2.7)",
 			tlsSecretScheme:  k8s.IdentityIssuerSchemeLinkerd,
 			schemeInConfig:   "",
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "fails if config scheme is empty and secret scheme is kubernetes.io/tls (pre 2.7)",
 			tlsSecretScheme:  string(corev1.SecretTypeTLS),
 			schemeInConfig:   "",
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid: key crt.pem containing the issuer certificate needs to exist in secret linkerd-identity-issuer if --identity-external-issuer=false"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "fails when config scheme is linkerd.io/tls but secret scheme is kubernetes.io/tls in config is different than the one in the issuer secret",
 			tlsSecretScheme:  string(corev1.SecretTypeTLS),
 			schemeInConfig:   k8s.IdentityIssuerSchemeLinkerd,
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid: key crt.pem containing the issuer certificate needs to exist in secret linkerd-identity-issuer if --identity-external-issuer=false"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
 			checkDescription: "fails when config scheme is kubernetes.io/tls but secret scheme is linkerd.io/tls in config is different than the one in the issuer secret",
 			tlsSecretScheme:  k8s.IdentityIssuerSchemeLinkerd,
 			schemeInConfig:   string(corev1.SecretTypeTLS),
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid: key ca.crt containing the trust anchors needs to exist in secret linkerd-identity-issuer if --identity-external-issuer=true"},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
-			checkDescription: "does not get influenced by newline differences between trust anchors (missing newline)",
+			checkDescription: "does not get influenced by newline differences between trust anchors (missing newline in configMap)",
+			tlsSecretScheme:  string(corev1.SecretTypeTLS),
+			schemeInConfig:   string(corev1.SecretTypeTLS),
+			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
+			configMapIssuerDataModifier: func(issuerData issuercerts.IssuerCertData) issuercerts.IssuerCertData {
+				issuerData.TrustAnchors = strings.TrimSpace(issuerData.TrustAnchors)
+				return issuerData
+			},
+		},
+		{
+			checkDescription: "does not get influenced by newline differences between trust anchors (extra newline in configMap)",
 			tlsSecretScheme:  string(corev1.SecretTypeTLS),
 			schemeInConfig:   string(corev1.SecretTypeTLS),
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
-			configAnchorsModifier: func(anchors string) string {
-				return strings.TrimSpace(anchors)
+			configMapIssuerDataModifier: func(issuerData issuercerts.IssuerCertData) issuercerts.IssuerCertData {
+				issuerData.TrustAnchors = issuerData.TrustAnchors + "\n"
+				return issuerData
 			},
 		},
 		{
-			checkerToTest:    "certificate config is valid",
-			checkDescription: "does not get influenced by newline differences between trust anchors (extra newline)",
+			checkDescription: "does not get influenced by newline differences between trust anchors (missing newline in secret)",
 			tlsSecretScheme:  string(corev1.SecretTypeTLS),
 			schemeInConfig:   string(corev1.SecretTypeTLS),
 			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid"},
-			configAnchorsModifier: func(anchors string) string {
-				return anchors + "\n"
+			tlsSecretIssuerDataModifier: func(issuerData issuercerts.IssuerCertData) issuercerts.IssuerCertData {
+				issuerData.TrustAnchors = strings.TrimSpace(issuerData.TrustAnchors)
+				return issuerData
 			},
 		},
 		{
-			checkerToTest:      "issuer cert is issued by the trust root",
-			checkDescription:   "fails when cert dns is wrong",
-			certificateDNSName: "wrong.linkerd.cluster.local",
-			expectedOutput:     []string{"linkerd-identity-test-cat issuer cert is issued by the trust root: x509: certificate is valid for wrong.linkerd.cluster.local, not identity.linkerd.cluster.local"},
+			checkDescription: "fails when trying to parse trust anchors from secret (extra newline in secret)",
+			tlsSecretScheme:  string(corev1.SecretTypeTLS),
+			schemeInConfig:   string(corev1.SecretTypeTLS),
+			expectedOutput:   []string{"linkerd-identity-test-cat certificate config is valid: not a PEM certificate"},
+			tlsSecretIssuerDataModifier: func(issuerData issuercerts.IssuerCertData) issuercerts.IssuerCertData {
+				issuerData.TrustAnchors = issuerData.TrustAnchors + "\n"
+				return issuerData
+			},
 		},
+	}
+
+	for id, testCase := range testCases {
+		testCase := testCase
+		issuerData := createIssuerData("identity.linkerd.cluster.local", time.Now().AddDate(-1, 0, 0), time.Now().AddDate(1, 0, 0))
+		var fakeConfigMap string
+		if testCase.configMapIssuerDataModifier != nil {
+			modifiedIssuerData := testCase.configMapIssuerDataModifier(*issuerData)
+			fakeConfigMap = getFakeConfigMap(testCase.schemeInConfig, &modifiedIssuerData)
+		} else {
+			fakeConfigMap = getFakeConfigMap(testCase.schemeInConfig, issuerData)
+		}
+
+		var fakeSecret string
+		if testCase.tlsSecretIssuerDataModifier != nil {
+			modifiedIssuerData := testCase.tlsSecretIssuerDataModifier(*issuerData)
+			fakeSecret = getFakeSecret(testCase.tlsSecretScheme, &modifiedIssuerData)
+		} else {
+			fakeSecret = getFakeSecret(testCase.tlsSecretScheme, issuerData)
+		}
+		runIdentityCheckTestCase(t, id, testCase.checkDescription, "certificate config is valid", fakeConfigMap, fakeSecret, testCase.expectedOutput)
+	}
+}
+
+func TestLinkerdIdentityCheckCertValidity(t *testing.T) {
+	var testCases = []struct {
+		checkDescription string
+		checkerToTest    string
+		lifespan         *lifeSpan
+		expectedOutput   []string
+	}{
 		{
 			checkerToTest:    "trust roots are within their validity period",
 			checkDescription: "fails when the only root cert is not valid yet",
@@ -2588,52 +2646,20 @@ func TestLinkerdIdentityCheck(t *testing.T) {
 
 	for id, testCase := range testCases {
 		testCase := testCase
+		issuerData := createIssuerData("identity.linkerd.cluster.local", testCase.lifespan.starts, testCase.lifespan.ends)
+		fakeConfigMap := getFakeConfigMap(k8s.IdentityIssuerSchemeLinkerd, issuerData)
+		fakeSecret := getFakeSecret(k8s.IdentityIssuerSchemeLinkerd, issuerData)
+		runIdentityCheckTestCase(t, id, testCase.checkDescription, testCase.checkerToTest, fakeConfigMap, fakeSecret, testCase.expectedOutput)
+	}
+}
 
-		if testCase.certificateDNSName == "" {
-			testCase.certificateDNSName = "identity.linkerd.cluster.local"
-		}
-		if testCase.lifespan == nil {
-			testCase.lifespan = &lifeSpan{
-				starts: time.Now().AddDate(-1, 0, 0),
-				ends:   time.Now().AddDate(1, 0, 0),
-			}
-		}
-
-		if testCase.schemeInConfig == "" && testCase.tlsSecretScheme == "" {
-			testCase.schemeInConfig = k8s.IdentityIssuerSchemeLinkerd
-			testCase.tlsSecretScheme = k8s.IdentityIssuerSchemeLinkerd
-		}
-
-		t.Run(fmt.Sprintf("%d/%s", id, testCase.checkDescription), func(t *testing.T) {
-			hc := NewHealthChecker(
-				[]CategoryID{},
-				&Options{
-					DataPlaneNamespace: "linkerd",
-				},
-			)
-			hc.addCheckAsCategory("linkerd-identity-test-cat", LinkerdIdentity, testCase.checkerToTest)
-			var err error
-			hc.ControlPlaneNamespace = "linkerd"
-			issuerData := createIssuerData(testCase.certificateDNSName, testCase.lifespan.starts, testCase.lifespan.ends)
-			config := getFakeConfig(testCase.tlsSecretScheme, testCase.schemeInConfig, issuerData, testCase.configAnchorsModifier)
-			hc.kubeAPI, err = k8s.NewFakeAPI(config...)
-			_, hc.linkerdConfig, _ = hc.checkLinkerdConfigConfigMap()
-
-			if testCase.checkDescription != "certificate config is valid" {
-				hc.issuerCert, hc.roots, _ = hc.checkCertificatesConfig()
-			}
-
-			if err != nil {
-				t.Fatalf("Unexpected error: %s", err)
-			}
+func TestLinkerdIdentityCheckWrongDns(t *testing.T) {
+	expectedOutput := []string{"linkerd-identity-test-cat issuer cert is issued by the trust root: x509: certificate is valid for wrong.linkerd.cluster.local, not identity.linkerd.cluster.local"}
+	issuerData := createIssuerData("wrong.linkerd.cluster.local", time.Now().AddDate(-1, 0, 0), time.Now().AddDate(1, 0, 0))
+	fakeConfigMap := getFakeConfigMap(k8s.IdentityIssuerSchemeLinkerd, issuerData)
+	fakeSecret := getFakeSecret(k8s.IdentityIssuerSchemeLinkerd, issuerData)
+	runIdentityCheckTestCase(t, 0, "fails when cert dns is wrong", "issuer cert is issued by the trust root", fakeConfigMap, fakeSecret, expectedOutput)
 
-			obs := newObserver()
-			hc.RunChecks(obs.resultFn)
-			if !reflect.DeepEqual(obs.results, testCase.expectedOutput) {
-				t.Fatalf("Expected results %v, but got %v", testCase.expectedOutput, obs.results)
-			}
-		})
-	}
 }
 
 type fakeCniResourcesOpts struct {
@@ -3003,3 +3029,125 @@ func TestCniChecks(t *testing.T) {
 	}
 
 }
+
+func TestMinReplicaCheck(t *testing.T) {
+	hc := NewHealthChecker(
+		[]CategoryID{LinkerdHAChecks},
+		&Options{
+			ControlPlaneNamespace: "linkerd",
+		},
+	)
+
+	var err error
+
+	testCases := []struct {
+		controlPlaneResourceDefs []string
+		expected                 error
+	}{
+		{
+			controlPlaneResourceDefs: generateAllControlPlaneDef(&controlPlaneReplicaOptions{
+				controller:    1,
+				destination:   3,
+				identity:      3,
+				proxyInjector: 3,
+				spValidator:   1,
+				tap:           3,
+			}, t),
+			expected: fmt.Errorf("not enough replicas available for [linkerd-controller linkerd-sp-validator]"),
+		},
+		{
+			controlPlaneResourceDefs: generateAllControlPlaneDef(&controlPlaneReplicaOptions{
+				controller:    3,
+				destination:   2,
+				identity:      1,
+				proxyInjector: 1,
+				spValidator:   0,
+				tap:           3,
+			}, t),
+			expected: fmt.Errorf("not enough replicas available for [linkerd-identity linkerd-proxy-injector linkerd-sp-validator]"),
+		},
+		{
+			controlPlaneResourceDefs: generateAllControlPlaneDef(&controlPlaneReplicaOptions{
+				controller:    3,
+				destination:   2,
+				identity:      2,
+				proxyInjector: 3,
+				spValidator:   2,
+				tap:           3,
+			}, t),
+			expected: nil,
+		},
+	}
+
+	for i, tc := range testCases {
+		tc := tc //pin
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			hc.kubeAPI, err = k8s.NewFakeAPI(tc.controlPlaneResourceDefs...)
+			if err != nil {
+				t.Fatal(err)
+			}
+			err = hc.checkMinReplicasAvailable()
+			if err == nil && tc.expected != nil {
+				t.Log("Expected error: nil")
+				t.Logf("Received error: %s\n", err)
+				t.Fatal("test case failed")
+			}
+			if err != nil {
+				if err.Error() != tc.expected.Error() {
+					t.Logf("Expected error: %s\n", tc.expected)
+					t.Logf("Received error: %s\n", err)
+					t.Fatal("test case failed")
+				}
+			}
+		})
+	}
+}
+
+type controlPlaneReplicaOptions struct {
+	controller    int
+	destination   int
+	identity      int
+	proxyInjector int
+	spValidator   int
+	tap           int
+}
+
+func getSingleControlPlaneDef(component string, availableReplicas int) string {
+	return fmt.Sprintf(`
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: %s
+  namespace: linkerd
+spec:
+  template:
+    spec:
+      containers:
+        - image: "hello-world"
+          name: test
+status:
+  availableReplicas: %d`, component, availableReplicas)
+}
+
+func generateAllControlPlaneDef(replicaOptions *controlPlaneReplicaOptions, t *testing.T) []string {
+	resourceDefs := []string{}
+	for _, component := range linkerdHAControlPlaneComponents {
+		switch component {
+		case "linkerd-controller":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.controller))
+		case "linkerd-destination":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.destination))
+		case "linkerd-identity":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.identity))
+		case "linkerd-sp-validator":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.spValidator))
+		case "linkerd-proxy-injector":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.proxyInjector))
+		case "linkerd-tap":
+			resourceDefs = append(resourceDefs, getSingleControlPlaneDef(component, replicaOptions.tap))
+		default:
+			t.Fatal("Could not find the resource")
+		}
+	}
+	return resourceDefs
+}
diff --git a/test/egress/egress_test.go b/test/egress/egress_test.go
index d942ebfd2..2216db3cc 100644
--- a/test/egress/egress_test.go
+++ b/test/egress/egress_test.go
@@ -1,10 +1,8 @@
 package egress
 
 import (
-	"encoding/json"
 	"fmt"
 	"os"
-	"strings"
 	"testing"
 
 	"github.com/linkerd/linkerd2/testutil"
@@ -16,14 +14,6 @@ import (
 
 var TestHelper *testutil.TestHelper
 
-var egressHTTPDeployments = []string{
-	"egress-test-https-post",
-	"egress-test-http-post",
-	"egress-test-https-get",
-	"egress-test-http-get",
-	"egress-test-not-www-get",
-}
-
 func TestMain(m *testing.M) {
 	TestHelper = testutil.NewTestHelper()
 	os.Exit(m.Run())
@@ -33,14 +23,6 @@ func TestMain(m *testing.M) {
 /// TEST EXECUTION ///
 //////////////////////
 
-// issue: https://github.com/linkerd/linkerd2/issues/2316
-//
-// The response from `http://httpbin.org/get` is non-deterministic--returning
-// either `http://..` or `https://..` for GET requests. As #2316 mentions,
-// this test should not have an external dependency on this endpoint. As a
-// workaround for edge-20.1.3, temporarily expect either `http` or `https` so
-// that the test is not completely disabled.
-
 func TestEgressHttp(t *testing.T) {
 	out, stderr, err := TestHelper.LinkerdRun("inject", "testdata/proxy.yaml")
 	if err != nil {
@@ -57,43 +39,31 @@ func TestEgressHttp(t *testing.T) {
 		t.Fatalf("Unexpected error: %v output:\n%s", err, out)
 	}
 
-	for _, deploy := range egressHTTPDeployments {
-		err = TestHelper.CheckPods(prefixedNs, deploy, 1)
-		if err != nil {
-			t.Fatalf("Unexpected error: %v", err)
-		}
+	err = TestHelper.CheckPods(prefixedNs, "egress-test", 1)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
 	}
 
-	testCase := func(deployName, dnsName, protocolToUse, methodToUse string) {
-		testName := fmt.Sprintf("Can use egress to send %s request to %s (%s)", methodToUse, protocolToUse, deployName)
+	testCase := func(url, methodToUse string) {
+		testName := fmt.Sprintf("Can use egress to send %s request to (%s)", methodToUse, url)
 		t.Run(testName, func(t *testing.T) {
-			expectedURL := fmt.Sprintf("%s://%s/%s", protocolToUse, dnsName, strings.ToLower(methodToUse))
-
-			url, err := TestHelper.URLFor(prefixedNs, deployName, 8080)
-			if err != nil {
-				t.Fatalf("Failed to get proxy URL: %s", err)
+			cmd := []string{
+				"-n", prefixedNs, "exec", "deploy/egress-test", "-c", "http-egress",
+				"--", "curl", "-sko", "/dev/null", "-w", "%{http_code}", "-X", methodToUse, url,
 			}
-
-			output, err := TestHelper.HTTPGetURL(url)
+			out, err := TestHelper.Kubectl("", cmd...)
 			if err != nil {
-				t.Fatalf("Unexpected error: %v", err)
+				t.Fatalf("Failed to exec %s: %s (%s)", cmd, err, out)
 			}
-			var jsonResponse map[string]interface{}
-			json.Unmarshal([]byte(output), &jsonResponse)
 
-			payloadText := jsonResponse["payload"]
-			if payloadText == nil {
-				t.Fatalf("Expected [%s] request to [%s] to return a payload, got nil. Response:\n%s\n", methodToUse, expectedURL, output)
+			var status int
+			_, err = fmt.Sscanf(out, "%d", &status)
+			if err != nil {
+				t.Fatalf("Failed to parse status code (%s): %s", out, err)
 			}
 
-			var messagePayload map[string]interface{}
-			json.Unmarshal([]byte(payloadText.(string)), &messagePayload)
-
-			expectedResponseURL1 := fmt.Sprintf("http://%s/%s", dnsName, strings.ToLower(methodToUse))
-			expectedResponseURL2 := fmt.Sprintf("https://%s/%s", dnsName, strings.ToLower(methodToUse))
-			actualURL := messagePayload["url"]
-			if actualURL != expectedResponseURL1 && actualURL != expectedResponseURL2 {
-				t.Fatalf("Expecting response to say egress sent [%s] request to be either URL [%s] or URL [%s], but got [%s]. Response:\n%s\n", methodToUse, expectedResponseURL1, expectedResponseURL2, actualURL, output)
+			if status < 100 || status >= 500 {
+				t.Fatalf("Got HTTP error code: %d\n", status)
 			}
 		})
 	}
@@ -102,11 +72,11 @@ func TestEgressHttp(t *testing.T) {
 	methods := []string{"GET", "POST"}
 	for _, protocolToUse := range supportedProtocols {
 		for _, methodToUse := range methods {
-			serviceName := fmt.Sprintf("egress-test-%s-%s", protocolToUse, strings.ToLower(methodToUse))
-			testCase(serviceName, "www.httpbin.org", protocolToUse, methodToUse)
+			serviceName := fmt.Sprintf("%s://www.linkerd.io", protocolToUse)
+			testCase(serviceName, methodToUse)
 		}
 	}
 
 	// Test egress for a domain with fewer than 3 segments.
-	testCase("egress-test-not-www-get", "httpbin.org", "https", "GET")
+	testCase("http://linkerd.io", "GET")
 }
diff --git a/test/egress/testdata/proxy.yaml b/test/egress/testdata/proxy.yaml
index f222064b5..e7c134873 100644
--- a/test/egress/testdata/proxy.yaml
+++ b/test/egress/testdata/proxy.yaml
@@ -1,203 +1,18 @@
-#POST
 ---
 apiVersion: apps/v1
 kind: Deployment
 metadata:
-  name: egress-test-https-post
+  name: egress-test
 spec:
   replicas: 1
   selector:
     matchLabels:
-      app: egress-test-https-post
+      app: egress-test
   template:
     metadata:
       labels:
-        app: egress-test-https-post
+        app: egress-test
     spec:
       containers:
-      - name: http-egress-https-post
-        image: buoyantio/bb:v0.0.5
-        args:
-          - "http-egress"
-          - "--h1-server-port"
-          - "8080"
-          - "--url"
-          - "https://www.httpbin.org/post"
-          - "--method"
-          - "POST"
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: egress-test-https-post-svc
-spec:
-  selector:
-    app: egress-test-https-post
-  ports:
-  - name: http
-    port: 8080
-    targetPort: 8080
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: egress-test-http-post
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: egress-test-http-post
-  template:
-    metadata:
-      labels:
-        app: egress-test-http-post
-    spec:
-      containers:
-      - name: http-egress-http-post
-        image: buoyantio/bb:v0.0.5
-        args:
-          - "http-egress"
-          - "--h1-server-port"
-          - "8080"
-          - "--url"
-          - "http://www.httpbin.org/post"
-          - "--method"
-          - "POST"
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: egress-test-http-post-svc
-spec:
-  selector:
-    app: egress-test-http-post
-  ports:
-  - name: http
-    port: 8080
-    targetPort: 8080
-#GET
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: egress-test-https-get
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: egress-test-https-get
-  template:
-    metadata:
-      labels:
-        app: egress-test-https-get
-    spec:
-      containers:
-      - name: http-egress-https-get
-        image: buoyantio/bb:v0.0.5
-        args:
-          - "http-egress"
-          - "--h1-server-port"
-          - "8080"
-          - "--url"
-          - "https://www.httpbin.org/get"
-          - "--method"
-          - "GET"
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: egress-test-https-get-svc
-spec:
-  selector:
-    app: egress-test-https-get
-  ports:
-  - name: http
-    port: 8080
-    targetPort: 8080
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: egress-test-http-get
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: egress-test-http-get
-  template:
-    metadata:
-      labels:
-        app: egress-test-http-get
-    spec:
-      containers:
-      - name: http-egress-http-get
-        image: buoyantio/bb:v0.0.5
-        args:
-          - "http-egress"
-          - "--h1-server-port"
-          - "8080"
-          - "--url"
-          - "http://www.httpbin.org/get"
-          - "--method"
-          - "GET"
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: egress-test-http-get-svc
-spec:
-  selector:
-    app: egress-test-http-get
-  ports:
-  - name: http
-    port: 8080
-    targetPort: 8080
-## Non www domains
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: egress-test-not-www-get
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: egress-test-not-www-get
-  template:
-    metadata:
-      labels:
-        app: egress-test-not-www-get
-    spec:
-      containers:
-      - name: egress-test-not-www-get
-        image: buoyantio/bb:v0.0.5
-        args:
-          - "http-egress"
-          - "--h1-server-port"
-          - "8080"
-          - "--url"
-          - "https://httpbin.org/get"
-          - "--method"
-          - "GET"
-        ports:
-        - containerPort: 8080
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: egress-test-not-www-get-svc
-spec:
-  selector:
-    app: egress-test-not-www-get
-  ports:
-  - name: http
-    port: 8080
-    targetPort: 8080
+      - name: http-egress
+        image: gcr.io/linkerd-io/debug:stable-2.6.1
diff --git a/test/externalissuer/external_issuer_test.go b/test/externalissuer/external_issuer_test.go
index 12a03e4a2..75e97f7c8 100644
--- a/test/externalissuer/external_issuer_test.go
+++ b/test/externalissuer/external_issuer_test.go
@@ -52,13 +52,12 @@ func verifyInstallApp(t *testing.T) {
 		t.Fatalf("kubectl apply command failed\n%s", out)
 	}
 
-	// wait for deployment to start
 	if err := TestHelper.CheckPods(prefixedNs, TestAppBackendDeploymentName, 1); err != nil {
 		t.Error(err)
 	}
 
-	if err := TestHelper.CheckDeployment(prefixedNs, TestAppBackendDeploymentName, 1); err != nil {
-		t.Error(fmt.Errorf("Error validating deployment [%s]:\n%s", TestAppBackendDeploymentName, err))
+	if err := TestHelper.CheckPods(prefixedNs, "slow-cooker", 1); err != nil {
+		t.Error(err)
 	}
 }
 
diff --git a/test/install_test.go b/test/install_test.go
index 23026e932..2dcfdee30 100644
--- a/test/install_test.go
+++ b/test/install_test.go
@@ -34,6 +34,8 @@ func TestMain(m *testing.M) {
 var (
 	configMapUID string
 
+	helmTLSCerts *tls.CA
+
 	linkerdSvcs = []string{
 		"linkerd-controller-api",
 		"linkerd-dst",
@@ -329,18 +331,23 @@ func TestInstallOrUpgradeCli(t *testing.T) {
 
 }
 
-func TestInstallHelm(t *testing.T) {
-	if TestHelper.GetHelmReleaseName() == "" {
-		return
-	}
-
-	cn := fmt.Sprintf("identity.%s.cluster.local", TestHelper.GetLinkerdNamespace())
-	root, err := tls.GenerateRootCAWithDefaults(cn)
-	if err != nil {
-		t.Fatalf("failed to generate root certificate for identity: %s", err)
+// These need to be updated (if there are changes) once a new stable is released
+func helmOverridesStable(root *tls.CA) []string {
+	return []string{
+		"--set", "controllerLogLevel=debug",
+		"--set", "global.linkerdVersion=" + TestHelper.UpgradeHelmFromVersion(),
+		"--set", "global.proxy.image.version=" + TestHelper.UpgradeHelmFromVersion(),
+		"--set", "global.identityTrustDomain=cluster.local",
+		"--set", "global.identityTrustAnchorsPEM=" + root.Cred.Crt.EncodeCertificatePEM(),
+		"--set", "identity.issuer.tls.crtPEM=" + root.Cred.Crt.EncodeCertificatePEM(),
+		"--set", "identity.issuer.tls.keyPEM=" + root.Cred.EncodePrivateKeyPEM(),
+		"--set", "identity.issuer.crtExpiry=" + root.Cred.Crt.Certificate.NotAfter.Format(time.RFC3339),
 	}
+}
 
-	args := []string{
+// These need to correspond to the flags in the current edge
+func helmOverridesEdge(root *tls.CA) []string {
+	return []string{
 		"--set", "controllerLogLevel=debug",
 		"--set", "global.linkerdVersion=" + TestHelper.GetVersion(),
 		"--set", "global.proxy.image.version=" + TestHelper.GetVersion(),
@@ -350,7 +357,32 @@ func TestInstallHelm(t *testing.T) {
 		"--set", "identity.issuer.tls.keyPEM=" + root.Cred.EncodePrivateKeyPEM(),
 		"--set", "identity.issuer.crtExpiry=" + root.Cred.Crt.Certificate.NotAfter.Format(time.RFC3339),
 	}
-	if stdout, stderr, err := TestHelper.HelmRun("install", args...); err != nil {
+}
+
+func TestInstallHelm(t *testing.T) {
+	if TestHelper.GetHelmReleaseName() == "" {
+		return
+	}
+
+	cn := fmt.Sprintf("identity.%s.cluster.local", TestHelper.GetLinkerdNamespace())
+	var err error
+	helmTLSCerts, err = tls.GenerateRootCAWithDefaults(cn)
+	if err != nil {
+		t.Fatalf("failed to generate root certificate for identity: %s", err)
+	}
+
+	var chartToInstall string
+	args := []string{"--name", TestHelper.GetHelmReleaseName()}
+
+	if TestHelper.UpgradeHelmFromVersion() != "" {
+		chartToInstall = TestHelper.GetHelmStableChart()
+		args = append(args, helmOverridesStable(helmTLSCerts)...)
+	} else {
+		chartToInstall = TestHelper.GetHelmChart()
+		args = append(args, helmOverridesEdge(helmTLSCerts)...)
+	}
+
+	if stdout, stderr, err := TestHelper.HelmInstall(chartToInstall, args...); err != nil {
 		t.Fatalf("helm install command failed\n%s\n%s", stdout, stderr)
 	}
 }
@@ -380,6 +412,29 @@ func TestResourcesPostInstall(t *testing.T) {
 	}
 }
 
+func TestCheckHelmStableBeforeUpgrade(t *testing.T) {
+	if TestHelper.UpgradeHelmFromVersion() == "" {
+		t.Skip("Skipping as this is not a helm upgrade test")
+	}
+	testCheckCommand(t, "", TestHelper.UpgradeHelmFromVersion(), "", TestHelper.UpgradeHelmFromVersion())
+}
+
+func TestUpgradeHelm(t *testing.T) {
+	if TestHelper.UpgradeHelmFromVersion() == "" {
+		t.Skip("Skipping as this is not a helm upgrade test")
+	}
+
+	args := []string{
+		"--reset-values",
+		"--atomic",
+		"--wait",
+	}
+	args = append(args, helmOverridesEdge(helmTLSCerts)...)
+	if stdout, stderr, err := TestHelper.HelmUpgrade(TestHelper.GetHelmChart(), args...); err != nil {
+		t.Fatalf("helm upgrade command failed\n%s\n%s", stdout, stderr)
+	}
+}
+
 func TestRetrieveUidPostUpgrade(t *testing.T) {
 	if TestHelper.UpgradeFromVersion() != "" {
 		newConfigMapUID, err := TestHelper.KubernetesHelper.GetConfigUID(TestHelper.GetLinkerdNamespace())
@@ -402,12 +457,25 @@ func TestVersionPostInstall(t *testing.T) {
 	}
 }
 
-// TODO: run this after a `linkerd install config`
-func TestCheckConfigPostInstall(t *testing.T) {
-	cmd := []string{"check", "config", "--expected-version", TestHelper.GetVersion(), "--wait=0"}
-	golden := "check.config.golden"
+func testCheckCommand(t *testing.T, stage string, expectedVersion string, namespace string, cliVersionOverride string) {
+	var cmd []string
+	var golden string
+	if stage == "proxy" {
+		cmd = []string{"check", "--proxy", "--expected-version", expectedVersion, "--namespace", namespace, "--wait=0"}
+		golden = "check.proxy.golden"
+	} else if stage == "config" {
+		cmd = []string{"check", "config", "--expected-version", expectedVersion, "--wait=0"}
+		golden = "check.config.golden"
+	} else {
+		cmd = []string{"check", "--expected-version", expectedVersion, "--wait=0"}
+		golden = "check.golden"
+	}
 
 	err := TestHelper.RetryFor(time.Minute, func() error {
+		if cliVersionOverride != "" {
+			cliVOverride := []string{"--cli-version-override", cliVersionOverride}
+			cmd = append(cmd, cliVOverride...)
+		}
 		out, stderr, err := TestHelper.LinkerdRun(cmd...)
 
 		if err != nil {
@@ -426,28 +494,15 @@ func TestCheckConfigPostInstall(t *testing.T) {
 	}
 }
 
-func TestCheckPostInstall(t *testing.T) {
-	cmd := []string{"check", "--expected-version", TestHelper.GetVersion(), "--wait=0"}
-	golden := "check.golden"
-
-	err := TestHelper.RetryFor(time.Minute, func() error {
-		out, stderr, err := TestHelper.LinkerdRun(cmd...)
-
-		if err != nil {
-			return fmt.Errorf("Check command failed\n%s\n%s", stderr, out)
-		}
-
-		err = TestHelper.ValidateOutput(out, golden)
-		if err != nil {
-			return fmt.Errorf("Received unexpected output\n%s", err.Error())
-		}
+// TODO: run this after a `linkerd install config`
+func TestCheckConfigPostInstall(t *testing.T) {
+	testCheckCommand(t, "config", TestHelper.GetVersion(), "", "")
+}
 
-		return nil
-	})
-	if err != nil {
-		t.Fatal(err.Error())
-	}
+func TestCheckPostInstall(t *testing.T) {
+	testCheckCommand(t, "", TestHelper.GetVersion(), "", "")
 }
+
 func TestUpgradeTestAppWorksAfterUpgrade(t *testing.T) {
 	if TestHelper.UpgradeFromVersion() != "" {
 		testAppNamespace := TestHelper.GetTestNamespace("upgrade-test")
@@ -603,25 +658,7 @@ func TestCheckProxy(t *testing.T) {
 		tc := tc // pin
 		t.Run(tc.ns, func(t *testing.T) {
 			prefixedNs := TestHelper.GetTestNamespace(tc.ns)
-			cmd := []string{"check", "--proxy", "--expected-version", TestHelper.GetVersion(), "--namespace", prefixedNs, "--wait=0"}
-			golden := "check.proxy.golden"
-
-			err := TestHelper.RetryFor(time.Minute, func() error {
-				out, stderr, err := TestHelper.LinkerdRun(cmd...)
-				if err != nil {
-					return fmt.Errorf("Check command failed\n%s\n%s", out, stderr)
-				}
-
-				err = TestHelper.ValidateOutput(out, golden)
-				if err != nil {
-					return fmt.Errorf("Received unexpected output\n%s", err.Error())
-				}
-
-				return nil
-			})
-			if err != nil {
-				t.Fatal(err.Error())
-			}
+			testCheckCommand(t, "proxy", TestHelper.GetVersion(), prefixedNs, "")
 		})
 	}
 }
diff --git a/testutil/kubernetes_helper.go b/testutil/kubernetes_helper.go
index 8176617d6..eaa332eee 100644
--- a/testutil/kubernetes_helper.go
+++ b/testutil/kubernetes_helper.go
@@ -107,7 +107,7 @@ func (h *KubernetesHelper) KubectlApply(stdin string, namespace string) (string,
 
 // Kubectl executes an arbitrary Kubectl command
 func (h *KubernetesHelper) Kubectl(stdin string, arg ...string) (string, error) {
-	withContext := append(arg, "--context="+h.k8sContext)
+	withContext := append([]string{"--context=" + h.k8sContext}, arg...)
 	cmd := exec.Command("kubectl", withContext...)
 	cmd.Stdin = strings.NewReader(stdin)
 	out, err := cmd.CombinedOutput()
diff --git a/testutil/test_helper.go b/testutil/test_helper.go
index 0f1f2e8ee..21d91ac27 100644
--- a/testutil/test_helper.go
+++ b/testutil/test_helper.go
@@ -33,10 +33,12 @@ type TestHelper struct {
 }
 
 type helm struct {
-	path        string
-	chart       string
-	releaseName string
-	tillerNs    string
+	path               string
+	chart              string
+	stableChart        string
+	releaseName        string
+	tillerNs           string
+	upgradeFromVersion string
 }
 
 // NewTestHelper creates a new instance of TestHelper for the current test run.
@@ -52,6 +54,7 @@ func NewTestHelper() *TestHelper {
 	namespace := flag.String("linkerd-namespace", "l5d-integration", "the namespace where linkerd is installed")
 	helmPath := flag.String("helm-path", "target/helm", "path of the Helm binary")
 	helmChart := flag.String("helm-chart", "charts/linkerd2", "path to linkerd2's Helm chart")
+	helmStableChart := flag.String("helm-stable-chart", "linkerd/linkerd2", "path to linkerd2's stable Helm chart")
 	helmReleaseName := flag.String("helm-release", "", "install linkerd via Helm using this release name")
 	tillerNs := flag.String("tiller-ns", "kube-system", "namespace under which Tiller will be installed")
 	upgradeFromVersion := flag.String("upgrade-from-version", "", "when specified, the upgrade test uses it as the base version of the upgrade")
@@ -59,6 +62,7 @@ func NewTestHelper() *TestHelper {
 	externalIssuer := flag.Bool("external-issuer", false, "when specified, the install test uses it to install linkerd with --identity-external-issuer=true")
 	runTests := flag.Bool("integration-tests", false, "must be provided to run the integration tests")
 	verbose := flag.Bool("verbose", false, "turn on debug logging")
+	upgradeHelmFromVersion := flag.String("upgrade-helm-from-version", "", "Indicate a version of the Linkerd helm chart from which the helm installation is being upgraded")
 	flag.Parse()
 
 	if !*runTests {
@@ -89,10 +93,12 @@ func NewTestHelper() *TestHelper {
 		namespace:          *namespace,
 		upgradeFromVersion: *upgradeFromVersion,
 		helm: helm{
-			path:        *helmPath,
-			chart:       *helmChart,
-			releaseName: *helmReleaseName,
-			tillerNs:    *tillerNs,
+			path:               *helmPath,
+			chart:              *helmChart,
+			stableChart:        *helmStableChart,
+			releaseName:        *helmReleaseName,
+			tillerNs:           *tillerNs,
+			upgradeFromVersion: *upgradeHelmFromVersion,
 		},
 		clusterDomain:  *clusterDomain,
 		externalIssuer: *externalIssuer,
@@ -141,6 +147,21 @@ func (h *TestHelper) GetHelmReleaseName() string {
 	return h.helm.releaseName
 }
 
+// GetHelmChart returns the path to the Linkerd Helm chart
+func (h *TestHelper) GetHelmChart() string {
+	return h.helm.chart
+}
+
+// GetHelmStableChart returns the path to the Linkerd Helm stable chart
+func (h *TestHelper) GetHelmStableChart() string {
+	return h.helm.stableChart
+}
+
+// UpgradeHelmFromVersion returns the version from which Linkerd should be upgraded with Helm
+func (h *TestHelper) UpgradeHelmFromVersion() string {
+	return h.helm.upgradeFromVersion
+}
+
 // ExternalIssuer determines whether linkerd should be installed with --identity-external-issuer
 func (h *TestHelper) ExternalIssuer() bool {
 	return h.externalIssuer
@@ -212,15 +233,28 @@ func (h *TestHelper) LinkerdRunStream(arg ...string) (*Stream, error) {
 	return &Stream{cmd: cmd, out: cmdReader}, nil
 }
 
-// HelmRun runs the provided Helm subcommand, with the provided arguments
-func (h *TestHelper) HelmRun(cmd string, arg ...string) (string, string, error) {
+// HelmUpgrade runs the helm upgrade subcommand, with the provided arguments
+func (h *TestHelper) HelmUpgrade(chart string, arg ...string) (string, string, error) {
+	withParams := append([]string{
+		"upgrade",
+		h.helm.releaseName,
+		"--kube-context", h.k8sContext,
+		"--tiller-namespace", h.helm.tillerNs,
+		"--set", "global.namespace=" + h.namespace,
+		chart,
+	}, arg...)
+	return combinedOutput("", h.helm.path, withParams...)
+}
+
+// HelmInstall runs the helm install subcommand, with the provided arguments
+func (h *TestHelper) HelmInstall(chart string, arg ...string) (string, string, error) {
 	withParams := append([]string{
-		cmd,
+		"install",
 		"--kube-context", h.k8sContext,
 		"--tiller-namespace", h.helm.tillerNs,
 		"--name", h.helm.releaseName,
 		"--set", "global.namespace=" + h.namespace,
-		h.helm.chart,
+		chart,
 	}, arg...)
 	return combinedOutput("", h.helm.path, withParams...)
 }