diff --git a/api/datadoghq/v1alpha1/datadoggenericresource_types.go b/api/datadoghq/v1alpha1/datadoggenericresource_types.go new file mode 100644 index 000000000..cd5596faf --- /dev/null +++ b/api/datadoghq/v1alpha1/datadoggenericresource_types.go @@ -0,0 +1,95 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type SupportedResourcesType string + +// When adding a new type, make sure to update the kubebuilder validation enum marker +const ( + Notebook SupportedResourcesType = "notebook" + SyntheticsAPITest SupportedResourcesType = "synthetics_api_test" + SyntheticsBrowserTest SupportedResourcesType = "synthetics_browser_test" +) + +// DatadogGenericResourceSpec defines the desired state of DatadogGenericResource +// +k8s:openapi-gen=true +type DatadogGenericResourceSpec struct { + // Type is the type of the API object + // +kubebuilder:validation:Enum=notebook;synthetics_api_test;synthetics_browser_test + Type SupportedResourcesType `json:"type"` + // JsonSpec is the specification of the API object + JsonSpec string `json:"jsonSpec"` +} + +// DatadogGenericResourceStatus defines the observed state of DatadogGenericResource +// +k8s:openapi-gen=true +type DatadogGenericResourceStatus struct { + // Conditions represents the latest available observations of the state of a DatadogGenericResource. + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Id is the object unique identifier generated in Datadog. + Id string `json:"id,omitempty"` + // Creator is the identity of the creator. + Creator string `json:"creator,omitempty"` + // Created is the time the object was created. + Created *metav1.Time `json:"created,omitempty"` + // SyncStatus shows the health of syncing the object state to Datadog. + SyncStatus DatadogSyncStatus `json:"syncStatus,omitempty"` + // CurrentHash tracks the hash of the current DatadogGenericResourceSpec to know + // if the JsonSpec has changed and needs an update. + CurrentHash string `json:"currentHash,omitempty"` + // LastForceSyncTime is the last time the API object was last force synced with the custom resource + LastForceSyncTime *metav1.Time `json:"lastForceSyncTime,omitempty"` +} + +type DatadogSyncStatus string + +const ( + // DatadogSyncStatusOK means syncing is OK. + DatadogSyncStatusOK DatadogSyncStatus = "OK" + // DatadogSyncStatusValidateError means there is an object validation error. + DatadogSyncStatusValidateError DatadogSyncStatus = "error validating object" + // DatadogSyncStatusUpdateError means there is an object update error. + DatadogSyncStatusUpdateError DatadogSyncStatus = "error updating object" + // DatadogSyncStatusCreateError means there is an error getting the object. + DatadogSyncStatusCreateError DatadogSyncStatus = "error creating object" + // DatadogSyncStatusGetError means there is an error getting the object. + DatadogSyncStatusGetError DatadogSyncStatus = "error getting object" +) + +// DatadogGenericResource is the Schema for the DatadogGenericResources API +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=datadoggenericresources,scope=Namespaced,shortName=ddgr +// +kubebuilder:printcolumn:name="id",type="string",JSONPath=".status.id" +// +kubebuilder:printcolumn:name="sync status",type="string",JSONPath=".status.syncStatus" +// +kubebuilder:printcolumn:name="age",type="date",JSONPath=".metadata.creationTimestamp" +// +k8s:openapi-gen=true +// +genclient +type DatadogGenericResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DatadogGenericResourceSpec `json:"spec,omitempty"` + Status DatadogGenericResourceStatus `json:"status,omitempty"` +} + +// DatadogGenericResourceList contains a list of DatadogGenericResource +// +kubebuilder:object:root=true +type DatadogGenericResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DatadogGenericResource `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DatadogGenericResource{}, &DatadogGenericResourceList{}) +} diff --git a/api/datadoghq/v1alpha1/datadoggenericresource_validation.go b/api/datadoghq/v1alpha1/datadoggenericresource_validation.go new file mode 100644 index 000000000..e83e1eeb8 --- /dev/null +++ b/api/datadoghq/v1alpha1/datadoggenericresource_validation.go @@ -0,0 +1,33 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package v1alpha1 + +import ( + "fmt" + + utilserrors "k8s.io/apimachinery/pkg/util/errors" +) + +var allowedCustomResourcesEnumMap = map[SupportedResourcesType]string{ + Notebook: "", + SyntheticsAPITest: "", + SyntheticsBrowserTest: "", + // mockSubresource is used to mock the subresource in tests + "mock_resource": "", +} + +func IsValidDatadogGenericResource(spec *DatadogGenericResourceSpec) error { + var errs []error + if _, ok := allowedCustomResourcesEnumMap[spec.Type]; !ok { + errs = append(errs, fmt.Errorf("spec.Type must be a supported resource type")) + } + + if spec.JsonSpec == "" { + errs = append(errs, fmt.Errorf("spec.JsonSpec must be defined")) + } + + return utilserrors.NewAggregate(errs) +} diff --git a/api/datadoghq/v1alpha1/datadoggenericresource_validation_test.go b/api/datadoghq/v1alpha1/datadoggenericresource_validation_test.go new file mode 100644 index 000000000..b5b0e0d3b --- /dev/null +++ b/api/datadoghq/v1alpha1/datadoggenericresource_validation_test.go @@ -0,0 +1,57 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_IsValidDatadogGenericResource(t *testing.T) { + tests := []struct { + name string + spec *DatadogGenericResourceSpec + wantErr string + }{ + { + name: "supported resource type and non empty json spec", + spec: &DatadogGenericResourceSpec{ + Type: SyntheticsBrowserTest, + // N.B. This is a valid JSON string but not valid for the API (not a model payload). + // This is just for testing purposes. + JsonSpec: "{\"foo\": \"bar\"}", + }, + wantErr: "", + }, + { + name: "unsupported resource type", + spec: &DatadogGenericResourceSpec{ + Type: "foo", + JsonSpec: "{\"foo\": \"bar\"}", + }, + wantErr: "spec.Type must be a supported resource type", + }, + { + name: "empty json spec", + spec: &DatadogGenericResourceSpec{ + Type: Notebook, + JsonSpec: "", + }, + wantErr: "spec.JsonSpec must be defined", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := IsValidDatadogGenericResource(test.spec) + if test.wantErr != "" { + assert.EqualError(t, err, test.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/api/datadoghq/v1alpha1/zz_generated.deepcopy.go b/api/datadoghq/v1alpha1/zz_generated.deepcopy.go index ed91413b6..7389a8ca4 100644 --- a/api/datadoghq/v1alpha1/zz_generated.deepcopy.go +++ b/api/datadoghq/v1alpha1/zz_generated.deepcopy.go @@ -428,6 +428,110 @@ func (in *DatadogDashboardStatus) DeepCopy() *DatadogDashboardStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DatadogGenericResource) DeepCopyInto(out *DatadogGenericResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatadogGenericResource. +func (in *DatadogGenericResource) DeepCopy() *DatadogGenericResource { + if in == nil { + return nil + } + out := new(DatadogGenericResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DatadogGenericResource) 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 *DatadogGenericResourceList) DeepCopyInto(out *DatadogGenericResourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DatadogGenericResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatadogGenericResourceList. +func (in *DatadogGenericResourceList) DeepCopy() *DatadogGenericResourceList { + if in == nil { + return nil + } + out := new(DatadogGenericResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DatadogGenericResourceList) 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 *DatadogGenericResourceSpec) DeepCopyInto(out *DatadogGenericResourceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatadogGenericResourceSpec. +func (in *DatadogGenericResourceSpec) DeepCopy() *DatadogGenericResourceSpec { + if in == nil { + return nil + } + out := new(DatadogGenericResourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DatadogGenericResourceStatus) DeepCopyInto(out *DatadogGenericResourceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Created != nil { + in, out := &in.Created, &out.Created + *out = (*in).DeepCopy() + } + if in.LastForceSyncTime != nil { + in, out := &in.LastForceSyncTime, &out.LastForceSyncTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatadogGenericResourceStatus. +func (in *DatadogGenericResourceStatus) DeepCopy() *DatadogGenericResourceStatus { + if in == nil { + return nil + } + out := new(DatadogGenericResourceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DatadogMetric) DeepCopyInto(out *DatadogMetric) { *out = *in diff --git a/api/datadoghq/v1alpha1/zz_generated.openapi.go b/api/datadoghq/v1alpha1/zz_generated.openapi.go index 7f485515c..6875efc91 100644 --- a/api/datadoghq/v1alpha1/zz_generated.openapi.go +++ b/api/datadoghq/v1alpha1/zz_generated.openapi.go @@ -26,6 +26,9 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogDashboard": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogDashboard(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogDashboardSpec": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogDashboardSpec(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogDashboardStatus": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogDashboardStatus(ref), + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResource": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResource(ref), + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceSpec": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResourceSpec(ref), + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceStatus": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResourceStatus(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogMetric": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogMetric(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogMetricCondition": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogMetricCondition(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogMonitor": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogMonitor(ref), @@ -623,6 +626,160 @@ func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogDashboardStatus(ref c } } +func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "DatadogGenericResource is the Schema for the DatadogGenericResources API", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceSpec"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceStatus"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceSpec", "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogGenericResourceStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResourceSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "DatadogGenericResourceSpec defines the desired state of DatadogGenericResource", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Description: "Type is the type of the API object", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "jsonSpec": { + SchemaProps: spec.SchemaProps{ + Description: "JsonSpec is the specification of the API object", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"type", "jsonSpec"}, + }, + }, + } +} + +func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogGenericResourceStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "DatadogGenericResourceStatus defines the observed state of DatadogGenericResource", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "conditions": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "type", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Conditions represents the latest available observations of the state of a DatadogGenericResource.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"), + }, + }, + }, + }, + }, + "id": { + SchemaProps: spec.SchemaProps{ + Description: "Id is the object unique identifier generated in Datadog.", + Type: []string{"string"}, + Format: "", + }, + }, + "creator": { + SchemaProps: spec.SchemaProps{ + Description: "Creator is the identity of the creator.", + Type: []string{"string"}, + Format: "", + }, + }, + "created": { + SchemaProps: spec.SchemaProps{ + Description: "Created is the time the object was created.", + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), + }, + }, + "syncStatus": { + SchemaProps: spec.SchemaProps{ + Description: "SyncStatus shows the health of syncing the object state to Datadog.", + Type: []string{"string"}, + Format: "", + }, + }, + "currentHash": { + SchemaProps: spec.SchemaProps{ + Description: "CurrentHash tracks the hash of the current DatadogGenericResourceSpec to know if the JsonSpec has changed and needs an update.", + Type: []string{"string"}, + Format: "", + }, + }, + "lastForceSyncTime": { + SchemaProps: spec.SchemaProps{ + Description: "LastForceSyncTime is the last time the API object was last force synced with the custom resource", + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/apis/meta/v1.Condition", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, + } +} + func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogMetric(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/cmd/main.go b/cmd/main.go index 089aa3e75..4f1ce97c9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -126,6 +126,7 @@ type options struct { datadogAgentProfileEnabled bool remoteConfigEnabled bool datadogDashboardEnabled bool + datadogGenericResourceEnabled bool // Secret Backend options secretBackendCommand string @@ -160,6 +161,7 @@ func (opts *options) Parse() { flag.BoolVar(&opts.datadogAgentProfileEnabled, "datadogAgentProfileEnabled", false, "Enable DatadogAgentProfile controller (beta)") flag.BoolVar(&opts.remoteConfigEnabled, "remoteConfigEnabled", false, "Enable RemoteConfig capabilities in the Operator (beta)") flag.BoolVar(&opts.datadogDashboardEnabled, "datadogDashboardEnabled", false, "Enable the DatadogDashboard controller") + flag.BoolVar(&opts.datadogGenericResourceEnabled, "datadogGenericResourceEnabled", false, "Enable the DatadogGenericResource controller") // ExtendedDaemonset configuration flag.BoolVar(&opts.supportExtendedDaemonset, "supportExtendedDaemonset", false, "Support usage of Datadog ExtendedDaemonset CRD.") @@ -302,16 +304,17 @@ func run(opts *options) error { CanaryAutoPauseMaxSlowStartDuration: opts.edsCanaryAutoPauseMaxSlowStartDuration, MaxPodSchedulerFailure: opts.edsMaxPodSchedulerFailure, }, - SupportCilium: opts.supportCilium, - Creds: creds, - DatadogAgentEnabled: opts.datadogAgentEnabled, - DatadogMonitorEnabled: opts.datadogMonitorEnabled, - DatadogSLOEnabled: opts.datadogSLOEnabled, - OperatorMetricsEnabled: opts.operatorMetricsEnabled, - V2APIEnabled: true, - IntrospectionEnabled: opts.introspectionEnabled, - DatadogAgentProfileEnabled: opts.datadogAgentProfileEnabled, - DatadogDashboardEnabled: opts.datadogDashboardEnabled, + SupportCilium: opts.supportCilium, + Creds: creds, + DatadogAgentEnabled: opts.datadogAgentEnabled, + DatadogMonitorEnabled: opts.datadogMonitorEnabled, + DatadogSLOEnabled: opts.datadogSLOEnabled, + OperatorMetricsEnabled: opts.operatorMetricsEnabled, + V2APIEnabled: true, + IntrospectionEnabled: opts.introspectionEnabled, + DatadogAgentProfileEnabled: opts.datadogAgentProfileEnabled, + DatadogDashboardEnabled: opts.datadogDashboardEnabled, + DatadogGenericResourceEnabled: opts.datadogGenericResourceEnabled, } if err = controller.SetupControllers(setupLog, mgr, options); err != nil { diff --git a/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml b/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml new file mode 100644 index 000000000..2e0ff0a11 --- /dev/null +++ b/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml @@ -0,0 +1,157 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: datadoggenericresources.datadoghq.com +spec: + group: datadoghq.com + names: + kind: DatadogGenericResource + listKind: DatadogGenericResourceList + plural: datadoggenericresources + shortNames: + - ddgr + singular: datadoggenericresource + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.id + name: id + type: string + - jsonPath: .status.syncStatus + name: sync status + type: string + - jsonPath: .metadata.creationTimestamp + name: age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: DatadogGenericResource is the Schema for the DatadogGenericResources 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: + description: DatadogGenericResourceSpec defines the desired state of DatadogGenericResource + properties: + jsonSpec: + description: JsonSpec is the specification of the API object + type: string + type: + description: Type is the type of the API object + enum: + - notebook + - synthetics_api_test + - synthetics_browser_test + type: string + required: + - jsonSpec + - type + type: object + status: + description: DatadogGenericResourceStatus defines the observed state of DatadogGenericResource + properties: + conditions: + description: Conditions represents the latest available observations of the state of a DatadogGenericResource. + items: + description: Condition contains details for one aspect of the current state of this API Resource. + 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. + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + created: + description: Created is the time the object was created. + format: date-time + type: string + creator: + description: Creator is the identity of the creator. + type: string + currentHash: + description: |- + CurrentHash tracks the hash of the current DatadogGenericResourceSpec to know + if the JsonSpec has changed and needs an update. + type: string + id: + description: Id is the object unique identifier generated in Datadog. + type: string + lastForceSyncTime: + description: LastForceSyncTime is the last time the API object was last force synced with the custom resource + format: date-time + type: string + syncStatus: + description: SyncStatus shows the health of syncing the object state to Datadog. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json b/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json new file mode 100644 index 000000000..516fa4279 --- /dev/null +++ b/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json @@ -0,0 +1,135 @@ +{ + "additionalProperties": false, + "description": "DatadogGenericResource is the Schema for the DatadogGenericResources API", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore 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.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "type": "object" + }, + "spec": { + "additionalProperties": false, + "description": "DatadogGenericResourceSpec defines the desired state of DatadogGenericResource", + "properties": { + "jsonSpec": { + "description": "JsonSpec is the specification of the API object", + "type": "string" + }, + "type": { + "description": "Type is the type of the API object", + "enum": [ + "notebook", + "synthetics_api_test", + "synthetics_browser_test" + ], + "type": "string" + } + }, + "required": [ + "jsonSpec", + "type" + ], + "type": "object" + }, + "status": { + "additionalProperties": false, + "description": "DatadogGenericResourceStatus defines the observed state of DatadogGenericResource", + "properties": { + "conditions": { + "description": "Conditions represents the latest available observations of the state of a DatadogGenericResource.", + "items": { + "additionalProperties": false, + "description": "Condition contains details for one aspect of the current state of this API Resource.", + "properties": { + "lastTransitionTime": { + "description": "lastTransitionTime is the last time the condition transitioned from one status to another.\nThis 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.\nThis may be an empty string.", + "maxLength": 32768, + "type": "string" + }, + "observedGeneration": { + "description": "observedGeneration represents the .metadata.generation that the condition was set based upon.\nFor instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date\nwith 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.\nProducers of specific condition types may define expected values and meanings for this field,\nand whether the values are considered a guaranteed API.\nThe value should be a CamelCase string.\nThis 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.", + "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", + "x-kubernetes-list-map-keys": [ + "type" + ], + "x-kubernetes-list-type": "map" + }, + "created": { + "description": "Created is the time the object was created.", + "format": "date-time", + "type": "string" + }, + "creator": { + "description": "Creator is the identity of the creator.", + "type": "string" + }, + "currentHash": { + "description": "CurrentHash tracks the hash of the current DatadogGenericResourceSpec to know\nif the JsonSpec has changed and needs an update.", + "type": "string" + }, + "id": { + "description": "Id is the object unique identifier generated in Datadog.", + "type": "string" + }, + "lastForceSyncTime": { + "description": "LastForceSyncTime is the last time the API object was last force synced with the custom resource", + "format": "date-time", + "type": "string" + }, + "syncStatus": { + "description": "SyncStatus shows the health of syncing the object state to Datadog.", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} \ No newline at end of file diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 9411a8f98..8a87fe457 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -11,6 +11,7 @@ resources: - bases/v1/datadoghq.com_datadogagentprofiles.yaml - bases/v1/datadoghq.com_datadogpodautoscalers.yaml - bases/v1/datadoghq.com_datadogdashboards.yaml +- bases/v1/datadoghq.com_datadoggenericresources.yaml # +kubebuilder:scaffold:crdkustomizeresource #patches: diff --git a/config/crd/patches/cainjection_in_datadoghq_datadoggenericresources.yaml b/config/crd/patches/cainjection_in_datadoghq_datadoggenericresources.yaml new file mode 100644 index 000000000..d042c7deb --- /dev/null +++ b/config/crd/patches/cainjection_in_datadoghq_datadoggenericresources.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: datadoggenericresources.datadoghq.com diff --git a/config/crd/patches/webhook_in_datadoghq_datadoggenericresources.yaml b/config/crd/patches/webhook_in_datadoghq_datadoggenericresources.yaml new file mode 100644 index 000000000..c3fcb4799 --- /dev/null +++ b/config/crd/patches/webhook_in_datadoghq_datadoggenericresources.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: datadoggenericresources.datadoghq.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/datadoghq_datadoggenericresource_editor_role.yaml b/config/rbac/datadoghq_datadoggenericresource_editor_role.yaml new file mode 100644 index 000000000..b9228372f --- /dev/null +++ b/config/rbac/datadoghq_datadoggenericresource_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit datadoggenericresources. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: datadog-genericresource-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: datadog-operator + app.kubernetes.io/part-of: datadog-operator + app.kubernetes.io/managed-by: kustomize + name: datadoggenericresource-editor-role +rules: +- apiGroups: + - datadoghq.com + resources: + - datadoggenericresources + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - datadoghq.com + resources: + - datadoggenericresources/status + verbs: + - get diff --git a/config/rbac/datadoghq_datadoggenericresource_viewer_role.yaml b/config/rbac/datadoghq_datadoggenericresource_viewer_role.yaml new file mode 100644 index 000000000..24f6d41d1 --- /dev/null +++ b/config/rbac/datadoghq_datadoggenericresource_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit datadoggenericresources. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: datadog-genericresource-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: datadog-operator + app.kubernetes.io/part-of: datadog-operator + app.kubernetes.io/managed-by: kustomize + name: datadoggenericresource-viewer-role +rules: +- apiGroups: + - datadoghq.com + resources: + - datadoggenericresources + verbs: + - get + - list + - watch +- apiGroups: + - datadoghq.com + resources: + - datadoggenericresources/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1187eb280..3f76d9a56 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -234,6 +234,8 @@ rules: - datadogagents - datadogagents/finalizers - datadogdashboards + - datadoggenericresources + - datadoggenericresources/finalizers - datadogmonitors - datadogmonitors/finalizers - datadogslos @@ -253,6 +255,7 @@ rules: - datadogagentprofiles/status - datadogagents/status - datadogdashboards/status + - datadoggenericresources/status - datadogmonitors/status - datadogslos/status verbs: diff --git a/config/samples/datadoghq_v1alpha1_datadoggenericresource.yaml b/config/samples/datadoghq_v1alpha1_datadoggenericresource.yaml new file mode 100644 index 000000000..0576f1d59 --- /dev/null +++ b/config/samples/datadoghq_v1alpha1_datadoggenericresource.yaml @@ -0,0 +1,59 @@ +apiVersion: datadoghq.com/v1alpha1 +kind: DatadogGenericResource +metadata: + name: datadoggenericresource-notebook-sample +spec: + type: notebook + jsonSpec: |- + { + "data": { + "attributes": { + "cells": [ + { + "attributes": { + "definition": { + "text": "## Some test markdown\n\n```js\nvar x, y;\nx = 5;\ny = 6;\n```", + "type": "markdown" + } + }, + "type": "notebook_cells" + }, + { + "attributes": { + "definition": { + "requests": [ + { + "display_type": "line", + "q": "avg:system.load.1{*}", + "style": { + "line_type": "solid", + "line_width": "normal", + "palette": "dog_classic" + } + } + ], + "show_legend": true, + "type": "timeseries", + "yaxis": { + "scale": "linear" + } + }, + "graph_size": "m", + "split_by": { + "keys": [], + "tags": [] + }, + "time": null + }, + "type": "notebook_cells" + } + ], + "name": "Example-Notebook", + "status": "published", + "time": { + "live_span": "1h" + } + }, + "type": "notebooks" + } + } \ No newline at end of file diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 1ef325729..240e4c139 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -7,5 +7,6 @@ resources: - datadoghq_v1alpha1_datadogslo.yaml - datadoghq_v1alpha1_datadogpodautoscaler.yaml - datadoghq_v1alpha1_datadogdashboard.yaml +- datadoghq_v1alpha1_datadoggenericresource.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization diff --git a/internal/controller/datadoggenericresource/controller.go b/internal/controller/datadoggenericresource/controller.go new file mode 100644 index 000000000..dde6fa0af --- /dev/null +++ b/internal/controller/datadoggenericresource/controller.go @@ -0,0 +1,221 @@ +package datadoggenericresource + +import ( + "context" + "strings" + "time" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/DataDog/datadog-operator/pkg/controller/utils/comparison" + "github.com/DataDog/datadog-operator/pkg/controller/utils/condition" + "github.com/DataDog/datadog-operator/pkg/controller/utils/datadog" + "github.com/DataDog/datadog-operator/pkg/datadogclient" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/DataDog/datadog-operator/internal/controller/utils" + ctrutils "github.com/DataDog/datadog-operator/pkg/controller/utils" +) + +const ( + defaultRequeuePeriod = 60 * time.Second + defaultErrRequeuePeriod = 5 * time.Second + defaultForceSyncPeriod = 60 * time.Minute + datadogGenericResourceKind = "DatadogGenericResource" +) + +type Reconciler struct { + client client.Client + datadogSyntheticsClient *datadogV1.SyntheticsApi + datadogNotebooksClient *datadogV1.NotebooksApi + // TODO: add other clients + datadogAuth context.Context + scheme *runtime.Scheme + log logr.Logger + recorder record.EventRecorder +} + +func NewReconciler(client client.Client, ddClient datadogclient.DatadogGenericClient, scheme *runtime.Scheme, log logr.Logger, recorder record.EventRecorder) *Reconciler { + return &Reconciler{ + client: client, + datadogSyntheticsClient: ddClient.SyntheticsClient, + datadogNotebooksClient: ddClient.NotebooksClient, + // TODO: add other clients + // datadogOtherClient: ddClient.OtherClient, + datadogAuth: ddClient.Auth, + scheme: scheme, + log: log, + recorder: recorder, + } +} + +func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + return r.internalReconcile(ctx, request) +} + +func (r *Reconciler) internalReconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := r.log.WithValues("datadoggenericresource", req.NamespacedName) + logger.Info("Reconciling Datadog Generic Resource") + now := metav1.NewTime(time.Now()) + + instance := &v1alpha1.DatadogGenericResource{} + var result ctrl.Result + var err error + + if err = r.client.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, instance); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, err + } + + if result, err = r.handleFinalizer(logger, instance); ctrutils.ShouldReturn(result, err) { + return result, err + } + + status := instance.Status.DeepCopy() + statusSpecHash := instance.Status.CurrentHash + + if err = v1alpha1.IsValidDatadogGenericResource(&instance.Spec); err != nil { + logger.Error(err, "invalid DatadogGenericResource") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusValidateError, "ValidatingGenericResource", err) + return r.updateStatusIfNeeded(logger, instance, status, result) + } + + instanceSpecHash, err := comparison.GenerateMD5ForSpec(&instance.Spec) + + if err != nil { + logger.Error(err, "error generating hash") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusUpdateError, "GeneratingGenericResourceSpecHash", err) + return r.updateStatusIfNeeded(logger, instance, status, result) + } + + shouldCreate := false + shouldUpdate := false + + if instance.Status.Id == "" { + shouldCreate = true + } else { + if instanceSpecHash != statusSpecHash { + logger.Info("DatadogGenericResource manifest has changed") + shouldUpdate = true + } else if instance.Status.LastForceSyncTime == nil || ((defaultForceSyncPeriod - now.Sub(instance.Status.LastForceSyncTime.Time)) <= 0) { + // Periodically force a sync with the API to ensure parity + // Make sure it exists before trying any updates. If it doesn't, set shouldCreate + err = r.get(instance) + if err != nil { + logger.Error(err, "error getting custom resource", "custom resource Id", instance.Status.Id, "resource type", instance.Spec.Type) + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusGetError, "GettingCustomResource", err) + if strings.Contains(err.Error(), ctrutils.NotFoundString) { + shouldCreate = true + } + } else { + shouldUpdate = true + } + status.LastForceSyncTime = &now + } + } + + if shouldCreate || shouldUpdate { + + if shouldCreate { + err = r.create(logger, instance, status, now, instanceSpecHash) + } else if shouldUpdate { + err = r.update(logger, instance, status, now, instanceSpecHash) + } + + if err != nil { + result.RequeueAfter = defaultErrRequeuePeriod + } + } + + // If reconcile was successful and uneventful, requeue with period defaultRequeuePeriod + if !result.Requeue && result.RequeueAfter == 0 { + result.RequeueAfter = defaultRequeuePeriod + } + + return r.updateStatusIfNeeded(logger, instance, status, result) +} + +func (r *Reconciler) get(instance *v1alpha1.DatadogGenericResource) error { + return apiGet(r, instance) +} + +func (r *Reconciler) update(logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + err := apiUpdate(r, instance) + if err != nil { + logger.Error(err, "error updating generic resource", "generic resource Id", instance.Status.Id) + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusUpdateError, "UpdatingGenericResource", err) + return err + } + + event := buildEventInfo(instance.Name, instance.Namespace, datadog.UpdateEvent) + r.recordEvent(instance, event) + + // Set condition and status + condition.UpdateStatusConditions(&status.Conditions, now, condition.DatadogConditionTypeUpdated, metav1.ConditionTrue, "UpdatingGenericResource", "DatadogGenericResource Update") + status.SyncStatus = v1alpha1.DatadogSyncStatusOK + status.CurrentHash = hash + status.LastForceSyncTime = &now + + logger.Info("Updated Datadog Generic Resource", "Generic Resource Id", instance.Status.Id) + return nil +} + +func (r *Reconciler) create(logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + logger.V(1).Info("Generic resource Id is not set; creating resource in Datadog") + + err := apiCreateAndUpdateStatus(r, logger, instance, status, now, hash) + if err != nil { + return err + } + event := buildEventInfo(instance.Name, instance.Namespace, datadog.CreationEvent) + r.recordEvent(instance, event) + + // Set condition and status + condition.UpdateStatusConditions(&status.Conditions, now, condition.DatadogConditionTypeCreated, metav1.ConditionTrue, "CreatingGenericResource", "DatadogGenericResource Created") + logger.Info("created a new DatadogGenericResource", "generic resource Id", status.Id) + + return nil +} + +func updateErrStatus(status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, syncStatus v1alpha1.DatadogSyncStatus, reason string, err error) { + condition.UpdateFailureStatusConditions(&status.Conditions, now, condition.DatadogConditionTypeError, reason, err) + status.SyncStatus = syncStatus +} + +func (r *Reconciler) updateStatusIfNeeded(logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, result ctrl.Result) (ctrl.Result, error) { + if !apiequality.Semantic.DeepEqual(&instance.Status, status) { + instance.Status = *status + if err := r.client.Status().Update(context.TODO(), instance); err != nil { + if apierrors.IsConflict(err) { + logger.Error(err, "unable to update DatadogGenericResource status due to update conflict") + return ctrl.Result{Requeue: true, RequeueAfter: defaultErrRequeuePeriod}, nil + } + logger.Error(err, "unable to update DatadogGenericResource status") + return ctrl.Result{Requeue: true, RequeueAfter: defaultRequeuePeriod}, err + } + } + return result, nil +} + +// buildEventInfo creates a new EventInfo instance. +func buildEventInfo(name, ns string, eventType datadog.EventType) utils.EventInfo { + return utils.BuildEventInfo(name, ns, datadogGenericResourceKind, eventType) +} + +// recordEvent wraps the manager event recorder +func (r *Reconciler) recordEvent(genericresource runtime.Object, info utils.EventInfo) { + r.recorder.Event(genericresource, corev1.EventTypeNormal, info.GetReason(), info.GetMessage()) +} diff --git a/internal/controller/datadoggenericresource/controller_test.go b/internal/controller/datadoggenericresource/controller_test.go new file mode 100644 index 000000000..62f4a6cd0 --- /dev/null +++ b/internal/controller/datadoggenericresource/controller_test.go @@ -0,0 +1,264 @@ +package datadoggenericresource + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/kubectl/pkg/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + datadogapi "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + datadoghqv1alpha1 "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/DataDog/datadog-operator/pkg/controller/utils/comparison" + "github.com/stretchr/testify/assert" +) + +const ( + resourcesName = "foo" + resourcesNamespace = "bar" +) + +func TestReconcileGenericResource_Reconcile(t *testing.T) { + eventBroadcaster := record.NewBroadcaster() + recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "TestReconcileGenericResource_Reconcile"}) + + logf.SetLogger(zap.New(zap.UseDevMode(true))) + + s := scheme.Scheme + s.AddKnownTypes(datadoghqv1alpha1.GroupVersion, &datadoghqv1alpha1.DatadogGenericResource{}) + + type args struct { + request reconcile.Request + firstAction func(c client.Client) + firstReconcileCount int + secondAction func(c client.Client) + secondReconcileCount int + } + + tests := []struct { + name string + args args + wantResult reconcile.Result + wantErr bool + wantFunc func(c client.Client) error + }{ + { + name: "DatadogGenericResource not created", + args: args{ + request: newRequest(resourcesNamespace, resourcesName), + }, + wantResult: reconcile.Result{}, + }, + { + name: "DatadogGenericResource created, add finalizer", + args: args{ + request: newRequest(resourcesNamespace, resourcesName), + firstAction: func(c client.Client) { + _ = c.Create(context.TODO(), mockGenericResource()) + }, + }, + wantResult: reconcile.Result{Requeue: true}, + wantFunc: func(c client.Client) error { + obj := &datadoghqv1alpha1.DatadogGenericResource{} + if err := c.Get(context.TODO(), types.NamespacedName{Name: resourcesName, Namespace: resourcesNamespace}, obj); err != nil { + return err + } + assert.Contains(t, obj.GetFinalizers(), "finalizer.datadoghq.com/genericresource") + return nil + }, + }, + { + name: "DatadogGenericResource exists, needs update", + args: args{ + request: newRequest(resourcesNamespace, resourcesName), + firstAction: func(c client.Client) { + _ = c.Create(context.TODO(), mockGenericResource()) + }, + firstReconcileCount: 2, + secondAction: func(c client.Client) { + _ = c.Update(context.TODO(), &datadoghqv1alpha1.DatadogGenericResource{ + TypeMeta: metav1.TypeMeta{ + Kind: "DatadogGenericResource", + APIVersion: fmt.Sprintf("%s/%s", datadoghqv1alpha1.GroupVersion.Group, datadoghqv1alpha1.GroupVersion.Version), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: resourcesNamespace, + Name: resourcesName, + }, + Spec: datadoghqv1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + JsonSpec: "{\"bar\": \"baz\"}", + }, + }) + }, + secondReconcileCount: 2, + }, + wantResult: reconcile.Result{RequeueAfter: defaultRequeuePeriod}, + wantFunc: func(c client.Client) error { + obj := &datadoghqv1alpha1.DatadogGenericResource{} + if err := c.Get(context.TODO(), types.NamespacedName{Name: resourcesName, Namespace: resourcesNamespace}, obj); err != nil { + return err + } + // Make sure status hash is up to date + hash, _ := comparison.GenerateMD5ForSpec(obj.Spec) + assert.Equal(t, obj.Status.CurrentHash, hash) + return nil + }, + }, + { + name: "DatadogGenericResource exists, needs delete", + args: args{ + request: newRequest(resourcesNamespace, resourcesName), + firstAction: func(c client.Client) { + err := c.Create(context.TODO(), mockGenericResource()) + assert.NoError(t, err) + }, + firstReconcileCount: 2, + secondAction: func(c client.Client) { + err := c.Delete(context.TODO(), mockGenericResource()) + assert.NoError(t, err) + }, + }, + wantResult: reconcile.Result{RequeueAfter: defaultRequeuePeriod}, + wantErr: true, + wantFunc: func(c client.Client) error { + obj := &datadoghqv1alpha1.DatadogGenericResource{} + if err := c.Get(context.TODO(), types.NamespacedName{Name: resourcesName, Namespace: resourcesNamespace}, obj); err != nil { + return err + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + })) + defer httpServer.Close() + + testConfig := datadogapi.NewConfiguration() + testConfig.HTTPClient = httpServer.Client() + apiClient := datadogapi.NewAPIClient(testConfig) + synthClient := datadogV1.NewSyntheticsApi(apiClient) + nbClient := datadogV1.NewNotebooksApi(apiClient) + + testAuth := setupTestAuth(httpServer.URL) + + // Set up + r := &Reconciler{ + client: fake.NewClientBuilder().WithScheme(s).WithStatusSubresource(&datadoghqv1alpha1.DatadogGenericResource{}).Build(), + datadogSyntheticsClient: synthClient, + datadogNotebooksClient: nbClient, + datadogAuth: testAuth, + scheme: s, + recorder: recorder, + log: logf.Log.WithName(tt.name), + } + + // First action + if tt.args.firstAction != nil { + tt.args.firstAction(r.client) + // Make sure there's minimum 1 reconcile loop + if tt.args.firstReconcileCount == 0 { + tt.args.firstReconcileCount = 1 + } + } + var result ctrl.Result + var err error + for i := 0; i < tt.args.firstReconcileCount; i++ { + result, err = r.Reconcile(context.TODO(), tt.args.request) + } + + assert.NoError(t, err, "unexpected error: %v", err) + assert.Equal(t, tt.wantResult, result, "unexpected result") + + // Second action + if tt.args.secondAction != nil { + tt.args.secondAction(r.client) + // Make sure there's minimum 1 reconcile loop + if tt.args.secondReconcileCount == 0 { + tt.args.secondReconcileCount = 1 + } + } + for i := 0; i < tt.args.secondReconcileCount; i++ { + _, err := r.Reconcile(context.TODO(), tt.args.request) + assert.NoError(t, err, "unexpected error: %v", err) + } + + if tt.wantFunc != nil { + err := tt.wantFunc(r.client) + if tt.wantErr { + assert.Error(t, err, "expected an error") + } else { + assert.NoError(t, err, "wantFunc validation error: %v", err) + } + } + }) + + } +} + +func newRequest(ns, name string) reconcile.Request { + return reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: name, + }, + } +} + +func mockGenericResource() *datadoghqv1alpha1.DatadogGenericResource { + return &datadoghqv1alpha1.DatadogGenericResource{ + TypeMeta: metav1.TypeMeta{ + Kind: "DatadogGenericResource", + APIVersion: fmt.Sprintf("%s/%s", datadoghqv1alpha1.GroupVersion.Group, datadoghqv1alpha1.GroupVersion.Version), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: resourcesNamespace, + Name: resourcesName, + }, + Spec: datadoghqv1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + JsonSpec: "{\"foo\": \"bar\"}", + }, + } +} + +func setupTestAuth(apiURL string) context.Context { + testAuth := context.WithValue( + context.Background(), + datadogapi.ContextAPIKeys, + map[string]datadogapi.APIKey{ + "apiKeyAuth": { + Key: "DUMMY_API_KEY", + }, + "appKeyAuth": { + Key: "DUMMY_APP_KEY", + }, + }, + ) + parsedAPIURL, _ := url.Parse(apiURL) + testAuth = context.WithValue(testAuth, datadogapi.ContextServerIndex, 1) + testAuth = context.WithValue(testAuth, datadogapi.ContextServerVariables, map[string]string{ + "name": parsedAPIURL.Host, + "protocol": parsedAPIURL.Scheme, + }) + + return testAuth +} diff --git a/internal/controller/datadoggenericresource/finalizer.go b/internal/controller/datadoggenericresource/finalizer.go new file mode 100644 index 000000000..9a22f2496 --- /dev/null +++ b/internal/controller/datadoggenericresource/finalizer.go @@ -0,0 +1,78 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package datadoggenericresource + +import ( + "context" + "fmt" + + datadoghqv1alpha1 "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/DataDog/datadog-operator/pkg/controller/utils" + "github.com/DataDog/datadog-operator/pkg/controller/utils/datadog" +) + +const ( + datadogGenericResourceFinalizer = "finalizer.datadoghq.com/genericresource" +) + +func (r *Reconciler) handleFinalizer(logger logr.Logger, instance *datadoghqv1alpha1.DatadogGenericResource) (ctrl.Result, error) { + // Check if the DatadogGenericResource instance is marked to be deleted, which is indicated by the deletion timestamp being set. + if instance.GetDeletionTimestamp() != nil { + if utils.ContainsString(instance.GetFinalizers(), datadogGenericResourceFinalizer) { + r.finalizeDatadogCustomResource(logger, instance) + + instance.SetFinalizers(utils.RemoveString(instance.GetFinalizers(), datadogGenericResourceFinalizer)) + err := r.client.Update(context.TODO(), instance) + if err != nil { + return ctrl.Result{RequeueAfter: defaultErrRequeuePeriod}, err + } + } + + // Requeue until the object is properly deleted by Kubernetes + return ctrl.Result{RequeueAfter: defaultRequeuePeriod}, nil + } + + // Add finalizer for this resource if it doesn't already exist. + if !utils.ContainsString(instance.GetFinalizers(), datadogGenericResourceFinalizer) { + if err := r.addFinalizer(logger, instance); err != nil { + return ctrl.Result{RequeueAfter: defaultErrRequeuePeriod}, err + } + + return ctrl.Result{Requeue: true}, nil + } + + // Proceed in reconcile loop. + return ctrl.Result{}, nil +} + +func (r *Reconciler) finalizeDatadogCustomResource(logger logr.Logger, instance *datadoghqv1alpha1.DatadogGenericResource) { + err := apiDelete(r, instance) + if err != nil { + logger.Error(err, "failed to finalize ", "custom resource Id", fmt.Sprint(instance.Status.Id)) + + return + } + logger.Info("Successfully finalized DatadogGenericResource", "custom resource Id", fmt.Sprint(instance.Status.Id)) + event := buildEventInfo(instance.Name, instance.Namespace, datadog.DeletionEvent) + r.recordEvent(instance, event) +} + +func (r *Reconciler) addFinalizer(logger logr.Logger, instance *datadoghqv1alpha1.DatadogGenericResource) error { + logger.Info("Adding Finalizer for the DatadogGenericResource") + + instance.SetFinalizers(append(instance.GetFinalizers(), datadogGenericResourceFinalizer)) + + err := r.client.Update(context.TODO(), instance) + if err != nil { + logger.Error(err, "failed to update DatadogGenericResource with finalizer", "custom resource Id", fmt.Sprint(instance.Status.Id)) + return err + } + + return nil +} diff --git a/internal/controller/datadoggenericresource/finalizer_test.go b/internal/controller/datadoggenericresource/finalizer_test.go new file mode 100644 index 000000000..37760a592 --- /dev/null +++ b/internal/controller/datadoggenericresource/finalizer_test.go @@ -0,0 +1,109 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package datadoggenericresource + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + + datadoghqv1alpha1 "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + ctrl "sigs.k8s.io/controller-runtime" +) + +const ( + genericResourceKind = "DatadogGenericResource" + testNamespace = "foo" +) + +var ( + testMgr, _ = ctrl.NewManager(&rest.Config{}, manager.Options{}) + testLogger logr.Logger = zap.New(zap.UseDevMode(true)) +) + +func Test_handleFinalizer(t *testing.T) { + s := scheme.Scheme + s.AddKnownTypes(datadoghqv1alpha1.GroupVersion, &datadoghqv1alpha1.DatadogGenericResource{}) + metaNow := metav1.NewTime(time.Now()) + + r := &Reconciler{ + client: fake.NewClientBuilder(). + WithRuntimeObjects( + &datadoghqv1alpha1.DatadogGenericResource{ + TypeMeta: metav1.TypeMeta{ + Kind: genericResourceKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "genericresource-create", + Namespace: testNamespace, + }, + }, + // Fake client preventes deletion timestamp from being set, so we init the store with an object that has: + // - deletion timestamp (added by running kubectl delete) + // - finalizer (added by the reconciler at creation time (see first test case)) + // Ref: https://github.com/kubernetes-sigs/controller-runtime/commit/7a66d580c0c53504f5b509b45e9300cc18a1cc30#diff-20ecedbf30721c01c33fb67d911da11c277e29990497a600d20cb0ec7215affdR683-R686 + &datadoghqv1alpha1.DatadogGenericResource{ + TypeMeta: metav1.TypeMeta{ + Kind: genericResourceKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "genericresource-delete", + Namespace: testNamespace, + Finalizers: []string{datadogGenericResourceFinalizer}, + DeletionTimestamp: &metaNow, + }, + }, + ). + WithStatusSubresource(&datadoghqv1alpha1.DatadogGenericResource{}).Build(), + scheme: s, + log: testLogger, + recorder: testMgr.GetEventRecorderFor(genericResourceKind), + } + + tests := []struct { + testName string + resourceName string + finalizerShouldExist bool + }{ + { + testName: "a new DatadogGenericResource object gets a finalizer added successfully", + resourceName: "genericresource-create", + finalizerShouldExist: true, + }, + { + testName: "a DatadogGenericResource object (with the finalizer) has a deletion timestamp", + resourceName: "genericresource-delete", + finalizerShouldExist: false, + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + reqLogger := testLogger.WithValues("test:", test.testName) + testGcr := &datadoghqv1alpha1.DatadogGenericResource{} + err := r.client.Get(context.TODO(), client.ObjectKey{Name: test.resourceName, Namespace: testNamespace}, testGcr) + + _, err = r.handleFinalizer(reqLogger, testGcr) + + assert.NoError(t, err) + if test.finalizerShouldExist { + assert.Contains(t, testGcr.GetFinalizers(), datadogGenericResourceFinalizer) + } else { + assert.NotContains(t, testGcr.GetFinalizers(), datadogGenericResourceFinalizer) + } + }) + } + +} diff --git a/internal/controller/datadoggenericresource/notebooks.go b/internal/controller/datadoggenericresource/notebooks.go new file mode 100644 index 000000000..3e2999708 --- /dev/null +++ b/internal/controller/datadoggenericresource/notebooks.go @@ -0,0 +1,71 @@ +package datadoggenericresource + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" +) + +func getNotebook(auth context.Context, client *datadogV1.NotebooksApi, notebookStringID string) (datadogV1.NotebookResponse, error) { + notebookID, err := notebookStringToInt64(notebookStringID) + if err != nil { + return datadogV1.NotebookResponse{}, err + } + notebook, _, err := client.GetNotebook(auth, notebookID) + if err != nil { + return datadogV1.NotebookResponse{}, translateClientError(err, "error getting notebook") + } + return notebook, nil +} + +func deleteNotebook(auth context.Context, client *datadogV1.NotebooksApi, notebookStringID string) error { + notebookID, err := notebookStringToInt64(notebookStringID) + if err != nil { + return err + } + if _, err := client.DeleteNotebook(auth, notebookID); err != nil { + return translateClientError(err, "error deleting notebook") + } + return nil +} + +func createNotebook(auth context.Context, client *datadogV1.NotebooksApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.NotebookResponse, error) { + notebookCreateData := &datadogV1.NotebookCreateRequest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), notebookCreateData) + notebook, _, err := client.CreateNotebook(auth, *notebookCreateData) + if err != nil { + return datadogV1.NotebookResponse{}, translateClientError(err, "error creating notebook") + } + return notebook, nil +} + +func updateNotebook(auth context.Context, client *datadogV1.NotebooksApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.NotebookResponse, error) { + notebookUpdateData := &datadogV1.NotebookUpdateRequest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), notebookUpdateData) + notebookID, err := notebookStringToInt64(instance.Status.Id) + if err != nil { + return datadogV1.NotebookResponse{}, err + } + notebookUpdated, _, err := client.UpdateNotebook(auth, notebookID, *notebookUpdateData) + if err != nil { + return datadogV1.NotebookResponse{}, translateClientError(err, "error updating browser test") + } + return notebookUpdated, nil +} + +func notebookStringToInt64(notebookStringID string) (int64, error) { + notebookID, err := strconv.ParseInt(notebookStringID, 10, 64) + if err != nil { + return 0, fmt.Errorf("error parsing notebook Id: %w", err) + } + return notebookID, nil +} + +func notebookInt64ToString(notebookID int64) string { + return strconv.FormatInt(notebookID, 10) +} diff --git a/internal/controller/datadoggenericresource/notebooks_test.go b/internal/controller/datadoggenericresource/notebooks_test.go new file mode 100644 index 000000000..a434bb369 --- /dev/null +++ b/internal/controller/datadoggenericresource/notebooks_test.go @@ -0,0 +1,27 @@ +package datadoggenericresource + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_notebookStringToInt64(t *testing.T) { + notebookStringID := "123" + expectedNotebookID := int64(123) + notebookID, err := notebookStringToInt64(notebookStringID) + assert.NoError(t, err) + assert.Equal(t, expectedNotebookID, notebookID) + + // Invalid notebook ID + notebookStringID = "invalid" + notebookID, err = notebookStringToInt64(notebookStringID) + assert.EqualError(t, err, "error parsing notebook Id: strconv.ParseInt: parsing \"invalid\": invalid syntax") +} + +func Test_notebookInt64ToString(t *testing.T) { + notebookID := int64(123) + expectedNotebookStringID := "123" + notebookStringID := notebookInt64ToString(notebookID) + assert.Equal(t, expectedNotebookStringID, notebookStringID) +} diff --git a/internal/controller/datadoggenericresource/synthetics.go b/internal/controller/datadoggenericresource/synthetics.go new file mode 100644 index 000000000..f5d1811f8 --- /dev/null +++ b/internal/controller/datadoggenericresource/synthetics.go @@ -0,0 +1,76 @@ +package datadoggenericresource + +import ( + "context" + "encoding/json" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" +) + +// Synthetic tests (encompass browser and API tests): get +func getSyntheticsTest(auth context.Context, client *datadogV1.SyntheticsApi, testID string) (datadogV1.SyntheticsTestDetails, error) { + test, _, err := client.GetTest(auth, testID) + if err != nil { + return datadogV1.SyntheticsTestDetails{}, translateClientError(err, "error getting synthetic test") + } + return test, nil +} + +// Synthetic tests (encompass browser and API tests): delete +func deleteSyntheticTest(auth context.Context, client *datadogV1.SyntheticsApi, ID string) error { + body := datadogV1.SyntheticsDeleteTestsPayload{ + PublicIds: []string{ + ID, + }, + } + if _, _, err := client.DeleteTests(auth, body); err != nil { + return translateClientError(err, "error deleting synthetic test") + } + return nil +} + +// Browser test: create +func createSyntheticBrowserTest(auth context.Context, client *datadogV1.SyntheticsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.SyntheticsBrowserTest, error) { + browserTestBody := &datadogV1.SyntheticsBrowserTest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), browserTestBody) + test, _, err := client.CreateSyntheticsBrowserTest(auth, *browserTestBody) + if err != nil { + return datadogV1.SyntheticsBrowserTest{}, translateClientError(err, "error creating browser test") + } + return test, nil +} + +// Browser test: update +func updateSyntheticsBrowserTest(auth context.Context, client *datadogV1.SyntheticsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.SyntheticsBrowserTest, error) { + browserTestBody := &datadogV1.SyntheticsBrowserTest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), browserTestBody) + testUpdated, _, err := client.UpdateBrowserTest(auth, instance.Status.Id, *browserTestBody) + if err != nil { + return datadogV1.SyntheticsBrowserTest{}, translateClientError(err, "error updating browser test") + } + return testUpdated, nil +} + +// API test: create +func createSyntheticsAPITest(auth context.Context, client *datadogV1.SyntheticsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.SyntheticsAPITest, error) { + apiTestBody := &datadogV1.SyntheticsAPITest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), apiTestBody) + test, _, err := client.CreateSyntheticsAPITest(auth, *apiTestBody) + if err != nil { + return datadogV1.SyntheticsAPITest{}, translateClientError(err, "error creating API test") + } + return test, nil +} + +// API test: update +func updateSyntheticsAPITest(auth context.Context, client *datadogV1.SyntheticsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.SyntheticsAPITest, error) { + apiTestBody := &datadogV1.SyntheticsAPITest{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), apiTestBody) + testUpdated, _, err := client.UpdateAPITest(auth, instance.Status.Id, *apiTestBody) + if err != nil { + return datadogV1.SyntheticsAPITest{}, translateClientError(err, "error updating API test") + } + return testUpdated, nil +} diff --git a/internal/controller/datadoggenericresource/utils.go b/internal/controller/datadoggenericresource/utils.go new file mode 100644 index 000000000..0c52f60d1 --- /dev/null +++ b/internal/controller/datadoggenericresource/utils.go @@ -0,0 +1,225 @@ +package datadoggenericresource + +import ( + "errors" + "fmt" + "net/url" + "time" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/go-logr/logr" + + datadogapi "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type operation string + +const ( + // mockSubresource is used to mock the subresource in tests + mockSubresource = "mock_resource" + operationDelete operation = "delete" + operationGet operation = "get" + operationUpdate operation = "update" +) + +type apiHandlerKey struct { + resourceType v1alpha1.SupportedResourcesType + op operation +} + +// Delete, Get and Update operations share the same signature +type apiHandlerFunc func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error + +var apiHandlers = map[apiHandlerKey]apiHandlerFunc{ + {v1alpha1.SyntheticsBrowserTest, operationGet}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := getSyntheticsTest(r.datadogAuth, r.datadogSyntheticsClient, instance.Status.Id) + return err + }, + {v1alpha1.SyntheticsBrowserTest, operationUpdate}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := updateSyntheticsBrowserTest(r.datadogAuth, r.datadogSyntheticsClient, instance) + return err + }, + {v1alpha1.SyntheticsBrowserTest, operationDelete}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return deleteSyntheticTest(r.datadogAuth, r.datadogSyntheticsClient, instance.Status.Id) + }, + {v1alpha1.SyntheticsAPITest, operationGet}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := getSyntheticsTest(r.datadogAuth, r.datadogSyntheticsClient, instance.Status.Id) + return err + }, + {v1alpha1.SyntheticsAPITest, operationUpdate}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := updateSyntheticsAPITest(r.datadogAuth, r.datadogSyntheticsClient, instance) + return err + }, + {v1alpha1.SyntheticsAPITest, operationDelete}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return deleteSyntheticTest(r.datadogAuth, r.datadogSyntheticsClient, instance.Status.Id) + }, + {v1alpha1.Notebook, operationGet}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := getNotebook(r.datadogAuth, r.datadogNotebooksClient, instance.Status.Id) + return err + }, + {v1alpha1.Notebook, operationUpdate}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := updateNotebook(r.datadogAuth, r.datadogNotebooksClient, instance) + return err + }, + {v1alpha1.Notebook, operationDelete}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return deleteNotebook(r.datadogAuth, r.datadogNotebooksClient, instance.Status.Id) + }, + {mockSubresource, operationGet}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return nil + }, + {mockSubresource, operationUpdate}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return nil + }, + {mockSubresource, operationDelete}: func(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return nil + }, +} + +// Common handler executor (delete, get and update) +func executeHandler(operation operation, r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + key := apiHandlerKey{resourceType: instance.Spec.Type, op: operation} + if handler, found := apiHandlers[key]; found { + return handler(r, instance) + } + return unsupportedInstanceType(instance) +} + +// Create is handled separately due to the dynamic signature and need to extract/update status based on the returned struct +type createHandlerFunc func(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error + +var createHandlers = map[v1alpha1.SupportedResourcesType]createHandlerFunc{ + v1alpha1.SyntheticsBrowserTest: func(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + createdTest, err := createSyntheticBrowserTest(r.datadogAuth, r.datadogSyntheticsClient, instance) + if err != nil { + logger.Error(err, "error creating browser test") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusCreateError, "CreatingCustomResource", err) + return err + } + additionalProperties := createdTest.AdditionalProperties + return updateStatusFromSyntheticsTest(&createdTest, additionalProperties, status, logger, hash) + }, + v1alpha1.SyntheticsAPITest: func(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + createdTest, err := createSyntheticsAPITest(r.datadogAuth, r.datadogSyntheticsClient, instance) + if err != nil { + logger.Error(err, "error creating API test") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusCreateError, "CreatingCustomResource", err) + return err + } + additionalProperties := createdTest.AdditionalProperties + return updateStatusFromSyntheticsTest(&createdTest, additionalProperties, status, logger, hash) + }, + v1alpha1.Notebook: func(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + createdNotebook, err := createNotebook(r.datadogAuth, r.datadogNotebooksClient, instance) + if err != nil { + logger.Error(err, "error creating notebook") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusCreateError, "CreatingCustomResource", err) + return err + } + logger.Info("created a new notebook", "notebook Id", createdNotebook.Data.GetId()) + status.Id = notebookInt64ToString(createdNotebook.Data.GetId()) + createdTime := metav1.NewTime(*createdNotebook.Data.GetAttributes().Created) + status.Created = &createdTime + status.LastForceSyncTime = &createdTime + status.Creator = *createdNotebook.Data.GetAttributes().Author.Handle + status.SyncStatus = v1alpha1.DatadogSyncStatusOK + status.CurrentHash = hash + return nil + }, + mockSubresource: func(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + status.Id = "mock-id" + status.Created = &now + status.LastForceSyncTime = &now + status.Creator = "mock-creator" + status.SyncStatus = v1alpha1.DatadogSyncStatusOK + status.CurrentHash = hash + return nil + }, +} + +func executeCreateHandler(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + if handler, found := createHandlers[instance.Spec.Type]; found { + return handler(r, logger, instance, status, now, hash) + } + return unsupportedInstanceType(instance) +} + +func translateClientError(err error, msg string) error { + if msg == "" { + msg = "an error occurred" + } + + var apiErr datadogapi.GenericOpenAPIError + var errURL *url.Error + if errors.As(err, &apiErr) { + return fmt.Errorf(msg+": %w: %s", err, apiErr.Body()) + } + + if errors.As(err, &errURL) { + return fmt.Errorf(msg+" (url.Error): %s", errURL) + } + + return fmt.Errorf(msg+": %w", err) +} + +func unsupportedInstanceType(instance *v1alpha1.DatadogGenericResource) error { + return fmt.Errorf("unsupported type: %s", instance.Spec.Type) +} + +func apiDelete(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return executeHandler(operationDelete, r, instance) +} + +func apiGet(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return executeHandler(operationGet, r, instance) +} + +func apiUpdate(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return executeHandler(operationUpdate, r, instance) +} + +func apiCreateAndUpdateStatus(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + return executeCreateHandler(r, logger, instance, status, now, hash) +} + +func updateStatusFromSyntheticsTest(createdTest interface{ GetPublicId() string }, additionalProperties map[string]interface{}, status *v1alpha1.DatadogGenericResourceStatus, logger logr.Logger, hash string) error { + // All synthetic test types share this method + status.Id = createdTest.GetPublicId() + + // Parse Created Time + createdTimeString, ok := additionalProperties["created_at"].(string) + if !ok { + logger.Error(nil, "missing or invalid created_at field, using current time") + createdTimeString = time.Now().Format(time.RFC3339) + } + + createdTimeParsed, err := time.Parse(time.RFC3339, createdTimeString) + if err != nil { + logger.Error(err, "error parsing created time, using current time") + createdTimeParsed = time.Now() + } + createdTime := metav1.NewTime(createdTimeParsed) + + // Update status fields + status.Created = &createdTime + status.LastForceSyncTime = &createdTime + + // Update Creator + if createdBy, ok := additionalProperties["created_by"].(map[string]interface{}); ok { + if handle, ok := createdBy["handle"].(string); ok { + status.Creator = handle + } else { + logger.Error(nil, "missing handle field in created_by") + status.Creator = "" + } + } else { + logger.Error(nil, "missing or invalid created_by field") + status.Creator = "" + } + + // Update Sync Status and Hash + status.SyncStatus = v1alpha1.DatadogSyncStatusOK + status.CurrentHash = hash + + return nil +} diff --git a/internal/controller/datadoggenericresource/utils_test.go b/internal/controller/datadoggenericresource/utils_test.go new file mode 100644 index 000000000..38f7aa133 --- /dev/null +++ b/internal/controller/datadoggenericresource/utils_test.go @@ -0,0 +1,254 @@ +package datadoggenericresource + +import ( + "errors" + "fmt" + "net/url" + "testing" + "time" + + datadogapi "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Test_executeHandler(t *testing.T) { + mockReconciler := &Reconciler{} + instance := &v1alpha1.DatadogGenericResource{ + Spec: v1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + }, + } + + // Valid operation and subresource case + err := executeHandler(operationGet, mockReconciler, instance) + assert.NoError(t, err) + + // Valid operation and invalid subresource case + instance.Spec.Type = "unsupportedType" + err = executeHandler(operationGet, mockReconciler, instance) + assert.EqualError(t, err, "unsupported type: unsupportedType") +} + +func Test_executeCreateHandler(t *testing.T) { + mockReconciler := &Reconciler{} + logger := &logr.Logger{} + instance := &v1alpha1.DatadogGenericResource{ + Spec: v1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + }, + } + status := &v1alpha1.DatadogGenericResourceStatus{} + + // Valid subresource case + err := executeCreateHandler(mockReconciler, *logger, instance, status, metav1.Now(), "test-hash") + assert.NoError(t, err) + + // Invalid subresource case + instance.Spec.Type = "unsupportedType" + err = executeCreateHandler(mockReconciler, *logger, instance, status, metav1.Now(), "test-hash") + assert.EqualError(t, err, "unsupported type: unsupportedType") +} + +func Test_apiGet(t *testing.T) { + mockReconciler := &Reconciler{} + instance := &v1alpha1.DatadogGenericResource{ + Spec: v1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + }, + } + + err := apiGet(mockReconciler, instance) + assert.NoError(t, err) +} + +func Test_apiUpdate(t *testing.T) { + mockReconciler := &Reconciler{} + instance := &v1alpha1.DatadogGenericResource{ + Spec: v1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + }, + } + + err := apiUpdate(mockReconciler, instance) + assert.NoError(t, err) +} + +func Test_apiDelete(t *testing.T) { + mockReconciler := &Reconciler{} + instance := &v1alpha1.DatadogGenericResource{ + Spec: v1alpha1.DatadogGenericResourceSpec{ + Type: mockSubresource, + }, + } + + err := apiDelete(mockReconciler, instance) + assert.NoError(t, err) +} + +func Test_translateClientError(t *testing.T) { + var ErrGeneric = errors.New("generic error") + + testCases := []struct { + name string + error error + message string + expectedErrorType error + expectedError error + expectedErrorInterface interface{} + }{ + { + name: "no message, generic error", + error: ErrGeneric, + message: "", + expectedErrorType: ErrGeneric, + }, + { + name: "generic message, generic error", + error: ErrGeneric, + message: "generic message", + expectedErrorType: ErrGeneric, + }, + { + name: "generic message, error type datadogV1.GenericOpenAPIError", + error: datadogapi.GenericOpenAPIError{}, + message: "generic message", + expectedErrorInterface: &datadogapi.GenericOpenAPIError{}, + }, + { + name: "generic message, error type *url.Error", + error: &url.Error{Err: fmt.Errorf("generic url error")}, + message: "generic message", + expectedError: fmt.Errorf("generic message (url.Error): \"\": generic url error"), + }, + } + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + result := translateClientError(test.error, test.message) + + if test.expectedErrorType != nil { + assert.True(t, errors.Is(result, test.expectedErrorType)) + } + + if test.expectedErrorInterface != nil { + assert.True(t, errors.As(result, test.expectedErrorInterface)) + } + + if test.expectedError != nil { + assert.Equal(t, test.expectedError, result) + } + }) + } +} + +func Test_updateStatusFromSyntheticsTest(t *testing.T) { + mockLogger := logr.Discard() + hash := "test-hash" + + tests := []struct { + name string + additionalProperties map[string]interface{} + expectedStatus v1alpha1.DatadogGenericResourceStatus + }{ + { + name: "valid properties", + additionalProperties: map[string]interface{}{ + "created_at": "2024-01-01T00:00:00Z", + "created_by": map[string]interface{}{ + "handle": "test-handle", + }, + }, + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "test-id", + Creator: "test-handle", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + Created: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + LastForceSyncTime: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "missing created_at", + additionalProperties: map[string]interface{}{ + "created_by": map[string]interface{}{ + "handle": "test-handle", + }, + }, + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "test-id", + Creator: "test-handle", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + Created: &metav1.Time{Time: time.Now()}, + LastForceSyncTime: &metav1.Time{Time: time.Now()}, + }, + }, + { + name: "invalid created_at", + additionalProperties: map[string]interface{}{ + "created_at": "invalid-date", + "created_by": map[string]interface{}{ + "handle": "test-handle", + }, + }, + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "test-id", + Creator: "test-handle", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + Created: &metav1.Time{Time: time.Now()}, + LastForceSyncTime: &metav1.Time{Time: time.Now()}, + }, + }, + { + name: "missing created_by", + additionalProperties: map[string]interface{}{ + "created_at": "2024-01-01T00:00:00Z", + }, + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "test-id", + Creator: "", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + Created: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + LastForceSyncTime: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "missing handle in created_by", + additionalProperties: map[string]interface{}{ + "created_at": "2024-01-01T00:00:00Z", + "created_by": map[string]interface{}{}, + }, + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "test-id", + Creator: "", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + Created: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + LastForceSyncTime: &metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := &v1alpha1.DatadogGenericResourceStatus{} + syntheticTest := &datadogV1.SyntheticsAPITest{} + syntheticTest.SetPublicId("test-id") + err := updateStatusFromSyntheticsTest(syntheticTest, tt.additionalProperties, status, mockLogger, hash) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus.Id, status.Id) + assert.Equal(t, tt.expectedStatus.Creator, status.Creator) + assert.Equal(t, tt.expectedStatus.SyncStatus, status.SyncStatus) + assert.Equal(t, tt.expectedStatus.CurrentHash, status.CurrentHash) + // Compare time with a tolerance of 1 ms (time.Now() is called in the function) + assert.True(t, status.Created.Time.Sub(tt.expectedStatus.Created.Time) < time.Millisecond) + assert.True(t, status.LastForceSyncTime.Time.Sub(tt.expectedStatus.LastForceSyncTime.Time) < time.Millisecond) + }) + } +} diff --git a/internal/controller/datadoggenericresource_controller.go b/internal/controller/datadoggenericresource_controller.go new file mode 100644 index 000000000..2868d672a --- /dev/null +++ b/internal/controller/datadoggenericresource_controller.go @@ -0,0 +1,55 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package controller + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" + ddgr "github.com/DataDog/datadog-operator/internal/controller/datadoggenericresource" + "github.com/DataDog/datadog-operator/pkg/datadogclient" + "github.com/go-logr/logr" +) + +// DatadogGenericResourceReconciler reconciles a DatadogGenericResource object +type DatadogGenericResourceReconciler struct { + Client client.Client + DDClient datadogclient.DatadogGenericClient + Log logr.Logger + Scheme *runtime.Scheme + Recorder record.EventRecorder + internal *ddgr.Reconciler +} + +// +kubebuilder:rbac:groups=datadoghq.com,resources=datadoggenericresources,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=datadoghq.com,resources=datadoggenericresources/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=datadoghq.com,resources=datadoggenericresources/finalizers,verbs=get;list;watch;create;update;patch;delete + +func (r *DatadogGenericResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.internal.Reconcile(ctx, req) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DatadogGenericResourceReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.internal = ddgr.NewReconciler(r.Client, r.DDClient, r.Scheme, r.Log, r.Recorder) + + builder := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.DatadogGenericResource{}). + WithEventFilter(predicate.GenerationChangedPredicate{}) + + err := builder.Complete(r) + + if err != nil { + return err + } + return nil +} diff --git a/internal/controller/setup.go b/internal/controller/setup.go index 7f3eef8af..ae1334fb1 100644 --- a/internal/controller/setup.go +++ b/internal/controller/setup.go @@ -28,26 +28,29 @@ import ( ) const ( - agentControllerName = "DatadogAgent" - monitorControllerName = "DatadogMonitor" - sloControllerName = "DatadogSLO" - profileControllerName = "DatadogAgentProfile" - dashboardControllerName = "DatadogDashboard" + agentControllerName = "DatadogAgent" + monitorControllerName = "DatadogMonitor" + sloControllerName = "DatadogSLO" + profileControllerName = "DatadogAgentProfile" + dashboardControllerName = "DatadogDashboard" + genericResourceControllerName = "DatadogGenericResource" ) // SetupOptions defines options for setting up controllers to ease testing type SetupOptions struct { - SupportExtendedDaemonset ExtendedDaemonsetOptions - SupportCilium bool - Creds config.Creds - DatadogAgentEnabled bool - DatadogMonitorEnabled bool - DatadogSLOEnabled bool - OperatorMetricsEnabled bool - V2APIEnabled bool - IntrospectionEnabled bool - DatadogAgentProfileEnabled bool - DatadogDashboardEnabled bool + SupportExtendedDaemonset ExtendedDaemonsetOptions + SupportCilium bool + Creds config.Creds + DatadogAgentEnabled bool + DatadogMonitorEnabled bool + DatadogSLOEnabled bool + OperatorMetricsEnabled bool + V2APIEnabled bool + IntrospectionEnabled bool + DatadogAgentProfileEnabled bool + OtelAgentEnabled bool + DatadogDashboardEnabled bool + DatadogGenericResourceEnabled bool } // ExtendedDaemonsetOptions defines ExtendedDaemonset options @@ -68,11 +71,12 @@ type ExtendedDaemonsetOptions struct { type starterFunc func(logr.Logger, manager.Manager, kubernetes.PlatformInfo, SetupOptions, datadog.MetricForwardersManager) error var controllerStarters = map[string]starterFunc{ - agentControllerName: startDatadogAgent, - monitorControllerName: startDatadogMonitor, - sloControllerName: startDatadogSLO, - profileControllerName: startDatadogAgentProfiles, - dashboardControllerName: startDatadogDashboard, + agentControllerName: startDatadogAgent, + monitorControllerName: startDatadogMonitor, + sloControllerName: startDatadogSLO, + profileControllerName: startDatadogAgentProfiles, + dashboardControllerName: startDatadogDashboard, + genericResourceControllerName: startDatadogGenericResource, } // SetupControllers starts all controllers (also used by e2e tests) @@ -204,6 +208,26 @@ func startDatadogDashboard(logger logr.Logger, mgr manager.Manager, pInfo kubern }).SetupWithManager(mgr) } +func startDatadogGenericResource(logger logr.Logger, mgr manager.Manager, pInfo kubernetes.PlatformInfo, options SetupOptions, metricForwardersMgr datadog.MetricForwardersManager) error { + if !options.DatadogGenericResourceEnabled { + logger.Info("Feature disabled, not starting the controller", "controller", genericResourceControllerName) + return nil + } + + ddClient, err := datadogclient.InitDatadogGenericClient(logger, options.Creds) + if err != nil { + return fmt.Errorf("unable to create Datadog API Client: %w", err) + } + + return (&DatadogGenericResourceReconciler{ + Client: mgr.GetClient(), + DDClient: ddClient, + Log: ctrl.Log.WithName("controllers").WithName(genericResourceControllerName), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor(genericResourceControllerName), + }).SetupWithManager(mgr) +} + func startDatadogSLO(logger logr.Logger, mgr manager.Manager, pInfo kubernetes.PlatformInfo, options SetupOptions, metricForwardersMgr datadog.MetricForwardersManager) error { if !options.DatadogSLOEnabled { logger.Info("Feature disabled, not starting the controller", "controller", sloControllerName) diff --git a/pkg/datadogclient/client.go b/pkg/datadogclient/client.go index 7c1d560ad..88cf203e4 100644 --- a/pkg/datadogclient/client.go +++ b/pkg/datadogclient/client.go @@ -95,6 +95,32 @@ func InitDatadogDashboardClient(logger logr.Logger, creds config.Creds) (Datadog return DatadogDashboardClient{Client: client, Auth: authV1}, nil } +type DatadogGenericClient struct { + SyntheticsClient *datadogV1.SyntheticsApi + NotebooksClient *datadogV1.NotebooksApi + // TODO: other clients depending on the resource + Auth context.Context +} + +// InitDatadogGenericClient initializes the Datadog Generic API Client and establishes credentials. +func InitDatadogGenericClient(logger logr.Logger, creds config.Creds) (DatadogGenericClient, error) { + if creds.APIKey == "" || creds.AppKey == "" { + return DatadogGenericClient{}, errors.New("error obtaining API key and/or app key") + } + + configV1 := datadogapi.NewConfiguration() + apiClient := datadogapi.NewAPIClient(configV1) + syntheticsClient := datadogV1.NewSyntheticsApi(apiClient) + notebooksClient := datadogV1.NewNotebooksApi(apiClient) + + authV1, err := setupAuth(logger, creds) + if err != nil { + return DatadogGenericClient{}, err + } + + return DatadogGenericClient{SyntheticsClient: syntheticsClient, NotebooksClient: notebooksClient, Auth: authV1}, nil +} + func setupAuth(logger logr.Logger, creds config.Creds) (context.Context, error) { // Initialize the official Datadog V1 API client. authV1 := context.WithValue(