From f7480317c30accddc207d6bb243e0d2770b62ad5 Mon Sep 17 00:00:00 2001 From: Wassim DHIF Date: Wed, 15 Apr 2026 12:04:48 +0200 Subject: [PATCH 1/4] feat(ddgr): add dashboard support Signed-off-by: Wassim DHIF --- .../v1alpha1/datadoggenericresource_types.go | 3 +- .../datadoggenericresource_validation.go | 1 + ...datadoghq.com_datadoggenericresources.yaml | 1 + ....com_datadoggenericresources_v1alpha1.json | 1 + .../datadoggenericresource/controller.go | 3 + .../datadoggenericresource/dashboards.go | 91 +++++++++++++++++++ .../datadoggenericresource/dashboards_test.go | 78 ++++++++++++++++ .../datadoggenericresource/utils.go | 2 + pkg/datadogclient/client.go | 3 + 9 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 internal/controller/datadoggenericresource/dashboards.go create mode 100644 internal/controller/datadoggenericresource/dashboards_test.go diff --git a/api/datadoghq/v1alpha1/datadoggenericresource_types.go b/api/datadoghq/v1alpha1/datadoggenericresource_types.go index 3d1fc1d13a..9e4f2e13ce 100644 --- a/api/datadoghq/v1alpha1/datadoggenericresource_types.go +++ b/api/datadoghq/v1alpha1/datadoggenericresource_types.go @@ -13,6 +13,7 @@ type SupportedResourcesType string // When adding a new type, make sure to update the kubebuilder validation enum marker const ( + Dashboard SupportedResourcesType = "dashboard" Downtime SupportedResourcesType = "downtime" Monitor SupportedResourcesType = "monitor" Notebook SupportedResourcesType = "notebook" @@ -24,7 +25,7 @@ const ( // +k8s:openapi-gen=true type DatadogGenericResourceSpec struct { // Type is the type of the API object - // +kubebuilder:validation:Enum=downtime;monitor;notebook;synthetics_api_test;synthetics_browser_test + // +kubebuilder:validation:Enum=dashboard;downtime;monitor;notebook;synthetics_api_test;synthetics_browser_test Type SupportedResourcesType `json:"type"` // JsonSpec is the specification of the API object JsonSpec string `json:"jsonSpec"` diff --git a/api/datadoghq/v1alpha1/datadoggenericresource_validation.go b/api/datadoghq/v1alpha1/datadoggenericresource_validation.go index 157b4a4662..a4c811b371 100644 --- a/api/datadoghq/v1alpha1/datadoggenericresource_validation.go +++ b/api/datadoghq/v1alpha1/datadoggenericresource_validation.go @@ -12,6 +12,7 @@ import ( ) var allowedCustomResourcesEnumMap = map[SupportedResourcesType]string{ + Dashboard: "", Downtime: "", Monitor: "", Notebook: "", diff --git a/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml b/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml index c404d2b616..9034412bcb 100644 --- a/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml +++ b/config/crd/bases/v1/datadoghq.com_datadoggenericresources.yaml @@ -57,6 +57,7 @@ spec: type: description: Type is the type of the API object enum: + - dashboard - downtime - monitor - notebook diff --git a/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json b/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json index 3b0201ade9..60bc7a8b2e 100644 --- a/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json +++ b/config/crd/bases/v1/datadoghq.com_datadoggenericresources_v1alpha1.json @@ -24,6 +24,7 @@ "type": { "description": "Type is the type of the API object", "enum": [ + "dashboard", "downtime", "monitor", "notebook", diff --git a/internal/controller/datadoggenericresource/controller.go b/internal/controller/datadoggenericresource/controller.go index 0b5c75058c..f807e62791 100644 --- a/internal/controller/datadoggenericresource/controller.go +++ b/internal/controller/datadoggenericresource/controller.go @@ -39,6 +39,7 @@ const ( type Reconciler struct { client client.Client + datadogDashboardsClient *datadogV1.DashboardsApi datadogSyntheticsClient *datadogV1.SyntheticsApi datadogNotebooksClient *datadogV1.NotebooksApi datadogMonitorsClient *datadogV1.MonitorsApi @@ -57,6 +58,7 @@ func NewReconciler(client client.Client, creds config.Creds, scheme *runtime.Sch return &Reconciler{ client: client, + datadogDashboardsClient: ddClient.DashboardsClient, datadogSyntheticsClient: ddClient.SyntheticsClient, datadogNotebooksClient: ddClient.NotebooksClient, datadogMonitorsClient: ddClient.MonitorsClient, @@ -74,6 +76,7 @@ func (r *Reconciler) UpdateDatadogClient(newCreds config.Creds) error { if err != nil { return fmt.Errorf("unable to create Datadog API Client in DatadogGenericResource: %w", err) } + r.datadogDashboardsClient = ddClient.DashboardsClient r.datadogSyntheticsClient = ddClient.SyntheticsClient r.datadogNotebooksClient = ddClient.NotebooksClient r.datadogMonitorsClient = ddClient.MonitorsClient diff --git a/internal/controller/datadoggenericresource/dashboards.go b/internal/controller/datadoggenericresource/dashboards.go new file mode 100644 index 0000000000..921dc6d350 --- /dev/null +++ b/internal/controller/datadoggenericresource/dashboards.go @@ -0,0 +1,91 @@ +// 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" + "encoding/json" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" +) + +type DashboardHandler struct{} + +func (h *DashboardHandler) createResourcefunc(r *Reconciler, logger logr.Logger, instance *v1alpha1.DatadogGenericResource, status *v1alpha1.DatadogGenericResourceStatus, now metav1.Time, hash string) error { + createdDashboard, err := createDashboard(r.datadogAuth, r.datadogDashboardsClient, instance) + if err != nil { + logger.Error(err, "error creating dashboard") + updateErrStatus(status, now, v1alpha1.DatadogSyncStatusCreateError, "CreatingCustomResource", err) + return err + } + logger.Info("created a new dashboard", "dashboard Id", createdDashboard.GetId()) + updateStatusFromDashboard(createdDashboard, status, hash) + return nil +} + +// updateStatusFromDashboard populates the status fields from a Datadog Dashboard API response. +func updateStatusFromDashboard(dashboard datadogV1.Dashboard, status *v1alpha1.DatadogGenericResourceStatus, hash string) { + status.Id = dashboard.GetId() + createdTime := metav1.NewTime(dashboard.GetCreatedAt()) + status.Created = &createdTime + status.LastForceSyncTime = &createdTime + status.Creator = dashboard.GetAuthorHandle() + status.SyncStatus = v1alpha1.DatadogSyncStatusOK + status.CurrentHash = hash +} + +func (h *DashboardHandler) getResourcefunc(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := getDashboard(r.datadogAuth, r.datadogDashboardsClient, instance.Status.Id) + return err +} + +func (h *DashboardHandler) updateResourcefunc(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + _, err := updateDashboard(r.datadogAuth, r.datadogDashboardsClient, instance) + return err +} + +func (h *DashboardHandler) deleteResourcefunc(r *Reconciler, instance *v1alpha1.DatadogGenericResource) error { + return deleteDashboard(r.datadogAuth, r.datadogDashboardsClient, instance.Status.Id) +} + +func getDashboard(auth context.Context, client *datadogV1.DashboardsApi, dashboardID string) (datadogV1.Dashboard, error) { + dashboard, _, err := client.GetDashboard(auth, dashboardID) + if err != nil { + return datadogV1.Dashboard{}, translateClientError(err, "error getting dashboard") + } + return dashboard, nil +} + +func createDashboard(auth context.Context, client *datadogV1.DashboardsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.Dashboard, error) { + dashboardCreateData := &datadogV1.Dashboard{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardCreateData) + dashboard, _, err := client.CreateDashboard(auth, *dashboardCreateData) + if err != nil { + return datadogV1.Dashboard{}, translateClientError(err, "error creating dashboard") + } + return dashboard, nil +} + +func updateDashboard(auth context.Context, client *datadogV1.DashboardsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.Dashboard, error) { + dashboardUpdateData := &datadogV1.Dashboard{} + json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardUpdateData) + dashboardUpdated, _, err := client.UpdateDashboard(auth, instance.Status.Id, *dashboardUpdateData) + if err != nil { + return datadogV1.Dashboard{}, translateClientError(err, "error updating dashboard") + } + return dashboardUpdated, nil +} + +func deleteDashboard(auth context.Context, client *datadogV1.DashboardsApi, dashboardID string) error { + if _, _, err := client.DeleteDashboard(auth, dashboardID); err != nil { + return translateClientError(err, "error deleting dashboard") + } + return nil +} diff --git a/internal/controller/datadoggenericresource/dashboards_test.go b/internal/controller/datadoggenericresource/dashboards_test.go new file mode 100644 index 0000000000..93eecca043 --- /dev/null +++ b/internal/controller/datadoggenericresource/dashboards_test.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 ( + "testing" + "time" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + "github.com/stretchr/testify/assert" + + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1" +) + +func Test_updateStatusFromDashboard(t *testing.T) { + hash := "test-hash" + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + dashboard datadogV1.Dashboard + expectedStatus v1alpha1.DatadogGenericResourceStatus + }{ + { + name: "all fields populated", + dashboard: func() datadogV1.Dashboard { + d := datadogV1.Dashboard{} + d.SetId("abc-123") + d.SetAuthorHandle("wassim.dhif@datadoghq.com") + d.SetCreatedAt(createdAt) + return d + }(), + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "abc-123", + Creator: "wassim.dhif@datadoghq.com", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + }, + }, + { + name: "missing author handle", + dashboard: func() datadogV1.Dashboard { + d := datadogV1.Dashboard{} + d.SetId("abc-456") + d.SetCreatedAt(createdAt) + return d + }(), + expectedStatus: v1alpha1.DatadogGenericResourceStatus{ + Id: "abc-456", + Creator: "", + SyncStatus: v1alpha1.DatadogSyncStatusOK, + CurrentHash: hash, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := &v1alpha1.DatadogGenericResourceStatus{} + updateStatusFromDashboard(tt.dashboard, status, hash) + + 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) + assert.Equal(t, createdAt, status.Created.Time) + assert.Equal(t, createdAt, status.LastForceSyncTime.Time) + }) + } +} + +func Test_DashboardHandler_getHandler(t *testing.T) { + handler := getHandler(v1alpha1.Dashboard) + assert.IsType(t, &DashboardHandler{}, handler) +} diff --git a/internal/controller/datadoggenericresource/utils.go b/internal/controller/datadoggenericresource/utils.go index 3a7f0226e2..7633d06ed6 100644 --- a/internal/controller/datadoggenericresource/utils.go +++ b/internal/controller/datadoggenericresource/utils.go @@ -56,6 +56,8 @@ func apiCreateAndUpdateStatus(r *Reconciler, logger logr.Logger, instance *v1alp func getHandler(resourceType v1alpha1.SupportedResourcesType) ResourceHandler { switch resourceType { + case v1alpha1.Dashboard: + return &DashboardHandler{} case v1alpha1.Downtime: return &DowntimeHandler{} case v1alpha1.Monitor: diff --git a/pkg/datadogclient/client.go b/pkg/datadogclient/client.go index f82ee2a64d..f0a5b17b86 100644 --- a/pkg/datadogclient/client.go +++ b/pkg/datadogclient/client.go @@ -96,6 +96,7 @@ func InitDatadogDashboardClient(logger logr.Logger, creds config.Creds) (Datadog } type DatadogGenericClient struct { + DashboardsClient *datadogV1.DashboardsApi SyntheticsClient *datadogV1.SyntheticsApi NotebooksClient *datadogV1.NotebooksApi MonitorsClient *datadogV1.MonitorsApi @@ -111,6 +112,7 @@ func InitDatadogGenericClient(logger logr.Logger, creds config.Creds) (DatadogGe configV1 := datadogapi.NewConfiguration() apiClient := datadogapi.NewAPIClient(configV1) + dashboardsClient := datadogV1.NewDashboardsApi(apiClient) syntheticsClient := datadogV1.NewSyntheticsApi(apiClient) notebooksClient := datadogV1.NewNotebooksApi(apiClient) monitorsClient := datadogV1.NewMonitorsApi(apiClient) @@ -122,6 +124,7 @@ func InitDatadogGenericClient(logger logr.Logger, creds config.Creds) (DatadogGe } return DatadogGenericClient{ + DashboardsClient: dashboardsClient, SyntheticsClient: syntheticsClient, NotebooksClient: notebooksClient, MonitorsClient: monitorsClient, From 2a551440c0ec2857bf2810adfe4c8a9b8d136564 Mon Sep 17 00:00:00 2001 From: Wassim Dhif Date: Wed, 15 Apr 2026 14:08:37 +0200 Subject: [PATCH 2/4] Update internal/controller/datadoggenericresource/dashboards_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/controller/datadoggenericresource/dashboards_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/controller/datadoggenericresource/dashboards_test.go b/internal/controller/datadoggenericresource/dashboards_test.go index 93eecca043..108aca0595 100644 --- a/internal/controller/datadoggenericresource/dashboards_test.go +++ b/internal/controller/datadoggenericresource/dashboards_test.go @@ -29,13 +29,13 @@ func Test_updateStatusFromDashboard(t *testing.T) { dashboard: func() datadogV1.Dashboard { d := datadogV1.Dashboard{} d.SetId("abc-123") - d.SetAuthorHandle("wassim.dhif@datadoghq.com") + d.SetAuthorHandle("user@example.com") d.SetCreatedAt(createdAt) return d }(), expectedStatus: v1alpha1.DatadogGenericResourceStatus{ Id: "abc-123", - Creator: "wassim.dhif@datadoghq.com", + Creator: "user@example.com", SyncStatus: v1alpha1.DatadogSyncStatusOK, CurrentHash: hash, }, From 29b074ae30b9748a749ae96cfdddaa14a3062e92 Mon Sep 17 00:00:00 2001 From: Wassim DHIF Date: Wed, 15 Apr 2026 14:13:46 +0200 Subject: [PATCH 3/4] fix(ddgr): catch json errors Signed-off-by: Wassim DHIF --- internal/controller/datadoggenericresource/dashboards.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/controller/datadoggenericresource/dashboards.go b/internal/controller/datadoggenericresource/dashboards.go index 921dc6d350..bd14e1d27b 100644 --- a/internal/controller/datadoggenericresource/dashboards.go +++ b/internal/controller/datadoggenericresource/dashboards.go @@ -65,7 +65,9 @@ func getDashboard(auth context.Context, client *datadogV1.DashboardsApi, dashboa func createDashboard(auth context.Context, client *datadogV1.DashboardsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.Dashboard, error) { dashboardCreateData := &datadogV1.Dashboard{} - json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardCreateData) + if err := json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardCreateData); err != nil { + return datadogV1.Dashboard{}, translateClientError(err, "error unmarshalling dashboard spec") + } dashboard, _, err := client.CreateDashboard(auth, *dashboardCreateData) if err != nil { return datadogV1.Dashboard{}, translateClientError(err, "error creating dashboard") @@ -75,7 +77,9 @@ func createDashboard(auth context.Context, client *datadogV1.DashboardsApi, inst func updateDashboard(auth context.Context, client *datadogV1.DashboardsApi, instance *v1alpha1.DatadogGenericResource) (datadogV1.Dashboard, error) { dashboardUpdateData := &datadogV1.Dashboard{} - json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardUpdateData) + if err := json.Unmarshal([]byte(instance.Spec.JsonSpec), dashboardUpdateData); err != nil { + return datadogV1.Dashboard{}, translateClientError(err, "error unmarshalling dashboard spec") + } dashboardUpdated, _, err := client.UpdateDashboard(auth, instance.Status.Id, *dashboardUpdateData) if err != nil { return datadogV1.Dashboard{}, translateClientError(err, "error updating dashboard") From 070fc646cb2e77f6695d405c010158e5db8e900f Mon Sep 17 00:00:00 2001 From: Wassim DHIF Date: Thu, 16 Apr 2026 14:44:07 +0200 Subject: [PATCH 4/4] fix: add missing post-development tasks Signed-off-by: Wassim DHIF --- docs/datadog_generic_resource.md | 1 + .../dashboard-sample.yaml | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 examples/datadoggenericresource/dashboard-sample.yaml diff --git a/docs/datadog_generic_resource.md b/docs/datadog_generic_resource.md index b0c8f50ff8..4b6a899065 100644 --- a/docs/datadog_generic_resource.md +++ b/docs/datadog_generic_resource.md @@ -50,6 +50,7 @@ A `DatadogGenericResource` object has two fields: | `synthetics_browser_test` | v1.12.0 | https://docs.datadoghq.com/api/latest/synthetics/#create-a-browser-test | [Browser test manifest](../examples/datadoggenericresource/browser-test-sample.yaml) | | `monitor` | v1.13.0 | https://docs.datadoghq.com/api/latest/monitors/#create-a-monitor | [Monitor manifest](../examples/datadoggenericresource/monitor-sample.yaml) | | `downtime` | v1.22.0 | https://docs.datadoghq.com/api/latest/downtimes/#schedule-a-downtime | [Downtime manifest](../examples/datadoggenericresource/downtime-sample.yaml) | +| `dashboard` | v1.27.0 | https://docs.datadoghq.com/api/latest/dashboards/#create-a-dashboard | [Dashboard manifest](../examples/datadoggenericresource/dashboard-sample.yaml) | ## Prerequisites diff --git a/examples/datadoggenericresource/dashboard-sample.yaml b/examples/datadoggenericresource/dashboard-sample.yaml new file mode 100644 index 0000000000..90c84a5afd --- /dev/null +++ b/examples/datadoggenericresource/dashboard-sample.yaml @@ -0,0 +1,54 @@ +apiVersion: datadoghq.com/v1alpha1 +kind: DatadogGenericResource +metadata: + name: ddgr-dashboard-sample +spec: + type: dashboard + jsonSpec: |- + { + "title": "Example Dashboard (DatadogGenericResource)", + "layout_type": "ordered", + "tags": [ + "team:example" + ], + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "System CPU Usage", + "title_size": "16", + "title_align": "left", + "show_legend": true, + "requests": [ + { + "formulas": [ + { + "formula": "query1" + } + ], + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:system.cpu.user{*} by {host}" + } + ], + "response_format": "timeseries", + "style": { + "palette": "dog_classic", + "line_type": "solid", + "line_width": "normal" + }, + "display_type": "line" + } + ] + }, + "layout": { + "x": 0, + "y": 0, + "width": 6, + "height": 3 + } + } + ] + }