diff --git a/internal/api/skip.go b/internal/api/skip.go new file mode 100644 index 0000000..06553e6 --- /dev/null +++ b/internal/api/skip.go @@ -0,0 +1,11 @@ +package api + +// SkippedResult returns a CheckResult indicating the check was skipped. +func SkippedResult(name string, summary string) *CheckResult { + return &CheckResult{ + Name: name, + State: StateSkipped, + Summary: summary, + Details: map[string]interface{}{}, + } +} diff --git a/internal/api/skip_test.go b/internal/api/skip_test.go new file mode 100644 index 0000000..b2a5bf1 --- /dev/null +++ b/internal/api/skip_test.go @@ -0,0 +1,19 @@ +package api_test + +import ( + "testing" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/cdr/coder-doctor/internal/api" +) + +func TestSkippedResult(t *testing.T) { + t.Parallel() + + checkName := "don't wanna" + checkSummary := "just because" + res := api.SkippedResult(checkName, checkSummary) + assert.Equal(t, "name matches", checkName, res.Name) + assert.Equal(t, "state matches", api.StateSkipped, res.State) + assert.Equal(t, "summary matches", checkSummary, res.Summary) +} diff --git a/internal/api/types.go b/internal/api/types.go index 04ed835..83f3c67 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -119,3 +119,17 @@ type CheckResult struct { Summary string `json:"summary"` Details map[string]interface{} `json:"details,omitempty"` } + +// CheckTarget indicates the subject of a Checker +type CheckTarget string + +const ( + // CheckTargetUndefined indicates that a Checker does not run against any specific target. + CheckTargetUndefined CheckTarget = "" + + // CheckTargetLocal indicates that a Checker runs against the local machine. + CheckTargetLocal CheckTarget = "local" + + // CheckTargetKubernetes indicates that a Checker runs against a Kubernetes cluster. + CheckTargetKubernetes = "kubernetes" +) diff --git a/internal/api/version.go b/internal/api/version.go new file mode 100644 index 0000000..409ffe6 --- /dev/null +++ b/internal/api/version.go @@ -0,0 +1,15 @@ +package api + +import ( + "github.com/Masterminds/semver/v3" + "golang.org/x/xerrors" +) + +func MustConstraint(s string) *semver.Constraints { + c, err := semver.NewConstraint(s) + if err != nil { + panic(xerrors.Errorf("parse constraint: %w", err)) + } + + return c +} diff --git a/internal/checks/kube/kubernetes.go b/internal/checks/kube/kubernetes.go index 79dd642..486d817 100644 --- a/internal/checks/kube/kubernetes.go +++ b/internal/checks/kube/kubernetes.go @@ -5,8 +5,8 @@ import ( "io" "github.com/Masterminds/semver/v3" - "k8s.io/client-go/kubernetes" "golang.org/x/xerrors" + "k8s.io/client-go/kubernetes" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" diff --git a/internal/checks/local/helm.go b/internal/checks/local/helm.go new file mode 100644 index 0000000..4b1343f --- /dev/null +++ b/internal/checks/local/helm.go @@ -0,0 +1,101 @@ +package local + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + + "cdr.dev/slog" + "github.com/cdr/coder-doctor/internal/api" +) + +const LocalHelmVersionCheck = "local-helm-version" + +type VersionRequirement struct { + Coder *semver.Version + HelmConstraint *semver.Constraints +} + +var versionRequirements = []VersionRequirement{ + { + Coder: semver.MustParse("1.21.0"), + HelmConstraint: api.MustConstraint(">= 3.6.0"), + }, + { + Coder: semver.MustParse("1.20.0"), + HelmConstraint: api.MustConstraint(">= 3.6.0"), + }, +} + +func (l *Checker) CheckLocalHelmVersion(ctx context.Context) *api.CheckResult { + if l.target != api.CheckTargetKubernetes { + return api.SkippedResult(LocalHelmVersionCheck, "not applicable for target "+string(l.target)) + } + + helmBin, err := l.lookPathF("helm") + if err != nil { + return api.ErrorResult(LocalHelmVersionCheck, "could not find helm binary in $PATH", err) + } + + helmVersionRaw, err := l.execF(ctx, helmBin, "version", "--short") + if err != nil { + return api.ErrorResult(LocalHelmVersionCheck, "failed to determine helm version", err) + } + + helmVersion, err := semver.NewVersion(string(bytes.TrimSpace(helmVersionRaw))) + if err != nil { + return api.ErrorResult(LocalHelmVersionCheck, "failed to parse helm version", err) + } + + selectedVersion := findNearestHelmVersion(l.coderVersion) + if selectedVersion == nil { + return api.ErrorResult(LocalHelmVersionCheck, fmt.Sprintf("checking coder version %s not supported", l.coderVersion.String()), nil) + } + l.log.Debug(ctx, "selected coder version", slog.F("requested", l.coderVersion), slog.F("selected", selectedVersion.Coder)) + + result := &api.CheckResult{ + Name: LocalHelmVersionCheck, + Details: map[string]interface{}{ + "helm-bin": helmBin, + "helm-version": helmVersion.String(), + "helm-version-constraints": selectedVersion.HelmConstraint.String(), + }, + } + + if ok, cerrs := selectedVersion.HelmConstraint.Validate(helmVersion); !ok { + result.State = api.StateFailed + var b strings.Builder + _, err := fmt.Fprintf(&b, "Coder %s requires Helm version %s (installed: %s)\n", selectedVersion.Coder, selectedVersion.HelmConstraint, helmVersion) + if err != nil { + return api.ErrorResult(LocalHelmVersionCheck, "failed to write error result", err) + } + for _, cerr := range cerrs { + if _, err := fmt.Fprintf(&b, "constraint failed: %s\n", cerr); err != nil { + return api.ErrorResult(LocalHelmVersionCheck, "failed to write constraint error", err) + } + } + result.Summary = b.String() + } else { + result.State = api.StatePassed + result.Summary = fmt.Sprintf("Coder %s supports Helm %s", selectedVersion.Coder, selectedVersion.HelmConstraint) + } + + return result +} + +func findNearestHelmVersion(target *semver.Version) *VersionRequirement { + var selected *VersionRequirement + + for _, v := range versionRequirements { + v := v + if !v.Coder.GreaterThan(target) { + selected = &v + break + } + } + + return selected +} diff --git a/internal/checks/local/helm_test.go b/internal/checks/local/helm_test.go new file mode 100644 index 0000000..34575ff --- /dev/null +++ b/internal/checks/local/helm_test.go @@ -0,0 +1,144 @@ +package local + +import ( + "context" + "os" + "testing" + + "github.com/Masterminds/semver/v3" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/cdr/coder-doctor/internal/api" +) + +func Test_CheckLocalHelmVersion(t *testing.T) { + t.Parallel() + + type params struct { + W *api.CaptureWriter + EX *fakeExecer + LP *fakeLookPather + Opts []Option + Ctx context.Context + } + + run := func(t *testing.T, name string, fn func(t *testing.T, p *params)) { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + cw := &api.CaptureWriter{} + ex := newFakeExecer(t) + lp := newFakeLookPather(t) + opts := []Option{ + WithWriter(cw), + WithExecF(ex.ExecContext), + WithLookPathF(lp.LookPath), + WithTarget(api.CheckTargetKubernetes), // default + } + p := ¶ms{ + W: cw, + EX: ex, + LP: lp, + Opts: opts, + Ctx: ctx, + } + fn(t, p) + }) + } + + run(t, "helm: when not running against kubernetes", func(t *testing.T, p *params) { + p.Opts = append(p.Opts, WithTarget(api.CheckTargetUndefined)) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should skip helm check if not running against kubernetes", api.StateSkipped, res.State) + } + } + }) + + run(t, "helm: with version 3.6", func(t *testing.T, p *params) { + p.LP.Handle("helm", "/usr/local/bin/helm", nil) + p.EX.Handle("/usr/local/bin/helm version --short", []byte("v3.6.0+g7f2df64"), nil) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should pass", api.StatePassed, res.State) + } + } + }) + + run(t, "helm: with version 2", func(t *testing.T, p *params) { + p.LP.Handle("helm", "/usr/local/bin/helm", nil) + p.EX.Handle("/usr/local/bin/helm version --short", []byte("v2.0.0"), nil) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should fail", api.StateFailed, res.State) + } + } + }) + + run(t, "helm: not in path", func(t *testing.T, p *params) { + p.LP.Handle("helm", "", os.ErrNotExist) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should fail", api.StateFailed, res.State) + } + } + }) + + run(t, "helm: cannot be executed", func(t *testing.T, p *params) { + p.LP.Handle("helm", "/usr/local/bin/helm", nil) + p.EX.Handle("/usr/local/bin/helm version --short", []byte(""), os.ErrPermission) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should fail", api.StateFailed, res.State) + } + } + }) + + run(t, "helm: returns garbage version", func(t *testing.T, p *params) { + p.LP.Handle("helm", "/usr/local/bin/helm", nil) + p.EX.Handle("/usr/local/bin/helm version --short", []byte(""), nil) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should fail", api.StateFailed, res.State) + } + } + }) + + run(t, "helm: coder version is unsupported", func(t *testing.T, p *params) { + p.Opts = append(p.Opts, WithCoderVersion(semver.MustParse("v1.19"))) + p.LP.Handle("helm", "/usr/local/bin/helm", nil) + p.EX.Handle("/usr/local/bin/helm version --short", []byte("v3.6.0+g7f2df64"), nil) + lc := NewChecker(p.Opts...) + err := lc.Run(p.Ctx) + assert.Success(t, "run local checker", err) + assert.False(t, "results should not be empty", p.W.Empty()) + for _, res := range p.W.Get() { + if res.Name == LocalHelmVersionCheck { + assert.Equal(t, "should fail", api.StateFailed, res.State) + } + } + }) +} diff --git a/internal/checks/local/local.go b/internal/checks/local/local.go new file mode 100644 index 0000000..1ca5fd4 --- /dev/null +++ b/internal/checks/local/local.go @@ -0,0 +1,105 @@ +package local + +import ( + "context" + "io" + "os/exec" + + "github.com/Masterminds/semver/v3" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/cdr/coder-doctor/internal/api" +) + +var _ api.Checker = &Checker{} + +type ExecF func(ctx context.Context, name string, args ...string) ([]byte, error) +type LookPathF func(string) (string, error) + +// local.Checker checks the local environment. +type Checker struct { + writer api.ResultWriter + coderVersion *semver.Version + log slog.Logger + target api.CheckTarget + execF ExecF + lookPathF LookPathF +} + +type Option func(*Checker) + +func NewChecker(opts ...Option) *Checker { + checker := &Checker{ + writer: &api.DiscardWriter{}, + coderVersion: semver.MustParse("100.0.0"), + log: slog.Make(sloghuman.Sink(io.Discard)), + execF: defaultExecCommand, + lookPathF: exec.LookPath, + } + + for _, opt := range opts { + opt(checker) + } + + return checker +} + +func WithTarget(t api.CheckTarget) Option { + return func(l *Checker) { + l.target = t + } +} + +func WithWriter(writer api.ResultWriter) Option { + return func(l *Checker) { + l.writer = writer + } +} + +func WithCoderVersion(version *semver.Version) Option { + return func(l *Checker) { + l.coderVersion = version + } +} + +func WithLogger(log slog.Logger) Option { + return func(l *Checker) { + l.log = log + } +} + +func WithExecF(f ExecF) Option { + return func(l *Checker) { + l.execF = f + } +} + +func WithLookPathF(f LookPathF) Option { + return func(l *Checker) { + l.lookPathF = f + } +} + +func (*Checker) Validate() error { + return nil +} + +func (l *Checker) Run(ctx context.Context) error { + if err := l.writer.WriteResult(l.CheckLocalHelmVersion(ctx)); err != nil { + return xerrors.Errorf("check local helm version: %w", err) + } + return nil +} + +func defaultExecCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, xerrors.Errorf("exec %q %+q: %w", name, args, err) + } + + return out, nil +} diff --git a/internal/checks/local/local_test.go b/internal/checks/local/local_test.go new file mode 100644 index 0000000..f3b5570 --- /dev/null +++ b/internal/checks/local/local_test.go @@ -0,0 +1,86 @@ +package local + +import ( + "context" + "strings" + "testing" +) + +type execResult struct { + Output []byte + Err error +} + +func newFakeExecer(t *testing.T) *fakeExecer { + m := make(map[string]execResult) + return &fakeExecer{ + M: m, + T: t, + } +} + +type fakeExecer struct { + M map[string]execResult + T *testing.T +} + +func (f *fakeExecer) Handle(cmd string, output []byte, err error) { + f.M[cmd] = execResult{ + Output: output, + Err: err, + } +} + +func (f *fakeExecer) ExecContext(_ context.Context, name string, args ...string) ([]byte, error) { + var sb strings.Builder + _, _ = sb.WriteString(name) + for _, arg := range args { + _, _ = sb.WriteString(" ") + _, _ = sb.WriteString(arg) + } + + fullCmd := sb.String() + res, ok := f.M[fullCmd] + if !ok { + f.T.Logf("unhandled ExecContext: %s", fullCmd) + f.T.FailNow() + return nil, nil // should never happen + } + + return res.Output, res.Err +} + +type lookPathResult struct { + S string + Err error +} + +type fakeLookPather struct { + M map[string]lookPathResult + T *testing.T +} + +func (f *fakeLookPather) LookPath(name string) (string, error) { + res, ok := f.M[name] + if !ok { + f.T.Logf("unhandled LookPath: %s", name) + f.T.FailNow() + } + + return res.S, res.Err +} + +func (f *fakeLookPather) Handle(name string, path string, err error) { + f.M[name] = lookPathResult{ + S: path, + Err: err, + } +} + +func newFakeLookPather(t *testing.T) *fakeLookPather { + m := make(map[string]lookPathResult) + return &fakeLookPather{ + M: m, + T: t, + } +} diff --git a/internal/cmd/check/kubernetes/kubernetes.go b/internal/cmd/check/kubernetes/kubernetes.go index bddd6d6..533fa95 100644 --- a/internal/cmd/check/kubernetes/kubernetes.go +++ b/internal/cmd/check/kubernetes/kubernetes.go @@ -19,7 +19,9 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth/openstack" "k8s.io/client-go/tools/clientcmd" + "github.com/cdr/coder-doctor/internal/api" "github.com/cdr/coder-doctor/internal/checks/kube" + "github.com/cdr/coder-doctor/internal/checks/local" "github.com/cdr/coder-doctor/internal/humanwriter" ) @@ -75,11 +77,17 @@ func run(cmd *cobra.Command, _ []string) error { return xerrors.Errorf("parse flags: %w", err) } - config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides).ClientConfig() + configLoader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) + config, err := configLoader.ClientConfig() if err != nil { return xerrors.Errorf("creating NonInteractiveDeferredLoadingClientConfig: %w", err) } + rawConfig, err := configLoader.RawConfig() + if err != nil { + return xerrors.Errorf("creating RawConfig: %w", err) + } + clientset, err := kclient.NewForConfig(config) if err != nil { return xerrors.Errorf("creating kube client from config: %w", err) @@ -107,15 +115,34 @@ func run(cmd *cobra.Command, _ []string) error { log = log.Leveled(slog.LevelDebug) } - checker := kube.NewKubernetesChecker( + log.Info(cmd.Context(), "kubernetes config:", + slog.F("context", rawConfig.CurrentContext), + slog.F("cluster", rawConfig.Contexts[rawConfig.CurrentContext].Cluster), + slog.F("namespace", rawConfig.Contexts[rawConfig.CurrentContext].Namespace), + slog.F("authinfo", rawConfig.Contexts[rawConfig.CurrentContext].AuthInfo), + ) + + hw := humanwriter.New(os.Stdout) + + localChecker := local.NewChecker( + local.WithLogger(log), + local.WithCoderVersion(cv), + local.WithWriter(hw), + local.WithTarget(api.CheckTargetKubernetes), + ) + + kubeChecker := kube.NewKubernetesChecker( clientset, kube.WithLogger(log), kube.WithCoderVersion(cv), - kube.WithWriter(humanwriter.New(os.Stdout)), + kube.WithWriter(hw), ) - err = checker.Run(cmd.Context()) - if err != nil { + if err := localChecker.Run(cmd.Context()); err != nil { + return xerrors.Errorf("run local checker: %w", err) + } + + if err := kubeChecker.Run(cmd.Context()); err != nil { return xerrors.Errorf("run kube checker: %w", err) }