Repository: linkerd/linkerd2
Pull request #5295 (2020-11-30 15:22:06)

jaeger: add check sub command
This adds a new `linkerd jaeger check` command to have checks w.r.t
jaeger extension. This is similar to that of the `linkerd check` cmd.
As jaeger is a separate package, It was a bit complex for this to work
as not all types and fields from healthcheck pkg are public, Helper
funcs were used to mitigate this.

This has the following changes:

- Adds a new `check.go` file under the jaeger extension pkg
- Moves some commonly needed funcs and types from `cli/cmd/check.go`
  and `pkg/healthcheck/health.go` into
  `pkg/healthcheck/healthcheck_output.go`.

Example Output
```bash
tarun in dev in on  kind-kind () linkerd2 on  tarun/jaeger-check [$!?] via 🐹 v1.15.4 took 2s
❯ ./bin/go-run cli jaeger check
linkerd-jaeger
--------------
√ collector service account exists
√ jaeger service account exists
√ collector config map exists
‼ collector pod is running
    collector-69cc44dfbc-8qkxw status is Pending
‼ jaeger pod is running
    jaeger-6f98d5c979-5vnpt status is Pending
√ jaeger extension pods are injected

Status check results are √

tarun in dev in on  kind-kind () linkerd2 on  tarun/jaeger-check [$!?] via 🐹 v1.15.4 took 2s
❯ ./bin/go-run cli jaeger check
linkerd-jaeger
--------------
√ collector service account exists
√ jaeger service account exists
√ collector config map exists
√ collector pod is running
√ jaeger pod is running
√ jaeger extension pods are injected

Status check results are √
```

Signed-off-by: Tarun Pothulapati <tarunpothulapati@outlook.com>


Pothulapati: @adleong Yep, Plan to make the dataplane checks in a separate PR!
Pothulapati: @adleong Addressed the feedback.

Example Output
```bash
tarun in dev in on  kind-kind () linkerd2 on  tarun/jaeger-check [$!?] via 🐹 v1.15.4 took 2s
❯ ./bin/go-run cli jaeger check
linkerd-jaeger
--------------
√ collector service account exists
√ jaeger service account exists
√ collector config map exists
‼ collector pod is running
    collector-69cc44dfbc-8qkxw status is Pending
‼ jaeger pod is running
    jaeger-6f98d5c979-5vnpt status is Pending
√ jaeger extension pods are injected

Status check results are √

tarun in dev in on  kind-kind () linkerd2 on  tarun/jaeger-check [$!?] via 🐹 v1.15.4 took 2s
❯ ./bin/go-run cli jaeger check
linkerd-jaeger
--------------
√ collector service account exists
√ jaeger service account exists
√ collector config map exists
√ collector pod is running
√ jaeger pod is running
√ jaeger extension pods are injected

Status check results are √
```

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/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/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"


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/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),