...

Source file src/github.com/linkerd/linkerd2/cli/cmd/check_extensions.go

Documentation: github.com/linkerd/linkerd2/cli/cmd

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"sort"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/briandowns/spinner"
    17  	"github.com/linkerd/linkerd2/pkg/healthcheck"
    18  	"github.com/linkerd/linkerd2/pkg/version"
    19  	"github.com/mattn/go-isatty"
    20  	utilsexec "k8s.io/utils/exec"
    21  )
    22  
    23  // glob is satisfied by filepath.Glob.
    24  type glob func(string) ([]string, error)
    25  
    26  // extension contains the full path of an extension executable. If it's a
    27  // a built-in extension, path will be the `linkerd` executable and builtin will
    28  // be the extension name (jaeger, multicluster, or viz).
    29  type extension struct {
    30  	path    string
    31  	builtin string
    32  }
    33  
    34  var (
    35  	builtInChecks = map[string]struct{}{
    36  		"jaeger":       {},
    37  		"multicluster": {},
    38  		"viz":          {},
    39  	}
    40  )
    41  
    42  // findExtensions searches the path for all linkerd-* executables and returns a
    43  // slice of check commands, and a slice of missing checks.
    44  func findExtensions(pathEnv string, glob glob, exec utilsexec.Interface, nsLabels []string) ([]extension, []string) {
    45  	cliExtensions := findCLIExtensionsOnPath(pathEnv, glob, exec)
    46  
    47  	// first, collect extensions that are "always" enabled
    48  	extensions := findAlwaysChecks(cliExtensions, exec)
    49  
    50  	alwaysSuffixSet := map[string]struct{}{}
    51  	for _, e := range extensions {
    52  		alwaysSuffixSet[suffix(e.path)] = struct{}{}
    53  	}
    54  
    55  	// nsLabelSet is the set of extension names which are installed on the cluster
    56  	// but are not "always" checks
    57  	nsLabelSet := map[string]struct{}{}
    58  	for _, label := range nsLabels {
    59  		if _, ok := alwaysSuffixSet[label]; !ok {
    60  			nsLabelSet[label] = struct{}{}
    61  		}
    62  	}
    63  
    64  	// second, collect on-cluster extensions
    65  	for _, e := range cliExtensions {
    66  		suffix := suffix(e)
    67  		if _, ok := nsLabelSet[suffix]; ok {
    68  			extensions = append(extensions, extension{path: e})
    69  			delete(nsLabelSet, suffix)
    70  		}
    71  	}
    72  
    73  	// third, collect built-in extensions
    74  	for label := range nsLabelSet {
    75  		if _, ok := builtInChecks[label]; ok {
    76  			extensions = append(extensions, extension{path: os.Args[0], builtin: label})
    77  			delete(nsLabelSet, label)
    78  		}
    79  	}
    80  
    81  	// anything left in nsLabelSet is a missing executable
    82  	missing := []string{}
    83  	for label := range nsLabelSet {
    84  		missing = append(missing, fmt.Sprintf("linkerd-%s", label))
    85  	}
    86  
    87  	sort.Slice(extensions, func(i, j int) bool {
    88  		if extensions[i].path != extensions[j].path {
    89  			_, filename1 := filepath.Split(extensions[i].path)
    90  			_, filename2 := filepath.Split(extensions[j].path)
    91  			return filename1 < filename2
    92  		}
    93  		return extensions[i].builtin < extensions[j].builtin
    94  	})
    95  	sort.Strings(missing)
    96  
    97  	return extensions, missing
    98  }
    99  
   100  // findCLIExtensionsOnPath searches the path for all linkerd-* executables and
   101  // returns a slice of unique filepaths. if multiple executables have the same
   102  // name, only the one which comes earliest in the pathEnv is returned.
   103  func findCLIExtensionsOnPath(pathEnv string, glob glob, exec utilsexec.Interface) []string {
   104  	executables := []string{}
   105  	seen := map[string]struct{}{}
   106  
   107  	for _, dir := range filepath.SplitList(pathEnv) {
   108  		matches, err := glob(filepath.Join(dir, "linkerd-*"))
   109  		if err != nil {
   110  			continue
   111  		}
   112  		sort.Strings(matches)
   113  
   114  		for _, match := range matches {
   115  			suffix := suffix(match)
   116  			if _, ok := seen[suffix]; ok {
   117  				continue
   118  			}
   119  
   120  			path, err := exec.LookPath(match)
   121  			if err != nil {
   122  				continue
   123  			}
   124  
   125  			executables = append(executables, path)
   126  			seen[suffix] = struct{}{}
   127  		}
   128  	}
   129  
   130  	return executables
   131  }
   132  
   133  // findAlwaysChecks filters a slice of linkerd-* executables to only those that
   134  // support the "_extension-metadata" subcommand, and announce themselves to
   135  // "always" run.
   136  func findAlwaysChecks(cliExtensions []string, exec utilsexec.Interface) []extension {
   137  	extensions := []extension{}
   138  
   139  	for _, e := range cliExtensions {
   140  		if isAlwaysCheck(e, exec) {
   141  			extensions = append(extensions, extension{path: e})
   142  		}
   143  	}
   144  
   145  	return extensions
   146  }
   147  
   148  // isAlwaysCheck executes a command with an "_extension-metadata" subcommand,
   149  // and returns true if the output is a valid ExtensionMetadataOutput struct.
   150  func isAlwaysCheck(path string, exec utilsexec.Interface) bool {
   151  	cmd := exec.Command(path, healthcheck.ExtensionMetadataSubcommand)
   152  	var stdout, stderr bytes.Buffer
   153  	cmd.SetStdout(&stdout)
   154  	cmd.SetStderr(&stderr)
   155  	err := cmd.Run()
   156  	if err != nil {
   157  		return false
   158  	}
   159  
   160  	metadataOutput, err := parseJSONMetadataOutput(stdout.Bytes())
   161  	if err != nil {
   162  		return false
   163  	}
   164  
   165  	// output of _extension-metadata must match the executable name, and specific
   166  	// "always"
   167  	// i.e. linkerd-foo is allowed, linkerd-foo-v0.XX.X is not
   168  	_, filename := filepath.Split(path)
   169  	return strings.EqualFold(metadataOutput.Name, filename) && metadataOutput.Checks == healthcheck.Always
   170  }
   171  
   172  // parseJSONMetadataOutput parses the output of an _extension-metadata
   173  // subcommand. The data is expected to be a ExtensionMetadataOutput struct
   174  // serialized to json.
   175  func parseJSONMetadataOutput(data []byte) (healthcheck.ExtensionMetadataOutput, error) {
   176  	var metadata healthcheck.ExtensionMetadataOutput
   177  	err := json.Unmarshal(data, &metadata)
   178  	if err != nil {
   179  		return healthcheck.ExtensionMetadataOutput{}, err
   180  	}
   181  	return metadata, nil
   182  }
   183  
   184  // runExtensionsChecks runs checks for each extension name passed into the
   185  // `extensions` parameter and handles formatting the output for each extension's
   186  // check. This function also prints check warnings for missing extensions.
   187  func runExtensionsChecks(
   188  	wout io.Writer, werr io.Writer, extensions []extension, missing []string, utilsexec utilsexec.Interface, flags []string, output string,
   189  ) (bool, bool) {
   190  	spin := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
   191  	spin.Writer = wout
   192  
   193  	success := true
   194  	warning := false
   195  	for _, extension := range extensions {
   196  		args := append([]string{"check"}, flags...)
   197  		if extension.builtin != "" {
   198  			args = append([]string{extension.builtin}, args...)
   199  		}
   200  
   201  		if isatty.IsTerminal(os.Stdout.Fd()) {
   202  			name := suffix(extension.path)
   203  			if extension.builtin != "" {
   204  				name = extension.builtin
   205  			}
   206  
   207  			spin.Suffix = fmt.Sprintf(" Running %s extension check", name)
   208  			spin.Color("bold") // this calls spin.Restart()
   209  		}
   210  
   211  		plugin := utilsexec.Command(extension.path, args...)
   212  		var stdout, stderr bytes.Buffer
   213  		plugin.SetStdout(&stdout)
   214  		plugin.SetStderr(&stderr)
   215  		plugin.Run()
   216  		results, err := parseJSONCheckOutput(stdout.Bytes())
   217  		spin.Stop()
   218  		if err != nil {
   219  			success = false
   220  
   221  			command := fmt.Sprintf("%s %s", extension.path, strings.Join(args, " "))
   222  			if len(stderr.String()) > 0 {
   223  				err = errors.New(stderr.String())
   224  			} else {
   225  				err = fmt.Errorf("invalid extension check output from \"%s\" (JSON object expected):\n%s\n[%w]", command, stdout.String(), err)
   226  			}
   227  			_, filename := filepath.Split(extension.path)
   228  			results = healthcheck.CheckResults{
   229  				Results: []healthcheck.CheckResult{
   230  					{
   231  						Category:    healthcheck.CategoryID(filename),
   232  						Description: fmt.Sprintf("Running: %s", command),
   233  						Err:         err,
   234  						HintURL:     healthcheck.HintBaseURL(version.Version) + "extensions",
   235  					},
   236  				},
   237  			}
   238  		}
   239  
   240  		extensionSuccess, extensionWarning := healthcheck.RunChecks(wout, werr, results, output)
   241  		if !extensionSuccess {
   242  			success = false
   243  		}
   244  		if extensionWarning {
   245  			warning = true
   246  		}
   247  	}
   248  
   249  	for _, m := range missing {
   250  		results := healthcheck.CheckResults{
   251  			Results: []healthcheck.CheckResult{
   252  				{
   253  					Category:    healthcheck.CategoryID(m),
   254  					Description: fmt.Sprintf("Linkerd extension command %s exists", m),
   255  					Err:         &exec.Error{Name: m, Err: exec.ErrNotFound},
   256  					HintURL:     healthcheck.HintBaseURL(version.Version) + "extensions",
   257  					Warning:     true,
   258  				},
   259  			},
   260  		}
   261  
   262  		extensionSuccess, extensionWarning := healthcheck.RunChecks(wout, werr, results, output)
   263  		if !extensionSuccess {
   264  			success = false
   265  		}
   266  		if extensionWarning {
   267  			warning = true
   268  		}
   269  	}
   270  
   271  	return success, warning
   272  }
   273  
   274  // parseJSONCheckOutput parses the output of a check command run with json
   275  // output mode. The data is expected to be a CheckOutput struct serialized
   276  // to json. In addition to deserializing, this function will convert the result
   277  // to a CheckResults struct.
   278  func parseJSONCheckOutput(data []byte) (healthcheck.CheckResults, error) {
   279  	var checks healthcheck.CheckOutput
   280  	err := json.Unmarshal(data, &checks)
   281  	if err != nil {
   282  		return healthcheck.CheckResults{}, err
   283  	}
   284  	results := []healthcheck.CheckResult{}
   285  	for _, category := range checks.Categories {
   286  		for _, check := range category.Checks {
   287  			var err error
   288  			if check.Error != "" {
   289  				err = errors.New(check.Error)
   290  			}
   291  			results = append(results, healthcheck.CheckResult{
   292  				Category:    category.Name,
   293  				Description: check.Description,
   294  				Err:         err,
   295  				HintURL:     check.Hint,
   296  				Warning:     check.Result == healthcheck.CheckWarn,
   297  			})
   298  		}
   299  	}
   300  	return healthcheck.CheckResults{Results: results}, nil
   301  }
   302  
   303  // suffix returns the last part of a CLI check name, e.g.:
   304  // linkerd-foo                => foo
   305  // linkerd-foo-bar            => foo-bar
   306  // /usr/local/bin/linkerd-foo => foo
   307  // s is assumed to be a filepath where the filename begins with "linkerd-"
   308  func suffix(s string) string {
   309  	_, filename := filepath.Split(s)
   310  	suffix := strings.TrimPrefix(filename, "linkerd-")
   311  	if suffix == filename {
   312  		// we should never get here
   313  		return ""
   314  	}
   315  	return suffix
   316  }
   317  

View as plain text