diff --git a/internal/checks/kube/kubernetes.go b/internal/checks/kube/kubernetes.go index 6c0bd0f..669e9a4 100644 --- a/internal/checks/kube/kubernetes.go +++ b/internal/checks/kube/kubernetes.go @@ -22,7 +22,7 @@ type KubernetesChecker struct { writer api.ResultWriter coderVersion *semver.Version log slog.Logger - rbacRequirements []*RBACRequirement + rbacRequirements map[*ResourceRequirement]ResourceVerbs } type Option func(k *KubernetesChecker) @@ -87,6 +87,12 @@ func (k *KubernetesChecker) Run(ctx context.Context) error { return xerrors.Errorf("check version: %w", err) } + for _, res := range k.CheckResources(ctx) { + if err := k.writer.WriteResult(res); err != nil { + return xerrors.Errorf("check api resources: %w", err) + } + } + for _, res := range k.CheckRBAC(ctx) { if err := k.writer.WriteResult(res); err != nil { return xerrors.Errorf("check RBAC: %w", err) diff --git a/internal/checks/kube/rbac.go b/internal/checks/kube/rbac.go index 5084f17..811c125 100644 --- a/internal/checks/kube/rbac.go +++ b/internal/checks/kube/rbac.go @@ -16,73 +16,34 @@ import ( authorizationclientv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" ) -type RBACRequirement struct { - APIGroup string - Resource string - Verbs []string -} - -type VersionedRBACRequirements struct { - VersionConstraints *semver.Constraints - RBACRequirements []*RBACRequirement -} - -var verbsCreateDeleteList = []string{"create", "delete", "list"} - -func NewRBACRequirement(apiGroup, resource string, verbs ...string) *RBACRequirement { - return &RBACRequirement{ - APIGroup: apiGroup, - Resource: resource, - Verbs: verbs, - } -} - -var allVersionedRBACRequirements = []VersionedRBACRequirements{ - { - VersionConstraints: api.MustConstraint(">= 1.20"), - RBACRequirements: []*RBACRequirement{ - NewRBACRequirement("", "pods", verbsCreateDeleteList...), - NewRBACRequirement("", "roles", verbsCreateDeleteList...), - NewRBACRequirement("", "rolebindings", verbsCreateDeleteList...), - NewRBACRequirement("", "secrets", verbsCreateDeleteList...), - NewRBACRequirement("", "serviceaccounts", verbsCreateDeleteList...), - NewRBACRequirement("", "services", verbsCreateDeleteList...), - NewRBACRequirement("apps", "deployments", verbsCreateDeleteList...), - NewRBACRequirement("apps", "replicasets", verbsCreateDeleteList...), - NewRBACRequirement("apps", "statefulsets", verbsCreateDeleteList...), - NewRBACRequirement("extensions", "ingresses", verbsCreateDeleteList...), - }, - }, -} - func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult { const checkName = "kubernetes-rbac" authClient := k.client.AuthorizationV1() results := make([]*api.CheckResult, 0) - for _, req := range k.rbacRequirements { + for req, reqVerbs := range k.rbacRequirements { resName := fmt.Sprintf("%s-%s", checkName, req.Resource) - if err := k.checkOneRBAC(ctx, authClient, req); err != nil { + if err := k.checkOneRBAC(ctx, authClient, req, reqVerbs); err != nil { summary := fmt.Sprintf("missing permissions on resource %s: %s", req.Resource, err) results = append(results, api.ErrorResult(resName, summary, err)) continue } - summary := fmt.Sprintf("%s: can %s", req.Resource, strings.Join(req.Verbs, ", ")) + summary := fmt.Sprintf("%s: can %s", req.Resource, strings.Join(reqVerbs, ", ")) results = append(results, api.PassResult(resName, summary)) } return results } -func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *RBACRequirement) error { - have := make([]string, 0, len(req.Verbs)) - for _, verb := range req.Verbs { +func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *ResourceRequirement, reqVerbs ResourceVerbs) error { + have := make([]string, 0, len(reqVerbs)) + for _, verb := range reqVerbs { sar := &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: k.namespace, - Group: req.APIGroup, + Group: req.Group, Resource: req.Resource, Verb: verb, }, @@ -102,17 +63,17 @@ func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authori } } - if len(have) != len(req.Verbs) { - return xerrors.Errorf(fmt.Sprintf("need: %+v have: %+v", req.Verbs, have)) + if len(have) != len(reqVerbs) { + return xerrors.Errorf(fmt.Sprintf("need: %+v have: %+v", reqVerbs, have)) } return nil } -func findClosestVersionRequirements(v *semver.Version) []*RBACRequirement { - for _, vreqs := range allVersionedRBACRequirements { +func findClosestVersionRequirements(v *semver.Version) map[*ResourceRequirement]ResourceVerbs { + for _, vreqs := range allRequirements { if vreqs.VersionConstraints.Check(v) { - return vreqs.RBACRequirements + return vreqs.ResourceRequirements } } return nil diff --git a/internal/checks/kube/resources.go b/internal/checks/kube/resources.go index 079e790..6aa583f 100644 --- a/internal/checks/kube/resources.go +++ b/internal/checks/kube/resources.go @@ -2,11 +2,60 @@ package kube import ( "context" + "fmt" + + "golang.org/x/xerrors" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/cdr/coder-doctor/internal/api" ) -func (k *KubernetesChecker) CheckResources(ctx context.Context) []*api.CheckResult { +func (k *KubernetesChecker) CheckResources(_ context.Context) []*api.CheckResult { + const checkName = "kubernetes-resources" results := make([]*api.CheckResult, 0) + dc := k.client.Discovery() + lists, err := dc.ServerPreferredResources() + if err != nil { + results = append(results, api.ErrorResult(checkName, "unable to fetch api resources from server", err)) + return results + } + + resourcesAvailable := make(map[ResourceRequirement]bool) + for _, list := range lists { + if len(list.APIResources) == 0 { + continue + } + + gv, err := schema.ParseGroupVersion(list.GroupVersion) + if err != nil { + continue + } + + for _, resource := range list.APIResources { + if len(resource.Verbs) == 0 { + continue + } + + r := ResourceRequirement{ + Group: gv.Group, + Version: gv.String(), + Resource: resource.Name, + } + resourcesAvailable[r] = true + } + } + + versionReqs := findClosestVersionRequirements(k.coderVersion) + for versionReq := range versionReqs { + if !resourcesAvailable[*versionReq] { + msg := fmt.Sprintf("missing required resource:%q group:%q version:%q", versionReq.Resource, versionReq.Group, versionReq.Version) + errResult := api.ErrorResult(checkName, msg, xerrors.New(msg)) + results = append(results, errResult) + continue + } + msg := fmt.Sprintf("found required resource:%q group:%q version:%q", versionReq.Resource, versionReq.Group, versionReq.Version) + results = append(results, api.PassResult(checkName, msg)) + } + return results } diff --git a/internal/checks/kube/resources_list.go b/internal/checks/kube/resources_list.go new file mode 100644 index 0000000..a7d7d75 --- /dev/null +++ b/internal/checks/kube/resources_list.go @@ -0,0 +1,53 @@ +package kube + +import ( + "github.com/Masterminds/semver/v3" + + "github.com/cdr/coder-doctor/internal/api" +) + +var allRequirements = []VersionedResourceRequirements{ + { + VersionConstraints: api.MustConstraint(">= 1.20"), + ResourceRequirements: map[*ResourceRequirement]ResourceVerbs{ + NewResourceRequirement("", "v1", "pods"): verbsCreateDeleteList, + NewResourceRequirement("", "v1", "secrets"): verbsCreateDeleteList, + NewResourceRequirement("", "v1", "serviceaccounts"): verbsCreateDeleteList, + NewResourceRequirement("", "v1", "services"): verbsCreateDeleteList, + NewResourceRequirement("apps", "apps/v1", "deployments"): verbsCreateDeleteList, + NewResourceRequirement("apps", "apps/v1", "replicasets"): verbsCreateDeleteList, + NewResourceRequirement("apps", "apps/v1", "statefulsets"): verbsCreateDeleteList, + NewResourceRequirement("networking.k8s.io", "networking.k8s.io/v1", "ingresses"): verbsCreateDeleteList, + NewResourceRequirement("rbac.authorization.k8s.io", "rbac.authorization.k8s.io/v1", "roles"): verbsCreateDeleteList, + NewResourceRequirement("rbac.authorization.k8s.io", "rbac.authorization.k8s.io/v1", "rolebindings"): verbsCreateDeleteList, + }, + }, +} + +// ResourceRequirement describes a set of requirements on a specific version of a resource: +// whether it exists with that specific version, and what verbs the current user is permitted to perform +// on the resource. +type ResourceRequirement struct { + Group string + Resource string + Version string +} + +type ResourceVerbs []string + +// VersionedResourceRequirements is a set of ResourceRequirements for a specific version of Coder. +type VersionedResourceRequirements struct { + VersionConstraints *semver.Constraints + ResourceRequirements map[*ResourceRequirement]ResourceVerbs +} + +var verbsCreateDeleteList ResourceVerbs = []string{"create", "delete", "list"} + +// NewResourceRequirement is just a convenience function for creating ResourceRequirements. +func NewResourceRequirement(apiGroup, version, resource string) *ResourceRequirement { + return &ResourceRequirement{ + Group: apiGroup, + Resource: resource, + Version: version, + } +} diff --git a/internal/checks/kube/resources_test.go b/internal/checks/kube/resources_test.go new file mode 100644 index 0000000..4a5f9eb --- /dev/null +++ b/internal/checks/kube/resources_test.go @@ -0,0 +1,225 @@ +package kube + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/cdr/coder-doctor/internal/api" +) + +func Test_KubernetesChecker_CheckResources(t *testing.T) { + t.Parallel() + + tests := []struct { + Name string + GroupListResponse *metav1.APIGroupList + F func(*testing.T, []*api.CheckResult) + }{ + { + Name: "all resources available", + GroupListResponse: fullAPIGroupList, + F: func(t *testing.T, results []*api.CheckResult) { + assert.False(t, "results should not be empty", len(results) == 0) + for _, result := range results { + assert.Equal(t, result.Name+" should have no error", nil, result.Details["error"]) + assert.Equal(t, result.Name+" should pass", api.StatePassed, result.State) + } + }, + }, + { + Name: "no resources available", + GroupListResponse: emptyAPIGroupList, + F: func(t *testing.T, results []*api.CheckResult) { + assert.False(t, "results should not be empty", len(results) == 0) + for _, result := range results { + resErr, ok := result.Details["error"].(error) + assert.True(t, result.Name+" should have an error", ok && resErr != nil) + assert.ErrorContains(t, result.Name+" should have an expected error", resErr, "missing required resource") + assert.True(t, result.Name+" should fail", result.State == api.StateFailed) + } + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch req.URL.Path { + case "/apis": + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(test.GroupListResponse) + assert.Success(t, "failed to encode response", err) + case "/api/v1": + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(v1ResourceList) + assert.Success(t, "failed to encode response", err) + case "/apis/apps/v1": + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(appsV1ResourceList) + assert.Success(t, "failed to encode response", err) + case "/apis/networking.k8s.io/v1": + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(networkingV1ResourceList) + assert.Success(t, "failed to encode response", err) + case "/apis/rbac.authorization.k8s.io/v1": + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(rbacV1ResourceList) + assert.Success(t, "failed to encode response", err) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL}) + assert.Success(t, "failed to create client", err) + + checker := NewKubernetesChecker(client) + results := checker.CheckResources(context.Background()) + test.F(t, results) + }) + } +} + +var emptyAPIGroupList *metav1.APIGroupList = &metav1.APIGroupList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupList", + APIVersion: "v1", + }, + Groups: []metav1.APIGroup{}, +} + +var fullAPIGroupList *metav1.APIGroupList = &metav1.APIGroupList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupList", + APIVersion: "v1", + }, + Groups: []metav1.APIGroup{ + { + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "v1", + Version: "v1", + }, + }, + }, + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + }, + { + Name: "networking.k8s.io", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "networking.k8s.io/v1", + Version: "v1", + }, + }, + }, + { + Name: "rbac.authorization.k8s.io", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "rbac.authorization.k8s.io/v1", + Version: "v1", + }, + }, + }, + }, +} + +var v1ResourceList = &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIResourceList", + }, + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + Verbs: []string{"get"}, + }, + { + Name: "secrets", + Verbs: []string{"get"}, + }, + { + Name: "serviceaccounts", + Verbs: []string{"get"}, + }, + { + Name: "services", + Verbs: []string{"get"}, + }, + }, +} + +var appsV1ResourceList = &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIResourceList", + APIVersion: "v1", + }, + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Verbs: []string{"get"}, + }, + { + Name: "replicasets", + Verbs: []string{"get"}, + }, + { + Name: "statefulsets", + Verbs: []string{"get"}, + }, + }, +} + +var networkingV1ResourceList = &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIResourceList", + APIVersion: "v1", + }, + GroupVersion: "networking.k8s.io/v1", + APIResources: []metav1.APIResource{ + { + Name: "ingresses", + Verbs: []string{"get"}, + }, + }, +} + +var rbacV1ResourceList = &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIResourceList", + APIVersion: "v1", + }, + GroupVersion: "rbac.authorization.k8s.io/v1", + APIResources: []metav1.APIResource{ + { + Name: "roles", + Verbs: []string{"get"}, + }, + { + Name: "rolebindings", + Verbs: []string{"get"}, + }, + }, +}