Repository: linkerd/linkerd2
Pull request #5294 (2020-11-30 15:17:30)

Add opaque ports to destination service updates
## Summary

This changes the destination service to start indicating whether a profile is an
opaque protocol or not.

Currently, profiles returned by the destination service are built by chaining
together updates coming from watching Profile and Traffic Split updates.

With this change, we now also watch updates to Opaque Port annotations on pods
and namespaces; if an update occurs this is now included in building a profile
update and is sent to the client.

## Details

Watching updates to Profiles and Traffic Splits is straightforward--we watch
those resources and if an update occurs on one associated to a service we care
about then the update is passed through.

For Opaque Ports this is a little different because it is an annotation on pods
or namespaces. To account for this, we watch the endpoints that we should care
about.

### When host is a Pod IP

When getting the profile for a Pod IP, we check for the opaque ports annotation
on the pod and the pod's namespace. If one is found, we'll indicate if the
profile is an opaque protocol if the requested port is in the annotation.

We do not subscribe for updates to this pod IP. The only update we really care
about is if the pod is deleted and this is already handled by the proxy.

### When host is a Service

When getting the profile for a Service, we subscribe for updates to the
endpoints of that service. For any ports set in the opaque ports annotation on
any of the pods, we check if the requested port is present.

Since the endpoints for a service can be added and removed, we do subscribe for
updates to the endpoints of the service.

Signed-off-by: Kevin Leimkuhler <kevin@kleimkuhler.com>



Patch:
diff --git a/.proxy-version b/.proxy-version
index 75813ee72..52be079c9 100644
--- a/.proxy-version
+++ b/.proxy-version
@@ -1,1 +1,1 @@
-v2.124.0
+v2.125.0
diff --git a/ADOPTERS.md b/ADOPTERS.md
index 5eafa0c0a..ad67e5cc4 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -3,6 +3,7 @@
 - [Advance Latam](http://www.advlatam.com)
 - [Allotrac](https://allotrac.com.au)
 - [AlphaSights](https://www.alphasights.com)
+- [Altinn](https://www.altinn.no/en/)
 - [Apester](https://apester.com)
 - [AppddictionStudio](https://appddicitonstudio.com)
 - [Attest](https://www.askattest.com)
diff --git a/CHANGES.md b/CHANGES.md
index 68625f4f3..27e167c9f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,11 @@
 
 # Changes
 
+## edge-20.12.3
+
+This edge release is functionally the same as `edge-20.12.2`. It fixes an issue
+that prevented the release build from occurring.
+
 ## edge-20.12.2
 
 * Fixed an issue where the `proxy-injector` and `sp-validator` did not refresh
diff --git a/charts/linkerd2-cni/templates/cni-plugin.yaml b/charts/linkerd2-cni/templates/cni-plugin.yaml
index 68e15a254..f899797c9 100644
--- a/charts/linkerd2-cni/templates/cni-plugin.yaml
+++ b/charts/linkerd2-cni/templates/cni-plugin.yaml
@@ -184,6 +184,9 @@ spec:
       annotations:
         {{.Values.createdByAnnotation}}: {{default (printf "linkerd/helm %s" .Values.cniPluginVersion) .Values.cliVersion}}
     spec:
+      {{- if .Values.tolerations }}
+      {{- include "linkerd.tolerations" . | nindent 6 }}
+      {{- end }}
       nodeSelector:
         beta.kubernetes.io/os: linux
       hostNetwork: true
diff --git a/charts/linkerd2-cni/values.yaml b/charts/linkerd2-cni/values.yaml
index c6112ca05..4dfb13ac8 100644
--- a/charts/linkerd2-cni/values.yaml
+++ b/charts/linkerd2-cni/values.yaml
@@ -41,6 +41,11 @@ priorityClassName: ""
 proxyInjectAnnotation: linkerd.io/inject
 proxyInjectDisabled: disabled
 
+# -|- Tolerations section, See the 
+# [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)
+# for more information
+#tolerations:
+
 #
 ## For Private docker registries, authentication is needed.
 # If the control plane service images are pulled from a
diff --git a/charts/linkerd2/templates/destination-rbac.yaml b/charts/linkerd2/templates/destination-rbac.yaml
index 64e7c9a9a..744db7c4b 100644
--- a/charts/linkerd2/templates/destination-rbac.yaml
+++ b/charts/linkerd2/templates/destination-rbac.yaml
@@ -18,7 +18,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/check.go b/cli/cmd/check.go
index 556051228..2ea0c6476 100644
--- a/cli/cmd/check.go
+++ b/cli/cmd/check.go
@@ -2,7 +2,6 @@ package cmd
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -14,9 +13,7 @@ import (
 	charts "github.com/linkerd/linkerd2/pkg/charts/linkerd2"
 	"github.com/linkerd/linkerd2/pkg/version"
 
-	"github.com/briandowns/spinner"
 	"github.com/linkerd/linkerd2/pkg/healthcheck"
-	"github.com/mattn/go-isatty"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 )
@@ -214,7 +211,7 @@ func configureAndRunChecks(ctx context.Context, wout io.Writer, werr io.Writer,
 		MultiCluster:          options.multicluster,
 	})
 
-	success := runChecks(wout, werr, hc, options.output)
+	success := healthcheck.RunChecks(wout, werr, hc, options.output)
 
 	if !success {
 		os.Exit(1)
@@ -223,153 +220,8 @@ func configureAndRunChecks(ctx context.Context, wout io.Writer, werr io.Writer,
 	return nil
 }
 
-func runChecks(wout io.Writer, werr io.Writer, hc *healthcheck.HealthChecker, output string) bool {
-	if output == jsonOutput {
-		return runChecksJSON(wout, werr, hc)
-	}
-	return runChecksTable(wout, hc)
-}
-
-func runChecksTable(wout io.Writer, hc *healthcheck.HealthChecker) bool {
-	var lastCategory healthcheck.CategoryID
-	spin := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
-	spin.Writer = wout
-
-	prettyPrintResults := func(result *healthcheck.CheckResult) {
-		if lastCategory != result.Category {
-			if lastCategory != "" {
-				fmt.Fprintln(wout)
-			}
-
-			fmt.Fprintln(wout, result.Category)
-			fmt.Fprintln(wout, strings.Repeat("-", len(result.Category)))
-
-			lastCategory = result.Category
-		}
-
-		spin.Stop()
-		if result.Retry {
-			if isatty.IsTerminal(os.Stdout.Fd()) {
-				spin.Suffix = fmt.Sprintf(" %s", result.Err)
-				spin.Color("bold") // this calls spin.Restart()
-			}
-			return
-		}
-
-		status := okStatus
-		if result.Err != nil {
-			status = failStatus
-			if result.Warning {
-				status = warnStatus
-			}
-		}
-
-		fmt.Fprintf(wout, "%s %s\n", status, result.Description)
-		if result.Err != nil {
-			fmt.Fprintf(wout, "    %s\n", result.Err)
-			if result.HintAnchor != "" {
-				fmt.Fprintf(wout, "    see %s%s for hints\n", healthcheck.HintBaseURL, result.HintAnchor)
-			}
-		}
-	}
-
-	success := hc.RunChecks(prettyPrintResults)
-	// this empty line separates final results from the checks list in the output
-	fmt.Fprintln(wout, "")
-
-	if !success {
-		fmt.Fprintf(wout, "Status check results are %s\n", failStatus)
-	} else {
-		fmt.Fprintf(wout, "Status check results are %s\n", okStatus)
-	}
-
-	return success
-}
-
-type checkOutput struct {
-	Success    bool             `json:"success"`
-	Categories []*checkCategory `json:"categories"`
-}
-
-type checkCategory struct {
-	Name   string   `json:"categoryName"`
-	Checks []*check `json:"checks"`
-}
-
-// check is a user-facing version of `healthcheck.CheckResult`, for output via
-// `linkerd check -o json`.
-type check struct {
-	Description string      `json:"description"`
-	Hint        string      `json:"hint,omitempty"`
-	Error       string      `json:"error,omitempty"`
-	Result      checkResult `json:"result"`
-}
-
-type checkResult string
-
-const (
-	checkSuccess checkResult = "success"
-	checkWarn    checkResult = "warning"
-	checkErr     checkResult = "error"
-)
-
-func runChecksJSON(wout io.Writer, werr io.Writer, hc *healthcheck.HealthChecker) bool {
-	var categories []*checkCategory
-
-	collectJSONOutput := func(result *healthcheck.CheckResult) {
-		categoryName := string(result.Category)
-		if categories == nil || categories[len(categories)-1].Name != categoryName {
-			categories = append(categories, &checkCategory{
-				Name:   categoryName,
-				Checks: []*check{},
-			})
-		}
-
-		if !result.Retry {
-			currentCategory := categories[len(categories)-1]
-			// ignore checks that are going to be retried, we want only final results
-			status := checkSuccess
-			if result.Err != nil {
-				status = checkErr
-				if result.Warning {
-					status = checkWarn
-				}
-			}
-
-			currentCheck := &check{
-				Description: result.Description,
-				Result:      status,
-			}
-
-			if result.Err != nil {
-				currentCheck.Error = result.Err.Error()
-
-				if result.HintAnchor != "" {
-					currentCheck.Hint = fmt.Sprintf("%s%s", healthcheck.HintBaseURL, result.HintAnchor)
-				}
-			}
-			currentCategory.Checks = append(currentCategory.Checks, currentCheck)
-		}
-	}
-
-	result := hc.RunChecks(collectJSONOutput)
-
-	outputJSON := checkOutput{
-		Success:    result,
-		Categories: categories,
-	}
-
-	resultJSON, err := json.MarshalIndent(outputJSON, "", "  ")
-	if err == nil {
-		fmt.Fprintf(wout, "%s\n", string(resultJSON))
-	} else {
-		fmt.Fprintf(werr, "JSON serialization of the check result failed with %s", err)
-	}
-	return result
-}
-
 func renderInstallManifest(ctx context.Context) (string, error) {
-	values, err := charts.NewValues(false)
+	values, err := charts.NewValues()
 	if err != nil {
 		return "", err
 	}
diff --git a/cli/cmd/inject.go b/cli/cmd/inject.go
index c1cfcfd19..603e857e6 100644
--- a/cli/cmd/inject.go
+++ b/cli/cmd/inject.go
@@ -48,7 +48,7 @@ func runInjectCmd(inputs []io.Reader, errWriter, outWriter io.Writer, transforme
 }
 
 func newCmdInject() *cobra.Command {
-	defaults, err := charts.NewValues(false)
+	defaults, err := charts.NewValues()
 	if err != nil {
 		fmt.Fprint(os.Stderr, err.Error())
 		os.Exit(1)
diff --git a/cli/cmd/install.go b/cli/cmd/install.go
index d0337867f..3639e1a46 100644
--- a/cli/cmd/install.go
+++ b/cli/cmd/install.go
@@ -211,7 +211,7 @@ control plane. It should be run after "linkerd install config".`,
 }
 
 func newCmdInstall() *cobra.Command {
-	values, err := l5dcharts.NewValues(false)
+	values, err := l5dcharts.NewValues()
 
 	allStageFlags, allStageFlagSet := makeAllStageFlags(values)
 	installOnlyFlags, installOnlyFlagSet := makeInstallFlags(values)
@@ -412,7 +412,7 @@ func render(w io.Writer, values *l5dcharts.Values, stage string) error {
 // resource is not part of the Helm chart and will not be present when installing
 // with Helm.
 func renderOverrides(values *l5dcharts.Values, namespace string) ([]byte, error) {
-	defaults, err := l5dcharts.NewValues(false)
+	defaults, err := l5dcharts.NewValues()
 	if err != nil {
 		return nil, err
 	}
diff --git a/cli/cmd/options.go b/cli/cmd/options.go
index 05801b941..bad985a5d 100644
--- a/cli/cmd/options.go
+++ b/cli/cmd/options.go
@@ -54,12 +54,7 @@ func makeInstallUpgradeFlags(defaults *l5dcharts.Values) ([]flag.Flag, *pflag.Fl
 			func(values *l5dcharts.Values, value bool) error {
 				values.GetGlobal().HighAvailability = value
 				if value {
-					haValues, err := l5dcharts.NewValues(true)
-					if err != nil {
-						return err
-					}
-					*values, err = values.Merge(*haValues)
-					if err != nil {
+					if err := l5dcharts.MergeHAValues(values); err != nil {
 						return err
 					}
 				}
diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go
index 5c4338aa3..e98a48828 100644
--- a/cli/cmd/uninstall.go
+++ b/cli/cmd/uninstall.go
@@ -8,15 +8,7 @@ import (
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/k8s/resource"
 	"github.com/spf13/cobra"
-	admissionRegistration "k8s.io/api/admissionregistration/v1beta1"
-	core "k8s.io/api/core/v1"
-	policy "k8s.io/api/policy/v1beta1"
-	rbac "k8s.io/api/rbac/v1"
-	apiextension "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
-	kerrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	apiRegistration "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
-	apiregistrationv1client "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1"
 )
 
 const (
@@ -46,7 +38,9 @@ func uninstallRunE(ctx context.Context) error {
 		return err
 	}
 
-	resources, err := fetchKubernetesResources(ctx, k8sAPI)
+	resources, err := resource.FetchKubernetesResources(ctx, k8sAPI,
+		metav1.ListOptions{LabelSelector: k8s.ControllerNSLabel},
+	)
 	if err != nil {
 		return err
 	}
@@ -58,202 +52,3 @@ func uninstallRunE(ctx context.Context) error {
 	}
 	return nil
 }
-
-func fetchKubernetesResources(ctx context.Context, k *k8s.KubernetesAPI) ([]resource.Kubernetes, error) {
-	options := metav1.ListOptions{
-		LabelSelector: k8s.ControllerNSLabel,
-	}
-
-	resources := make([]resource.Kubernetes, 0)
-
-	clusterRoles, err := fetchClusterRoles(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch ClusterRole resources:%v", err)
-	}
-	resources = append(resources, clusterRoles...)
-
-	clusterRoleBindings, err := fetchClusterRoleBindings(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch ClusterRoleBinding resources:%v", err)
-	}
-	resources = append(resources, clusterRoleBindings...)
-
-	roleBindings, err := fetchKubeSystemRoleBindings(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch RoleBindings from kube-system namespace:%v", err)
-	}
-	resources = append(resources, roleBindings...)
-
-	crds, err := fetchCustomResourceDefinitions(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch CustomResourceDefinition resources:%v", err)
-	}
-	resources = append(resources, crds...)
-
-	apiCRDs, err := fetchAPIRegistrationResources(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch APIService CRDs:%v", err)
-	}
-	resources = append(resources, apiCRDs...)
-
-	psps, err := fetchPodSecurityPolicy(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch PodSecurityPolicy resources:%v", err)
-	}
-	resources = append(resources, psps...)
-
-	mutatinghooks, err := fetchMutatingWebhooksConfiguration(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch MutatingWebhookConfigurations:%v", err)
-	}
-	resources = append(resources, mutatinghooks...)
-
-	validationhooks, err := fetchValidatingWebhooksConfiguration(ctx, k, options)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch ValidatingWebhookConfiguration:%v", err)
-	}
-	resources = append(resources, validationhooks...)
-
-	namespace, err := fetchNamespaceResource(ctx, k)
-	if err != nil {
-		return nil, fmt.Errorf("could not fetch Namespace %s:%v", controlPlaneNamespace, err)
-	}
-
-	if namespace.Name != "" {
-		resources = append(resources, namespace)
-	}
-
-	return resources, nil
-}
-
-func fetchClusterRoles(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.RbacV1().ClusterRoles().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(rbac.SchemeGroupVersion.String(), "ClusterRole", item.Name)
-	}
-
-	return resources, nil
-}
-
-func fetchClusterRoleBindings(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.RbacV1().ClusterRoleBindings().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(rbac.SchemeGroupVersion.String(), "ClusterRoleBinding", item.Name)
-	}
-
-	return resources, nil
-}
-
-// Although role bindings are namespaced resources in nature
-// some admin role bindings are created and persisted in the kube-system namespace and will not be deleted
-// when the namespace is deleted
-func fetchKubeSystemRoleBindings(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.RbacV1().RoleBindings("kube-system").List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		r := resource.New(rbac.SchemeGroupVersion.String(), "RoleBinding", item.Name)
-		r.Namespace = item.Namespace
-		resources[i] = r
-	}
-	return resources, nil
-}
-
-func fetchCustomResourceDefinitions(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.Apiextensions.ApiextensionsV1beta1().CustomResourceDefinitions().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(apiextension.SchemeGroupVersion.String(), "CustomResourceDefinition", item.Name)
-	}
-
-	return resources, nil
-}
-
-func fetchNamespaceResource(ctx context.Context, k *k8s.KubernetesAPI) (resource.Kubernetes, error) {
-	obj, err := k.CoreV1().Namespaces().Get(ctx, controlPlaneNamespace, metav1.GetOptions{})
-	if err != nil {
-		if kerrors.IsNotFound(err) {
-			return resource.Kubernetes{}, nil
-		}
-		return resource.Kubernetes{}, err
-	}
-
-	return resource.New(core.SchemeGroupVersion.String(), "Namespace", obj.Name), nil
-}
-
-func fetchPodSecurityPolicy(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.PolicyV1beta1().PodSecurityPolicies().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(policy.SchemeGroupVersion.String(), "PodSecurityPolicy", item.Name)
-	}
-
-	return resources, nil
-}
-
-func fetchValidatingWebhooksConfiguration(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.AdmissionregistrationV1beta1().ValidatingWebhookConfigurations().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(admissionRegistration.SchemeGroupVersion.String(), "ValidatingWebhookConfiguration", item.Name)
-	}
-
-	return resources, nil
-}
-
-func fetchMutatingWebhooksConfiguration(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	list, err := k.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(admissionRegistration.SchemeGroupVersion.String(), "MutatingWebhookConfiguration", item.Name)
-	}
-
-	return resources, nil
-}
-func fetchAPIRegistrationResources(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]resource.Kubernetes, error) {
-	apiClient, err := apiregistrationv1client.NewForConfig(k.Config)
-	if err != nil {
-		return nil, err
-	}
-
-	list, err := apiClient.APIServices().List(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-
-	resources := make([]resource.Kubernetes, len(list.Items))
-	for i, item := range list.Items {
-		resources[i] = resource.New(apiRegistration.SchemeGroupVersion.String(), "APIService", item.Name)
-	}
-
-	return resources, nil
-}
diff --git a/cli/cmd/upgrade.go b/cli/cmd/upgrade.go
index fdca7e314..23f3cd2c6 100644
--- a/cli/cmd/upgrade.go
+++ b/cli/cmd/upgrade.go
@@ -114,7 +114,7 @@ install command. It should be run after "linkerd upgrade config".`,
 }
 
 func newCmdUpgrade() *cobra.Command {
-	values, err := l5dcharts.NewValues(false)
+	values, err := l5dcharts.NewValues()
 	if err != nil {
 		fmt.Fprint(os.Stderr, err.Error())
 		os.Exit(1)
@@ -290,7 +290,7 @@ func upgrade(ctx context.Context, k *k8s.KubernetesAPI, flags []flag.Flag, stage
 
 func loadStoredValues(ctx context.Context, k *k8s.KubernetesAPI) (*charts.Values, error) {
 	// Load the default values from the chart.
-	values, err := charts.NewValues(false)
+	values, err := charts.NewValues()
 	if err != nil {
 		return nil, err
 	}
@@ -361,7 +361,7 @@ func ensureIssuerCertWorksWithAllProxies(ctx context.Context, k *k8s.KubernetesA
 }
 
 func clearAddonOverrides(values *l5dcharts.Values) error {
-	defaults, err := l5dcharts.NewValues(false)
+	defaults, err := l5dcharts.NewValues()
 	if err != nil {
 		return err
 	}
diff --git a/cli/cmd/upgrade_legacy.go b/cli/cmd/upgrade_legacy.go
index 759cbdb1e..bb71e909c 100644
--- a/cli/cmd/upgrade_legacy.go
+++ b/cli/cmd/upgrade_legacy.go
@@ -32,7 +32,7 @@ func loadStoredValuesLegacy(ctx context.Context, k *k8s.KubernetesAPI) (*charts.
 	}
 	repairConfigs(configs)
 
-	values, err := charts.NewValues(false)
+	values, err := charts.NewValues()
 	if err != nil {
 		return nil, err
 	}
diff --git a/controller/api/destination/opaque_ports_adaptor.go b/controller/api/destination/opaque_ports_adaptor.go
new file mode 100644
index 000000000..0fcf9da0f
--- /dev/null
+++ b/controller/api/destination/opaque_ports_adaptor.go
@@ -0,0 +1,133 @@
+package destination
+
+import (
+	"fmt"
+	"strconv"
+
+	"github.com/linkerd/linkerd2/controller/api/destination/watcher"
+	sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
+	"github.com/linkerd/linkerd2/controller/k8s"
+	pkgk8s "github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/util"
+	logging "github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/labels"
+)
+
+// opaquePortsAdaptor implements EndpointUpdateListener so that it can watch
+// endpoints for services. When endpoints change, it can check for the opaque
+// ports annotation on the pods and update the list of opaque ports.
+//
+// opaquePortsAdaptor also implements ProfileUpdateListener so that it can
+// receive updates from trafficSplitAdaptor and merge the service profile by
+// adding the list of opaque ports.
+//
+// When either of these implemented interfaces has an update,
+// opaquePortsAdaptor publishes the new service profile to the
+// profileTranslator.
+type opaquePortsAdaptor struct {
+	listener    watcher.ProfileUpdateListener
+	k8sAPI      *k8s.API
+	log         *logging.Entry
+	profile     *sp.ServiceProfile
+	opaquePorts map[uint32]struct{}
+}
+
+func newOpaquePortsAdaptor(listener watcher.ProfileUpdateListener, k8sAPI *k8s.API, log *logging.Entry) *opaquePortsAdaptor {
+	return &opaquePortsAdaptor{
+		listener:    listener,
+		k8sAPI:      k8sAPI,
+		log:         log,
+		opaquePorts: make(map[uint32]struct{}),
+	}
+}
+
+func (opa *opaquePortsAdaptor) Add(set watcher.AddressSet) {
+	ports := opa.getOpaquePorts(set)
+	for port := range ports {
+		opa.opaquePorts[port] = struct{}{}
+	}
+	opa.publish()
+}
+
+func (opa *opaquePortsAdaptor) Remove(set watcher.AddressSet) {
+	ports := opa.getOpaquePorts(set)
+	for port := range ports {
+		delete(opa.opaquePorts, port)
+	}
+	opa.publish()
+}
+
+func (opa *opaquePortsAdaptor) NoEndpoints(exists bool) {
+	opa.opaquePorts = make(map[uint32]struct{})
+	opa.publish()
+}
+
+func (opa *opaquePortsAdaptor) Update(profile *sp.ServiceProfile) {
+	opa.profile = profile
+	opa.publish()
+}
+
+func (opa *opaquePortsAdaptor) getOpaquePorts(set watcher.AddressSet) map[uint32]struct{} {
+	ports := make(map[uint32]struct{})
+	for _, address := range set.Addresses {
+		pod := address.Pod
+		if pod != nil {
+			override, err := getOpaquePortsAnnotations(opa.k8sAPI, pod)
+			if err != nil {
+				opa.log.Errorf("Failed to get opaque ports annotation for pod %s: %s", pod, err)
+			}
+			for port := range override {
+				ports[port] = struct{}{}
+			}
+		}
+	}
+	return ports
+}
+
+func (opa *opaquePortsAdaptor) publish() {
+	merged := sp.ServiceProfile{}
+	if opa.profile != nil {
+		merged = *opa.profile
+	}
+	merged.Spec.OpaquePorts = opa.opaquePorts
+	fmt.Printf("publishing SP: %v", merged)
+	opa.listener.Update(&merged)
+}
+
+func getOpaquePortsAnnotations(k8sAPI *k8s.API, pod *corev1.Pod) (map[uint32]struct{}, error) {
+	opaquePorts := make(map[uint32]struct{})
+	obj, err := k8sAPI.GetObjects("", pkgk8s.Namespace, pod.Namespace, labels.Everything())
+	if err != nil {
+		return nil, err
+	}
+	if len(obj) > 1 {
+		return nil, fmt.Errorf("Namespace conflict: %v, %v", obj[0], obj[1])
+	}
+	if len(obj) != 1 {
+		return nil, fmt.Errorf("Namespace not found: %v", pod.Namespace)
+	}
+	ns, ok := obj[0].(*corev1.Namespace)
+	if !ok {
+		// This is very unlikely due to how `GetObjects` works
+		return nil, fmt.Errorf("Object with name %s was not a namespace", pod.Namespace)
+	}
+	override := ns.Annotations[pkgk8s.ProxyOpaquePortsAnnotation]
+
+	// Pod annotations override namespace annotations
+	if podOverride, ok := pod.Annotations[pkgk8s.ProxyOpaquePortsAnnotation]; ok {
+		override = podOverride
+	}
+
+	if override != "" {
+		for _, portStr := range util.ParseOpaquePorts(override, pod.Spec.Containers) {
+			port, err := strconv.ParseUint(portStr, 10, 32)
+			if err != nil {
+				return nil, err
+			}
+			opaquePorts[uint32(port)] = struct{}{}
+		}
+	}
+
+	return opaquePorts, nil
+}
diff --git a/controller/api/destination/profile_translator.go b/controller/api/destination/profile_translator.go
index 97b910794..3fea68825 100644
--- a/controller/api/destination/profile_translator.go
+++ b/controller/api/destination/profile_translator.go
@@ -2,12 +2,10 @@ package destination
 
 import (
 	"errors"
-	"fmt"
 	"time"
 
 	"github.com/golang/protobuf/ptypes/duration"
 	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
-	"github.com/linkerd/linkerd2/controller/api/destination/watcher"
 	sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
 	"github.com/linkerd/linkerd2/pkg/profiles"
 	"github.com/linkerd/linkerd2/pkg/util"
@@ -21,18 +19,16 @@ type profileTranslator struct {
 	stream             pb.Destination_GetProfileServer
 	log                *logging.Entry
 	fullyQualifiedName string
+	port               uint32
 	endpoint           *pb.WeightedAddr
 }
 
-func newProfileTranslator(stream pb.Destination_GetProfileServer, log *logging.Entry, id *watcher.ServiceID, clusterDomain string, endpoint *pb.WeightedAddr) *profileTranslator {
-	var fullyQualifiedName string
-	if id != nil {
-		fullyQualifiedName = fmt.Sprintf("%s.%s.svc.%s", id.Name, id.Namespace, clusterDomain)
-	}
+func newProfileTranslator(stream pb.Destination_GetProfileServer, log *logging.Entry, fqn string, port uint32, endpoint *pb.WeightedAddr) *profileTranslator {
 	return &profileTranslator{
 		stream:             stream,
 		log:                log.WithField("component", "profile-translator"),
-		fullyQualifiedName: fullyQualifiedName,
+		fullyQualifiedName: fqn,
+		port:               port,
 		endpoint:           endpoint,
 	}
 }
@@ -101,11 +97,17 @@ func (pt *profileTranslator) toServiceProfile(profile *sp.ServiceProfile) (*pb.D
 		}
 		budget.Ttl = toDuration(ttl)
 	}
+	var opaqueProtocol bool
+	if profile.Spec.OpaquePorts != nil {
+		_, opaqueProtocol = profile.Spec.OpaquePorts[pt.port]
+	}
 	return &pb.DestinationProfile{
 		Routes:             routes,
 		RetryBudget:        budget,
 		DstOverrides:       toDstOverrides(profile.Spec.DstOverrides),
 		FullyQualifiedName: pt.fullyQualifiedName,
+		Endpoint:           pt.endpoint,
+		OpaqueProtocol:     opaqueProtocol,
 	}, nil
 }
 
diff --git a/controller/api/destination/server.go b/controller/api/destination/server.go
index 12d08b0c6..ba5b0351d 100644
--- a/controller/api/destination/server.go
+++ b/controller/api/destination/server.go
@@ -9,6 +9,7 @@ import (
 
 	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
 	"github.com/linkerd/linkerd2/controller/api/destination/watcher"
+	sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
 	"github.com/linkerd/linkerd2/controller/k8s"
 	"github.com/linkerd/linkerd2/pkg/prometheus"
 	logging "github.com/sirupsen/logrus"
@@ -165,18 +166,21 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 	}
 	log.Debugf("GetProfile(%+v)", dest)
 
+	path := dest.GetPath()
 	// The host must be fully-qualified or be an IP address.
-	host, port, err := getHostAndPort(dest.GetPath())
+	host, port, err := getHostAndPort(path)
 	if err != nil {
-		log.Debugf("Invalid authority %s", dest.GetPath())
+		log.Debugf("Invalid authority %s", path)
 		return status.Errorf(codes.InvalidArgument, "invalid authority: %s", err)
 	}
 
 	// The stream will subscribe to profile updates for `service`.
 	var service watcher.ServiceID
-	// If `host` is an IP, path must be constructed from the namespace and
+	// `instanceID` is used when subscribing to endpoints of `service`.
+	var instanceID string
+	// If `host` is an IP, `fqn` must be constructed from the namespace and
 	// name of the service that the IP maps to.
-	var path string
+	var fqn string
 
 	if ip := net.ParseIP(host); ip != nil {
 		// Get the service that the IP currently maps to.
@@ -186,14 +190,16 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 		}
 		if svcID != nil {
 			service = *svcID
-			path = fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, s.clusterDomain)
+			fqn = fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, s.clusterDomain)
 		} else {
 			// If the IP does not map to a service, check if it maps to a pod
 			pod, err := s.ips.GetPod(ip.String())
 			if err != nil {
 				return err
 			}
+
 			var endpoint *pb.WeightedAddr
+			opaquePorts := make(map[uint32]struct{})
 			if pod != nil {
 				// If the IP maps to a pod, we create a single endpoint and
 				// return it in the DestinationProfile response
@@ -203,9 +209,15 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 					Name:      pod.Name,
 				}
 				endpoint, err = toWeightedAddr(podSet.Addresses[podID], s.enableH2Upgrade, s.identityTrustDomain, s.controllerNS)
+				if err != nil {
+					return err
+				}
+
 				// `Get` doesn't include the namespace in the per-endpoint
 				// metadata, so it needs to be special-cased.
 				endpoint.MetricLabels["namespace"] = pod.Namespace
+
+				opaquePorts, err = getOpaquePortsAnnotations(s.k8sAPI, pod)
 				if err != nil {
 					return err
 				}
@@ -214,8 +226,20 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 			// When the IP does not map to a service, the default profile is
 			// sent without subscribing for future updates. If the IP mapped
 			// to a pod, then the endpoint will be set in the response.
-			translator := newProfileTranslator(stream, log, nil, "", endpoint)
-			translator.Update(nil)
+			translator := newProfileTranslator(stream, log, "", port, endpoint)
+
+			// If there are opaque ports then update the profile translator
+			// with a service profile that has those values
+			//
+			// TODO: Remove endpoint from profileTranslator and set it here
+			// similar to opaque ports
+			if len(opaquePorts) != 0 {
+				sp := sp.ServiceProfile{}
+				sp.Spec.OpaquePorts = opaquePorts
+				translator.Update(&sp)
+			} else {
+				translator.Update(nil)
+			}
 
 			select {
 			case <-s.shutdown:
@@ -226,22 +250,34 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 			return nil
 		}
 	} else {
-		service, _, err = parseK8sServiceName(host, s.clusterDomain)
+		service, instanceID, err = parseK8sServiceName(host, s.clusterDomain)
 		if err != nil {
-			log.Debugf("Invalid service %s", dest.GetPath())
+			log.Debugf("Invalid service %s", path)
 			return status.Errorf(codes.InvalidArgument, "invalid service: %s", err)
 		}
-		path = dest.GetPath()
+		fqn = host
 	}
 
 	// We build up the pipeline of profile updaters backwards, starting from
 	// the translator which takes profile updates, translates them to protobuf
 	// and pushes them onto the gRPC stream.
-	translator := newProfileTranslator(stream, log, &service, s.clusterDomain, nil)
+	translator := newProfileTranslator(stream, log, fqn, port, nil)
+
+	// The opaque ports adaptor merges traffic split updates with opaque ports
+	// updates and publishes the result to the translator
+	opAdaptor := newOpaquePortsAdaptor(translator, s.k8sAPI, log)
+
+	// Subscribe the adaptor to endpoint updates.
+	// TODO: use a hostname?
+	err = s.endpoints.Subscribe(service, port, instanceID, opAdaptor)
+	if err != nil {
+		log.Warnf("Failed to subscribe to endpoint updates for %s: %s", path, err)
+	}
+	defer s.endpoints.Unsubscribe(service, port, instanceID, opAdaptor)
 
-	// The adaptor merges profile updates with traffic split updates and
-	// publishes the result to the translator.
-	tsAdaptor := newTrafficSplitAdaptor(translator, service, port, s.clusterDomain)
+	// The traffic split adaptor merges profile updates with traffic split
+	// updates and publishes the result to the opaque port adaptor.
+	tsAdaptor := newTrafficSplitAdaptor(opAdaptor, service, port, s.clusterDomain)
 
 	// Subscribe the adaptor to traffic split updates.
 	err = s.trafficSplits.Subscribe(service, tsAdaptor)
@@ -263,7 +299,7 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 	if dest.GetContextToken() != "" {
 		ctxToken := s.parseContextToken(dest.GetContextToken())
 
-		profile, err := profileID(path, ctxToken, s.clusterDomain)
+		profile, err := profileID(fqn, ctxToken, s.clusterDomain)
 		if err != nil {
 			log.Debugf("Invalid service %s", path)
 			return status.Errorf(codes.InvalidArgument, "invalid profile ID: %s", err)
@@ -277,7 +313,7 @@ func (s *server) GetProfile(dest *pb.GetDestination, stream pb.Destination_GetPr
 		defer s.profiles.Unsubscribe(profile, primary)
 	}
 
-	profile, err := profileID(path, contextToken{}, s.clusterDomain)
+	profile, err := profileID(fqn, contextToken{}, s.clusterDomain)
 	if err != nil {
 		log.Debugf("Invalid service %s", path)
 		return status.Errorf(codes.InvalidArgument, "invalid profile ID: %s", err)
diff --git a/controller/cmd/destination/main.go b/controller/cmd/destination/main.go
index 7d79fb21d..b34c22f01 100644
--- a/controller/cmd/destination/main.go
+++ b/controller/cmd/destination/main.go
@@ -84,14 +84,14 @@ func Main(args []string) {
 			ctx,
 			*kubeConfigPath,
 			true,
-			k8s.Endpoint, k8s.ES, k8s.Pod, k8s.RS, k8s.Svc, k8s.SP, k8s.TS, k8s.Job,
+			k8s.Endpoint, k8s.ES, k8s.Pod, k8s.RS, k8s.Svc, k8s.SP, k8s.TS, k8s.Job, k8s.NS,
 		)
 	} else {
 		k8sAPI, err = k8s.InitializeAPI(
 			ctx,
 			*kubeConfigPath,
 			true,
-			k8s.Endpoint, k8s.Pod, k8s.RS, k8s.Svc, k8s.SP, k8s.TS, k8s.Job,
+			k8s.Endpoint, k8s.Pod, k8s.RS, k8s.Svc, k8s.SP, k8s.TS, k8s.Job, k8s.NS,
 		)
 	}
 	if err != nil {
diff --git a/controller/cmd/tap/main.go b/controller/cmd/tap/main.go
index 67d74c467..dae386674 100644
--- a/controller/cmd/tap/main.go
+++ b/controller/cmd/tap/main.go
@@ -2,7 +2,6 @@ package tap
 
 import (
 	"context"
-	"crypto/tls"
 	"flag"
 	"os"
 	"os/signal"
@@ -12,7 +11,6 @@ import (
 	"github.com/linkerd/linkerd2/controller/tap"
 	"github.com/linkerd/linkerd2/pkg/admin"
 	"github.com/linkerd/linkerd2/pkg/flags"
-	pkgK8s "github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/trace"
 	log "github.com/sirupsen/logrus"
 )
@@ -28,8 +26,6 @@ func Main(args []string) {
 	kubeConfigPath := cmd.String("kubeconfig", "", "path to kube config")
 	controllerNamespace := cmd.String("controller-namespace", "linkerd", "namespace in which Linkerd is installed")
 	tapPort := cmd.Uint("tap-port", 4190, "proxy tap port to connect to")
-	tlsCertPath := cmd.String("tls-cert", pkgK8s.MountPathTLSCrtPEM, "path to TLS Cert PEM")
-	tlsKeyPath := cmd.String("tls-key", pkgK8s.MountPathTLSKeyPEM, "path to TLS Key PEM")
 	disableCommonNames := cmd.Bool("disable-common-names", false, "disable checks for Common Names (for development)")
 	trustDomain := cmd.String("identity-trust-domain", defaultDomain, "configures the name suffix used for identities")
 
@@ -70,24 +66,14 @@ func Main(args []string) {
 	}
 	grpcTapServer := tap.NewGrpcTapServer(*tapPort, *controllerNamespace, *trustDomain, k8sAPI)
 
-	// TODO: make this configurable for local development
-	cert, err := tls.LoadX509KeyPair(*tlsCertPath, *tlsKeyPath)
-	if err != nil {
-		log.Fatal(err.Error())
-	}
-
-	apiServer, apiLis, err := tap.NewAPIServer(ctx, *apiServerAddr, cert, k8sAPI, grpcTapServer, *disableCommonNames)
+	apiServer, err := tap.NewAPIServer(ctx, *apiServerAddr, k8sAPI, grpcTapServer, *disableCommonNames)
 	if err != nil {
 		log.Fatal(err.Error())
 	}
 
 	k8sAPI.Sync(nil) // blocks until caches are synced
 
-	go func() {
-		log.Infof("starting APIServer on %s", *apiServerAddr)
-		apiServer.ServeTLS(apiLis, "", "")
-	}()
-
+	go apiServer.Start(ctx)
 	go admin.StartServer(*metricsAddr)
 
 	<-stop
diff --git a/controller/gen/apis/serviceprofile/v1alpha2/types.go b/controller/gen/apis/serviceprofile/v1alpha2/types.go
index d60820ab7..396a0a785 100644
--- a/controller/gen/apis/serviceprofile/v1alpha2/types.go
+++ b/controller/gen/apis/serviceprofile/v1alpha2/types.go
@@ -28,9 +28,10 @@ type ServiceProfile struct {
 
 // ServiceProfileSpec specifies a ServiceProfile resource.
 type ServiceProfileSpec struct {
-	Routes       []*RouteSpec   `json:"routes"`
-	RetryBudget  *RetryBudget   `json:"retryBudget,omitempty"`
-	DstOverrides []*WeightedDst `json:"dstOverrides,omitempty"`
+	Routes       []*RouteSpec        `json:"routes"`
+	RetryBudget  *RetryBudget        `json:"retryBudget,omitempty"`
+	DstOverrides []*WeightedDst      `json:"dstOverrides,omitempty"`
+	OpaquePorts  map[uint32]struct{} `json:"opaquePorts,omitempty"`
 }
 
 // RouteSpec specifies a Route resource.
diff --git a/controller/tap/apiserver.go b/controller/tap/apiserver.go
index 0b4daf5f5..d9c329cca 100644
--- a/controller/tap/apiserver.go
+++ b/controller/tap/apiserver.go
@@ -8,19 +8,27 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"sync/atomic"
 
 	"github.com/julienschmidt/httprouter"
 	"github.com/linkerd/linkerd2/controller/gen/controller/tap"
 	"github.com/linkerd/linkerd2/controller/k8s"
 	k8sutils "github.com/linkerd/linkerd2/pkg/k8s"
+	pkgk8s "github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/linkerd/linkerd2/pkg/prometheus"
+	pkgTls "github.com/linkerd/linkerd2/pkg/tls"
+	"github.com/prometheus/common/log"
 	"github.com/sirupsen/logrus"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
-type apiServer struct {
+// APIServer holds the underlying http server and its config
+type APIServer struct {
+	*http.Server
+	listener     net.Listener
 	router       *httprouter.Router
 	allowedNames []string
+	certValue    *atomic.Value
 	log          *logrus.Entry
 }
 
@@ -28,14 +36,23 @@ type apiServer struct {
 func NewAPIServer(
 	ctx context.Context,
 	addr string,
-	cert tls.Certificate,
 	k8sAPI *k8s.API,
 	grpcTapServer tap.TapServer,
 	disableCommonNames bool,
-) (*http.Server, net.Listener, error) {
+) (*APIServer, error) {
+	updateEvent := make(chan struct{})
+	errEvent := make(chan error)
+	watcher := pkgTls.NewFsCredsWatcher(pkgk8s.MountPathTLSBase, updateEvent, errEvent).
+		WithFilePaths(pkgk8s.MountPathTLSCrtPEM, pkgk8s.MountPathTLSKeyPEM)
+	go func() {
+		if err := watcher.StartWatching(ctx); err != nil {
+			log.Fatalf("Failed to start creds watcher: %s", err)
+		}
+	}()
+
 	clientCAPem, allowedNames, usernameHeader, groupHeader, err := apiServerAuth(ctx, k8sAPI)
 	if err != nil {
-		return nil, nil, err
+		return nil, err
 	}
 
 	// for development
@@ -48,6 +65,18 @@ func NewAPIServer(
 		"addr":      addr,
 	})
 
+	clientCertPool := x509.NewCertPool()
+	clientCertPool.AppendCertsFromPEM([]byte(clientCAPem))
+
+	httpServer := &http.Server{
+		Addr: addr,
+		TLSConfig: &tls.Config{
+			ClientAuth: tls.VerifyClientCertIfGiven,
+			ClientCAs:  clientCertPool,
+		},
+	}
+
+	var emptyCert atomic.Value
 	h := &handler{
 		k8sAPI:         k8sAPI,
 		usernameHeader: usernameHeader,
@@ -56,39 +85,48 @@ func NewAPIServer(
 		log:            log,
 	}
 
-	router := initRouter(h)
+	lis, err := net.Listen("tcp", addr)
+	if err != nil {
+		return nil, fmt.Errorf("net.Listen failed with: %s", err)
+	}
 
-	server := &apiServer{
-		router:       router,
+	s := &APIServer{
+		Server:       httpServer,
+		listener:     lis,
+		router:       initRouter(h),
 		allowedNames: allowedNames,
+		certValue:    &emptyCert,
 		log:          log,
 	}
+	s.Handler = prometheus.WithTelemetry(s)
+	httpServer.TLSConfig.GetCertificate = s.getCertificate
 
-	clientCertPool := x509.NewCertPool()
-	clientCertPool.AppendCertsFromPEM([]byte(clientCAPem))
+	if err := watcher.UpdateCert(s.certValue); err != nil {
+		return nil, fmt.Errorf("Failed to initialized certificate: %s", err)
+	}
 
-	wrappedServer := prometheus.WithTelemetry(server)
+	go watcher.ProcessEvents(log, s.certValue, updateEvent, errEvent)
 
-	s := &http.Server{
-		Addr:    addr,
-		Handler: wrappedServer,
-		TLSConfig: &tls.Config{
-			Certificates: []tls.Certificate{cert},
-			ClientAuth:   tls.VerifyClientCertIfGiven,
-			ClientCAs:    clientCertPool,
-		},
-	}
+	return s, nil
+}
 
-	lis, err := net.Listen("tcp", addr)
-	if err != nil {
-		log.Fatalf("net.Listen failed with: %s", err)
+// Start starts the https server
+func (a *APIServer) Start(ctx context.Context) {
+	a.log.Infof("starting APIServer on %s", a.Server.Addr)
+	if err := a.ServeTLS(a.listener, "", ""); err != nil {
+		if err == http.ErrServerClosed {
+			return
+		}
+		a.log.Fatal(err)
 	}
+}
 
-	return s, lis, nil
+func (a *APIServer) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	return a.certValue.Load().(*tls.Certificate), nil
 }
 
 // ServeHTTP handles all routes for the APIServer.
-func (a *apiServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+func (a *APIServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 	a.log.Debugf("ServeHTTP(): %+v", req)
 	if err := a.validate(req); err != nil {
 		a.log.Debug(err)
@@ -99,7 +137,7 @@ func (a *apiServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 }
 
 // validate ensures that the request should be honored returning an error otherwise.
-func (a *apiServer) validate(req *http.Request) error {
+func (a *APIServer) validate(req *http.Request) error {
 	// if `requestheader-allowed-names` was empty, allow any CN
 	if len(a.allowedNames) > 0 {
 		for _, cn := range a.allowedNames {
diff --git a/controller/webhook/server.go b/controller/webhook/server.go
index b1588b0c1..542ff9f55 100644
--- a/controller/webhook/server.go
+++ b/controller/webhook/server.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"crypto/tls"
 	"encoding/json"
-	"fmt"
 	"io/ioutil"
 	"net/http"
 	"sync/atomic"
@@ -12,6 +11,7 @@ import (
 	"github.com/linkerd/linkerd2/controller/k8s"
 	pkgk8s "github.com/linkerd/linkerd2/pkg/k8s"
 	pkgTls "github.com/linkerd/linkerd2/pkg/tls"
+	"github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 	admissionv1beta1 "k8s.io/api/admission/v1beta1"
 	v1 "k8s.io/api/core/v1"
@@ -36,7 +36,7 @@ type Server struct {
 	*http.Server
 	api       *k8s.API
 	handler   Handler
-	certValue atomic.Value
+	certValue *atomic.Value
 	recorder  record.EventRecorder
 }
 
@@ -50,7 +50,8 @@ func NewServer(
 ) (*Server, error) {
 	updateEvent := make(chan struct{})
 	errEvent := make(chan error)
-	watcher := pkgTls.NewFsCredsWatcher(certPath, updateEvent, errEvent)
+	watcher := pkgTls.NewFsCredsWatcher(certPath, updateEvent, errEvent).
+		WithFilePaths(pkgk8s.MountPathTLSCrtPEM, pkgk8s.MountPathTLSKeyPEM)
 	go func() {
 		if err := watcher.StartWatching(ctx); err != nil {
 			log.Fatalf("Failed to start creds watcher: %s", err)
@@ -71,13 +72,16 @@ func NewServer(
 	recorder := eventBroadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: component})
 
 	s := getConfiguredServer(server, api, handler, recorder)
-	if err := s.updateCert(); err != nil {
+	if err := watcher.UpdateCert(s.certValue); err != nil {
 		log.Fatalf("Failed to initialized certificate: %s", err)
 	}
 
-	go func() {
-		s.run(updateEvent, errEvent)
-	}()
+	log := logrus.WithFields(logrus.Fields{
+		"component": "proxy-injector",
+		"addr":      addr,
+	})
+
+	go watcher.ProcessEvents(log, s.certValue, updateEvent, errEvent)
 
 	return s, nil
 }
@@ -89,32 +93,12 @@ func getConfiguredServer(
 	recorder record.EventRecorder,
 ) *Server {
 	var emptyCert atomic.Value
-	s := &Server{httpServer, api, handler, emptyCert, recorder}
+	s := &Server{httpServer, api, handler, &emptyCert, recorder}
 	s.Handler = http.HandlerFunc(s.serve)
 	httpServer.TLSConfig.GetCertificate = s.getCertificate
 	return s
 }
 
-func (s *Server) updateCert() error {
-	creds, err := pkgTls.ReadPEMCreds(
-		pkgk8s.MountPathTLSKeyPEM,
-		pkgk8s.MountPathTLSCrtPEM,
-	)
-	if err != nil {
-		return fmt.Errorf("failed to read cert from disk: %s", err)
-	}
-
-	certPEM := creds.EncodePEM()
-	keyPEM := creds.EncodePrivateKeyPEM()
-	cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
-	if err != nil {
-		return err
-	}
-	s.certValue.Store(&cert)
-	log.Debug("Certificate has been updated")
-	return nil
-}
-
 // Start starts the https server
 func (s *Server) Start() {
 	log.Infof("listening at %s", s.Server.Addr)
@@ -131,22 +115,6 @@ func (s *Server) getCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error
 	return s.certValue.Load().(*tls.Certificate), nil
 }
 
-// run reads from the update and error channels and reloads the certs when necessary
-func (s *Server) run(updateEvent <-chan struct{}, errEvent <-chan error) {
-	for {
-		select {
-		case <-updateEvent:
-			if err := s.updateCert(); err != nil {
-				log.Warnf("Skipping update as cert could not be read from disk: %s", err)
-			} else {
-				log.Infof("Updated certificate")
-			}
-		case err := <-errEvent:
-			log.Warnf("Received error from fs watcher: %s", err)
-		}
-	}
-}
-
 func (s *Server) serve(res http.ResponseWriter, req *http.Request) {
 	var (
 		data []byte
diff --git a/jaeger/charts/jaeger/Chart.yaml b/jaeger/charts/jaeger/Chart.yaml
index f17e45bff..d487720d0 100644
--- a/jaeger/charts/jaeger/Chart.yaml
+++ b/jaeger/charts/jaeger/Chart.yaml
@@ -3,6 +3,8 @@ appVersion: "1.0"
 description: A Helm chart for the jaeger add-on in Linkerd
 name: jaeger
 version: 0.1.0
+kubeVersion: ">=1.15.0-0"
+icon: https://linkerd.io/images/logo-only-200h.png
 maintainers:
   - name: Linkerd authors
     email: cncf-linkerd-dev@lists.cncf.io
diff --git a/jaeger/charts/jaeger/README.md b/jaeger/charts/jaeger/README.md
index b5629d786..de6bab171 100644
--- a/jaeger/charts/jaeger/README.md
+++ b/jaeger/charts/jaeger/README.md
@@ -8,6 +8,8 @@ A Helm chart for the jaeger add-on in Linkerd
 
 ## Requirements
 
+Kubernetes: `>=1.15.0-0`
+
 | Repository | Name | Version |
 |------------|------|---------|
 | file://../../../charts/partials | partials | 0.1.0 |
diff --git a/jaeger/charts/jaeger/templates/rbac.yaml b/jaeger/charts/jaeger/templates/rbac.yaml
index 303762ad1..ae613a10a 100644
--- a/jaeger/charts/jaeger/templates/rbac.yaml
+++ b/jaeger/charts/jaeger/templates/rbac.yaml
@@ -28,6 +28,8 @@ kind: ClusterRole
 apiVersion: rbac.authorization.k8s.io/v1
 metadata:
   name: linkerd-jaeger-{{.Values.namespace}}-proxy-mutator
+  labels:
+    linkerd.io/extension: linkerd-jaeger
 rules:
 - apiGroups: [""]
   resources: ["namespaces"]
@@ -37,6 +39,8 @@ kind: ClusterRoleBinding
 apiVersion: rbac.authorization.k8s.io/v1
 metadata:
   name: linkerd-jaeger-{{.Values.namespace}}-proxy-mutator
+  labels:
+    linkerd.io/extension: linkerd-jaeger
 subjects:
 - kind: ServiceAccount
   name: proxy-mutator
@@ -71,6 +75,8 @@ apiVersion: admissionregistration.k8s.io/v1beta1
 kind: MutatingWebhookConfiguration
 metadata:
   name: linkerd-proxy-mutator-webhook-config
+  labels:
+    linkerd.io/extension: linkerd-jaeger
 webhooks:
 - name: proxy-mutator.linkerd.io
   {{- if .Values.webhook.namespaceSelector }}
diff --git a/jaeger/cmd/check.go b/jaeger/cmd/check.go
new file mode 100644
index 000000000..292073ebc
--- /dev/null
+++ b/jaeger/cmd/check.go
@@ -0,0 +1,226 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"time"
+
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+
+	// jaegerExtensionName is the name of jaeger extension
+	jaegerExtensionName = "linkerd-jaeger"
+
+	// linkerdJaegerExtensionCheck adds checks related to the jaeger extension
+	linkerdJaegerExtensionCheck healthcheck.CategoryID = jaegerExtensionName
+)
+
+var (
+	jaegerNamespace string
+)
+
+type checkOptions struct {
+	wait   time.Duration
+	output string
+}
+
+func jaegerCategory(hc *healthcheck.HealthChecker) (*healthcheck.Category, error) {
+
+	kubeAPI, err := k8s.NewAPI(hc.KubeConfig, hc.KubeContext, hc.Impersonate, hc.ImpersonateGroup, 0)
+	if err != nil {
+		return nil, err
+	}
+
+	checkers := []healthcheck.Checker{}
+	checkers = append(checkers,
+		*healthcheck.NewChecker("collector and jaeger service account exists", "l5d-jaeger-sc-exists", true, true, time.Time{}, false).
+			WithCheck(func(ctx context.Context) error {
+				// Check for Collector Service Account
+				return healthcheck.CheckServiceAccounts(ctx, kubeAPI, []string{"collector", "jaeger"}, jaegerNamespace, "")
+			}))
+
+	checkers = append(checkers,
+		*healthcheck.NewChecker("collector config map exists", "l5d-jaeger-oc-cm-exists", false, true, time.Time{}, false).
+			WithCheck(func(ctx context.Context) error {
+				// Check for Jaeger Service Account
+				_, err = kubeAPI.CoreV1().ConfigMaps(jaegerNamespace).Get(ctx, "collector-config", metav1.GetOptions{})
+				if err != nil {
+					return err
+				}
+				return nil
+			}))
+
+	checkers = append(checkers,
+		*healthcheck.NewChecker("collector pod is running", "l5d-jaeger-collector-running", false, true, hc.RetryDeadline, true).
+			WithCheck(func(ctx context.Context) error {
+				// Check for Collector pod
+				podList, err := kubeAPI.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: "component=collector"})
+				if err != nil {
+					return err
+				}
+				return checkPodsRunning(podList.Items)
+			}))
+
+	checkers = append(checkers,
+		*healthcheck.NewChecker("jaeger pod is running", "l5d-jaeger-jaeger-running", false, true, hc.RetryDeadline, true).
+			WithCheck(func(ctx context.Context) error {
+				// Check for Jaeger pod
+				podList, err := kubeAPI.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: "component=jaeger"})
+				if err != nil {
+					return err
+				}
+				return checkPodsRunning(podList.Items)
+			}))
+
+	checkers = append(checkers,
+		*healthcheck.NewChecker("jaeger extension pods are injected", "l5d-jaeger-pods-injection", false, true, time.Time{}, false).
+			WithCheck(func(ctx context.Context) error {
+				// Check for Jaeger pod
+				pods, err := kubeAPI.GetPodsByNamespace(ctx, jaegerNamespace)
+				if err != nil {
+					return err
+				}
+				return checkIfDataPlanePodsExist(pods)
+			}))
+
+	return healthcheck.NewCategory(linkerdJaegerExtensionCheck, checkers, true), nil
+}
+
+func newCheckOptions() *checkOptions {
+	return &checkOptions{
+		wait:   300 * time.Second,
+		output: healthcheck.TableOutput,
+	}
+}
+
+func (options *checkOptions) validate() error {
+	if options.output != healthcheck.TableOutput && options.output != healthcheck.JSONOutput {
+		return fmt.Errorf("Invalid output type '%s'. Supported output types are: %s, %s", options.output, healthcheck.JSONOutput, healthcheck.TableOutput)
+	}
+	return nil
+}
+
+func newCmdCheck() *cobra.Command {
+	options := newCheckOptions()
+	cmd := &cobra.Command{
+		Use:   "check [flags]",
+		Args:  cobra.NoArgs,
+		Short: "Check the Jaeger extension for potential problems",
+		Long: `Check the Jaeger extension for potential problems.
+
+The check command will perform a series of checks to validate that the Jaeger
+extension is configured correctly. If the command encounters a failure it will
+print additional information about the failure and exit with a non-zero exit
+code.`,
+		Example: `  # Check that the Jaeger extension is up and running
+  linkerd jaeger check`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+
+			// Get jaeger Extension Namespace
+			ns, err := getNamespaceOfExtension(jaegerExtensionName)
+			if err != nil {
+				fmt.Fprintln(os.Stderr, err.Error())
+				os.Exit(1)
+			}
+			jaegerNamespace = ns.Name
+
+			return configureAndRunChecks(stdout, stderr, options)
+		},
+	}
+
+	cmd.PersistentFlags().StringVarP(&options.output, "output", "o", options.output, "Output format. One of: basic, json")
+	cmd.PersistentFlags().DurationVar(&options.wait, "wait", options.wait, "Maximum allowed time for all tests to pass")
+
+	return cmd
+}
+
+func configureAndRunChecks(wout io.Writer, werr io.Writer, options *checkOptions) error {
+	err := options.validate()
+	if err != nil {
+		return fmt.Errorf("Validation error when executing check command: %v", err)
+	}
+
+	checks := []healthcheck.CategoryID{
+		linkerdJaegerExtensionCheck,
+	}
+
+	hc := healthcheck.NewHealthChecker(checks, &healthcheck.Options{
+		ControlPlaneNamespace: controlPlaneNamespace,
+		KubeConfig:            kubeconfigPath,
+		KubeContext:           kubeContext,
+		Impersonate:           impersonate,
+		ImpersonateGroup:      impersonateGroup,
+		APIAddr:               apiAddr,
+		RetryDeadline:         time.Now().Add(options.wait),
+		MultiCluster:          false,
+	})
+
+	category, err := jaegerCategory(hc)
+	if err != nil {
+		return err
+	}
+	hc.AppendCategories(*category)
+
+	success := healthcheck.RunChecks(wout, werr, hc, options.output)
+
+	if !success {
+		os.Exit(1)
+	}
+
+	return nil
+}
+
+func getNamespaceOfExtension(name string) (*corev1.Namespace, error) {
+	kubeAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+	if err != nil {
+		return nil, err
+	}
+
+	namespaces, err := kubeAPI.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{LabelSelector: k8s.LinkerdExtensionLabel})
+	if err != nil {
+		return nil, err
+	}
+
+	for _, ns := range namespaces.Items {
+		if ns.Labels[k8s.LinkerdExtensionLabel] == name {
+			return &ns, err
+		}
+	}
+	return nil, fmt.Errorf("could not find the linkerd-jaeger extension. it can be installed by running `linkerd jaeger install | kubectl apply -f -`")
+}
+
+func checkIfDataPlanePodsExist(pods []corev1.Pod) error {
+	for _, pod := range pods {
+		if !containsProxy(pod) {
+			return fmt.Errorf("could not find proxy container for %s pod", pod.Name)
+		}
+	}
+	return nil
+}
+
+func containsProxy(pod corev1.Pod) bool {
+	for _, containerSpec := range pod.Spec.Containers {
+		if containerSpec.Name == k8s.ProxyContainerName {
+			return true
+		}
+	}
+	return false
+}
+
+// checkPodsRunning checks if the given pods are in running state
+func checkPodsRunning(pods []corev1.Pod) error {
+	for _, pod := range pods {
+		if pod.Status.Phase != "Running" {
+			return fmt.Errorf("%s status is %s", pod.Name, pod.Status.Phase)
+		}
+	}
+	return nil
+}
diff --git a/jaeger/cmd/root.go b/jaeger/cmd/root.go
index b8dd548a2..c52a7ba4a 100644
--- a/jaeger/cmd/root.go
+++ b/jaeger/cmd/root.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"regexp"
 
+	"github.com/fatih/color"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -14,6 +15,11 @@ const (
 )
 
 var (
+	// special handling for Windows, on all other platforms these resolve to
+	// os.Stdout and os.Stderr, thanks to https://github.com/mattn/go-colorable
+	stdout = color.Output
+	stderr = color.Error
+
 	apiAddr               string // An empty value means "use the Kubernetes configuration"
 	controlPlaneNamespace string
 	namespace             string
@@ -59,6 +65,8 @@ func NewCmdJaeger() *cobra.Command {
 	jaegerCmd.PersistentFlags().StringVar(&apiAddr, "api-addr", "", "Override kubeconfig and communicate directly with the control plane at host:port (mostly for testing)")
 	jaegerCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Turn on debug logging")
 	jaegerCmd.AddCommand(newCmdInstall())
+	jaegerCmd.AddCommand(newCmdCheck())
+	jaegerCmd.AddCommand(newCmdUninstall())
 	jaegerCmd.AddCommand(newCmdDashboard())
 
 	return jaegerCmd
diff --git a/jaeger/cmd/uninstall.go b/jaeger/cmd/uninstall.go
new file mode 100644
index 000000000..3a2efbba3
--- /dev/null
+++ b/jaeger/cmd/uninstall.go
@@ -0,0 +1,50 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/k8s/resource"
+	"github.com/spf13/cobra"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func newCmdUninstall() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "uninstall",
+		Args:  cobra.NoArgs,
+		Short: "Output Kubernetes resources to uninstall the Linkerd-jaeger extension",
+		Long: `Output Kubernetes resources to uninstall the Linkerd-jaeger extension.
+
+This command provides all Kubernetes namespace-scoped and cluster-scoped resources (e.g services, deployments, RBACs, etc.) necessary to uninstall the Linkerd-jaeger extension.`,
+		Example: `linkerd uninstall | kubectl delete -f -`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return uninstallRunE(cmd.Context())
+		},
+	}
+
+	return cmd
+}
+
+func uninstallRunE(ctx context.Context) error {
+	k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+	if err != nil {
+		return err
+	}
+
+	resources, err := resource.FetchKubernetesResources(ctx, k8sAPI,
+		metav1.ListOptions{LabelSelector: "linkerd.io/extension=linkerd-jaeger"},
+	)
+	if err != nil {
+		return err
+	}
+
+	for _, r := range resources {
+		if err := r.RenderResource(os.Stdout); err != nil {
+			return fmt.Errorf("error rendering Kubernetes resource: %v", err)
+		}
+	}
+	return nil
+}
diff --git a/pkg/charts/linkerd2/values.go b/pkg/charts/linkerd2/values.go
index 2f9a76b3e..2b9270fc1 100644
--- a/pkg/charts/linkerd2/values.go
+++ b/pkg/charts/linkerd2/values.go
@@ -250,9 +250,8 @@ type (
 )
 
 // NewValues returns a new instance of the Values type.
-func NewValues(ha bool) (*Values, error) {
-	chartDir := fmt.Sprintf("%s/", helmDefaultChartDir)
-	v, err := readDefaults(chartDir, ha)
+func NewValues() (*Values, error) {
+	v, err := readDefaults(false)
 	if err != nil {
 		return nil, err
 	}
@@ -269,38 +268,34 @@ func NewValues(ha bool) (*Values, error) {
 	return v, nil
 }
 
-// readDefaults read all the default variables from the values.yaml file.
-// chartDir is the root directory of the Helm chart where values.yaml is.
-func readDefaults(chartDir string, ha bool) (*Values, error) {
-	valuesFiles := []*loader.BufferedFile{
-		{Name: chartutil.ValuesfileName},
+// MergeHAValues retrieves the default HA values and merges them into the received values
+func MergeHAValues(values *Values) error {
+	haValues, err := readDefaults(true)
+	if err != nil {
+		return err
 	}
+	*values, err = values.Merge(*haValues)
+	return err
+}
 
+// readDefaults read all the default variables from the values.yaml file.
+func readDefaults(ha bool) (*Values, error) {
+	var valuesFile *loader.BufferedFile
 	if ha {
-		valuesFiles = append(valuesFiles, &loader.BufferedFile{
-			Name: helmDefaultHAValuesFile,
-		})
+		valuesFile = &loader.BufferedFile{Name: helmDefaultHAValuesFile}
+	} else {
+		valuesFile = &loader.BufferedFile{Name: chartutil.ValuesfileName}
 	}
 
-	if err := charts.FilesReader(static.Templates, chartDir, valuesFiles); err != nil {
+	chartDir := fmt.Sprintf("%s/", helmDefaultChartDir)
+	if err := charts.ReadFile(static.Templates, chartDir, valuesFile); err != nil {
 		return nil, err
 	}
 
-	values := Values{}
-	for _, valuesFile := range valuesFiles {
-		var v Values
-		if err := yaml.Unmarshal(charts.InsertVersion(valuesFile.Data), &v); err != nil {
-			return nil, err
-		}
-
-		var err error
-		values, err = values.Merge(v)
-		if err != nil {
-			return nil, err
-		}
-	}
+	var values Values
+	err := yaml.Unmarshal(charts.InsertVersion(valuesFile.Data), &values)
 
-	return &values, nil
+	return &values, err
 }
 
 // Merge merges the non-empty properties of src into v.
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index 6cf7b9809..8c5b2139d 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -284,7 +284,8 @@ func (e *VerboseSuccess) Error() string {
 	return ""
 }
 
-type checker struct {
+// Checker is a smallest unit performing a single check
+type Checker struct {
 	// description is the short description that's printed to the command line
 	// when the check is executed
 	description string
@@ -321,6 +322,30 @@ type checker struct {
 	checkRPC func(context.Context) (*healthcheckPb.SelfCheckResponse, error)
 }
 
+// NewChecker returns a new instance of checker type
+func NewChecker(description, hintAnchor string, fatal, warning bool, retryDeadline time.Time, surfaceErrorOnRetry bool) *Checker {
+	return &Checker{
+		description:         description,
+		hintAnchor:          hintAnchor,
+		fatal:               fatal,
+		warning:             warning,
+		retryDeadline:       retryDeadline,
+		surfaceErrorOnRetry: surfaceErrorOnRetry,
+	}
+}
+
+// WithCheck Returns a checker with the provided check func
+func (c *Checker) WithCheck(check func(context.Context) error) *Checker {
+	c.check = check
+	return c
+}
+
+// WithCheckRPC Returns a checker with the provided checkRPC func
+func (c *Checker) WithCheckRPC(checkRPC func(context.Context) (*healthcheckPb.SelfCheckResponse, error)) *Checker {
+	c.checkRPC = checkRPC
+	return c
+}
+
 // CheckResult encapsulates a check's identifying information and output
 // Note there exists an analogous user-facing type, `cmd.check`, for output via
 // `linkerd check -o json`.
@@ -336,12 +361,22 @@ type CheckResult struct {
 // CheckObserver receives the results of each check.
 type CheckObserver func(*CheckResult)
 
-type category struct {
+// Category is a group of checkers, to check a particular component or use-case
+type Category struct {
 	id       CategoryID
-	checkers []checker
+	checkers []Checker
 	enabled  bool
 }
 
+// NewCategory returns an instance of Category with the specified data
+func NewCategory(id CategoryID, checkers []Checker, enabled bool) *Category {
+	return &Category{
+		id:       id,
+		checkers: checkers,
+		enabled:  enabled,
+	}
+}
+
 // Options specifies configuration for a HealthChecker.
 type Options struct {
 	ControlPlaneNamespace string
@@ -362,7 +397,7 @@ type Options struct {
 // HealthChecker encapsulates all health check checkers, and clients required to
 // perform those checks.
 type HealthChecker struct {
-	categories []category
+	categories []Category
 	*Options
 
 	// these fields are set in the process of running checks
@@ -402,6 +437,12 @@ func NewHealthChecker(categoryIDs []CategoryID, options *Options) *HealthChecker
 	return hc
 }
 
+// AppendCategories returns a HealthChecker instance appending the provided Categories
+func (hc *HealthChecker) AppendCategories(categories ...Category) *HealthChecker {
+	hc.categories = append(hc.categories, categories...)
+	return hc
+}
+
 // allCategories is the global, ordered list of all checkers, grouped by
 // category. This method is attached to the HealthChecker struct because the
 // checkers directly reference other members of the struct, such as kubeAPI,
@@ -413,11 +454,11 @@ func NewHealthChecker(categoryIDs []CategoryID, options *Options) *HealthChecker
 // Note that all checks should include a `hintAnchor` with a corresponding section
 // in the linkerd check faq:
 // https://linkerd.io/checks/#
-func (hc *HealthChecker) allCategories() []category {
-	return []category{
+func (hc *HealthChecker) allCategories() []Category {
+	return []Category{
 		{
 			id: KubernetesAPIChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "can initialize the client",
 					hintAnchor:  "k8s-api",
@@ -440,7 +481,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: KubernetesVersionChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "is running the minimum Kubernetes API version",
 					hintAnchor:  "k8s-version",
@@ -459,7 +500,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdPreInstallChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "control plane namespace does not already exist",
 					hintAnchor:  "pre-ns",
@@ -542,7 +583,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdPreInstallCapabilityChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "has NET_ADMIN capability",
 					hintAnchor:  "pre-k8s-cluster-net-admin",
@@ -563,7 +604,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdPreInstallGlobalResourcesChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "no ClusterRoles exist",
 					hintAnchor:  "pre-l5d-existence",
@@ -610,7 +651,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdControlPlaneExistenceChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "'linkerd-config' config map exists",
 					hintAnchor:  "l5d-existence-linkerd-config",
@@ -709,7 +750,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdConfigChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "control plane Namespace exists",
 					hintAnchor:  "l5d-existence-ns",
@@ -778,7 +819,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdCNIPluginChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "cni plugin ConfigMap exists",
 					hintAnchor:  "cni-plugin-cm-exists",
@@ -923,7 +964,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdIdentity,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "certificate config is valid",
 					hintAnchor:  "l5d-identity-cert-config-valid",
@@ -1029,7 +1070,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdWebhooksAndAPISvcTLS,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "tap API server has valid cert",
 					hintAnchor:  "l5d-tap-cert-valid",
@@ -1144,7 +1185,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdIdentityDataPlane,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "data plane proxies certificate match CA",
 					hintAnchor:  "l5d-identity-data-plane-proxies-certs-match-ca",
@@ -1157,7 +1198,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdAPIChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description:         "control plane pods are ready",
 					hintAnchor:          "l5d-api-control-ready",
@@ -1198,7 +1239,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdVersionChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "can determine the latest version",
 					hintAnchor:  "l5d-version-latest",
@@ -1228,7 +1269,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdControlPlaneVersionChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "control plane is up-to-date",
 					hintAnchor:  "l5d-version-control",
@@ -1252,7 +1293,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdDataPlaneChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "data plane namespace exists",
 					hintAnchor:  "l5d-data-plane-exists",
@@ -1348,7 +1389,7 @@ func (hc *HealthChecker) allCategories() []category {
 		},
 		{
 			id: LinkerdHAChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "pod injection disabled on kube-system",
 					hintAnchor:  "l5d-injection-disabled",
@@ -1451,9 +1492,9 @@ func (hc *HealthChecker) checkMinReplicasAvailable(ctx context.Context) error {
 // NewHealthChecker.
 func (hc *HealthChecker) Add(categoryID CategoryID, description string, hintAnchor string, check func(context.Context) error) {
 	hc.addCategory(
-		category{
+		Category{
 			id: categoryID,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: description,
 					check:       check,
@@ -1465,7 +1506,7 @@ func (hc *HealthChecker) Add(categoryID CategoryID, description string, hintAnch
 }
 
 // addCategory is also for testing
-func (hc *HealthChecker) addCategory(c category) {
+func (hc *HealthChecker) addCategory(c Category) {
 	c.enabled = true
 	hc.categories = append(hc.categories, c)
 }
@@ -1509,7 +1550,7 @@ func (hc *HealthChecker) RunChecks(observer CheckObserver) bool {
 	return success
 }
 
-func (hc *HealthChecker) runCheck(categoryID CategoryID, c *checker, observer CheckObserver) bool {
+func (hc *HealthChecker) runCheck(categoryID CategoryID, c *Checker, observer CheckObserver) bool {
 	for {
 		ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
 		defer cancel()
@@ -1556,7 +1597,7 @@ func (hc *HealthChecker) runCheck(categoryID CategoryID, c *checker, observer Ch
 // We keep on retrying the same call until all the responses have an OK status
 // (or until timeout/deadline is reached), sending a message to `observer` for each response,
 // while making sure no duplicate messages are sent.
-func (hc *HealthChecker) runCheckRPC(categoryID CategoryID, c *checker, observer CheckObserver) bool {
+func (hc *HealthChecker) runCheckRPC(categoryID CategoryID, c *Checker, observer CheckObserver) bool {
 	observedResults := []CheckResult{}
 	for {
 		ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
@@ -1934,10 +1975,15 @@ func (hc *HealthChecker) isHeartbeatDisabled() bool {
 }
 
 func (hc *HealthChecker) checkServiceAccounts(ctx context.Context, saNames []string, ns, labelSelector string) error {
+	return CheckServiceAccounts(ctx, hc.kubeAPI, saNames, ns, labelSelector)
+}
+
+// CheckServiceAccounts check for serivceaccounts
+func CheckServiceAccounts(ctx context.Context, api *k8s.KubernetesAPI, saNames []string, ns, labelSelector string) error {
 	options := metav1.ListOptions{
 		LabelSelector: labelSelector,
 	}
-	saList, err := hc.kubeAPI.CoreV1().ServiceAccounts(ns).List(ctx, options)
+	saList, err := api.CoreV1().ServiceAccounts(ns).List(ctx, options)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/healthcheck/healthcheck_addons.go b/pkg/healthcheck/healthcheck_addons.go
index f2aa0a620..214660c1e 100644
--- a/pkg/healthcheck/healthcheck_addons.go
+++ b/pkg/healthcheck/healthcheck_addons.go
@@ -28,11 +28,11 @@ var (
 // addOnCategories contain all the checks w.r.t add-ons. It is strongly advised to
 // have warning as true, to not make the check fail for add-on failures as most of them are
 // not hard requirements unless otherwise.
-func (hc *HealthChecker) addOnCategories() []category {
-	return []category{
+func (hc *HealthChecker) addOnCategories() []Category {
+	return []Category{
 		{
 			id: LinkerdPrometheusAddOnChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "prometheus add-on service account exists",
 					warning:     true,
@@ -92,7 +92,7 @@ func (hc *HealthChecker) addOnCategories() []category {
 		},
 		{
 			id: LinkerdGrafanaAddOnChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description: "grafana add-on service account exists",
 					warning:     true,
diff --git a/pkg/healthcheck/healthcheck_multicluster.go b/pkg/healthcheck/healthcheck_multicluster.go
index cc8eeaa29..7b4d86f59 100644
--- a/pkg/healthcheck/healthcheck_multicluster.go
+++ b/pkg/healthcheck/healthcheck_multicluster.go
@@ -29,11 +29,11 @@ const (
 	linkerdServiceMirrorRoleName           = "linkerd-service-mirror-read-remote-creds-%s"
 )
 
-func (hc *HealthChecker) multiClusterCategory() []category {
-	return []category{
+func (hc *HealthChecker) multiClusterCategory() []Category {
+	return []Category{
 		{
 			id: LinkerdMulticlusterChecks,
-			checkers: []checker{
+			checkers: []Checker{
 				/* Link checks */
 				{
 					description: "Link CRD exists",
diff --git a/pkg/healthcheck/healthcheck_output.go b/pkg/healthcheck/healthcheck_output.go
new file mode 100644
index 000000000..2124e65d2
--- /dev/null
+++ b/pkg/healthcheck/healthcheck_output.go
@@ -0,0 +1,175 @@
+package healthcheck
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/briandowns/spinner"
+	"github.com/fatih/color"
+	"github.com/mattn/go-isatty"
+)
+
+const (
+	// JSONOutput is used to specify the json output format
+	JSONOutput = "json"
+	// TableOutput is used to specify the table output format
+	TableOutput = "table"
+	// WideOutput is used to specify the wide output format
+	WideOutput = "wide"
+)
+
+var (
+	okStatus   = color.New(color.FgGreen, color.Bold).SprintFunc()("\u221A")  // √
+	warnStatus = color.New(color.FgYellow, color.Bold).SprintFunc()("\u203C") // ‼
+	failStatus = color.New(color.FgRed, color.Bold).SprintFunc()("\u00D7")    // ×
+)
+
+// RunChecks runs the checks that are part of hc
+func RunChecks(wout io.Writer, werr io.Writer, hc *HealthChecker, output string) bool {
+	if output == JSONOutput {
+		return runChecksJSON(wout, werr, hc)
+	}
+	return runChecksTable(wout, hc)
+}
+
+func runChecksTable(wout io.Writer, hc *HealthChecker) bool {
+	var lastCategory CategoryID
+	spin := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	spin.Writer = wout
+
+	prettyPrintResults := func(result *CheckResult) {
+		if lastCategory != result.Category {
+			if lastCategory != "" {
+				fmt.Fprintln(wout)
+			}
+
+			fmt.Fprintln(wout, result.Category)
+			fmt.Fprintln(wout, strings.Repeat("-", len(result.Category)))
+
+			lastCategory = result.Category
+		}
+
+		spin.Stop()
+		if result.Retry {
+			if isatty.IsTerminal(os.Stdout.Fd()) {
+				spin.Suffix = fmt.Sprintf(" %s", result.Err)
+				spin.Color("bold") // this calls spin.Restart()
+			}
+			return
+		}
+
+		status := okStatus
+		if result.Err != nil {
+			status = failStatus
+			if result.Warning {
+				status = warnStatus
+			}
+		}
+
+		fmt.Fprintf(wout, "%s %s\n", status, result.Description)
+		if result.Err != nil {
+			fmt.Fprintf(wout, "    %s\n", result.Err)
+			if result.HintAnchor != "" {
+				fmt.Fprintf(wout, "    see %s%s for hints\n", HintBaseURL, result.HintAnchor)
+			}
+		}
+	}
+
+	success := hc.RunChecks(prettyPrintResults)
+	// this empty line separates final results from the checks list in the output
+	fmt.Fprintln(wout, "")
+
+	if !success {
+		fmt.Fprintf(wout, "Status check results are %s\n", failStatus)
+	} else {
+		fmt.Fprintf(wout, "Status check results are %s\n", okStatus)
+	}
+
+	return success
+}
+
+type checkOutput struct {
+	Success    bool             `json:"success"`
+	Categories []*checkCategory `json:"categories"`
+}
+
+type checkCategory struct {
+	Name   string   `json:"categoryName"`
+	Checks []*check `json:"checks"`
+}
+
+// check is a user-facing version of `healthcheck.CheckResult`, for output via
+// `linkerd check -o json`.
+type check struct {
+	Description string      `json:"description"`
+	Hint        string      `json:"hint,omitempty"`
+	Error       string      `json:"error,omitempty"`
+	Result      checkResult `json:"result"`
+}
+
+type checkResult string
+
+const (
+	checkSuccess checkResult = "success"
+	checkWarn    checkResult = "warning"
+	checkErr     checkResult = "error"
+)
+
+func runChecksJSON(wout io.Writer, werr io.Writer, hc *HealthChecker) bool {
+	var categories []*checkCategory
+
+	collectJSONOutput := func(result *CheckResult) {
+		categoryName := string(result.Category)
+		if categories == nil || categories[len(categories)-1].Name != categoryName {
+			categories = append(categories, &checkCategory{
+				Name:   categoryName,
+				Checks: []*check{},
+			})
+		}
+
+		if !result.Retry {
+			currentCategory := categories[len(categories)-1]
+			// ignore checks that are going to be retried, we want only final results
+			status := checkSuccess
+			if result.Err != nil {
+				status = checkErr
+				if result.Warning {
+					status = checkWarn
+				}
+			}
+
+			currentCheck := &check{
+				Description: result.Description,
+				Result:      status,
+			}
+
+			if result.Err != nil {
+				currentCheck.Error = result.Err.Error()
+
+				if result.HintAnchor != "" {
+					currentCheck.Hint = fmt.Sprintf("%s%s", HintBaseURL, result.HintAnchor)
+				}
+			}
+			currentCategory.Checks = append(currentCategory.Checks, currentCheck)
+		}
+	}
+
+	result := hc.RunChecks(collectJSONOutput)
+
+	outputJSON := checkOutput{
+		Success:    result,
+		Categories: categories,
+	}
+
+	resultJSON, err := json.MarshalIndent(outputJSON, "", "  ")
+	if err == nil {
+		fmt.Fprintf(wout, "%s\n", string(resultJSON))
+	} else {
+		fmt.Fprintf(werr, "JSON serialization of the check result failed with %s", err)
+	}
+	return result
+}
diff --git a/pkg/inject/inject.go b/pkg/inject/inject.go
index b8f42610f..59efbf9d9 100644
--- a/pkg/inject/inject.go
+++ b/pkg/inject/inject.go
@@ -13,13 +13,12 @@ import (
 	"time"
 
 	jsonfilter "github.com/clarketm/json"
-	"github.com/linkerd/linkerd2-proxy-init/ports"
-	"github.com/linkerd/linkerd2/controller/gen/config"
 	"github.com/linkerd/linkerd2/pkg/charts"
 	"github.com/linkerd/linkerd2/pkg/charts/linkerd2"
 	l5dcharts "github.com/linkerd/linkerd2/pkg/charts/linkerd2"
 	"github.com/linkerd/linkerd2/pkg/charts/static"
 	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/util"
 	log "github.com/sirupsen/logrus"
 	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/chartutil"
@@ -827,39 +826,8 @@ func (conf *ResourceConfig) applyAnnotationOverrides(values *l5dcharts.Values) {
 	}
 
 	if override, ok := annotations[k8s.ProxyOpaquePortsAnnotation]; ok {
-		var portRanges []*config.PortRange
-		split := strings.Split(strings.TrimSuffix(override, ","), ",")
-		portRanges = ToPortRanges(split)
-
-		var str string
-		for _, portRange := range portRanges {
-			pr := portRange.GetPortRange()
-
-			// It is valid for the format of a port name to be the same as a
-			// port range (e.g. `123-456` is a valid name, but also is a valid
-			// range). All port names must be checked before making it a list.
-			named := false
-			for _, c := range conf.pod.spec.Containers {
-				for _, p := range c.Ports {
-					if p.Name == pr {
-						named = true
-						str += strconv.Itoa(int(p.ContainerPort)) + ","
-					}
-				}
-			}
-
-			if !named {
-				pr, err := ports.ParsePortRange(pr)
-				if err != nil {
-					log.Warnf("Invalid port range [%v]: %s", pr, err)
-					continue
-				}
-				for i := pr.LowerBound; i <= pr.UpperBound; i++ {
-					str += strconv.Itoa(i) + ","
-				}
-			}
-		}
-		values.GetGlobal().Proxy.OpaquePorts = strings.TrimSuffix(str, ",")
+		opaquePortsStrs := util.ParseOpaquePorts(override, conf.pod.spec.Containers)
+		values.GetGlobal().Proxy.OpaquePorts = strings.Join(opaquePortsStrs, ",")
 	}
 
 	if override, ok := annotations[k8s.DebugImageAnnotation]; ok {
@@ -936,15 +904,6 @@ func getFilteredJSON(conf runtime.Object) ([]byte, error) {
 	return jsonfilter.Marshal(&conf)
 }
 
-// ToPortRanges converts a slice of strings into a slice of PortRanges.
-func ToPortRanges(portRanges []string) []*config.PortRange {
-	ports := make([]*config.PortRange, len(portRanges))
-	for i, p := range portRanges {
-		ports[i] = &config.PortRange{PortRange: p}
-	}
-	return ports
-}
-
 // ToWholeCPUCores coerces a k8s resource value to a whole integer value, rounding up.
 func ToWholeCPUCores(q k8sResource.Quantity) (int64, error) {
 	q.RoundUp(0)
diff --git a/pkg/k8s/labels.go b/pkg/k8s/labels.go
index 894f69215..160242dc1 100644
--- a/pkg/k8s/labels.go
+++ b/pkg/k8s/labels.go
@@ -26,6 +26,10 @@ const (
 	// that contain a Linkerd control plane
 	LinkerdNamespaceLabel = Prefix + "/is-control-plane"
 
+	// LinkerdExtensionLabel is a label that helps identifying the namespace
+	// that contain a Linkerd Extension
+	LinkerdExtensionLabel = Prefix + "/extension"
+
 	// ControllerComponentLabel identifies this object as a component of Linkerd's
 	// control plane (e.g. web, controller).
 	ControllerComponentLabel = Prefix + "/control-plane-component"
diff --git a/pkg/k8s/resource/resource.go b/pkg/k8s/resource/resource.go
index caf084d81..60e4e6233 100644
--- a/pkg/k8s/resource/resource.go
+++ b/pkg/k8s/resource/resource.go
@@ -1,10 +1,20 @@
 package resource
 
 import (
+	"context"
+	"fmt"
 	"io"
 
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	admissionRegistration "k8s.io/api/admissionregistration/v1beta1"
+	core "k8s.io/api/core/v1"
+	policy "k8s.io/api/policy/v1beta1"
+	rbac "k8s.io/api/rbac/v1"
+	apiextension "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	apiRegistration "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
+	apiregistrationv1client "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistration/v1"
 	"sigs.k8s.io/yaml"
 )
 
@@ -60,3 +70,201 @@ func (r Kubernetes) RenderResource(w io.Writer) error {
 	_, err = w.Write([]byte(yamlSep))
 	return err
 }
+
+// FetchKubernetesResources returns a slice of all cluster scoped kubernetes
+// resources which match the given ListOptions.
+func FetchKubernetesResources(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+
+	resources := make([]Kubernetes, 0)
+
+	clusterRoles, err := fetchClusterRoles(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch ClusterRole resources:%v", err)
+	}
+	resources = append(resources, clusterRoles...)
+
+	clusterRoleBindings, err := fetchClusterRoleBindings(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch ClusterRoleBinding resources:%v", err)
+	}
+	resources = append(resources, clusterRoleBindings...)
+
+	roleBindings, err := fetchKubeSystemRoleBindings(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch RoleBindings from kube-system namespace:%v", err)
+	}
+	resources = append(resources, roleBindings...)
+
+	crds, err := fetchCustomResourceDefinitions(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch CustomResourceDefinition resources:%v", err)
+	}
+	resources = append(resources, crds...)
+
+	apiCRDs, err := fetchAPIRegistrationResources(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch APIService CRDs:%v", err)
+	}
+	resources = append(resources, apiCRDs...)
+
+	psps, err := fetchPodSecurityPolicy(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch PodSecurityPolicy resources:%v", err)
+	}
+	resources = append(resources, psps...)
+
+	mutatinghooks, err := fetchMutatingWebhooksConfiguration(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch MutatingWebhookConfigurations:%v", err)
+	}
+	resources = append(resources, mutatinghooks...)
+
+	validationhooks, err := fetchValidatingWebhooksConfiguration(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch ValidatingWebhookConfiguration:%v", err)
+	}
+	resources = append(resources, validationhooks...)
+
+	namespaces, err := fetchNamespace(ctx, k, options)
+	if err != nil {
+		return nil, fmt.Errorf("could not fetch Namespace:%v", err)
+	}
+	resources = append(resources, namespaces...)
+
+	return resources, nil
+}
+
+func fetchClusterRoles(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.RbacV1().ClusterRoles().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(rbac.SchemeGroupVersion.String(), "ClusterRole", item.Name)
+	}
+
+	return resources, nil
+}
+
+func fetchClusterRoleBindings(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.RbacV1().ClusterRoleBindings().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(rbac.SchemeGroupVersion.String(), "ClusterRoleBinding", item.Name)
+	}
+
+	return resources, nil
+}
+
+// Although role bindings are namespaced resources in nature
+// some admin role bindings are created and persisted in the kube-system namespace and will not be deleted
+// when the namespace is deleted
+func fetchKubeSystemRoleBindings(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.RbacV1().RoleBindings("kube-system").List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		r := New(rbac.SchemeGroupVersion.String(), "RoleBinding", item.Name)
+		r.Namespace = item.Namespace
+		resources[i] = r
+	}
+	return resources, nil
+}
+
+func fetchCustomResourceDefinitions(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.Apiextensions.ApiextensionsV1beta1().CustomResourceDefinitions().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(apiextension.SchemeGroupVersion.String(), "CustomResourceDefinition", item.Name)
+	}
+
+	return resources, nil
+}
+
+func fetchNamespace(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.CoreV1().Namespaces().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		r := New(core.SchemeGroupVersion.String(), "Namespace", item.Name)
+		r.Namespace = item.Namespace
+		resources[i] = r
+	}
+	return resources, nil
+}
+
+func fetchPodSecurityPolicy(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.PolicyV1beta1().PodSecurityPolicies().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(policy.SchemeGroupVersion.String(), "PodSecurityPolicy", item.Name)
+	}
+
+	return resources, nil
+}
+
+func fetchValidatingWebhooksConfiguration(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.AdmissionregistrationV1beta1().ValidatingWebhookConfigurations().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(admissionRegistration.SchemeGroupVersion.String(), "ValidatingWebhookConfiguration", item.Name)
+	}
+
+	return resources, nil
+}
+
+func fetchMutatingWebhooksConfiguration(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	list, err := k.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(admissionRegistration.SchemeGroupVersion.String(), "MutatingWebhookConfiguration", item.Name)
+	}
+
+	return resources, nil
+}
+func fetchAPIRegistrationResources(ctx context.Context, k *k8s.KubernetesAPI, options metav1.ListOptions) ([]Kubernetes, error) {
+	apiClient, err := apiregistrationv1client.NewForConfig(k.Config)
+	if err != nil {
+		return nil, err
+	}
+
+	list, err := apiClient.APIServices().List(ctx, options)
+	if err != nil {
+		return nil, err
+	}
+
+	resources := make([]Kubernetes, len(list.Items))
+	for i, item := range list.Items {
+		resources[i] = New(apiRegistration.SchemeGroupVersion.String(), "APIService", item.Name)
+	}
+
+	return resources, nil
+}
diff --git a/pkg/tls/creds_watcher.go b/pkg/tls/creds_watcher.go
index d519f0afd..53bfeef58 100644
--- a/pkg/tls/creds_watcher.go
+++ b/pkg/tls/creds_watcher.go
@@ -2,9 +2,13 @@ package tls
 
 import (
 	"context"
+	"crypto/tls"
+	"fmt"
 	"path/filepath"
+	"sync/atomic"
 
 	"github.com/fsnotify/fsnotify"
+	"github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 )
 
@@ -12,14 +16,23 @@ const dataDirectoryLnName = "..data"
 
 // FsCredsWatcher is used to monitor tls credentials on the filesystem
 type FsCredsWatcher struct {
-	certPath  string
-	EventChan chan<- struct{}
-	ErrorChan chan<- error
+	certRootPath string
+	certFilePath string
+	keyFilePath  string
+	EventChan    chan<- struct{}
+	ErrorChan    chan<- error
 }
 
 // NewFsCredsWatcher constructs a FsCredsWatcher instance
-func NewFsCredsWatcher(certPath string, updateEvent chan<- struct{}, errEvent chan<- error) *FsCredsWatcher {
-	return &FsCredsWatcher{certPath, updateEvent, errEvent}
+func NewFsCredsWatcher(certRootPath string, updateEvent chan<- struct{}, errEvent chan<- error) *FsCredsWatcher {
+	return &FsCredsWatcher{certRootPath, "", "", updateEvent, errEvent}
+}
+
+// WithFilePaths completes the FsCredsWatcher instance with the cert and key files locations
+func (fscw *FsCredsWatcher) WithFilePaths(certFilePath, keyFilePath string) *FsCredsWatcher {
+	fscw.certFilePath = certFilePath
+	fscw.keyFilePath = keyFilePath
+	return fscw
 }
 
 // StartWatching starts watching the filesystem for cert updates
@@ -31,7 +44,7 @@ func (fscw *FsCredsWatcher) StartWatching(ctx context.Context) error {
 	defer watcher.Close()
 
 	// no point of proceeding if we fail to watch this
-	if err := watcher.Add(fscw.certPath); err != nil {
+	if err := watcher.Add(fscw.certRootPath); err != nil {
 		return err
 	}
 
@@ -43,12 +56,12 @@ LOOP:
 			// Watching the folder for create events as this indicates
 			// that the secret has been updated.
 			if event.Op&fsnotify.Create == fsnotify.Create &&
-				event.Name == filepath.Join(fscw.certPath, dataDirectoryLnName) {
+				event.Name == filepath.Join(fscw.certRootPath, dataDirectoryLnName) {
 				fscw.EventChan <- struct{}{}
 			}
 		case err := <-watcher.Errors:
 			fscw.ErrorChan <- err
-			log.Warnf("Error while watching %s: %s", fscw.certPath, err)
+			log.Warnf("Error while watching %s: %s", fscw.certRootPath, err)
 			break LOOP
 		case <-ctx.Done():
 			if err := ctx.Err(); err != nil {
@@ -60,3 +73,41 @@ LOOP:
 
 	return nil
 }
+
+// UpdateCert reads the cert and key files and stores the key pair in certVal
+func (fscw *FsCredsWatcher) UpdateCert(certVal *atomic.Value) error {
+	creds, err := ReadPEMCreds(fscw.keyFilePath, fscw.certFilePath)
+	if err != nil {
+		return fmt.Errorf("failed to read cert from disk: %s", err)
+	}
+
+	certPEM := creds.EncodePEM()
+	keyPEM := creds.EncodePrivateKeyPEM()
+	cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
+	if err != nil {
+		return err
+	}
+	certVal.Store(&cert)
+	return nil
+}
+
+// ProcessEvents reads from the update and error channels and reloads the certs when necessary
+func (fscw *FsCredsWatcher) ProcessEvents(
+	log *logrus.Entry,
+	certVal *atomic.Value,
+	updateEvent <-chan struct{},
+	errEvent <-chan error,
+) {
+	for {
+		select {
+		case <-updateEvent:
+			if err := fscw.UpdateCert(certVal); err != nil {
+				log.Warnf("Skipping update as cert could not be read from disk: %s", err)
+			} else {
+				log.Infof("Updated certificate")
+			}
+		case err := <-errEvent:
+			log.Warnf("Received error from fs watcher: %s", err)
+		}
+	}
+}
diff --git a/pkg/util/parsing.go b/pkg/util/parsing.go
new file mode 100644
index 000000000..b721abde6
--- /dev/null
+++ b/pkg/util/parsing.go
@@ -0,0 +1,59 @@
+package util
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/linkerd/linkerd2-proxy-init/ports"
+	"github.com/linkerd/linkerd2/controller/gen/config"
+	"github.com/prometheus/common/log"
+	corev1 "k8s.io/api/core/v1"
+)
+
+// ParseOpaquePorts parses the opaque ports annotation into a list of ports;
+// this includes converting port ranges into separate ports and named ports
+// into their port number equivalents.
+func ParseOpaquePorts(override string, containers []corev1.Container) []string {
+	var portRanges []*config.PortRange
+	split := strings.Split(strings.TrimSuffix(override, ","), ",")
+	portRanges = ToPortRanges(split)
+
+	var values []string
+	for _, portRange := range portRanges {
+		pr := portRange.GetPortRange()
+
+		// It is valid for the format of a port name to be the same as a
+		// port range (e.g. `123-456` is a valid name, but also is a valid
+		// range). All port names must be checked before making it a list.
+		named := false
+		for _, c := range containers {
+			for _, p := range c.Ports {
+				if p.Name == pr {
+					named = true
+					values = append(values, strconv.Itoa(int(p.ContainerPort)))
+				}
+			}
+		}
+
+		if !named {
+			pr, err := ports.ParsePortRange(pr)
+			if err != nil {
+				log.Warnf("Invalid port range [%v]: %s", pr, err)
+				continue
+			}
+			for i := pr.LowerBound; i <= pr.UpperBound; i++ {
+				values = append(values, strconv.Itoa(i))
+			}
+		}
+	}
+	return values
+}
+
+// ToPortRanges converts a slice of strings into a slice of PortRanges.
+func ToPortRanges(portRanges []string) []*config.PortRange {
+	ports := make([]*config.PortRange, len(portRanges))
+	for i, p := range portRanges {
+		ports[i] = &config.PortRange{PortRange: p}
+	}
+	return ports
+}


Test patch:
diff --git a/cli/cmd/check_test.go b/cli/cmd/check_test.go
index c0d71b21e..d89c487e8 100644
--- a/cli/cmd/check_test.go
+++ b/cli/cmd/check_test.go
@@ -24,7 +24,7 @@ func TestCheckStatus(t *testing.T) {
 		})
 
 		output := bytes.NewBufferString("")
-		runChecks(output, stderr, hc, tableOutput)
+		healthcheck.RunChecks(output, stderr, hc, tableOutput)
 
 		goldenFileBytes, err := ioutil.ReadFile("testdata/check_output.golden")
 		if err != nil {
@@ -51,7 +51,7 @@ func TestCheckStatus(t *testing.T) {
 		})
 
 		output := bytes.NewBufferString("")
-		runChecks(output, stderr, hc, jsonOutput)
+		healthcheck.RunChecks(output, stderr, hc, jsonOutput)
 
 		goldenFileBytes, err := ioutil.ReadFile("testdata/check_output_json.golden")
 		if err != nil {
diff --git a/cli/cmd/inject_test.go b/cli/cmd/inject_test.go
index 0f328b3e6..9668b143a 100644
--- a/cli/cmd/inject_test.go
+++ b/cli/cmd/inject_test.go
@@ -575,7 +575,7 @@ func TestWalk(t *testing.T) {
 }
 
 func TestProxyConfigurationAnnotations(t *testing.T) {
-	baseValues, err := linkerd2.NewValues(false)
+	baseValues, err := linkerd2.NewValues()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -627,7 +627,7 @@ func TestProxyConfigurationAnnotations(t *testing.T) {
 }
 
 func TestProxyImageAnnotations(t *testing.T) {
-	baseValues, err := linkerd2.NewValues(false)
+	baseValues, err := linkerd2.NewValues()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -653,7 +653,7 @@ func TestProxyImageAnnotations(t *testing.T) {
 }
 
 func TestProxyInitImageAnnotations(t *testing.T) {
-	baseValues, err := linkerd2.NewValues(false)
+	baseValues, err := linkerd2.NewValues()
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -677,7 +677,7 @@ func TestProxyInitImageAnnotations(t *testing.T) {
 }
 
 func TestNoAnnotations(t *testing.T) {
-	baseValues, err := linkerd2.NewValues(false)
+	baseValues, err := linkerd2.NewValues()
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/cli/cmd/install_helm_test.go b/cli/cmd/install_helm_test.go
index be57750db..ccf21ad53 100644
--- a/cli/cmd/install_helm_test.go
+++ b/cli/cmd/install_helm_test.go
@@ -332,10 +332,15 @@ func chartPartials(t *testing.T, paths []string) *chart.Chart {
 }
 
 func readTestValues(ha bool, ignoreOutboundPorts string, ignoreInboundPorts string) (*l5dcharts.Values, error) {
-	values, err := l5dcharts.NewValues(ha)
+	values, err := l5dcharts.NewValues()
 	if err != nil {
 		return nil, err
 	}
+	if ha {
+		if err = l5dcharts.MergeHAValues(values); err != nil {
+			return nil, err
+		}
+	}
 	values.GetGlobal().ProxyInit.IgnoreOutboundPorts = ignoreOutboundPorts
 	values.GetGlobal().ProxyInit.IgnoreInboundPorts = ignoreInboundPorts
 
diff --git a/cli/cmd/install_test.go b/cli/cmd/install_test.go
index 08cbf8c5e..10fa5dbd9 100644
--- a/cli/cmd/install_test.go
+++ b/cli/cmd/install_test.go
@@ -307,10 +307,15 @@ func testInstallOptionsHA(ha bool) (*charts.Values, error) {
 }
 
 func testInstallOptionsNoCerts(ha bool) (*charts.Values, error) {
-	values, err := charts.NewValues(ha)
+	values, err := charts.NewValues()
 	if err != nil {
 		return nil, err
 	}
+	if ha {
+		if err = charts.MergeHAValues(values); err != nil {
+			return nil, err
+		}
+	}
 
 	values.GetGlobal().Proxy.Image.Version = installProxyVersion
 	values.DebugContainer.Image.Version = installDebugVersion
@@ -321,7 +326,7 @@ func testInstallOptionsNoCerts(ha bool) (*charts.Values, error) {
 }
 
 func testInstallValues() (*charts.Values, error) {
-	values, err := charts.NewValues(false)
+	values, err := charts.NewValues()
 	if err != nil {
 		return nil, err
 	}
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index 7470327e9..f101c414a 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 9d010353c..8360e5380 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 3c2903f63..7b80b08f5 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
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 9e0606428..d6da2bb5b 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_grafana_existing.golden b/cli/cmd/testdata/install_grafana_existing.golden
index 8d1e2db87..b15a71c17 100644
--- a/cli/cmd/testdata/install_grafana_existing.golden
+++ b/cli/cmd/testdata/install_grafana_existing.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 102f86219..a7982d6cf 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index 3b142d812..2eec879cd 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index f9b593d0b..b2a22c9f9 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output.golden b/cli/cmd/testdata/install_helm_output.golden
index 06ab727c0..912a21820 100644
--- a/cli/cmd/testdata/install_helm_output.golden
+++ b/cli/cmd/testdata/install_helm_output.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_addons.golden b/cli/cmd/testdata/install_helm_output_addons.golden
index 06ab727c0..912a21820 100644
--- a/cli/cmd/testdata/install_helm_output_addons.golden
+++ b/cli/cmd/testdata/install_helm_output_addons.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_ha.golden b/cli/cmd/testdata/install_helm_output_ha.golden
index fb2fd3687..2d266520b 100644
--- a/cli/cmd/testdata/install_helm_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_output_ha.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index d78b56c9a..32d289f5f 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index f1fa14f57..cad5e76e5 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index d67d5f6b8..bea06f094 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index b381a9334..b0d2f0212 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_prometheus_overwrite.golden b/cli/cmd/testdata/install_prometheus_overwrite.golden
index 6e76aa678..ef316c060 100644
--- a/cli/cmd/testdata/install_prometheus_overwrite.golden
+++ b/cli/cmd/testdata/install_prometheus_overwrite.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index e4d450875..a3577232b 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_restricted_dashboard.golden b/cli/cmd/testdata/install_restricted_dashboard.golden
index 1fc7e48da..1ff8bf475 100644
--- a/cli/cmd/testdata/install_restricted_dashboard.golden
+++ b/cli/cmd/testdata/install_restricted_dashboard.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_tracing_overwrite.golden b/cli/cmd/testdata/install_tracing_overwrite.golden
index 6ef70965a..ecf426865 100644
--- a/cli/cmd/testdata/install_tracing_overwrite.golden
+++ b/cli/cmd/testdata/install_tracing_overwrite.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services", "nodes"]
+  resources: ["pods", "endpoints", "services", "nodes", "namespaces"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/uninject_test.go b/cli/cmd/uninject_test.go
index bb957894f..ae1312dfc 100644
--- a/cli/cmd/uninject_test.go
+++ b/cli/cmd/uninject_test.go
@@ -93,7 +93,7 @@ func TestUninjectYAML(t *testing.T) {
 		},
 	}
 
-	values, err := charts.NewValues(false)
+	values, err := charts.NewValues()
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/cli/cmd/upgrade_test.go b/cli/cmd/upgrade_test.go
index fc64476cf..6640a53c3 100644
--- a/cli/cmd/upgrade_test.go
+++ b/cli/cmd/upgrade_test.go
@@ -544,7 +544,7 @@ func TestUpgradeOverwriteRemoveAddonKeys(t *testing.T) {
 /* Helpers */
 
 func testUpgradeOptions() ([]flag.Flag, *pflag.FlagSet, error) {
-	defaults, err := charts.NewValues(false)
+	defaults, err := charts.NewValues()
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/controller/api/destination/server_test.go b/controller/api/destination/server_test.go
index b66c36a88..031ce6b54 100644
--- a/controller/api/destination/server_test.go
+++ b/controller/api/destination/server_test.go
@@ -18,6 +18,7 @@ const clusterIP = "172.17.12.0"
 const podIP1 = "172.17.0.12"
 const podIP2 = "172.17.0.13"
 const port uint32 = 8989
+const opaquePort uint32 = 4242
 
 type mockDestinationGetServer struct {
 	util.MockServerStream
@@ -42,6 +43,11 @@ func (m *mockDestinationGetProfileServer) Send(profile *pb.DestinationProfile) e
 func makeServer(t *testing.T) *server {
 	k8sAPI, err := k8s.NewFakeAPI(`
 apiVersion: v1
+kind: Namespace
+metadata:
+  name: ns`,
+		`
+apiVersion: v1
 kind: Service
 metadata:
   name: name1
@@ -72,6 +78,8 @@ kind: Pod
 metadata:
   labels:
     linkerd.io/control-plane-ns: linkerd
+  annotations:
+    config.linkerd.io/opaque-ports: "4242"
   name: name1-1
   namespace: ns
 status:
@@ -260,6 +268,9 @@ func TestGetProfiles(t *testing.T) {
 		}
 
 		lastUpdate := stream.updates[len(stream.updates)-1]
+		if lastUpdate.OpaqueProtocol {
+			t.Fatalf("Expected port %d to not be an opaque protocol, but it was", port)
+		}
 		routes := lastUpdate.GetRoutes()
 		if len(routes) != 1 {
 			t.Fatalf("Expected 1 route but got %d: %v", len(routes), routes)
@@ -411,11 +422,14 @@ func TestGetProfiles(t *testing.T) {
 			t.Fatalf("Expected 1 to 3 updates but got %d: %v", len(stream.updates), stream.updates)
 		}
 
-		first := stream.updates[0]
-		if first.FullyQualifiedName != fullyQualifiedName {
-			t.Fatalf("Expected fully qualified name '%s', but got '%s'", fullyQualifiedName, first.FullyQualifiedName)
+		last := stream.updates[len(stream.updates)-1]
+		if last.FullyQualifiedName != fullyQualifiedName {
+			t.Fatalf("Expected fully qualified name '%s', but got '%s'", fullyQualifiedName, last.FullyQualifiedName)
 		}
-		routes := first.GetRoutes()
+		if last.OpaqueProtocol {
+			t.Fatalf("Expected port %d to not be an opaque protocol, but it was", port)
+		}
+		routes := last.GetRoutes()
 		if len(routes) != 1 {
 			t.Fatalf("Expected 1 route but got %d: %v", len(routes), routes)
 		}
@@ -452,6 +466,9 @@ func TestGetProfiles(t *testing.T) {
 		if first.Endpoint == nil {
 			t.Fatalf("Expected response to have endpoint field")
 		}
+		if first.OpaqueProtocol {
+			t.Fatalf("Expected port %d to not be an opaque protocol, but it was", port)
+		}
 		_, exists := first.Endpoint.MetricLabels["namespace"]
 		if !exists {
 			t.Fatalf("Expected 'namespace' metric label to exist but it did not")
@@ -520,6 +537,89 @@ func TestGetProfiles(t *testing.T) {
 			t.Fatalf("Expected no protocol hint but found one")
 		}
 	})
+
+	t.Run("Return opaque protocol profile when using cluster IP and opaque protocol port", func(t *testing.T) {
+		server := makeServer(t)
+		stream := &bufferingGetProfileStream{
+			updates:          []*pb.DestinationProfile{},
+			MockServerStream: util.NewMockServerStream(),
+		}
+		stream.Cancel()
+		err := server.GetProfile(&pb.GetDestination{
+			Scheme: "k8s",
+			Path:   fmt.Sprintf("%s:%d", clusterIP, opaquePort),
+		}, stream)
+		if err != nil {
+			t.Fatalf("Got error: %s", err)
+		}
+
+		// An explanation for why we expect 1 to 3 updates is in test cases
+		// above
+		if len(stream.updates) == 0 || len(stream.updates) > 3 {
+			t.Fatalf("Expected 1 to 3 updates but got %d: %v", len(stream.updates), stream.updates)
+		}
+
+		last := stream.updates[len(stream.updates)-1]
+		if last.FullyQualifiedName != fullyQualifiedName {
+			t.Fatalf("Expected fully qualified name '%s', but got '%s'", fullyQualifiedName, last.FullyQualifiedName)
+		}
+		if !last.OpaqueProtocol {
+			t.Fatalf("Expected port %d to be an opaque protocol, but it was not", opaquePort)
+		}
+		routes := last.GetRoutes()
+		if len(routes) != 1 {
+			t.Fatalf("Expected 1 route but got %d: %v", len(routes), routes)
+		}
+	})
+
+	t.Run("Return opaque protocol profile with endpoint when using pod IP and opaque protocol port", func(t *testing.T) {
+		server := makeServer(t)
+		stream := &bufferingGetProfileStream{
+			updates:          []*pb.DestinationProfile{},
+			MockServerStream: util.NewMockServerStream(),
+		}
+		stream.Cancel()
+
+		epAddr, err := toAddress(podIP1, opaquePort)
+		if err != nil {
+			t.Fatalf("Got error: %s", err)
+		}
+
+		err = server.GetProfile(&pb.GetDestination{
+			Scheme: "k8s",
+			Path:   fmt.Sprintf("%s:%d", podIP1, opaquePort),
+		}, stream)
+		if err != nil {
+			t.Fatalf("Got error: %s", err)
+		}
+
+		// An explanation for why we expect 1 to 3 updates is in test cases
+		// above
+		if len(stream.updates) == 0 || len(stream.updates) > 3 {
+			t.Fatalf("Expected 1 to 3 updates but got %d: %v", len(stream.updates), stream.updates)
+		}
+
+		first := stream.updates[0]
+		if first.Endpoint == nil {
+			t.Fatalf("Expected response to have endpoint field")
+		}
+		if !first.OpaqueProtocol {
+			t.Fatalf("Expected port %d to be an opaque protocol, but it was not", opaquePort)
+		}
+		_, exists := first.Endpoint.MetricLabels["namespace"]
+		if !exists {
+			t.Fatalf("Expected 'namespace' metric label to exist but it did not")
+		}
+		if first.Endpoint.ProtocolHint == nil {
+			t.Fatalf("Expected protocol hint but found none")
+		}
+		if !first.OpaqueProtocol {
+			t.Fatalf("Expected protocol to be opaque but it was not")
+		}
+		if first.Endpoint.Addr.String() != epAddr.String() {
+			t.Fatalf("Expected endpoint IP port to be %d, but it was %d", epAddr.Port, first.Endpoint.Addr.Port)
+		}
+	})
 }
 
 func TestTokenStructure(t *testing.T) {
diff --git a/controller/proxy-injector/webhook_test.go b/controller/proxy-injector/webhook_test.go
index 20e803632..e6a9e3298 100644
--- a/controller/proxy-injector/webhook_test.go
+++ b/controller/proxy-injector/webhook_test.go
@@ -21,7 +21,7 @@ import (
 type unmarshalledPatch []map[string]interface{}
 
 var (
-	values, _ = linkerd2.NewValues(false)
+	values, _ = linkerd2.NewValues()
 )
 
 func confNsEnabled() *inject.ResourceConfig {
diff --git a/controller/tap/apiserver_test.go b/controller/tap/apiserver_test.go
index 52f53dc2e..1a007a696 100644
--- a/controller/tap/apiserver_test.go
+++ b/controller/tap/apiserver_test.go
@@ -16,54 +16,6 @@ import (
 	k8sutils "github.com/linkerd/linkerd2/pkg/k8s"
 )
 
-func TestNewAPIServer(t *testing.T) {
-	expectations := []struct {
-		k8sRes []string
-		err    error
-	}{
-		{
-			err: fmt.Errorf("failed to load [%s] config: configmaps %q not found", k8sutils.ExtensionAPIServerAuthenticationConfigMapName, k8sutils.ExtensionAPIServerAuthenticationConfigMapName),
-		},
-		{
-			err: nil,
-			k8sRes: []string{`
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: extension-apiserver-authentication
-  namespace: kube-system
-data:
-  client-ca-file: 'client-ca-file'
-  requestheader-allowed-names: '["name1", "name2"]'
-  requestheader-client-ca-file: 'requestheader-client-ca-file'
-  requestheader-extra-headers-prefix: '["X-Remote-Extra-"]'
-  requestheader-group-headers: '["X-Remote-Group"]'
-  requestheader-username-headers: '["X-Remote-User"]'
-`,
-			},
-		},
-	}
-
-	ctx := context.Background()
-	for i, exp := range expectations {
-		exp := exp // pin
-
-		t.Run(fmt.Sprintf("%d returns a configured API Server", i), func(t *testing.T) {
-			k8sAPI, err := k8s.NewFakeAPI(exp.k8sRes...)
-			if err != nil {
-				t.Fatalf("NewFakeAPI returned an error: %s", err)
-			}
-
-			fakeGrpcServer := newGRPCTapServer(4190, "controller-ns", "cluster.local", k8sAPI)
-
-			_, _, err = NewAPIServer(ctx, "localhost:0", tls.Certificate{}, k8sAPI, fakeGrpcServer, false)
-			if !reflect.DeepEqual(err, exp.err) {
-				t.Errorf("NewAPIServer returned unexpected error: %s, expected: %s", err, exp.err)
-			}
-		})
-	}
-}
-
 func TestAPIServerAuth(t *testing.T) {
 	expectations := []struct {
 		k8sRes         []string
@@ -138,7 +90,7 @@ func TestValidate(t *testing.T) {
 
 	req := http.Request{TLS: &tls}
 
-	server := apiServer{}
+	server := APIServer{}
 	if err := server.validate(&req); err != nil {
 		t.Fatalf("No error expected for %q but encountered %q", cert.Subject.CommonName, err.Error())
 	}
@@ -152,7 +104,7 @@ func TestValidate_ClientAllowed(t *testing.T) {
 
 	req := http.Request{TLS: &tls}
 
-	server := apiServer{allowedNames: []string{"name-trusted"}}
+	server := APIServer{allowedNames: []string{"name-trusted"}}
 	if err := server.validate(&req); err != nil {
 		t.Fatalf("No error expected for %q but encountered %q", cert.Subject.CommonName, err.Error())
 	}
@@ -166,7 +118,7 @@ func TestValidate_ClientAllowedViaSAN(t *testing.T) {
 
 	req := http.Request{TLS: &tls}
 
-	server := apiServer{allowedNames: []string{"linkerd.io"}}
+	server := APIServer{allowedNames: []string{"linkerd.io"}}
 	if err := server.validate(&req); err != nil {
 		t.Fatalf("No error expected for %q but encountered %q", cert.Subject.CommonName, err.Error())
 	}
@@ -180,7 +132,7 @@ func TestValidate_ClientNotAllowed(t *testing.T) {
 
 	req := http.Request{TLS: &tls}
 
-	server := apiServer{allowedNames: []string{"name-trusted"}}
+	server := APIServer{allowedNames: []string{"name-trusted"}}
 	if err := server.validate(&req); err == nil {
 		t.Fatalf("Expected request to be rejected for %q", cert.Subject.CommonName)
 	}
diff --git a/pkg/charts/linkerd2/values_test.go b/pkg/charts/linkerd2/values_test.go
index 44fc56c7f..a110d41cb 100644
--- a/pkg/charts/linkerd2/values_test.go
+++ b/pkg/charts/linkerd2/values_test.go
@@ -10,7 +10,7 @@ import (
 )
 
 func TestNewValues(t *testing.T) {
-	actual, err := NewValues(false)
+	actual, err := NewValues()
 	if err != nil {
 		t.Fatalf("Unexpected error: %v\n", err)
 	}
@@ -168,7 +168,7 @@ func TestNewValues(t *testing.T) {
 	}
 
 	t.Run("HA", func(t *testing.T) {
-		actual, err := NewValues(true)
+		err := MergeHAValues(actual)
 
 		if err != nil {
 			t.Fatalf("Unexpected error: %v\n", err)
diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go
index 836903cd6..015799c11 100644
--- a/pkg/healthcheck/healthcheck_test.go
+++ b/pkg/healthcheck/healthcheck_test.go
@@ -51,9 +51,9 @@ func (hc *HealthChecker) addCheckAsCategory(
 	categoryID CategoryID,
 	desc string,
 ) {
-	testCategory := category{
+	testCategory := Category{
 		id:       testCategoryID,
-		checkers: []checker{},
+		checkers: []Checker{},
 	}
 
 	for _, cat := range hc.categories {
@@ -73,9 +73,9 @@ func (hc *HealthChecker) addCheckAsCategory(
 func TestHealthChecker(t *testing.T) {
 	nullObserver := func(*CheckResult) {}
 
-	passingCheck1 := category{
+	passingCheck1 := Category{
 		id: "cat1",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc1",
 				check: func(context.Context) error {
@@ -86,9 +86,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	passingCheck2 := category{
+	passingCheck2 := Category{
 		id: "cat2",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc2",
 				check: func(context.Context) error {
@@ -99,9 +99,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	failingCheck := category{
+	failingCheck := Category{
 		id: "cat3",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc3",
 				check: func(context.Context) error {
@@ -124,9 +124,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	passingRPCCheck := category{
+	passingRPCCheck := Category{
 		id: "cat4",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc4",
 				checkRPC: func(context.Context) (*healthcheckPb.SelfCheckResponse, error) {
@@ -151,9 +151,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	failingRPCCheck := category{
+	failingRPCCheck := Category{
 		id: "cat5",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc5",
 				checkRPC: func(context.Context) (*healthcheckPb.SelfCheckResponse, error) {
@@ -165,9 +165,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	fatalCheck := category{
+	fatalCheck := Category{
 		id: "cat6",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "desc6",
 				fatal:       true,
@@ -179,9 +179,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	skippingCheck := category{
+	skippingCheck := Category{
 		id: "cat7",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "skip",
 				check: func(context.Context) error {
@@ -192,9 +192,9 @@ func TestHealthChecker(t *testing.T) {
 		},
 	}
 
-	skippingRPCCheck := category{
+	skippingRPCCheck := Category{
 		id: "cat8",
-		checkers: []checker{
+		checkers: []Checker{
 			{
 				description: "skipRpc",
 				checkRPC: func(context.Context) (*healthcheckPb.SelfCheckResponse, error) {
@@ -308,9 +308,9 @@ func TestHealthChecker(t *testing.T) {
 		retryWindow = 0
 		returnError := true
 
-		retryCheck := category{
+		retryCheck := Category{
 			id: "cat7",
-			checkers: []checker{
+			checkers: []Checker{
 				{
 					description:   "desc7",
 					retryDeadline: time.Now().Add(100 * time.Second),
@@ -2539,7 +2539,7 @@ data:
 }
 
 func TestFetchCurrentConfiguration(t *testing.T) {
-	defaultValues, err := linkerd2.NewValues(false)
+	defaultValues, err := linkerd2.NewValues()
 
 	if err != nil {
 		t.Fatalf("Unexpected error validating options: %v", err)
diff --git a/pkg/inject/inject_test.go b/pkg/inject/inject_test.go
index 27e440bcf..aab01fd97 100644
--- a/pkg/inject/inject_test.go
+++ b/pkg/inject/inject_test.go
@@ -23,7 +23,7 @@ func TestGetOverriddenValues(t *testing.T) {
 		pullPolicy           = "Always"
 	)
 
-	testConfig, err := l5dcharts.NewValues(false)
+	testConfig, err := l5dcharts.NewValues()
 	if err != nil {
 		t.Fatalf("Unexpected error: %v", err)
 	}
@@ -70,7 +70,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 
 				values.GetGlobal().Proxy.Cores = 2
 				values.GetGlobal().Proxy.DisableIdentity = true
@@ -116,7 +116,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 				return values
 			},
 		},
@@ -152,7 +152,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 
 				values.GetGlobal().Proxy.Cores = 2
 				values.GetGlobal().Proxy.DisableIdentity = true
@@ -200,7 +200,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 				return values
 			},
 		},
@@ -217,7 +217,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 				values.GetGlobal().Proxy.OutboundConnectTimeout = "6005ms"
 				values.GetGlobal().Proxy.InboundConnectTimeout = "2005ms"
 				return values
@@ -247,7 +247,7 @@ func TestGetOverriddenValues(t *testing.T) {
 				},
 			},
 			expected: func() *l5dcharts.Values {
-				values, _ := l5dcharts.NewValues(false)
+				values, _ := l5dcharts.NewValues()
 				values.GetGlobal().Proxy.OpaquePorts = "3306"
 				return values
 			},
diff --git a/cli/cmd/uninstall_test.go b/pkg/k8s/resource/resource_test.go
similarity index 99%
rename from cli/cmd/uninstall_test.go
rename to pkg/k8s/resource/resource_test.go
index 264dd96bf..8b7425ca0 100644
--- a/cli/cmd/uninstall_test.go
+++ b/pkg/k8s/resource/resource_test.go
@@ -1,4 +1,4 @@
-package cmd
+package resource
 
 import (
 	"context"
diff --git a/test/integration/opaqueports/opaque_ports_test.go b/test/integration/opaqueports/opaque_ports_test.go
new file mode 100644
index 000000000..42ac8a1fe
--- /dev/null
+++ b/test/integration/opaqueports/opaque_ports_test.go
@@ -0,0 +1,95 @@
+package opaqueports
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/linkerd/linkerd2/testutil"
+	v1 "k8s.io/api/core/v1"
+)
+
+var TestHelper *testutil.TestHelper
+
+var (
+	appName = "app"
+
+	// With the app's port marked as opaque, we expect to find a single open
+	// TCP connection that is not TLS'd because the port is skipped.
+	tcpMetric = "tcp_open_total{peer=\"src\",direction=\"inbound\",tls=\"no_identity\",no_tls_reason=\"port_skipped\"} 1"
+)
+
+func TestMain(m *testing.M) {
+	TestHelper = testutil.NewTestHelper()
+	os.Exit(m.Run())
+}
+
+//////////////////////
+/// TEST EXECUTION ///
+//////////////////////
+
+func TestOpaquePorts(t *testing.T) {
+	ctx := context.Background()
+
+	opaquePortsNs := TestHelper.GetTestNamespace("opaque-ports-test")
+	err := TestHelper.CreateDataPlaneNamespaceIfNotExists(ctx, opaquePortsNs, nil)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, fmt.Sprintf("failed to create %s namespace", opaquePortsNs),
+			"failed to create %s namespace: %s", opaquePortsNs, err)
+	}
+
+	out, err := TestHelper.LinkerdRun("inject", "--manual", "testdata/opaque_ports_application.yaml")
+	if err != nil {
+		testutil.AnnotatedFatal(t, "'linkerd inject' command failed", err)
+	}
+	out, err = TestHelper.KubectlApply(out, opaquePortsNs)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+			"'kubectl apply' command failed\n%s", out)
+	}
+
+	// Check that the test application started correctly
+	if err := TestHelper.CheckPods(ctx, opaquePortsNs, appName, 1); err != nil {
+		if rce, ok := err.(*testutil.RestartCountError); ok {
+			testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+		} else {
+			testutil.AnnotatedError(t, "CheckPods timed-out", err)
+		}
+	}
+
+	if err := TestHelper.CheckDeployment(ctx, opaquePortsNs, appName, 1); err != nil {
+		testutil.AnnotatedErrorf(t, "CheckDeployment timed-out", "Error validating deployment [%s]:\n%s", appName, err)
+	}
+
+	t.Run("expect inbound TCP connection with no TLS for port_skipped reason", func(t *testing.T) {
+		pods, err := TestHelper.GetPods(ctx, opaquePortsNs, map[string]string{"app": appName})
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error getting opaque ports app pods", "error getting opaque ports app pods\n%s", err)
+		}
+
+		// Wait for slow-cooker to start sending requests
+		time.Sleep(20 * time.Second)
+
+		// Get metrics for the app pod expecting to find TCP connection counters
+		metrics, err := getPodMetrics(pods[0], opaquePortsNs)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error getting metrics for pod", "error getting metrics for pod\n%s", err)
+		}
+		if !strings.Contains(metrics, tcpMetric) {
+			testutil.AnnotatedFatalf(t, "failed to find expected TCP metric when port is marked as opaque", "failed to find expected TCP metric when port is marked as opaque\n%s", metrics)
+		}
+	})
+}
+
+func getPodMetrics(pod v1.Pod, ns string) (string, error) {
+	podName := fmt.Sprintf("pod/%s", pod.Name)
+	cmd := []string{"metrics", "--namespace", ns, podName}
+	metrics, err := TestHelper.LinkerdRun(cmd...)
+	if err != nil {
+		return "", err
+	}
+	return metrics, nil
+}
diff --git a/test/integration/opaqueports/testdata/opaque_ports_application.yaml b/test/integration/opaqueports/testdata/opaque_ports_application.yaml
new file mode 100644
index 000000000..e1828e9cc
--- /dev/null
+++ b/test/integration/opaqueports/testdata/opaque_ports_application.yaml
@@ -0,0 +1,64 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: app
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: app
+  template:
+    metadata:
+      labels:
+        app: app
+      annotations:
+        config.linkerd.io/opaque-ports: "8080"
+    spec:
+      containers:
+      - name: app
+        image: buoyantio/bb:v0.0.6
+        args:
+        - terminus
+        - "--h1-server-port=8080"
+        - "--response-text=app"
+        ports:
+        - containerPort: 8080
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: svc
+  labels:
+    app: svc
+spec:
+  selector:
+    app: app
+  ports:
+  - name: http
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: slow-cooker
+spec:
+  template:
+    metadata:
+      labels:
+        app: slow-cooker
+    spec:
+      containers:
+      - name: slow-cooker
+        image: buoyantio/slow_cooker:1.3.0
+        command:
+        - "/bin/sh"
+        args:
+        - "-c"
+        - |
+          sleep 15 # wait for pods to start
+          /slow_cooker/slow_cooker -qps 1 -metric-addr 0.0.0.0:9999 http://svc:8080
+        ports:
+        - containerPort: 9999
+      restartPolicy: OnFailure