Repository: linkerd/linkerd2
Pull request #5397 (2020-12-16 14:26:53)

Add missing icon entry to jaeger chart
This is required for `helm lint` to pass. Its absence was what caused
the last CI edge release to fail and so we had to manually upload the
charts.



Patch:
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/cli/cmd/check.go b/cli/cmd/check.go
index 556051228..50aab13e1 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,151 +220,6 @@ 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)
 	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/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/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 e8dadb79b..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,7 @@ 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())
 
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/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/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)
+		}
+	}
+}


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/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/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go
index 836903cd6..501c1bfae 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),