diff --git a/EXTENSIONS.md b/EXTENSIONS.md index f1ebd07bc..516d97115 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -117,9 +117,28 @@ instead of the output format described above. E.g. ``` In particular, the `linkerd check` command will invoke the check command for -each extension installed in the cluster and will request json output. To -preserve forwards compatibility, it is recommended that the check command should -ignore any unknown flags. +each extension installed in the cluster and will request json output. +`linkerd check` may optinally invoke your extension if not installed in the +cluster (see `linkerd-name _extension-metadata` below to opt-in). To preserve +forwards compatibility, it is recommended that the check command should ignore +any unknown flags. + +### `linkerd-name _extension-metadata` + +This subcommand is optional, and enables an extension to opt-in to being +executed as part of `linkerd check`, even when there is no corresponding +extension on the cluster. To opt-in, the output of +`linkerd-name _extension-metadata` should be json of the form: + +```json +{ + "name": "linkerd-name", + "checks": "always", +} +``` + +Note that for `linkerd check` to validate which extensions are opting-in, it +runs `linkerd-* _extension-metadata` against every executable in the PATH. The extension may also implement further commands in addition to the ones defined here. diff --git a/cli/cmd/check.go b/cli/cmd/check.go index ad2e847c7..f91c88166 100644 --- a/cli/cmd/check.go +++ b/cli/cmd/check.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" @@ -18,6 +19,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" valuespkg "helm.sh/helm/v3/pkg/cli/values" + utilsexec "k8s.io/utils/exec" ) type checkOptions struct { @@ -196,9 +198,6 @@ func configureAndRunChecks(cmd *cobra.Command, wout io.Writer, werr io.Writer, o ChartValues: values, }) - if options.output == tableOutput { - healthcheck.PrintChecksHeader(wout, healthcheck.CoreHeader) - } success, warning := healthcheck.RunChecks(wout, werr, hc, options.output) if !options.preInstallOnly && !options.crdsOnly { @@ -231,19 +230,24 @@ func runExtensionChecks(cmd *cobra.Command, wout io.Writer, werr io.Writer, opts if err != nil { return false, false, err } + nsLabels := []string{} + for _, ns := range namespaces { + ext := ns.Labels[k8s.LinkerdExtensionLabel] + nsLabels = append(nsLabels, ext) + } + + exec := utilsexec.New() + + extensions, missing := findExtensions(os.Getenv("PATH"), filepath.Glob, exec, nsLabels) - success := true // no extensions to check - if len(namespaces) == 0 { - return success, false, nil + if len(extensions) == 0 && len(missing) == 0 { + return true, false, nil } - nsLabels := make([]string, len(namespaces)) - for i, ns := range namespaces { - nsLabels[i] = ns.Labels[k8s.LinkerdExtensionLabel] - } - - extensionSuccess, extensionWarning := healthcheck.RunExtensionsChecks(wout, werr, nsLabels, getExtensionCheckFlags(cmd.Flags()), opts.output) + extensionSuccess, extensionWarning := runExtensionsChecks( + wout, werr, extensions, missing, exec, getExtensionCheckFlags(cmd.Flags()), opts.output, + ) return extensionSuccess, extensionWarning, nil } diff --git a/cli/cmd/check_extensions.go b/cli/cmd/check_extensions.go new file mode 100644 index 000000000..6c1a0302d --- /dev/null +++ b/cli/cmd/check_extensions.go @@ -0,0 +1,316 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/briandowns/spinner" + "github.com/linkerd/linkerd2/pkg/healthcheck" + "github.com/linkerd/linkerd2/pkg/version" + "github.com/mattn/go-isatty" + utilsexec "k8s.io/utils/exec" +) + +// glob is satisfied by filepath.Glob. +type glob func(string) ([]string, error) + +// extension contains the full path of an extension executable. If it's a +// a built-in extension, path will be the `linkerd` executable and builtin will +// be the extension name (jaeger, multicluster, or viz). +type extension struct { + path string + builtin string +} + +var ( + builtInChecks = map[string]struct{}{ + "jaeger": {}, + "multicluster": {}, + "viz": {}, + } +) + +// findExtensions searches the path for all linkerd-* executables and returns a +// slice of check commands, and a slice of missing checks. +func findExtensions(pathEnv string, glob glob, exec utilsexec.Interface, nsLabels []string) ([]extension, []string) { + cliExtensions := findCLIExtensionsOnPath(pathEnv, glob, exec) + + // first, collect extensions that are "always" enabled + extensions := findAlwaysChecks(cliExtensions, exec) + + alwaysSuffixSet := map[string]struct{}{} + for _, e := range extensions { + alwaysSuffixSet[suffix(e.path)] = struct{}{} + } + + // nsLabelSet is the set of extension names which are installed on the cluster + // but are not "always" checks + nsLabelSet := map[string]struct{}{} + for _, label := range nsLabels { + if _, ok := alwaysSuffixSet[label]; !ok { + nsLabelSet[label] = struct{}{} + } + } + + // second, collect on-cluster extensions + for _, e := range cliExtensions { + suffix := suffix(e) + if _, ok := nsLabelSet[suffix]; ok { + extensions = append(extensions, extension{path: e}) + delete(nsLabelSet, suffix) + } + } + + // third, collect built-in extensions + for label := range nsLabelSet { + if _, ok := builtInChecks[label]; ok { + extensions = append(extensions, extension{path: os.Args[0], builtin: label}) + delete(nsLabelSet, label) + } + } + + // anything left in nsLabelSet is a missing executable + missing := []string{} + for label := range nsLabelSet { + missing = append(missing, fmt.Sprintf("linkerd-%s", label)) + } + + sort.Slice(extensions, func(i, j int) bool { + if extensions[i].path != extensions[j].path { + _, filename1 := filepath.Split(extensions[i].path) + _, filename2 := filepath.Split(extensions[j].path) + return filename1 < filename2 + } + return extensions[i].builtin < extensions[j].builtin + }) + sort.Strings(missing) + + return extensions, missing +} + +// findCLIExtensionsOnPath searches the path for all linkerd-* executables and +// returns a slice of unique filepaths. if multiple executables have the same +// name, only the one which comes earliest in the pathEnv is returned. +func findCLIExtensionsOnPath(pathEnv string, glob glob, exec utilsexec.Interface) []string { + executables := []string{} + seen := map[string]struct{}{} + + for _, dir := range filepath.SplitList(pathEnv) { + matches, err := glob(filepath.Join(dir, "linkerd-*")) + if err != nil { + continue + } + sort.Strings(matches) + + for _, match := range matches { + suffix := suffix(match) + if _, ok := seen[suffix]; ok { + continue + } + + path, err := exec.LookPath(match) + if err != nil { + continue + } + + executables = append(executables, path) + seen[suffix] = struct{}{} + } + } + + return executables +} + +// findAlwaysChecks filters a slice of linkerd-* executables to only those that +// support the "_extension-metadata" subcommand, and announce themselves to +// "always" run. +func findAlwaysChecks(cliExtensions []string, exec utilsexec.Interface) []extension { + extensions := []extension{} + + for _, e := range cliExtensions { + if isAlwaysCheck(e, exec) { + extensions = append(extensions, extension{path: e}) + } + } + + return extensions +} + +// isAlwaysCheck executes a command with an "_extension-metadata" subcommand, +// and returns true if the output is a valid ExtensionMetadataOutput struct. +func isAlwaysCheck(path string, exec utilsexec.Interface) bool { + cmd := exec.Command(path, healthcheck.ExtensionMetadataSubcommand) + var stdout, stderr bytes.Buffer + cmd.SetStdout(&stdout) + cmd.SetStderr(&stderr) + err := cmd.Run() + if err != nil { + return false + } + + metadataOutput, err := parseJSONMetadataOutput(stdout.Bytes()) + if err != nil { + return false + } + + // output of _extension-metadata must match the executable name, and specific + // "always" + // i.e. linkerd-foo is allowed, linkerd-foo-v0.XX.X is not + _, filename := filepath.Split(path) + return strings.EqualFold(metadataOutput.Name, filename) && metadataOutput.Checks == healthcheck.Always +} + +// parseJSONMetadataOutput parses the output of an _extension-metadata +// subcommand. The data is expected to be a ExtensionMetadataOutput struct +// serialized to json. +func parseJSONMetadataOutput(data []byte) (healthcheck.ExtensionMetadataOutput, error) { + var metadata healthcheck.ExtensionMetadataOutput + err := json.Unmarshal(data, &metadata) + if err != nil { + return healthcheck.ExtensionMetadataOutput{}, err + } + return metadata, nil +} + +// runExtensionsChecks runs checks for each extension name passed into the +// `extensions` parameter and handles formatting the output for each extension's +// check. This function also prints check warnings for missing extensions. +func runExtensionsChecks( + wout io.Writer, werr io.Writer, extensions []extension, missing []string, utilsexec utilsexec.Interface, flags []string, output string, +) (bool, bool) { + spin := spinner.New(spinner.CharSets[9], 100*time.Millisecond) + spin.Writer = wout + + success := true + warning := false + for _, extension := range extensions { + args := append([]string{"check"}, flags...) + if extension.builtin != "" { + args = append([]string{extension.builtin}, args...) + } + + if isatty.IsTerminal(os.Stdout.Fd()) { + name := suffix(extension.path) + if extension.builtin != "" { + name = extension.builtin + } + + spin.Suffix = fmt.Sprintf(" Running %s extension check", name) + spin.Color("bold") // this calls spin.Restart() + } + + plugin := utilsexec.Command(extension.path, args...) + var stdout, stderr bytes.Buffer + plugin.SetStdout(&stdout) + plugin.SetStderr(&stderr) + plugin.Run() + results, err := parseJSONCheckOutput(stdout.Bytes()) + spin.Stop() + if err != nil { + success = false + + command := fmt.Sprintf("%s %s", extension.path, strings.Join(args, " ")) + if len(stderr.String()) > 0 { + err = errors.New(stderr.String()) + } else { + err = fmt.Errorf("invalid extension check output from \"%s\" (JSON object expected):\n%s\n[%w]", command, stdout.String(), err) + } + _, filename := filepath.Split(extension.path) + results = healthcheck.CheckResults{ + Results: []healthcheck.CheckResult{ + { + Category: healthcheck.CategoryID(filename), + Description: fmt.Sprintf("Running: %s", command), + Err: err, + HintURL: healthcheck.HintBaseURL(version.Version) + "extensions", + }, + }, + } + } + + extensionSuccess, extensionWarning := healthcheck.RunChecks(wout, werr, results, output) + if !extensionSuccess { + success = false + } + if extensionWarning { + warning = true + } + } + + for _, m := range missing { + results := healthcheck.CheckResults{ + Results: []healthcheck.CheckResult{ + { + Category: healthcheck.CategoryID(m), + Description: fmt.Sprintf("Linkerd extension command %s exists", m), + Err: &exec.Error{Name: m, Err: exec.ErrNotFound}, + HintURL: healthcheck.HintBaseURL(version.Version) + "extensions", + Warning: true, + }, + }, + } + + extensionSuccess, extensionWarning := healthcheck.RunChecks(wout, werr, results, output) + if !extensionSuccess { + success = false + } + if extensionWarning { + warning = true + } + } + + return success, warning +} + +// parseJSONCheckOutput parses the output of a check command run with json +// output mode. The data is expected to be a CheckOutput struct serialized +// to json. In addition to deserializing, this function will convert the result +// to a CheckResults struct. +func parseJSONCheckOutput(data []byte) (healthcheck.CheckResults, error) { + var checks healthcheck.CheckOutput + err := json.Unmarshal(data, &checks) + if err != nil { + return healthcheck.CheckResults{}, err + } + results := []healthcheck.CheckResult{} + for _, category := range checks.Categories { + for _, check := range category.Checks { + var err error + if check.Error != "" { + err = errors.New(check.Error) + } + results = append(results, healthcheck.CheckResult{ + Category: category.Name, + Description: check.Description, + Err: err, + HintURL: check.Hint, + Warning: check.Result == healthcheck.CheckWarn, + }) + } + } + return healthcheck.CheckResults{Results: results}, nil +} + +// suffix returns the last part of a CLI check name, e.g.: +// linkerd-foo => foo +// linkerd-foo-bar => foo-bar +// /usr/local/bin/linkerd-foo => foo +// s is assumed to be a filepath where the filename begins with "linkerd-" +func suffix(s string) string { + _, filename := filepath.Split(s) + suffix := strings.TrimPrefix(filename, "linkerd-") + if suffix == filename { + // we should never get here + return "" + } + return suffix +} diff --git a/cli/cmd/check_extensions_test.go b/cli/cmd/check_extensions_test.go new file mode 100644 index 000000000..32a4add7f --- /dev/null +++ b/cli/cmd/check_extensions_test.go @@ -0,0 +1,388 @@ +package cmd + +import ( + "bytes" + "errors" + "path/filepath" + "reflect" + "testing" + + "k8s.io/utils/exec" + fakeexec "k8s.io/utils/exec/testing" +) + +func TestFindExtensions(t *testing.T) { + fakeGlob := func(path string) ([]string, error) { + dir, _ := filepath.Split(path) + return []string{ + filepath.Join(dir, "linkerd-bar"), + filepath.Join(dir, "linkerd-baz"), + filepath.Join(dir, "linkerd-foo"), + }, nil + } + + fcmd := fakeexec.FakeCmd{ + RunScript: []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte(`{"name":"linkerd-baz","checks":"always"}`), nil, nil + }, + func() ([]byte, []byte, error) { + return []byte(`{"name":"linkerd-foo-no-match","checks":"always"}`), nil, nil + }, + func() ([]byte, []byte, error) { return []byte(`{"name":"linkerd-bar","checks":"always"}`), nil, nil }, + }, + } + + lookPathSuccess := false + + fexec := &fakeexec.FakeExec{ + CommandScript: []fakeexec.FakeCommandAction{ + func(cmd string, args ...string) exec.Cmd { return fakeexec.InitFakeCmd(&fcmd, cmd, args...) }, + func(cmd string, args ...string) exec.Cmd { return fakeexec.InitFakeCmd(&fcmd, cmd, args...) }, + func(cmd string, args ...string) exec.Cmd { return fakeexec.InitFakeCmd(&fcmd, cmd, args...) }, + }, + LookPathFunc: func(cmd string) (string, error) { + if lookPathSuccess { + return cmd, nil + } + lookPathSuccess = true + return "", errors.New("fake-error") + }, + } + + extensions, missing := findExtensions("/path1:/this/is/a/fake/path2", fakeGlob, fexec, []string{"foo", "missing-cli"}) + + expExtensions := []extension{ + {path: "/this/is/a/fake/path2/linkerd-bar"}, + {path: "/path1/linkerd-baz"}, + {path: "/path1/linkerd-foo"}, + } + expMissing := []string{"linkerd-missing-cli"} + + if !reflect.DeepEqual(expExtensions, extensions) { + t.Errorf("Expected [%+v] Got [%+v]", expExtensions, extensions) + } + if !reflect.DeepEqual(expMissing, missing) { + t.Errorf("Expected [%+v] Got [%+v]", expMissing, missing) + } +} + +func TestRunExtensionsChecks(t *testing.T) { + successJSON := ` + { + "success": true, + "categories": [ + { + "categoryName": "success check name", + "checks": [ + { + "description": "success check desc", + "result": "success" + } + ] + } + ] + } + ` + + warningJSON := ` + { + "success": true, + "categories": [ + { + "categoryName": "warning check name", + "checks": [ + { + "description": "warning check desc", + "hint": "https://example.com/warning", + "error": "this is the warning message", + "result": "warning" + } + ] + } + ] + } + ` + + errorJSON := ` + { + "success": false, + "categories": [ + { + "categoryName": "error check name", + "checks": [ + { + "description": "error check desc", + "hint": "https://example.com/error", + "error": "this is the error message", + "result": "error" + } + ] + } + ] + } + ` + + multiJSON := ` + { + "success": true, + "categories": [ + { + "categoryName": "multi check name", + "checks": [ + { + "description": "multi check desc success", + "result": "success" + }, + { + "description": "multi check desc warning", + "hint": "https://example.com/multi", + "error": "this is the multi warning message", + "result": "warning" + } + ] + } + ] + } + ` + + testCases := []struct { + name string + extensions []extension + missing []string + fakeActions []fakeexec.FakeAction + expSuccess bool + expWarning bool + expOutput string + }{ + { + "no checks", + nil, + nil, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return nil, nil, nil + }, + }, + true, + false, + "", + }, + { + "invalid JSON", + []extension{{path: "/path/linkerd-invalid"}}, + nil, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte("bad json"), nil, nil + }, + }, + false, + false, + `linkerd-invalid +--------------- +× Running: /path/linkerd-invalid check + invalid extension check output from "/path/linkerd-invalid check" (JSON object expected): +bad json +[invalid character 'b' looking for beginning of value] + see https://linkerd.io/2/checks/#extensions for hints + +`, + }, + { + "one successful check", + []extension{{path: "/path/linkerd-success"}}, + nil, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte(successJSON), nil, nil + }, + }, + true, + false, + `success check name +------------------ +√ success check desc + +`, + }, + { + "one warning check", + []extension{{path: "/path/linkerd-warning"}}, + nil, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte(warningJSON), nil, nil + }, + }, + true, + true, + `warning check name +------------------ +‼ warning check desc + this is the warning message + see https://example.com/warning for hints + +`, + }, + { + "one error check", + []extension{{path: "/path/linkerd-error"}}, + nil, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte(errorJSON), nil, nil + }, + }, + false, + false, + `error check name +---------------- +× error check desc + this is the error message + see https://example.com/error for hints + +`, + }, + { + "one missing check", + nil, + []string{"missing"}, + nil, + true, + true, + `missing +------- +‼ Linkerd extension command missing exists + exec: "missing": executable file not found in $PATH + see https://linkerd.io/2/checks/#extensions for hints + +`, + }, + { + "multiple checks with success, warnings, errors, and missing", + []extension{{path: "/path/linkerd-success"}, {path: "/path/linkerd-warning"}, {path: "/path/linkerd-error"}, {path: "/path/linkerd-multi"}}, + []string{"missing1", "missing2"}, + []fakeexec.FakeAction{ + func() ([]byte, []byte, error) { + return []byte(successJSON), nil, nil + }, + func() ([]byte, []byte, error) { + return []byte(warningJSON), nil, nil + }, + func() ([]byte, []byte, error) { + return []byte(errorJSON), nil, nil + }, + func() ([]byte, []byte, error) { + return []byte(multiJSON), nil, nil + }, + }, + false, + true, + `success check name +------------------ +√ success check desc + +warning check name +------------------ +‼ warning check desc + this is the warning message + see https://example.com/warning for hints + +error check name +---------------- +× error check desc + this is the error message + see https://example.com/error for hints + +multi check name +---------------- +√ multi check desc success +‼ multi check desc warning + this is the multi warning message + see https://example.com/multi for hints + +missing1 +-------- +‼ Linkerd extension command missing1 exists + exec: "missing1": executable file not found in $PATH + see https://linkerd.io/2/checks/#extensions for hints + +missing2 +-------- +‼ Linkerd extension command missing2 exists + exec: "missing2": executable file not found in $PATH + see https://linkerd.io/2/checks/#extensions for hints + +`, + }, + } + + for _, tc := range testCases { + tc := tc // pin + t.Run(tc.name, func(t *testing.T) { + fcmd := fakeexec.FakeCmd{ + RunScript: tc.fakeActions, + } + + fakeCommandActions := make([]fakeexec.FakeCommandAction, len(tc.fakeActions)) + for i := 0; i < len(tc.fakeActions); i++ { + fakeCommandActions[i] = func(cmd string, args ...string) exec.Cmd { return fakeexec.InitFakeCmd(&fcmd, cmd, args...) } + } + fexec := &fakeexec.FakeExec{ + CommandScript: fakeCommandActions, + } + + var stdout, stderr bytes.Buffer + success, warning := runExtensionsChecks(&stdout, &stderr, tc.extensions, tc.missing, fexec, nil, "") + if tc.expSuccess != success { + t.Errorf("Expected success to be %t, got %t", tc.expSuccess, success) + } + if tc.expWarning != warning { + t.Errorf("Expected warning to be %t, got %t", tc.expWarning, warning) + } + output := stdout.String() + if tc.expOutput != output { + t.Errorf("Expected output to be:\n%s\nGot:\n%s", tc.expOutput, output) + } + }) + } +} + +func TestSuffix(t *testing.T) { + testCases := []*struct { + testName string + input string + exp string + }{ + { + "empty", + "", + "", + }, + { + "no path", + "linkerd-foo", + "foo", + }, + { + "extra dash", + "linkerd-foo-bar", + "foo-bar", + }, + { + "with path", + "/tmp/linkerd-foo", + "foo", + }, + } + for _, tc := range testCases { + tc := tc // pin + t.Run(tc.testName, func(t *testing.T) { + result := suffix(tc.input) + if !reflect.DeepEqual(tc.exp, result) { + t.Fatalf("Expected [%s] Got [%s]", tc.exp, result) + } + }) + } +} diff --git a/pkg/healthcheck/healthcheck_output.go b/pkg/healthcheck/healthcheck_output.go index d68e4fc17..9eda64403 100644 --- a/pkg/healthcheck/healthcheck_output.go +++ b/pkg/healthcheck/healthcheck_output.go @@ -1,20 +1,16 @@ package healthcheck import ( - "bytes" "encoding/json" - "errors" "fmt" "io" "os" - "os/exec" "regexp" "strings" "time" "github.com/briandowns/spinner" "github.com/fatih/color" - "github.com/linkerd/linkerd2/pkg/version" "github.com/mattn/go-isatty" ) @@ -28,11 +24,6 @@ const ( // ShortOutput is used to specify the short output format ShortOutput = "short" - // CoreHeader is used when printing core header checks - CoreHeader = "core" - // extensionsHeader is used when printing extensions header checks - extensionsHeader = "extensions" - // DefaultHintBaseURL is the default base URL on the linkerd.io website // that all check hints for the latest linkerd version point to. Each // check adds its own `hintAnchor` to specify a location on the page. @@ -47,6 +38,30 @@ var ( reStableVersion = regexp.MustCompile(`stable-(\d\.\d+)\.`) ) +// Checks describes the "checks" field on a CheckCLIOutput +type Checks string + +const ( + // ExtensionMetadataSubcommand is the subcommand name an extension must + // support in order to provide config metadata to the "linkerd" CLI. + ExtensionMetadataSubcommand = "_extension-metadata" + + // Always run the check, regardless of cluster state + Always Checks = "always" + // // TODO: + // // Cluster informs "linkerd check" to only run this extension if there are + // // on-cluster resources. + // Cluster Checks = "cluster" + // // Never informs "linkerd check" to never run this extension. + // Never Checks = "never" +) + +// ExtensionMetadataOutput contains the output of a _extension-metadata subcommand. +type ExtensionMetadataOutput struct { + Name string `json:"name"` + Checks Checks `json:"checks"` +} + // CheckResults contains a slice of CheckResult structs. type CheckResults struct { Results []CheckResult @@ -92,14 +107,6 @@ func (cr CheckResults) RunChecks(observer CheckObserver) (bool, bool) { return success, warning } -// PrintChecksHeader writes the header text for a check. -func PrintChecksHeader(wout io.Writer, header string) { - headerText := fmt.Sprintf("Linkerd %s checks", header) - fmt.Fprintln(wout, headerText) - fmt.Fprintln(wout, strings.Repeat("=", len(headerText))) - fmt.Fprintln(wout) -} - // PrintChecksResult writes the checks result. func PrintChecksResult(wout io.Writer, output string, success bool, warning bool) { if output == JSONOutput { @@ -114,95 +121,6 @@ func PrintChecksResult(wout io.Writer, output string, success bool, warning bool } } -// RunExtensionsChecks runs checks for each extension name passed into the `extensions` parameter -// and handles formatting the output for each extension's check. This function also handles -// finding the extension in the user's path and runs it. -func RunExtensionsChecks(wout io.Writer, werr io.Writer, extensions []string, flags []string, output string) (bool, bool) { - if output == TableOutput { - PrintChecksHeader(wout, extensionsHeader) - } - - spin := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - spin.Writer = wout - - success := true - warning := false - for _, extension := range extensions { - var path string - args := append([]string{"check"}, flags...) - var err error - results := CheckResults{ - Results: []CheckResult{}, - } - extensionCmd := fmt.Sprintf("linkerd-%s", extension) - - switch extension { - case "jaeger": - path = os.Args[0] - args = append([]string{"jaeger"}, args...) - case "viz": - path = os.Args[0] - args = append([]string{"viz"}, args...) - case "multicluster": - path = os.Args[0] - args = append([]string{"multicluster"}, args...) - default: - path, err = exec.LookPath(extensionCmd) - results.Results = []CheckResult{ - { - Category: CategoryID(extensionCmd), - Description: fmt.Sprintf("Linkerd extension command %s exists", extensionCmd), - Err: err, - HintURL: HintBaseURL(version.Version) + "extensions", - Warning: true, - }, - } - } - - if err == nil { - if isatty.IsTerminal(os.Stdout.Fd()) { - spin.Suffix = fmt.Sprintf(" Running %s extension check", extension) - spin.Color("bold") // this calls spin.Restart() - } - // Path is constructed from the switch statements above and will - // be a valid Linkerd subcommand. - //nolint:gosec - plugin := exec.Command(path, args...) - var stdout, stderr bytes.Buffer - plugin.Stdout = &stdout - plugin.Stderr = &stderr - plugin.Run() - extensionResults, err := parseJSONCheckOutput(stdout.Bytes()) - spin.Stop() - if err != nil { - command := fmt.Sprintf("%s %s", path, strings.Join(args, " ")) - if len(stderr.String()) > 0 { - err = errors.New(stderr.String()) - } else { - err = fmt.Errorf("invalid extension check output from \"%s\" (JSON object expected):\n%s\n[%w]", command, stdout.String(), err) - } - results.Results = append(results.Results, CheckResult{ - Category: CategoryID(extensionCmd), - Description: fmt.Sprintf("Running: %s", command), - Err: err, - HintURL: HintBaseURL(version.Version) + "extensions", - }) - success = false - } else { - results.Results = append(results.Results, extensionResults.Results...) - } - } - - var extensionSuccess bool - extensionSuccess, warning = RunChecks(wout, werr, results, output) - if !extensionSuccess { - success = false - } - } - - return success, warning -} - // RunChecks runs the checks that are part of hc func RunChecks(wout io.Writer, werr io.Writer, hc Runner, output string) (bool, bool) { if output == JSONOutput { @@ -235,14 +153,12 @@ func runChecksTable(wout io.Writer, hc Runner, output string) (bool, bool) { printResultDescription(wout, status, result) } - var headerPrinted bool prettyPrintResultsShort := func(result *CheckResult) { // bail out early and skip printing if we've got an okStatus if result.Err == nil { return } - headerPrinted = printHeader(wout, headerPrinted, hc) lastCategory = printCategory(wout, lastCategory, result) spin.Stop() @@ -340,35 +256,6 @@ func runChecksJSON(wout io.Writer, werr io.Writer, hc Runner) (bool, bool) { return success, warning } -// parseJSONCheckOutput parses the output of a check command run with json -// output mode. The data is expected to be a CheckOutput struct serialized -// to json. In addition to deserializing, this function will convert the result -// to a CheckResults struct. -func parseJSONCheckOutput(data []byte) (CheckResults, error) { - var checks CheckOutput - err := json.Unmarshal(data, &checks) - if err != nil { - return CheckResults{}, err - } - results := []CheckResult{} - for _, category := range checks.Categories { - for _, check := range category.Checks { - var err error - if check.Error != "" { - err = errors.New(check.Error) - } - results = append(results, CheckResult{ - Category: category.Name, - Description: check.Description, - Err: err, - HintURL: check.Hint, - Warning: check.Result == CheckWarn, - }) - } - } - return CheckResults{results}, nil -} - func printResultDescription(wout io.Writer, status string, result *CheckResult) { fmt.Fprintf(wout, "%s %s\n", status, result.Description) @@ -401,28 +288,6 @@ func restartSpinner(spin *spinner.Spinner, result *CheckResult) { } } -// When running in short mode, we defer writing the header -// until the first time we print a warning or error result. -func printHeader(wout io.Writer, headerPrinted bool, hc Runner) bool { - if headerPrinted { - return headerPrinted - } - - switch v := hc.(type) { - case *HealthChecker: - if v.IsMainCheckCommand { - PrintChecksHeader(wout, CoreHeader) - headerPrinted = true - } - // When RunExtensionChecks called - case CheckResults: - PrintChecksHeader(wout, extensionsHeader) - headerPrinted = true - } - - return headerPrinted -} - func printCategory(wout io.Writer, lastCategory CategoryID, result *CheckResult) CategoryID { if lastCategory == result.Category { return lastCategory diff --git a/test/integration/install/smoke/install_smoke_test.go b/test/integration/install/smoke/install_smoke_test.go index 204e15b66..cfb64a481 100644 --- a/test/integration/install/smoke/install_smoke_test.go +++ b/test/integration/install/smoke/install_smoke_test.go @@ -56,7 +56,7 @@ func TestSmoke(t *testing.T) { "'kubectl apply' command failed\n%s", out) } - // Wait for pods to in smoke-test dpeloyment to come up + // Wait for pods to in smoke-test deployment to come up for _, deploy := range []string{"smoke-test-terminus", "smoke-test-gateway"} { if err := TestHelper.CheckPods(ctx, ns, deploy, 1); err != nil { //nolint:errorlint diff --git a/test/integration/install/smoke/testdata/check.proxy.golden b/test/integration/install/smoke/testdata/check.proxy.golden index 2a53c475c..2155085f0 100644 --- a/test/integration/install/smoke/testdata/check.proxy.golden +++ b/test/integration/install/smoke/testdata/check.proxy.golden @@ -1,6 +1,3 @@ -Linkerd core checks -=================== - kubernetes-api -------------- √ can initialize the client diff --git a/test/integration/install/testdata/check.proxy.golden b/test/integration/install/testdata/check.proxy.golden index 2a53c475c..2155085f0 100644 --- a/test/integration/install/testdata/check.proxy.golden +++ b/test/integration/install/testdata/check.proxy.golden @@ -1,6 +1,3 @@ -Linkerd core checks -=================== - kubernetes-api -------------- √ can initialize the client