diff --git a/api/v1alpha1/perses_datasource.go b/api/v1alpha1/perses_datasource.go new file mode 100644 index 0000000..09f09ca --- /dev/null +++ b/api/v1alpha1/perses_datasource.go @@ -0,0 +1,20 @@ +package v1alpha1 + +import ( + "github.com/barkimedes/go-deepcopy" + persesv1 "github.com/perses/perses/pkg/model/api/v1" +) + +type Datasource struct { + persesv1.DatasourceSpec `json:",inline"` +} + +func (in *Datasource) DeepCopyInto(out *Datasource) { + temp, err := deepcopy.Anything(in) + + if err != nil { + panic(err) + } + + *out = *(temp.(*Datasource)) +} diff --git a/api/v1alpha1/persesdatasource_types.go b/api/v1alpha1/persesdatasource_types.go new file mode 100644 index 0000000..e83d8c8 --- /dev/null +++ b/api/v1alpha1/persesdatasource_types.go @@ -0,0 +1,52 @@ +/* +Copyright 2024. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PersesDatasourceStatus defines the observed state of PersesDatasource +type PersesDatasourceStatus struct { + // +operator-sdk:csv:customresourcedefinitions:type=status + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// PersesDatasource is the Schema for the PersesDatasources API +type PersesDatasource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec Datasource `json:"spec,omitempty"` + Status PersesDatasourceStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// PersesDatasourceList contains a list of PersesDatasource +type PersesDatasourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PersesDatasource `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PersesDatasource{}, &PersesDatasourceList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8781ba9..f1eea7a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -36,6 +36,16 @@ func (in *Dashboard) DeepCopy() *Dashboard { return out } +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Datasource. +func (in *Datasource) DeepCopy() *Datasource { + if in == nil { + return nil + } + out := new(Datasource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Perses) DeepCopyInto(out *Perses) { *out = *in @@ -154,6 +164,87 @@ func (in *PersesDashboardStatus) DeepCopy() *PersesDashboardStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersesDatasource) DeepCopyInto(out *PersesDatasource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersesDatasource. +func (in *PersesDatasource) DeepCopy() *PersesDatasource { + if in == nil { + return nil + } + out := new(PersesDatasource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PersesDatasource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersesDatasourceList) DeepCopyInto(out *PersesDatasourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PersesDatasource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersesDatasourceList. +func (in *PersesDatasourceList) DeepCopy() *PersesDatasourceList { + if in == nil { + return nil + } + out := new(PersesDatasourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PersesDatasourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersesDatasourceStatus) DeepCopyInto(out *PersesDatasourceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersesDatasourceStatus. +func (in *PersesDatasourceStatus) DeepCopy() *PersesDatasourceStatus { + if in == nil { + return nil + } + out := new(PersesDatasourceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersesList) DeepCopyInto(out *PersesList) { *out = *in diff --git a/config/crd/bases/perses.dev_persesdatasources.yaml b/config/crd/bases/perses.dev_persesdatasources.yaml new file mode 100644 index 0000000..502fd1e --- /dev/null +++ b/config/crd/bases/perses.dev_persesdatasources.yaml @@ -0,0 +1,138 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.1 + creationTimestamp: null + name: persesdatasources.perses.dev +spec: + group: perses.dev + names: + kind: PersesDatasource + listKind: PersesDatasourceList + plural: persesdatasources + singular: persesdatasource + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: PersesDatasource is the Schema for the PersesDatasources API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + default: + type: boolean + display: + properties: + description: + type: string + name: + type: string + type: object + plugin: + description: Plugin will contain the datasource configuration. The + data typed is available in Cue. + properties: + kind: + type: string + spec: + x-kubernetes-preserve-unknown-fields: true + required: + - kind + - spec + type: object + required: + - default + - plugin + type: object + status: + description: PersesDatasourceStatus defines the observed state of PersesDatasource + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 2bcd879..5c4d1ee 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -4,6 +4,7 @@ resources: - bases/perses.dev_perses.yaml - bases/perses.dev_persesdashboards.yaml + - bases/perses.dev_persesdatasources.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: diff --git a/config/crd/patches/cainjection_in_persesdatasource.yaml b/config/crd/patches/cainjection_in_persesdatasource.yaml new file mode 100644 index 0000000..b1daeec --- /dev/null +++ b/config/crd/patches/cainjection_in_persesdatasource.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: persesdatasource.perses.dev diff --git a/config/crd/patches/webhook_in_persesdatasource.yaml b/config/crd/patches/webhook_in_persesdatasource.yaml new file mode 100644 index 0000000..9739c51 --- /dev/null +++ b/config/crd/patches/webhook_in_persesdatasource.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: persesdashboards.perses.dev +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/persesdatasource_editor_role.yaml b/config/rbac/persesdatasource_editor_role.yaml new file mode 100644 index 0000000..676c0a6 --- /dev/null +++ b/config/rbac/persesdatasource_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit persesdatasources. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: persesdatasource-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: perses-operator + app.kubernetes.io/part-of: perses-operator + app.kubernetes.io/managed-by: kustomize + name: persesdatasource-editor-role +rules: + - apiGroups: + - perses.dev + resources: + - persesdatasources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - perses.dev + resources: + - persesdatasources/status + verbs: + - get diff --git a/config/rbac/persesdatasource_viewer_role.yaml b/config/rbac/persesdatasource_viewer_role.yaml new file mode 100644 index 0000000..d6b3d8c --- /dev/null +++ b/config/rbac/persesdatasource_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view persesdatasources. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: persesdatasource-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: perses-operator + app.kubernetes.io/part-of: perses-operator + app.kubernetes.io/managed-by: kustomize + name: persesdatasource-viewer-role +rules: + - apiGroups: + - perses.dev + resources: + - persesdatasources + verbs: + - get + - list + - watch + - apiGroups: + - perses.dev + resources: + - persesdatasources/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c58a7f1..c8b14ef 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -90,3 +90,29 @@ rules: - get - patch - update + - apiGroups: + - perses.dev + resources: + - persesdatasources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - perses.dev + resources: + - persesdatasources/finalizers + verbs: + - update + - apiGroups: + - perses.dev + resources: + - persesdatasources/status + verbs: + - get + - patch + - update diff --git a/config/samples/perses.dev_v1alpha1_persesdashboard-2.yaml b/config/samples/perses.dev_v1alpha1_persesdashboard-2.yaml index f0e4be2..08a39b6 100644 --- a/config/samples/perses.dev_v1alpha1_persesdashboard-2.yaml +++ b/config/samples/perses.dev_v1alpha1_persesdashboard-2.yaml @@ -18,16 +18,6 @@ spec: version: 0 spec: duration: 5m - datasources: - PrometheusLocal: - default: false - plugin: - kind: PrometheusDatasource - spec: - proxy: - kind: HTTPProxy - spec: - url: http://localhost:9090 variables: - kind: ListVariable spec: diff --git a/config/samples/perses.dev_v1alpha1_persesdatasource.yaml b/config/samples/perses.dev_v1alpha1_persesdatasource.yaml new file mode 100644 index 0000000..921eee4 --- /dev/null +++ b/config/samples/perses.dev_v1alpha1_persesdatasource.yaml @@ -0,0 +1,26 @@ +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource +metadata: + labels: + app.kubernetes.io/name: perses-datasource + app.kubernetes.io/instance: perses-prometheus-datasource + app.kubernetes.io/part-of: perses-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: perses-operator + name: perses-datasource-sample +spec: + kind: Datasource + metadata: + name: perses-prometheus-datasource + project: default + createdAt: "2021-11-09T00:00:00Z" + updatedAt: "2021-11-09T00:00:00Z" + version: 0 + spec: + display: + name: "Default Datasource" + default: true + plugin: + kind: "PrometheusDatasource" + spec: + directUrl: "https://prometheus.demo.do.prometheus.io" diff --git a/controllers/datasource_controller_test.go b/controllers/datasource_controller_test.go new file mode 100644 index 0000000..3245730 --- /dev/null +++ b/controllers/datasource_controller_test.go @@ -0,0 +1,203 @@ +package controllers + +import ( + "context" + "fmt" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + datasourcecontroller "github.com/perses/perses-operator/controllers/datasources" + internal "github.com/perses/perses-operator/internal/perses" + common "github.com/perses/perses-operator/internal/perses/common" + "github.com/perses/perses/pkg/client/perseshttp" + persesv1 "github.com/perses/perses/pkg/model/api/v1" + persescommon "github.com/perses/perses/pkg/model/api/v1/common" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var _ = Describe("Datasource controller", func() { + Context("Datasource controller test", func() { + const PersesName = "perses-for-datasource" + const PersesNamespace = "perses-datasource-test" + const DatasourceName = "my-custom-datasource" + + ctx := context.Background() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: PersesNamespace, + Namespace: PersesNamespace, + }, + } + + persesNamespaceName := types.NamespacedName{Name: PersesName, Namespace: PersesNamespace} + datasourceNamespaceName := types.NamespacedName{Name: DatasourceName, Namespace: PersesNamespace} + + persesImage := "perses-dev.io/perses:test" + + newDatasource := &persesv1.Datasource{ + Kind: "Datasource", + Metadata: persesv1.ProjectMetadata{ + Metadata: persesv1.Metadata{ + Name: DatasourceName, + }, + }, + Spec: persesv1.DatasourceSpec{ + Display: &persescommon.Display{ + Name: DatasourceName, + }, + Default: true, + Plugin: persescommon.Plugin{ + Kind: "Prometheus", + Spec: map[string]interface{}{}, + }, + }, + } + + BeforeEach(func() { + By("Creating the Namespace to perform the tests") + err := k8sClient.Create(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + + By("Setting the Image ENV VAR which stores the Operand image") + err = os.Setenv("PERSES_IMAGE", persesImage) + Expect(err).To(Not(HaveOccurred())) + }) + + AfterEach(func() { + By("Deleting the Namespace to perform the tests") + _ = k8sClient.Delete(ctx, namespace) + + By("Removing the Image ENV VAR which stores the Operand image") + _ = os.Unsetenv("PERSES_IMAGE") + }) + + It("should successfully reconcile a custom resource datasource for Perses", func() { + By("Creating the custom resource for the Kind Perses") + perses := &persesv1alpha1.Perses{} + err := k8sClient.Get(ctx, persesNamespaceName, perses) + if err != nil && errors.IsNotFound(err) { + perses := &persesv1alpha1.Perses{ + ObjectMeta: metav1.ObjectMeta{ + Name: PersesName, + Namespace: PersesNamespace, + }, + Spec: persesv1alpha1.PersesSpec{ + ContainerPort: 8080, + }, + } + + err = k8sClient.Create(ctx, perses) + Expect(err).To(Not(HaveOccurred())) + } + + By("Creating the custom resource for the Kind PersesDatasource") + datasource := &persesv1alpha1.PersesDatasource{} + err = k8sClient.Get(ctx, datasourceNamespaceName, datasource) + if err != nil && errors.IsNotFound(err) { + perses := &persesv1alpha1.PersesDatasource{ + ObjectMeta: metav1.ObjectMeta{ + Name: DatasourceName, + Namespace: PersesNamespace, + }, + Spec: persesv1alpha1.Datasource{ + DatasourceSpec: newDatasource.Spec, + }, + } + + err = k8sClient.Create(ctx, perses) + Expect(err).To(Not(HaveOccurred())) + } + + By("Checking if the custom resource was successfully created") + Eventually(func() error { + found := &persesv1alpha1.PersesDatasource{} + return k8sClient.Get(ctx, datasourceNamespaceName, found) + }, time.Minute, time.Second).Should(Succeed()) + + // Mock the Perses API to assert that Is creating a new datasource when reconciling + mockPersesClient := new(internal.MockClient) + mockDatasource := new(internal.MockDatasource) + + mockPersesClient.On("Datasource", PersesNamespace).Return(mockDatasource) + getDatasource := mockDatasource.On("Get", DatasourceName).Return(&persesv1.Datasource{}, perseshttp.RequestNotFoundError) + mockDatasource.On("Create", newDatasource).Return(&persesv1.Datasource{}, nil) + + By("Reconciling the custom resource created") + datasourceReconciler := &datasourcecontroller.PersesDatasourceReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ClientFactory: common.NewWithClient(mockPersesClient), + } + + _, err = datasourceReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: datasourceNamespaceName, + }) + + Expect(err).To(Not(HaveOccurred())) + + // The datasource was created in the Perses API + getDatasource.Unset() + mockDatasource.On("Get", DatasourceName).Return(&persesv1.Datasource{}, nil) + + By("Checking if the Perses API was called to create a datasource") + Eventually(func() error { + if !mockDatasource.AssertExpectations(GinkgoT()) { + return fmt.Errorf("The Perses API was not called to create a datasource") + } + return nil + }, time.Minute, time.Second).Should(Succeed()) + + By("Checking the latest Status Condition added to the Perses datasource instance") + Eventually(func() error { + datasourceWithStatus := &persesv1alpha1.PersesDatasource{} + err = k8sClient.Get(ctx, datasourceNamespaceName, datasourceWithStatus) + + if datasourceWithStatus.Status.Conditions == nil || len(datasourceWithStatus.Status.Conditions) == 0 { + return fmt.Errorf("No status condition was added to the perses datasource instance") + } else { + latestStatusCondition := datasourceWithStatus.Status.Conditions[len(datasourceWithStatus.Status.Conditions)-1] + expectedLatestStatusCondition := metav1.Condition{Type: common.TypeAvailablePerses, + Status: metav1.ConditionTrue, Reason: "Reconciling", + Message: fmt.Sprintf("Datasource (%s) created successfully", datasourceWithStatus.Name)} + if latestStatusCondition.Message != expectedLatestStatusCondition.Message && latestStatusCondition.Reason != expectedLatestStatusCondition.Reason && latestStatusCondition.Status != expectedLatestStatusCondition.Status && latestStatusCondition.Type != expectedLatestStatusCondition.Type { + return fmt.Errorf("The latest status condition added to the perses datasource instance is not as expected, got: %v", expectedLatestStatusCondition) + } + } + + return err + }, time.Minute, time.Second).Should(Succeed()) + + mockDatasource.On("Delete", DatasourceName).Return(nil) + + datasourceToDelete := &persesv1alpha1.PersesDatasource{} + err = k8sClient.Get(ctx, datasourceNamespaceName, datasourceToDelete) + Expect(err).To(Not(HaveOccurred())) + + By("Deleting the custom resource") + err = k8sClient.Delete(ctx, datasourceToDelete) + Expect(err).To(Not(HaveOccurred())) + + _, err = datasourceReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: datasourceNamespaceName, + }) + + Expect(err).To(Not(HaveOccurred())) + + By("Checking if the Perses API was called to delete a datasource") + Eventually(func() error { + if !mockDatasource.AssertExpectations(GinkgoT()) { + return fmt.Errorf("The Perses API was not called to create a datasource") + } + return nil + }, time.Minute, time.Second).Should(Succeed()) + }) + }) +}) diff --git a/controllers/datasources/datasource_controller.go b/controllers/datasources/datasource_controller.go new file mode 100644 index 0000000..896534f --- /dev/null +++ b/controllers/datasources/datasource_controller.go @@ -0,0 +1,184 @@ +/* +Copyright 2023 The Perses 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 datasources + +import ( + "context" + "errors" + "time" + + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + subreconciler "github.com/perses/perses-operator/internal/subreconciler" + perseshttp "github.com/perses/perses/pkg/client/perseshttp" + persesv1 "github.com/perses/perses/pkg/model/api/v1" + logger "github.com/sirupsen/logrus" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var dlog = logger.WithField("module", "datasource_controller") + +func (r *PersesDatasourceReconciler) reconcileDatasourcesInAllInstances(ctx context.Context, req ctrl.Request) (*ctrl.Result, error) { + persesInstances := &persesv1alpha1.PersesList{} + var opts []client.ListOption + err := r.Client.List(ctx, persesInstances, opts...) + if err != nil { + dlog.WithError(err).Error("Failed to get perses instances") + return subreconciler.RequeueWithError(err) + } + + if len(persesInstances.Items) == 0 { + dlog.Info("No Perses instances found") + return subreconciler.DoNotRequeue() + } + + datasource := &persesv1alpha1.PersesDatasource{} + + if r, err := r.getLatestPersesDatasource(ctx, req, datasource); subreconciler.ShouldHaltOrRequeue(r, err) { + return r, err + } + + for _, persesInstance := range persesInstances.Items { + if r, err := r.syncPersesDatasource(ctx, persesInstance, datasource); subreconciler.ShouldHaltOrRequeue(r, err) { + return r, err + } + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) syncPersesDatasource(ctx context.Context, perses persesv1alpha1.Perses, datasource *persesv1alpha1.PersesDatasource) (*ctrl.Result, error) { + persesClient, err := r.ClientFactory.CreateClient(perses) + + if err != nil { + dlog.WithError(err).Error("Failed to create perses rest client") + return subreconciler.RequeueWithError(err) + } + + _, err = persesClient.Project().Get(datasource.Namespace) + + if err != nil { + dlog.WithError(err).Errorf("project error: %s", datasource.Namespace) + if errors.Is(err, perseshttp.RequestNotFoundError) { + _, err := persesClient.Project().Create(&persesv1.Project{ + Kind: "Project", + Metadata: persesv1.Metadata{ + Name: datasource.Namespace, + }, + }) + + if err != nil { + dlog.WithError(err).Errorf("Failed to create perses project: %s", datasource.Namespace) + return subreconciler.RequeueWithError(err) + } + + dlog.Infof("Project created: %s", datasource.Namespace) + } + + return subreconciler.RequeueWithError(err) + } + + _, err = persesClient.Datasource(datasource.Namespace).Get(datasource.Name) + + datasourceWithName := &persesv1.Datasource{ + Kind: persesv1.KindDatasource, + Metadata: persesv1.ProjectMetadata{ + Metadata: persesv1.Metadata{ + Name: datasource.Name, + }, + }, + Spec: datasource.Spec.DatasourceSpec, + } + + if err != nil { + if errors.Is(err, perseshttp.RequestNotFoundError) { + _, err = persesClient.Datasource(datasource.Namespace).Create(datasourceWithName) + + if err != nil { + dlog.WithError(err).Errorf("Failed to create datasource: %s", datasource.Name) + return subreconciler.RequeueWithDelayAndError(time.Minute, err) + } + + dlog.Infof("Datasource created: %s", datasource.Name) + + return subreconciler.ContinueReconciling() + } + + return subreconciler.RequeueWithError(err) + } else { + _, err = persesClient.Datasource(datasource.Namespace).Update(datasourceWithName) + + if err != nil { + dlog.WithError(err).Errorf("Failed to update datasource: %s", datasource.Name) + return subreconciler.RequeueWithDelayAndError(time.Minute, err) + } + + dlog.Infof("Datasource updated: %s", datasource.Name) + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) deleteDatasourceInAllInstances(ctx context.Context, req ctrl.Request, datasourceNamespace string, datasourceName string) (*ctrl.Result, error) { + persesInstances := &persesv1alpha1.PersesList{} + var opts []client.ListOption + err := r.Client.List(ctx, persesInstances, opts...) + if err != nil { + dlog.WithError(err).Error("Failed to get perses instances") + return subreconciler.RequeueWithError(err) + } + + if len(persesInstances.Items) == 0 { + dlog.Info("No Perses instances found") + return subreconciler.DoNotRequeue() + } + + for _, persesInstance := range persesInstances.Items { + if r, err := r.deleteDatasource(ctx, persesInstance, datasourceNamespace, datasourceName); subreconciler.ShouldHaltOrRequeue(r, err) { + return r, err + } + } + + return subreconciler.DoNotRequeue() +} + +func (r *PersesDatasourceReconciler) deleteDatasource(ctx context.Context, perses persesv1alpha1.Perses, datasourceNamespace string, datasourceName string) (*ctrl.Result, error) { + persesClient, err := r.ClientFactory.CreateClient(perses) + + if err != nil { + dlog.WithError(err).Error("Failed to create perses rest client") + return subreconciler.RequeueWithError(err) + } + + _, err = persesClient.Project().Get(datasourceNamespace) + + if err != nil { + dlog.WithError(err).Errorf("project error: %s", datasourceNamespace) + + return subreconciler.RequeueWithError(err) + } + + err = persesClient.Datasource(datasourceNamespace).Delete(datasourceName) + + if err != nil && errors.Is(err, perseshttp.RequestNotFoundError) { + dlog.Infof("Datasource not found: %s", datasourceName) + } + + dlog.Infof("Datasource deleted: %s", datasourceName) + + return subreconciler.ContinueReconciling() +} diff --git a/controllers/datasources/persesdatasource_controller.go b/controllers/datasources/persesdatasource_controller.go new file mode 100644 index 0000000..990b074 --- /dev/null +++ b/controllers/datasources/persesdatasource_controller.go @@ -0,0 +1,140 @@ +/* +Copyright 2024. + +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 datasources + +import ( + "context" + "fmt" + "time" + + logger "github.com/sirupsen/logrus" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + common "github.com/perses/perses-operator/internal/perses/common" + subreconciler "github.com/perses/perses-operator/internal/subreconciler" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PersesDatasourceReconciler reconciles a PersesDatasource object +type PersesDatasourceReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + ClientFactory common.PersesClientFactory +} + +var log = logger.WithField("module", "perses_datasource_controller") + +// +kubebuilder:rbac:groups=perses.dev,resources=persesdatasources,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=perses.dev,resources=persesdatasources/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=perses.dev,resources=persesdatasources/finalizers,verbs=update +func (r *PersesDatasourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log.Infof("Reconciling PersesDatasource: %s/%s", req.Namespace, req.Name) + subreconcilersForPerses := []subreconciler.FnWithRequest{ + r.handleDelete, + r.setStatusToUnknown, + r.reconcileDatasourcesInAllInstances, + r.updateStatus, + } + + for _, f := range subreconcilersForPerses { + if r, err := f(ctx, req); subreconciler.ShouldHaltOrRequeue(r, err) { + return subreconciler.Evaluate(r, err) + } + } + + return subreconciler.Evaluate(subreconciler.DoNotRequeue()) +} + +func (r *PersesDatasourceReconciler) getLatestPersesDatasource(ctx context.Context, req ctrl.Request, datasource *persesv1alpha1.PersesDatasource) (*ctrl.Result, error) { + if err := r.Get(ctx, req.NamespacedName, datasource); err != nil { + if apierrors.IsNotFound(err) { + log.Info("perses datasource resource not found. Ignoring since object must be deleted") + return subreconciler.DoNotRequeue() + } + log.WithError(err).Error("Failed to get perses datasource") + return subreconciler.RequeueWithDelayAndError(time.Second, err) + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) handleDelete(ctx context.Context, req ctrl.Request) (*ctrl.Result, error) { + datasource := &persesv1alpha1.PersesDatasource{} + + if err := r.Get(ctx, req.NamespacedName, datasource); err != nil { + if !apierrors.IsNotFound(err) { + log.WithError(err).Error("Failed to get perses datasource") + return subreconciler.RequeueWithError(err) + } + + log.Infof("perses datasource resource not found. Deleting '%s' in '%s'", req.Name, req.Namespace) + + return r.deleteDatasourceInAllInstances(ctx, req, req.NamespacedName.Namespace, req.NamespacedName.Name) + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) setStatusToUnknown(ctx context.Context, req ctrl.Request) (*ctrl.Result, error) { + datasource := &persesv1alpha1.PersesDatasource{} + + if r, err := r.getLatestPersesDatasource(ctx, req, datasource); subreconciler.ShouldHaltOrRequeue(r, err) { + return r, err + } + + if datasource.Status.Conditions == nil || len(datasource.Status.Conditions) == 0 { + meta.SetStatusCondition(&datasource.Status.Conditions, metav1.Condition{Type: common.TypeAvailablePerses, Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "Starting reconciliation"}) + if err := r.Status().Update(ctx, datasource); err != nil { + log.WithError(err).Error("Failed to update Perses datasource status") + return subreconciler.RequeueWithDelayAndError(time.Second*10, err) + } + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) updateStatus(ctx context.Context, req ctrl.Request) (*ctrl.Result, error) { + datasource := &persesv1alpha1.PersesDatasource{} + + if r, err := r.getLatestPersesDatasource(ctx, req, datasource); subreconciler.ShouldHaltOrRequeue(r, err) { + return r, err + } + + meta.SetStatusCondition(&datasource.Status.Conditions, metav1.Condition{Type: common.TypeAvailablePerses, + Status: metav1.ConditionTrue, Reason: "Reconciling", + Message: fmt.Sprintf("Datasource (%s) created successfully", datasource.Name)}) + + if err := r.Status().Update(ctx, datasource); err != nil { + log.Error(err, "Failed to update Perses datasource status") + return subreconciler.RequeueWithError(err) + } + + return subreconciler.ContinueReconciling() +} + +func (r *PersesDatasourceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&persesv1alpha1.PersesDatasource{}). + Complete(r) +} diff --git a/internal/perses/perses_mock_api.go b/internal/perses/perses_mock_api.go index 895737a..589e5c8 100644 --- a/internal/perses/perses_mock_api.go +++ b/internal/perses/perses_mock_api.go @@ -52,3 +52,33 @@ func (d *MockDashboard) Create(dashboard *modelv1.Dashboard) (*modelv1.Dashboard args := d.Called(dashboard) return args.Get(0).(*modelv1.Dashboard), args.Error(1) } + +type MockDatasource struct { + v1.DatasourceInterface + mock.Mock +} + +func (c *MockClient) Datasource(project string) v1.DatasourceInterface { + args := c.Called(project) + return args.Get(0).(v1.DatasourceInterface) +} + +func (d *MockDatasource) Get(name string) (*modelv1.Datasource, error) { + args := d.Called(name) + return args.Get(0).(*modelv1.Datasource), args.Error(1) +} + +func (d *MockDatasource) Update(dashboard *modelv1.Datasource) (*modelv1.Datasource, error) { + args := d.Called(dashboard) + return args.Get(0).(*modelv1.Datasource), args.Error(1) +} + +func (d *MockDatasource) Delete(name string) error { + args := d.Called(name) + return args.Error(0) +} + +func (d *MockDatasource) Create(dashboard *modelv1.Datasource) (*modelv1.Datasource, error) { + args := d.Called(dashboard) + return args.Get(0).(*modelv1.Datasource), args.Error(1) +} diff --git a/main.go b/main.go index b28ad5d..be3beb0 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ import ( persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" dashboardcontroller "github.com/perses/perses-operator/controllers/dashboards" + datasourcecontroller "github.com/perses/perses-operator/controllers/datasources" persescontroller "github.com/perses/perses-operator/controllers/perses" "github.com/perses/perses-operator/internal/perses/common" //+kubebuilder:scaffold:imports @@ -131,6 +132,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "PersesDashboard") os.Exit(1) } + + if err = (&datasourcecontroller.PersesDatasourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ClientFactory: common.NewWithConfig(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PersesDatasource") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {