diff --git a/cmd/analyze.go b/cmd/analyze.go index 45652c2..d0765f4 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -27,6 +27,7 @@ type AnalyzeKind string const ( ServiceMonitor AnalyzeKind = "servicemonitor" + Operator AnalyzeKind = "operator" ) type AnalyzeFlags struct { @@ -68,6 +69,8 @@ func run(cmd *cobra.Command, _ []string) error { switch AnalyzeKind(strings.ToLower(analyzerFlags.Kind)) { case ServiceMonitor: return analyzers.RunServiceMonitorAnalyzer(cmd.Context(), clientSets, analyzerFlags.Name, analyzerFlags.Namespace) + case Operator: + return analyzers.RunOperatorAnalyzer(cmd.Context(), clientSets, analyzerFlags.Name, analyzerFlags.Namespace) default: return fmt.Errorf("kind %s not supported", analyzerFlags.Kind) } diff --git a/internal/analyzers/operator.go b/internal/analyzers/operator.go new file mode 100644 index 0000000..fd42f79 --- /dev/null +++ b/internal/analyzers/operator.go @@ -0,0 +1,116 @@ +// Copyright 2024 The prometheus-operator 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 analyzers + +import ( + "context" + "fmt" + + "github.com/prometheus-operator/poctl/internal/crds" + "github.com/prometheus-operator/poctl/internal/k8sutil" + v1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func RunOperatorAnalyzer(ctx context.Context, clientSets *k8sutil.ClientSets, name, namespace string) error { + op, err := clientSets.KClient.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get Prometheus Operator deployment: %w", err) + } + + cRb, err := clientSets.KClient.RbacV1().ClusterRoleBindings().List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/name=prometheus-operator", + }) + + if err != nil { + return fmt.Errorf("failed to list RoleBindings: %w", err) + } + + // Check if the ServiceAccount is bound to any ClusterRoleBindings + if !isServiceAccountBoundToRoleBindingList(cRb, op.Spec.Template.Spec.ServiceAccountName) { + return fmt.Errorf("ServiceAccount %s is not bound to any RoleBindings", op.Spec.Template.Spec.ServiceAccountName) + } + + for _, crb := range cRb.Items { + cr, err := clientSets.KClient.RbacV1().ClusterRoles().Get(ctx, crb.RoleRef.Name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get ClusterRole %s", crb.RoleRef.Name) + } + + err = analyzeClusterRoleAndCRDRules(ctx, clientSets, crb, cr) + if err != nil { + return err + } + } + + return nil +} + +func analyzeClusterRoleAndCRDRules(ctx context.Context, clientSets *k8sutil.ClientSets, crb v1.ClusterRoleBinding, cr *v1.ClusterRole) error { + foundAPIGroup := false + for _, rule := range cr.Rules { + for _, apiGroup := range rule.APIGroups { + if apiGroup == "monitoring.coreos.com" { + foundAPIGroup = true + err := analyzeCRDRules(ctx, clientSets, crb, rule) + if err != nil { + return err + } + break + } + } + } + + if !foundAPIGroup { + return fmt.Errorf("ClusterRole %s does not have monitoring.coreos.com APIGroup in its rules", crb.RoleRef.Name) + } + + return nil +} + +func analyzeCRDRules(ctx context.Context, clientSets *k8sutil.ClientSets, crb v1.ClusterRoleBinding, rule v1.PolicyRule) error { + for _, crd := range crds.List { + crd, err := clientSets.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crd, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get CRD %s", crd) + } + + found := false + for _, r := range rule.Resources { + if r == crd.Spec.Names.Plural || r == crd.Spec.Names.Singular || r == crd.Spec.Names.Plural+"/finalizers" || r == crd.Spec.Names.Singular+"/finalizers" { + found = true + break + } + } + + if !found { + return fmt.Errorf("ClusterRole %s does not have %s in its rules", crb.RoleRef.Name, crd.Spec.Names.Plural) + } + } + return nil +} + +func isServiceAccountBoundToRoleBindingList(clusterRoleBindings *v1.ClusterRoleBindingList, serviceAccountName string) bool { + for _, roleBinding := range clusterRoleBindings.Items { + if roleBinding.Subjects != nil { + for _, subject := range roleBinding.Subjects { + if subject.Kind == "ServiceAccount" && subject.Name == serviceAccountName { + return true + } + } + } + } + return false +} diff --git a/internal/analyzers/operator_test.go b/internal/analyzers/operator_test.go new file mode 100644 index 0000000..858c3e2 --- /dev/null +++ b/internal/analyzers/operator_test.go @@ -0,0 +1,309 @@ +// Copyright 2024 The prometheus-operator 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 analyzers + +import ( + "context" + "testing" + + "github.com/prometheus-operator/poctl/internal/k8sutil" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + fakeApiExtensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" + "k8s.io/utils/ptr" +) + +func getDefaultDeployment(name, namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "prometheus-operator", + }, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "prometheus-operator", + }, + }, + Spec: v1.PodSpec{ + ServiceAccountName: "prometheus-operator", + Containers: []v1.Container{ + { + Name: "prometheus-operator", + Image: "quay.io/coreos/prometheus-operator:v0.38.1", + }, + }, + }, + }, + }, + } +} + +func getDefaultClusterRoleBinding(namespace string) []rbacv1.ClusterRoleBinding { + return []rbacv1.ClusterRoleBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-operator", + Labels: map[string]string{ + "app.kubernetes.io/name": "prometheus-operator", + }, + }, + RoleRef: rbacv1.RoleRef{ + Name: "prometheus-operator", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "prometheus-operator", + Namespace: namespace, + }, + }, + }, + } +} + +func TestOperatorAnalyzer(t *testing.T) { + type testCase struct { + name string + namespace string + getMockedClientSets func(tc testCase) k8sutil.ClientSets + shouldFail bool + } + + tests := []testCase{ + { + name: "OperatorDeploymentNotFound", + namespace: "test", + shouldFail: true, + getMockedClientSets: func(_ testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}) + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.NewNotFound(appsv1.Resource("deployments"), "NotFound") + }) + return k8sutil.ClientSets{ + KClient: kClient, + } + }, + }, + { + name: "OperatorRoleBindingListError", + namespace: "test", + shouldFail: true, + getMockedClientSets: func(tc testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}, &rbacv1.ClusterRoleBindingList{}) + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, getDefaultDeployment(tc.name, tc.namespace), nil + }) + kClient.PrependReactor("list", "clusterrolebindings", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.NewInternalError(errors.NewInternalError(errors.FromObject(nil))) + }) + return k8sutil.ClientSets{ + KClient: kClient, + } + }, + }, + { + name: "ServiceAccountNotBoundToRoleBinding", + namespace: "test", + shouldFail: true, + getMockedClientSets: func(tc testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}, &rbacv1.ClusterRoleBindingList{}) + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + deployment := getDefaultDeployment(tc.name, tc.namespace) + deployment.Spec.Template.Spec.ServiceAccountName = "not-bound-service-account" + return true, deployment, nil + }) + kClient.PrependReactor("list", "clusterrolebindings", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRoleBindingList{ + Items: getDefaultClusterRoleBinding(tc.namespace), + }, nil + }) + return k8sutil.ClientSets{ + KClient: kClient, + } + }, + }, + { + name: "ApiGroupNotFoundInClusterRole", + namespace: "test", + shouldFail: true, + getMockedClientSets: func(tc testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}, &rbacv1.ClusterRoleBindingList{}) + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + deployment := getDefaultDeployment(tc.name, tc.namespace) + return true, deployment, nil + }) + kClient.PrependReactor("list", "clusterrolebindings", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRoleBindingList{ + Items: getDefaultClusterRoleBinding(tc.namespace), + }, nil + }) + + kClient.PrependReactor("get", "clusterroles", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-operator", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"not-monitoring.coreos.com"}, + Resources: []string{"prometheuses", "prometheusrules", "servicemonitors", "podmonitors", "thanosrulers"}, + }, + }, + }, nil + }) + + return k8sutil.ClientSets{ + KClient: kClient, + } + }, + }, + { + name: "CrdNotFoundInClusterRole", + namespace: "test", + shouldFail: true, + getMockedClientSets: func(tc testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}, &rbacv1.ClusterRoleBindingList{}) + + apiExtensionsClient := fakeApiExtensions.NewSimpleClientset(&apiextensions.CustomResourceDefinition{}, &apiextensions.CustomResourceDefinitionList{}) + + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + deployment := getDefaultDeployment(tc.name, tc.namespace) + return true, deployment, nil + }) + + kClient.PrependReactor("list", "clusterrolebindings", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRoleBindingList{ + Items: getDefaultClusterRoleBinding(tc.namespace), + }, nil + }) + + kClient.PrependReactor("get", "clusterroles", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-operator", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"monitoring.coreos.com"}, + Resources: []string{"prometheuses", "prometheusrules", "servicemonitors", "podmonitors", "thanosrulers"}, + }, + }, + }, nil + }) + + apiExtensionsClient.PrependReactor("get", "customresourcedefinitions", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alertmanager.monitoring.coreos.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Names: apiextensions.CustomResourceDefinitionNames{ + Singular: "alertmanager", + Plural: "alertmanagers", + }, + }, + }, nil + }) + + return k8sutil.ClientSets{ + KClient: kClient, + APIExtensionsClient: apiExtensionsClient, + } + }, + }, + { + name: "CrdFoundInClusterRole", + namespace: "test", + shouldFail: false, + getMockedClientSets: func(tc testCase) k8sutil.ClientSets { + kClient := fake.NewSimpleClientset(&appsv1.Deployment{}, &rbacv1.ClusterRoleBindingList{}) + + apiExtensionsClient := fakeApiExtensions.NewSimpleClientset(&apiextensions.CustomResourceDefinition{}, &apiextensions.CustomResourceDefinitionList{}) + + kClient.PrependReactor("get", "deployments", func(_ clienttesting.Action) (bool, runtime.Object, error) { + deployment := getDefaultDeployment(tc.name, tc.namespace) + return true, deployment, nil + }) + + kClient.PrependReactor("list", "clusterrolebindings", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRoleBindingList{ + Items: getDefaultClusterRoleBinding(tc.namespace), + }, nil + }) + + kClient.PrependReactor("get", "clusterroles", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus-operator", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"monitoring.coreos.com"}, + Resources: []string{"prometheuses", "prometheusrules", "servicemonitors", "podmonitors", "thanosrulers", "alertmanagers"}, + }, + }, + }, nil + }) + + apiExtensionsClient.PrependReactor("get", "customresourcedefinitions", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alertmanager.monitoring.coreos.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Names: apiextensions.CustomResourceDefinitionNames{ + Singular: "alertmanager", + Plural: "alertmanagers", + }, + }, + }, nil + }) + + return k8sutil.ClientSets{ + KClient: kClient, + APIExtensionsClient: apiExtensionsClient, + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clientSets := tc.getMockedClientSets(tc) + err := RunOperatorAnalyzer(context.Background(), &clientSets, tc.name, tc.namespace) + if tc.shouldFail { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/crds/crds.go b/internal/crds/crds.go new file mode 100644 index 0000000..531e259 --- /dev/null +++ b/internal/crds/crds.go @@ -0,0 +1,30 @@ +// Copyright 2024 The prometheus-operator 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 crds + +var ( + List = []string{ + "alertmanagers", + "alertmanagerconfigs", + "podmonitors", + "probes", + "prometheusagents", + "prometheuses", + "prometheusrules", + "scrapeconfigs", + "servicemonitors", + "thanosrulers", + } +) diff --git a/internal/k8sutil/k8sutil.go b/internal/k8sutil/k8sutil.go index 4ad317c..7a3102d 100644 --- a/internal/k8sutil/k8sutil.go +++ b/internal/k8sutil/k8sutil.go @@ -28,6 +28,7 @@ import ( monitoringclient "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" apiv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + apiExtensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -103,9 +104,10 @@ func CrdDeserilezer(logger *slog.Logger, reader io.ReadCloser) (runtime.Object, } type ClientSets struct { - KClient kubernetes.Interface - MClient monitoringclient.Interface - DClient dynamic.Interface + KClient kubernetes.Interface + MClient monitoringclient.Interface + DClient dynamic.Interface + APIExtensionsClient apiExtensions.Interface } func GetClientSets(kubeconfig string) (*ClientSets, error) { @@ -130,9 +132,15 @@ func GetClientSets(kubeconfig string) (*ClientSets, error) { return nil, fmt.Errorf("error while creating dynamic client: %v", err) } + apiExtensions, err := apiExtensions.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("error while creating apiextensions client: %v", err) + } + return &ClientSets{ - KClient: kclient, - MClient: mclient, - DClient: kdynamicClient, + KClient: kclient, + MClient: mclient, + DClient: kdynamicClient, + APIExtensionsClient: apiExtensions, }, nil }