mirror of https://github.com/linkerd/linkerd2.git
cli: Support running `check` on CLI-only extensions (#10588)
The existing `linkerd check` command runs extension checks based on extension namespaces already on-cluster. This approach does not permit running extension checks without cluster-side components. Introduce "CLI Checks". These extensions run as part of `linkerd check`, if they satisfy the following criteria: 1) executable in PATH 2) prefixed by `linkerd-` 3) supports an `_extension-metadata` subcommand, that outputs self-identifying JSON, for example: ``` $ linkerd-foo _extension-metadata { "name": "linkerd-foo", "checks": "always" } ``` 4) The `name` value from `_extension-metadata` must match the filename. And `checks` must equal `always`. If a CLI Check is found that also would have run as an on-cluster extension check, it is run as a CLI Check only. Fixes #10544
This commit is contained in:
parent
495c580efc
commit
e71266f2c9
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
Linkerd core checks
|
||||
===================
|
||||
|
||||
kubernetes-api
|
||||
--------------
|
||||
√ can initialize the client
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
Linkerd core checks
|
||||
===================
|
||||
|
||||
kubernetes-api
|
||||
--------------
|
||||
√ can initialize the client
|
||||
|
|
Loading…
Reference in New Issue