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 }