From 2336120c60f2d9206b147bd18d92533c09e0bbf2 Mon Sep 17 00:00:00 2001 From: Stephen Kitt Date: Fri, 18 Oct 2024 17:08:26 +0200 Subject: [PATCH] Initial implementation of skupper debug check This introduces a framework for diagnostics commands, with an initial implementation of two Kubernetes checks (verifying that the Kubernetes API is accessible, and that the Kubernetes version is supported). The framework supports simple declaration of Cobra commands constructed from individual diagnostics, and dependencies between diagnostics. The kind spinner is copied with some adaptations borrowed from the Submariner project. Signed-off-by: Stephen Kitt --- go.mod | 12 +- internal/cmd/skupper/debug/check/check.go | 111 ++++++++ .../cmd/skupper/debug/check/cli/logger.go | 259 ++++++++++++++++++ .../cmd/skupper/debug/check/cli/spinner.go | 171 ++++++++++++ .../cmd/skupper/debug/check/cli/status.go | 256 +++++++++++++++++ .../skupper/debug/check/command/diagnose.go | 41 +++ internal/cmd/skupper/debug/check/env/term.go | 54 ++++ internal/cmd/skupper/debug/check/kube/doc.go | 15 + .../skupper/debug/check/kube/kube_access.go | 27 ++ .../skupper/debug/check/kube/kube_support.go | 34 +++ .../skupper/debug/check/kube/kube_version.go | 66 +++++ .../debug/check/kube/kube_version_test.go | 28 ++ internal/cmd/skupper/debug/check/log/types.go | 66 +++++ internal/cmd/skupper/debug/debug.go | 3 + 14 files changed, 1137 insertions(+), 6 deletions(-) create mode 100644 internal/cmd/skupper/debug/check/check.go create mode 100644 internal/cmd/skupper/debug/check/cli/logger.go create mode 100644 internal/cmd/skupper/debug/check/cli/spinner.go create mode 100644 internal/cmd/skupper/debug/check/cli/status.go create mode 100644 internal/cmd/skupper/debug/check/command/diagnose.go create mode 100644 internal/cmd/skupper/debug/check/env/term.go create mode 100644 internal/cmd/skupper/debug/check/kube/doc.go create mode 100644 internal/cmd/skupper/debug/check/kube/kube_access.go create mode 100644 internal/cmd/skupper/debug/check/kube/kube_support.go create mode 100644 internal/cmd/skupper/debug/check/kube/kube_version.go create mode 100644 internal/cmd/skupper/debug/check/kube/kube_version_test.go create mode 100644 internal/cmd/skupper/debug/check/log/types.go diff --git a/go.mod b/go.mod index 176863345..6e2828e31 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,18 @@ require ( github.com/briandowns/spinner v1.23.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/fsnotify/fsnotify v1.7.0 + github.com/go-openapi/errors v0.20.3 github.com/go-openapi/runtime v0.24.1 github.com/go-openapi/strfmt v0.21.3 + github.com/go-openapi/swag v0.22.8 + github.com/go-openapi/validate v0.22.0 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/heimdalr/dag v1.5.0 github.com/interconnectedcloud/go-amqp v0.12.6-0.20200506124159-f51e540008b5 + github.com/mattn/go-isatty v0.0.20 github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 github.com/oapi-codegen/runtime v1.1.1 github.com/openshift/api v0.0.0-20210428205234-a8389931bee7 @@ -29,12 +33,12 @@ require ( golang.org/x/sys v0.28.0 golang.org/x/text v0.21.0 golang.org/x/time v0.5.0 - gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 k8s.io/code-generator v0.31.0 + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 sigs.k8s.io/yaml v1.4.0 ) @@ -58,13 +62,10 @@ require ( github.com/getkin/kin-openapi v0.124.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/analysis v0.21.2 // indirect - github.com/go-openapi/errors v0.20.3 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/loads v0.21.1 // indirect github.com/go-openapi/spec v0.20.4 // indirect - github.com/go-openapi/swag v0.22.8 // indirect - github.com/go-openapi/validate v0.22.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect @@ -76,7 +77,6 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -101,10 +101,10 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) diff --git a/internal/cmd/skupper/debug/check/check.go b/internal/cmd/skupper/debug/check/check.go new file mode 100644 index 000000000..c356fe9db --- /dev/null +++ b/internal/cmd/skupper/debug/check/check.go @@ -0,0 +1,111 @@ +package check + +import ( + "github.com/skupperproject/skupper/api/types" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/cli" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/command" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/kube" + "github.com/skupperproject/skupper/internal/config" + "github.com/spf13/cobra" + "k8s.io/utils/ptr" +) + +type cmdCheck struct { + cmd *cobra.Command + platformCommands map[types.Platform][]*command.Check +} + +func NewCmdCheck() *cobra.Command { + checkCmd := cmdCheck{ + platformCommands: map[types.Platform][]*command.Check{}, + } + + checkCmd.cmd = &cobra.Command{ + Use: "check", + Short: "Run diagnostics", + Long: `Runs diagnostics to identify potential issues in the environment hosting Skupper`, + Example: `skupper debug check -p kubernetes`, + Run: func(cmd *cobra.Command, args []string) { + utils.HandleError(utils.GenericError, checkCmd.Run(cmd, args)) + }, + } + + checkCmd.registerCommand(types.PlatformKubernetes, ptr.To(kube.NewCmdCheckK8sAccess())) + checkCmd.registerCommand(types.PlatformKubernetes, ptr.To(kube.NewCmdCheckK8sVersion())) + for _, cmds := range checkCmd.platformCommands { + for i := range cmds { + subCommand := *cmds[i] + cmd := &cobra.Command{ + Use: subCommand.Name(), + Short: "check that " + subCommand.CheckDescription(), + Run: func(cmd *cobra.Command, args []string) { + status := cli.NewReporter() + defer status.End() + runCommandWithDeps(status, subCommand, map[string]bool{}, cmd) + }, + } + // TODO Adjust "skupper" to args[0] + cmd.Example = "skupper debug check " + subCommand.Name() + checkCmd.cmd.AddCommand(cmd) + } + } + + return checkCmd.cmd +} + +func (c *cmdCheck) registerCommand(platform types.Platform, cmd *command.Check) { + c.platformCommands[platform] = append(c.platformCommands[platform], cmd) +} + +func (c cmdCheck) Run(cmd *cobra.Command, args []string) error { + platform := config.GetPlatform() + + // Run all available sub-commands, in dependency order (falling back to declaration order) + // In the map of processed dependencies, true indicates that the command previously ran successfully, + // false that it failed previously + processedDependencies := make(map[string]bool) + + status := cli.NewReporter() + defer status.End() + + for i := range c.platformCommands[platform] { + subCommand := *c.platformCommands[platform][i] + if _, seen := processedDependencies[subCommand.Name()]; seen { + // The command has already been run as a dependency, skip it + continue + } + _ = runCommandWithDeps(status, subCommand, processedDependencies, cmd) + } + + // For UX consistency, errors are handled internally + return nil +} + +func runCommandWithDeps(status cli.Reporter, dc command.Check, processed map[string]bool, cmd *cobra.Command) error { + dependencies := dc.Dependencies() + for i := range dependencies { + dependency := *dependencies[i] + if succeeded, seen := processed[dependency.Name()]; seen { + if succeeded { + // The command previously succeeded, skip it but continue + continue + } + // The command previously failed, stop (assuming that the previous run reported the error) + return nil + } + if err := runCommandWithDeps(status, dependency, processed, cmd); err != nil { + return err + } + } + + status.Start("Checking that " + dc.CheckDescription()) + if err := dc.Run(status, cmd); err != nil { + processed[dc.Name()] = false + return err + } + + processed[dc.Name()] = true + status.Success("") + return nil +} diff --git a/internal/cmd/skupper/debug/check/cli/logger.go b/internal/cmd/skupper/debug/check/cli/logger.go new file mode 100644 index 000000000..2ccd8b261 --- /dev/null +++ b/internal/cmd/skupper/debug/check/cli/logger.go @@ -0,0 +1,259 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "bytes" + "fmt" + "io" + "runtime" + "strings" + "sync" + "sync/atomic" + + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/env" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/log" +) + +// Logger is the kind cli's log.Logger implementation. +type Logger struct { + writer io.Writer + bufferPool *bufferPool + writerMu sync.Mutex + verbosity log.Level + // kind special additions + isSmartWriter bool +} + +var _ log.Logger = &Logger{} + +// NewLogger returns a new Logger with the given verbosity. +func NewLogger(writer io.Writer, verbosity log.Level) *Logger { + l := &Logger{ + verbosity: verbosity, + bufferPool: newBufferPool(), + } + l.SetWriter(writer) + + return l +} + +// SetWriter sets the output writer. +func (l *Logger) SetWriter(w io.Writer) { + l.writerMu.Lock() + defer l.writerMu.Unlock() + l.writer = w + _, isSpinner := w.(*Spinner) + l.isSmartWriter = isSpinner || env.IsSmartTerminal(w) +} + +// ColorEnabled returns true if the caller is OK to write colored output. +func (l *Logger) ColorEnabled() bool { + l.writerMu.Lock() + defer l.writerMu.Unlock() + + return l.isSmartWriter +} + +func (l *Logger) getVerbosity() log.Level { + return log.Level(atomic.LoadInt32((*int32)(&l.verbosity))) +} + +// SetVerbosity sets the loggers verbosity. +func (l *Logger) SetVerbosity(verbosity log.Level) { + atomic.StoreInt32((*int32)(&l.verbosity), int32(verbosity)) +} + +// synchronized write to the inner writer. +func (l *Logger) write(p []byte) (n int, err error) { + l.writerMu.Lock() + defer l.writerMu.Unlock() + + return l.writer.Write(p) //nolint:wrapcheck // No need to wrap here +} + +// writeBuffer writes buf with write, ensuring there is a trailing newline. +func (l *Logger) writeBuffer(buf *bytes.Buffer) { + // ensure trailing newline + if buf.Len() == 0 || buf.Bytes()[buf.Len()-1] != '\n' { + buf.WriteByte('\n') + } + + // TODO: should we handle this somehow?? + // Who logs for the logger? 🤔 + _, _ = l.write(buf.Bytes()) +} + +// print writes a simple string to the log writer. +func (l *Logger) print(message string) { + buf := bytes.NewBufferString(message) + l.writeBuffer(buf) +} + +// printf is roughly fmt.Fprintf against the log writer. +func (l *Logger) printf(format string, args ...interface{}) { + buf := l.bufferPool.Get() + fmt.Fprintf(buf, format, args...) + l.writeBuffer(buf) + l.bufferPool.Put(buf) +} + +// addDebugHeader inserts the debug line header to buf. +func addDebugHeader(buf *bytes.Buffer) { + _, file, line, ok := runtime.Caller(3) + // lifted from klog + if !ok { + file = "???" + line = 1 + } else if slash := strings.LastIndex(file, "/"); slash >= 0 { + path := file + file = path[slash+1:] + + if dirsep := strings.LastIndex(path[:slash], "/"); dirsep >= 0 { + file = path[dirsep+1:] + } + } + + buf.Grow(len(file) + 11) // we know at least this many bytes are needed. + buf.WriteString("DEBUG: ") + buf.WriteString(file) + buf.WriteByte(':') + fmt.Fprintf(buf, "%d", line) + buf.WriteByte(']') + buf.WriteByte(' ') +} + +// debug is like print but with a debug log header. +func (l *Logger) debug(message string) { + buf := l.bufferPool.Get() + addDebugHeader(buf) + buf.WriteString(message) + l.writeBuffer(buf) + l.bufferPool.Put(buf) +} + +// debugf is like printf but with a debug log header. +func (l *Logger) debugf(format string, args ...interface{}) { + buf := l.bufferPool.Get() + addDebugHeader(buf) + fmt.Fprintf(buf, format, args...) + l.writeBuffer(buf) + l.bufferPool.Put(buf) +} + +// Warn is part of the log.Logger interface. +func (l *Logger) Warn(message string) { + l.print(message) +} + +// Warnf is part of the log.Logger interface. +func (l *Logger) Warnf(format string, args ...interface{}) { + l.printf(format, args...) +} + +// Error is part of the log.Logger interface. +func (l *Logger) Error(message string) { + l.print(message) +} + +// Errorf is part of the log.Logger interface. +func (l *Logger) Errorf(format string, args ...interface{}) { + l.printf(format, args...) +} + +// V is part of the log.Logger interface. +func (l *Logger) V(level log.Level) log.InfoLogger { + return infoLogger{ + logger: l, + level: level, + enabled: level <= l.getVerbosity(), + } +} + +// infoLogger implements log.InfoLogger for Logger. +type infoLogger struct { + logger *Logger + level log.Level + enabled bool +} + +// Enabled is part of the log.InfoLogger interface. +func (i infoLogger) Enabled() bool { + return i.enabled +} + +// Info is part of the log.InfoLogger interface. +func (i infoLogger) Info(message string) { + if !i.enabled { + return + } + // for > 0, we are writing debug messages, include extra info + if i.level > 0 { + i.logger.debug(message) + } else { + i.logger.print(message) + } +} + +// Infof is part of the log.InfoLogger interface. +func (i infoLogger) Infof(format string, args ...interface{}) { + if !i.enabled { + return + } + // for > 0, we are writing debug messages, include extra info + if i.level > 0 { + i.logger.debugf(format, args...) + } else { + i.logger.printf(format, args...) + } +} + +// bufferPool is a type safe sync.Pool of *byte.Buffer, guaranteed to be Reset. +type bufferPool struct { + sync.Pool +} + +// newBufferPool returns a new bufferPool. +func newBufferPool() *bufferPool { + return &bufferPool{ + sync.Pool{ + New: func() interface{} { + // The Pool's New function should generally only return pointer + // types, since a pointer can be put into the return interface + // value without an allocation: + return new(bytes.Buffer) + }, + }, + } +} + +// Get obtains a buffer from the pool. +func (b *bufferPool) Get() *bytes.Buffer { + return b.Pool.Get().(*bytes.Buffer) +} + +// Put returns a buffer to the pool, resetting it first. +func (b *bufferPool) Put(x *bytes.Buffer) { + // only store small buffers to avoid pointless allocation + // avoid keeping arbitrarily large buffers + if x.Len() > 256 { + return + } + + x.Reset() + b.Pool.Put(x) +} diff --git a/internal/cmd/skupper/debug/check/cli/spinner.go b/internal/cmd/skupper/debug/check/cli/spinner.go new file mode 100644 index 000000000..9dd2919a5 --- /dev/null +++ b/internal/cmd/skupper/debug/check/cli/spinner.go @@ -0,0 +1,171 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + "io" + "runtime" + "sync" + "time" +) + +// custom CLI loading spinner for kind. +var spinnerFrames = []string{ + "⠈⠁", + "⠈⠑", + "⠈⠱", + "⠈⡱", + "⢀⡱", + "⢄⡱", + "⢄⡱", + "⢆⡱", + "⢎⡱", + "⢎⡰", + "⢎⡠", + "⢎⡀", + "⢎⠁", + "⠎⠁", + "⠊⠁", +} + +// Spinner is a simple and efficient CLI loading spinner used by kind +// It is simplistic and assumes that the line length will not change. +type Spinner struct { + stop chan struct{} // signals writer goroutine to stop from Stop() + stopped chan struct{} // signals Stop() that the writer goroutine stopped + mu *sync.Mutex // protects the mutable bits + // below are protected by mu + running bool + writer io.Writer + ticker *time.Ticker // signals that it is time to write a frame + prefix string + suffix string + // format string used to write a frame, depends on the host OS / terminal + frameFormat string +} + +// spinner implements writer. +var _ io.Writer = &Spinner{} + +// NewSpinner initializes and returns a new Spinner that will write to w +// NOTE: w should be os.Stderr or similar, and it should be a Terminal. +func NewSpinner(w io.Writer) *Spinner { + frameFormat := "\x1b[?7l\r%s%s%s\x1b[?7h" + // toggling wrapping seems to behave poorly on windows + // in general only the simplest escape codes behave well at the moment, + // and only in newer shells + if runtime.GOOS == "windows" { + frameFormat = "\r%s%s%s" + } + + return &Spinner{ + stop: make(chan struct{}, 1), + stopped: make(chan struct{}), + mu: &sync.Mutex{}, + writer: w, + frameFormat: frameFormat, + } +} + +// SetPrefix sets the prefix to print before the spinner. +func (s *Spinner) SetPrefix(prefix string) { + s.mu.Lock() + defer s.mu.Unlock() + s.prefix = prefix +} + +// SetSuffix sets the suffix to print after the spinner. +func (s *Spinner) SetSuffix(suffix string) { + s.mu.Lock() + defer s.mu.Unlock() + s.suffix = suffix +} + +// Start starts the spinner running. +func (s *Spinner) Start() { + s.mu.Lock() + defer s.mu.Unlock() + // don't start if we've already started. + if s.running { + return + } + // flag that we've started. + s.running = true + // start / create a frame ticker. + s.ticker = time.NewTicker(time.Millisecond * 100) + // spin in the background. + go func() { + // write frames forever (until signaled to stop). + for { + for _, frame := range spinnerFrames { + select { + // prefer stopping, select this signal first. + case <-s.stop: + func() { + s.mu.Lock() + defer s.mu.Unlock() + s.ticker.Stop() // free up the ticker + s.running = false // mark as stopped (it's fine to start now) + s.stopped <- struct{}{} // tell Stop() that we're done + }() + + return // ... and stop + // otherwise continue and write one frame. + case <-s.ticker.C: + func() { + s.mu.Lock() + defer s.mu.Unlock() + fmt.Fprintf(s.writer, s.frameFormat, s.prefix, frame, s.suffix) + }() + } + } + } + }() +} + +// Stop signals the spinner to stop. +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + // try to stop, do nothing if channel is full (IE already busy stopping). + s.stop <- struct{}{} + s.mu.Unlock() + // wait for stop to be finished. + <-s.stopped +} + +// Write implements io.Writer, interrupting the spinner and writing to +// the inner writer. +func (s *Spinner) Write(p []byte) (n int, err error) { + // lock first, so nothing else can start writing until we are done. + s.mu.Lock() + defer s.mu.Unlock() + // it the spinner is not running, just write directly. + if !s.running { + return s.writer.Write(p) //nolint:wrapcheck // No need to wrap here + } + // otherwise: we will rewrite the line first. + if _, err := s.writer.Write([]byte("\r")); err != nil { + return 0, err //nolint:wrapcheck // No need to wrap here + } + + return s.writer.Write(p) //nolint:wrapcheck // No need to wrap here +} diff --git a/internal/cmd/skupper/debug/check/cli/status.go b/internal/cmd/skupper/debug/check/cli/status.go new file mode 100644 index 000000000..42e2b69ee --- /dev/null +++ b/internal/cmd/skupper/debug/check/cli/status.go @@ -0,0 +1,256 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "fmt" + "io" + "os" + "unicode" + + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/env" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/log" +) + +type Reporter interface { + // Start reports that an operation or sequence of operations is starting; + // any operation in progress is ended. + Start(message string, args ...interface{}) + + // Success reports that the last operation succeeded with the specified message. + Success(message string, args ...interface{}) + + // Failure reports that the last operation failed with the specified message. + Failure(message string, args ...interface{}) + + // End the current operation that was previously initiated via Start. + End() + + // Warning reports a warning message for the last operation. + Warning(message string, args ...interface{}) + + // Error wraps err with the supplied message, reports it as a failure, ends the current operation, and returns the error. + // If err is nil, does nothing and returns nil. + Error(err error, message string, args ...interface{}) error +} + +type resultType int + +const ( + success resultType = iota + failure + warning +) + +type ( + successType string + warningType string + failureType string +) + +// status is used to track ongoing status in a CLI, with a nice loading spinner +// when attached to a terminal. +type status struct { + spinner *Spinner + status string + logger log.Logger + // for controlling coloring etc. + successFormat string + failureFormat string + warningFormat string + // message queue + messageQueue []interface{} +} + +func NewReporter() Reporter { + var writer io.Writer = os.Stderr + if env.IsSmartTerminal(writer) { + writer = NewSpinner(writer) + } + + s := &status{ + logger: NewLogger(writer, 0), + successFormat: " ✓ %s\n", + failureFormat: " ✗ %s\n", + warningFormat: " ⚠ %s\n", + messageQueue: []interface{}{}, + } + + // if we're using the CLI logger, check for if it has a spinner setup + // and wire the status to that. + if l, ok := s.logger.(*Logger); ok { + if w, ok := l.writer.(*Spinner); ok { + s.spinner = w + // use colored success / failure / warning messages. + s.successFormat = " \x1b[32m✓\x1b[0m %s\n" + s.failureFormat = " \x1b[31m✗\x1b[0m %s\n" + s.warningFormat = " \x1b[33m⚠\x1b[0m %s\n" + } + } + + return s +} + +func (s *status) hasFailureMessages() bool { + for _, message := range s.messageQueue { + if _, ok := message.(failureType); ok { + return true + } + } + + return false +} + +func (s *status) hasWarningMessages() bool { + for _, message := range s.messageQueue { + if _, ok := message.(warningType); ok { + return true + } + } + + return false +} + +func (s *status) resultFromMessages() resultType { + if s.hasFailureMessages() { + return failure + } + + if s.hasWarningMessages() { + return warning + } + + return success +} + +// Start starts a new phase of the status, if attached to a terminal +// there will be a loading spinner with this status. +func (s *status) Start(message string, args ...interface{}) { + s.End() + s.status = fmt.Sprintf(message, args...) + + if s.spinner != nil { + s.spinner.SetSuffix(fmt.Sprintf(" %s ", s.status)) + s.spinner.Start() + } else { + s.logger.V(0).Infof(" • %s ...\n", s.status) + } +} + +// Failure queues up a message, which will be displayed once +// the status ends (using the failure format). +func (s *status) Failure(message string, a ...interface{}) { + if message == "" { + return + } + + if s.status != "" { + s.messageQueue = append(s.messageQueue, failureType(fmt.Sprintf(message, a...))) + } else { + s.logger.V(0).Infof(s.failureFormat, fmt.Sprintf(message, a...)) + } +} + +// Success queues up a message, which will be displayed once +// the status ends (using the warning format). +// An empty message will result in the start message being confirmed. +func (s *status) Success(message string, a ...interface{}) { + if message == "" { + return + } + + if s.status != "" { + s.messageQueue = append(s.messageQueue, successType(fmt.Sprintf(message, a...))) + } else { + s.logger.V(0).Infof(s.successFormat, fmt.Sprintf(message, a...)) + } +} + +// Warning queues up a message, which will be displayed once +// the status ends (using the warning format). +func (s *status) Warning(message string, a ...interface{}) { + if message == "" { + return + } + + if s.status != "" { + s.messageQueue = append(s.messageQueue, warningType(fmt.Sprintf(message, a...))) + } else { + s.logger.V(0).Infof(s.warningFormat, fmt.Sprintf(message, a...)) + } +} + +func (s *status) Error(err error, message string, args ...interface{}) error { + if err == nil { + return nil + } + + if message != "" { + err = fmt.Errorf("%s: %w", fmt.Sprintf(message, args...), err) + } + + capitalizeFirst := func(str string) string { + for i, v := range str { + return string(unicode.ToUpper(v)) + str[i+1:] + } + + return "" + } + + s.Failure(capitalizeFirst(err.Error())) + s.End() + + return err +} + +// End completes the current status, ending any previous spinning and +// marking the status as success or failure. +func (s *status) End() { + if s.status == "" { + return + } + + if s.spinner != nil { + s.spinner.Stop() + fmt.Fprint(s.spinner.writer, "\r") + } + + result := s.resultFromMessages() + + switch result { + case success: + s.logger.V(0).Infof(s.successFormat, s.status) + case failure: + s.logger.V(0).Infof(s.failureFormat, s.status) + case warning: + s.logger.V(0).Infof(s.warningFormat, s.status) + } + + for _, message := range s.messageQueue { + switch m := message.(type) { + case successType: + s.logger.V(0).Infof(s.successFormat, m) + case failureType: + s.logger.V(0).Infof(s.failureFormat, m) + case warningType: + s.logger.V(0).Infof(s.warningFormat, m) + } + } + + s.status = "" + s.messageQueue = []interface{}{} +} diff --git a/internal/cmd/skupper/debug/check/command/diagnose.go b/internal/cmd/skupper/debug/check/command/diagnose.go new file mode 100644 index 000000000..d2aca96cb --- /dev/null +++ b/internal/cmd/skupper/debug/check/command/diagnose.go @@ -0,0 +1,41 @@ +package command + +import ( + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/cli" + "github.com/spf13/cobra" +) + +type Check interface { + Name() string + CheckDescription() string + Example() string + Run(cli.Reporter, *cobra.Command) error + Dependencies() []*Check +} + +type BaseCheck struct { + name string + checkDescription string + example string + dependencies []*Check +} + +func NewBaseCheckCommand(name, checkDescription string, dependencies ...*Check) BaseCheck { + return BaseCheck{name: name, checkDescription: checkDescription, dependencies: dependencies} +} + +func (bd *BaseCheck) Name() string { + return bd.name +} + +func (bd *BaseCheck) CheckDescription() string { + return bd.checkDescription +} + +func (bd *BaseCheck) Example() string { + return bd.example +} + +func (bd *BaseCheck) Dependencies() []*Check { + return bd.dependencies +} diff --git a/internal/cmd/skupper/debug/check/env/term.go b/internal/cmd/skupper/debug/check/env/term.go new file mode 100644 index 000000000..67c794423 --- /dev/null +++ b/internal/cmd/skupper/debug/check/env/term.go @@ -0,0 +1,54 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package env + +import ( + "io" + "os" + "runtime" + + isatty "github.com/mattn/go-isatty" +) + +// IsTerminal returns true if the writer w is a terminal. +func IsTerminal(w io.Writer) bool { + if v, ok := w.(*os.File); ok { + return isatty.IsTerminal(v.Fd()) + } + + return false +} + +// IsSmartTerminal returns true if the writer w is a terminal AND +// we think that the terminal is smart enough to use VT escape codes etc. +func IsSmartTerminal(w io.Writer) bool { + if !IsTerminal(w) { + return false + } + + // explicitly dumb terminals are not smart. + if os.Getenv("TERM") == "dumb" { + return false + } + // On Windows WT_SESSION is set by the modern terminal component. + // Older terminals have poor support for UTF-8, VT escape codes, etc. + if runtime.GOOS == "windows" && os.Getenv("WT_SESSION") == "" { + return false + } + + return true +} diff --git a/internal/cmd/skupper/debug/check/kube/doc.go b/internal/cmd/skupper/debug/check/kube/doc.go new file mode 100644 index 000000000..646c219eb --- /dev/null +++ b/internal/cmd/skupper/debug/check/kube/doc.go @@ -0,0 +1,15 @@ +/* +Package kube provides Kubernetes-specific diagnostics. + +Each diagnostic is implemented in a function which is given a KubeClient and a status reporter. +The status reporter should be used to report warnings only; +outright errors and success are indicated respectively by returning an error and returning nil, +and are handled by the caller. +Each diagnostic is described by an instance of KubeCheck, constructed using newKubeCheckCommand(). +The name should be suitable as a Cobra command name, and the check description should work in a sentence of the form +"Checks that …". The check description is used to build command-line help and to provide status messages. +Diagnostics can have dependencies, which are other diagnostics. +These should be strong dependencies: when a given diagnostic is invoked, its dependencies will be invoked first, +and if any of them return an error, the entire chain will be aborted. +*/ +package kube diff --git a/internal/cmd/skupper/debug/check/kube/kube_access.go b/internal/cmd/skupper/debug/check/kube/kube_access.go new file mode 100644 index 000000000..4c35d77e6 --- /dev/null +++ b/internal/cmd/skupper/debug/check/kube/kube_access.go @@ -0,0 +1,27 @@ +package kube + +import ( + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/cli" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/command" + "github.com/skupperproject/skupper/internal/kube/client" +) + +var checkK8sAccess = newKubeCheckCommand( + "kube-access", + "the Kubernetes API server is accessible", + kubeAccessRun, +) + +func NewCmdCheckK8sAccess() command.Check { + return checkK8sAccess +} + +func kubeAccessRun(status cli.Reporter, kubeClient *client.KubeClient) error { + // We use this as a proxy for access to the Kubernetes API + _, err := kubeClient.Kube.Discovery().ServerVersion() + if err != nil { + return status.Error(err, "The Kubernetes API server is not accessible") + } + + return nil +} diff --git a/internal/cmd/skupper/debug/check/kube/kube_support.go b/internal/cmd/skupper/debug/check/kube/kube_support.go new file mode 100644 index 000000000..d721ac45c --- /dev/null +++ b/internal/cmd/skupper/debug/check/kube/kube_support.go @@ -0,0 +1,34 @@ +package kube + +import ( + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/cli" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/command" + "github.com/skupperproject/skupper/internal/kube/client" + "github.com/spf13/cobra" +) + +type KubeCheck struct { + command.BaseCheck + diagnostic func(cli.Reporter, *client.KubeClient) error +} + +func newKubeCheckCommand( + name, + shortDescription string, + cmd func(cli.Reporter, *client.KubeClient) error, + dependencies ...*command.Check, +) command.Check { + return &KubeCheck{ + BaseCheck: command.NewBaseCheckCommand(name, shortDescription, dependencies...), + diagnostic: cmd, + } +} + +func (kd *KubeCheck) Run(status cli.Reporter, cmd *cobra.Command) error { + newClient, err := client.NewClient(cmd.Flag("namespace").Value.String(), cmd.Flag("context").Value.String(), cmd.Flag("kubeconfig").Value.String()) + if err != nil { + return status.Error(err, "failed to obtain a Kubernetes client") + } + + return kd.diagnostic(status, newClient) +} diff --git a/internal/cmd/skupper/debug/check/kube/kube_version.go b/internal/cmd/skupper/debug/check/kube/kube_version.go new file mode 100644 index 000000000..d4ee02915 --- /dev/null +++ b/internal/cmd/skupper/debug/check/kube/kube_version.go @@ -0,0 +1,66 @@ +package kube + +import ( + "fmt" + "strconv" + "strings" + + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/cli" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check/command" + "github.com/skupperproject/skupper/internal/kube/client" + "k8s.io/apimachinery/pkg/version" +) + +const ( + minKubeMajor = 1 + minKubeMinor = 24 +) + +var checkK8sVersion = newKubeCheckCommand( + "kube-version", + "the Kubernetes version is supported", + kubeVersionRun, + &checkK8sAccess, +) + +func NewCmdCheckK8sVersion() command.Check { + return checkK8sVersion +} + +func kubeVersionRun(status cli.Reporter, kubeClient *client.KubeClient) error { + version, err := kubeClient.Kube.Discovery().ServerVersion() + if err != nil { + return status.Error(err, "Failed to retrieve the Kubernetes API server version") + } + + return status.Error(checkVersion(version), "the Kubernetes version is not supported") +} + +func checkVersion(ver *version.Info) error { + major, err := strconv.Atoi(ver.Major) + if err != nil { + return fmt.Errorf("error parsing API server major version %v: %w", ver.Major, err) + } + + if major > minKubeMajor { + return nil + } + + var minor int + if strings.HasSuffix(ver.Minor, "+") { + minor, err = strconv.Atoi(ver.Minor[0 : len(ver.Minor)-1]) + } else { + minor, err = strconv.Atoi(ver.Minor) + } + + if err != nil { + return fmt.Errorf("error parsing API server minor version %v: %w", ver.Minor, err) + } + + if major < minKubeMajor || minor < minKubeMinor { + return fmt.Errorf("installed Kubernetes version %s.%s is too old; Skupper requires at least %d.%d", + ver.Major, ver.Minor, minKubeMajor, minKubeMinor) + } + + return nil +} diff --git a/internal/cmd/skupper/debug/check/kube/kube_version_test.go b/internal/cmd/skupper/debug/check/kube/kube_version_test.go new file mode 100644 index 000000000..6cdeffe10 --- /dev/null +++ b/internal/cmd/skupper/debug/check/kube/kube_version_test.go @@ -0,0 +1,28 @@ +package kube + +import ( + "testing" + + "k8s.io/apimachinery/pkg/version" +) + +func TestCheckVersion(t *testing.T) { + tests := []struct { + major string + minor string + errorExpected bool + }{ + {"0", "25", true}, + {"1", "23", true}, + {"1", "24", false}, + {"1", "25", false}, + {"2", "22", false}, + } + + for _, test := range tests { + err := checkVersion(&version.Info{Major: test.major, Minor: test.minor}) + if (test.errorExpected && err == nil) || (!test.errorExpected && err != nil) { + t.Fail() + } + } +} diff --git a/internal/cmd/skupper/debug/check/log/types.go b/internal/cmd/skupper/debug/check/log/types.go new file mode 100644 index 000000000..4f42ea261 --- /dev/null +++ b/internal/cmd/skupper/debug/check/log/types.go @@ -0,0 +1,66 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package log + +// Level is a verbosity logging level for Info logs. +// See also https://github.com/kubernetes/klog. +type Level int32 + +// Logger defines the logging interface kind uses. +// It is roughly a subset of github.com/kubernetes/klog. +type Logger interface { + // Warn should be used to write user facing warnings. + Warn(message string) + // Warnf should be used to write Printf style user facing warnings. + Warnf(format string, args ...interface{}) + // Error may be used to write an error message when it occurs. + // Prefer returning an error instead in most cases. + Error(message string) + // Errorf may be used to write a Printf style error message when it occurs. + // Prefer returning an error instead in most cases. + Errorf(format string, args ...interface{}) + // V() returns an InfoLogger for a given verbosity Level. + // + // Normal verbosity levels: + // V(0): normal user facing messages go to V(0) + // V(1): debug messages start when V(N > 0), these should be high level + // V(2): more detailed log messages + // V(3+): trace level logging, in increasing "noisiness" ... allowing + // arbitrarily detailed logging at extremely low cost unless the + // logger has actually been configured to display these (E.G. via the -v + // command line flag) + // + // It is expected that the returned InfoLogger will be extremely cheap + // to interact with for a Level greater than the enabled level. + V(Level) InfoLogger +} + +// InfoLogger defines the info logging interface kind uses. +// It is roughly a subset of Verbose from github.com/kubernetes/klog. +type InfoLogger interface { + // Info is used to write a user facing status message. + // + // See: Logger.V + Info(message string) + // Infof is used to write a Printf style user facing status message. + Infof(format string, args ...interface{}) + // Enabled should return true if this verbosity level is enabled. + // on the Logger + // + // See: Logger.V + Enabled() bool +} diff --git a/internal/cmd/skupper/debug/debug.go b/internal/cmd/skupper/debug/debug.go index 51342d222..272c7b2fd 100644 --- a/internal/cmd/skupper/debug/debug.go +++ b/internal/cmd/skupper/debug/debug.go @@ -2,6 +2,7 @@ package debug import ( "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/skupperproject/skupper/internal/cmd/skupper/debug/check" "github.com/skupperproject/skupper/internal/cmd/skupper/debug/kube" "github.com/skupperproject/skupper/internal/cmd/skupper/debug/nonkube" "github.com/skupperproject/skupper/internal/config" @@ -28,6 +29,8 @@ func CmdDebugFactory(configuredPlatform common.Platform) *cobra.Command { cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDebugDesc, kubeCommand, nonKubeCommand) + cmd.AddCommand(check.NewCmdCheck()) + cmdFlags := common.CommandDebugFlags{} //Add flags if necessary