Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion api/operator/v1alpha1/dash0spamfilters_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/conversion"

dash0common "github.com/dash0hq/dash0-operator/api/operator/common"
dash0v1alpha2 "github.com/dash0hq/dash0-operator/api/operator/v1alpha2"
)

// Dash0SpamFilter is the Schema for the Dash0SpamFilter API
//
// +kubebuilder:object:root=true
// +groupName=operator.dash0.com
// +kubebuilder:subresource:status
// +kubebuilder:conversion:spoke
type Dash0SpamFilter struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Expand All @@ -24,9 +27,12 @@ type Dash0SpamFilter struct {

// Dash0SpamFilterSpec defines the desired state of Dash0SpamFilter
type Dash0SpamFilterSpec struct {
// The signal contexts this spam filter applies to (e.g., log, span, metric).
// The signal contexts this spam filter applies to. Only the first element is used;
// additional elements are ignored when converting to the v1alpha2 hub version.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=1
// +kubebuilder:validation:items:Enum=datapoint;log;span;web_event
Contexts []string `json:"contexts"`

// The filter conditions that define which telemetry data to drop.
Expand Down Expand Up @@ -80,3 +86,86 @@ type Dash0SpamFilterList struct {
metav1.ListMeta `json:"metadata,omitempty"`
Items []Dash0SpamFilter `json:"items"`
}

// Ensure Dash0SpamFilter implements the conversion.Convertible interface.
var _ conversion.Convertible = &Dash0SpamFilter{}

// ConvertTo converts this Dash0SpamFilter resource (v1alpha1) to the hub version (v1alpha2).
func (src *Dash0SpamFilter) ConvertTo(dstRaw conversion.Hub) error {
dst := dstRaw.(*dash0v1alpha2.Dash0SpamFilter)
dst.ObjectMeta = src.ObjectMeta

// Spec: contexts (array) → context (scalar)
if len(src.Spec.Contexts) > 0 {
dst.Spec.Context = src.Spec.Contexts[0]
}
dst.Spec.Filter = make([]dash0v1alpha2.Dash0SpamFilterCondition, len(src.Spec.Filter))
for i, f := range src.Spec.Filter {
dst.Spec.Filter[i] = dash0v1alpha2.Dash0SpamFilterCondition{
Key: f.Key,
Operator: f.Operator,
Value: f.Value,
}
}

// Status
dst.Status.SynchronizationStatus = src.Status.SynchronizationStatus
dst.Status.SynchronizedAt = src.Status.SynchronizedAt
dst.Status.ValidationIssues = src.Status.ValidationIssues
dst.Status.SynchronizationResults = make(
[]dash0v1alpha2.Dash0SpamFilterSynchronizationResultPerEndpointAndDataset,
len(src.Status.SynchronizationResults),
)
for i, r := range src.Status.SynchronizationResults {
dst.Status.SynchronizationResults[i] = dash0v1alpha2.Dash0SpamFilterSynchronizationResultPerEndpointAndDataset{
SynchronizationStatus: r.SynchronizationStatus,
Dash0ApiEndpoint: r.Dash0ApiEndpoint,
Dash0Dataset: r.Dash0Dataset,
Dash0Id: r.Dash0Id,
Dash0Origin: r.Dash0Origin,
SynchronizationError: r.SynchronizationError,
}
}

return nil
}

// ConvertFrom converts the hub version (v1alpha2) to this Dash0SpamFilter resource version (v1alpha1).
func (dst *Dash0SpamFilter) ConvertFrom(srcRaw conversion.Hub) error {
src := srcRaw.(*dash0v1alpha2.Dash0SpamFilter)
dst.ObjectMeta = src.ObjectMeta

// Spec: context (scalar) → contexts (array)
if src.Spec.Context != "" {
dst.Spec.Contexts = []string{src.Spec.Context}
}
dst.Spec.Filter = make([]Dash0SpamFilterCondition, len(src.Spec.Filter))
for i, f := range src.Spec.Filter {
dst.Spec.Filter[i] = Dash0SpamFilterCondition{
Key: f.Key,
Operator: f.Operator,
Value: f.Value,
}
}

// Status
dst.Status.SynchronizationStatus = src.Status.SynchronizationStatus
dst.Status.SynchronizedAt = src.Status.SynchronizedAt
dst.Status.ValidationIssues = src.Status.ValidationIssues
dst.Status.SynchronizationResults = make(
[]Dash0SpamFilterSynchronizationResultPerEndpointAndDataset,
len(src.Status.SynchronizationResults),
)
for i, r := range src.Status.SynchronizationResults {
dst.Status.SynchronizationResults[i] = Dash0SpamFilterSynchronizationResultPerEndpointAndDataset{
SynchronizationStatus: r.SynchronizationStatus,
Dash0ApiEndpoint: r.Dash0ApiEndpoint,
Dash0Dataset: r.Dash0Dataset,
Dash0Id: r.Dash0Id,
Dash0Origin: r.Dash0Origin,
SynchronizationError: r.SynchronizationError,
}
}

return nil
}
75 changes: 75 additions & 0 deletions api/operator/v1alpha1/dash0spamfilters_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"

dash0v1alpha2 "github.com/dash0hq/dash0-operator/api/operator/v1alpha2"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("v1alpha1 Dash0 spam filter CRD", func() {

Describe("converting to and from hub version", func() {

It("should convert v1alpha1 to v1alpha2 (contexts → context)", func() {
src := &Dash0SpamFilter{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-spam-filter",
},
Spec: Dash0SpamFilterSpec{
Contexts: []string{"log"},
Filter: []Dash0SpamFilterCondition{
{
Key: "k8s.namespace.name",
Operator: "is",
Value: ptr.To("kube-system"),
},
},
},
}

dst := &dash0v1alpha2.Dash0SpamFilter{}
Expect(src.ConvertTo(dst)).To(Succeed())

Expect(dst.ObjectMeta).To(Equal(src.ObjectMeta))
Expect(dst.Spec.Context).To(Equal("log"))
Expect(dst.Spec.Filter).To(HaveLen(1))
Expect(dst.Spec.Filter[0].Key).To(Equal("k8s.namespace.name"))
Expect(dst.Spec.Filter[0].Operator).To(Equal("is"))
Expect(*dst.Spec.Filter[0].Value).To(Equal("kube-system"))
})

It("should preserve data through a round-trip starting from v1alpha1", func() {
original := &Dash0SpamFilter{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-spam-filter",
},
Spec: Dash0SpamFilterSpec{
Contexts: []string{"log"},
Filter: []Dash0SpamFilterCondition{
{Key: "k8s.namespace.name", Operator: "is", Value: ptr.To("kube-system")},
{Key: "level", Operator: "is_set"},
},
},
}

hub := &dash0v1alpha2.Dash0SpamFilter{}
Expect(original.ConvertTo(hub)).To(Succeed())

roundTripped := &Dash0SpamFilter{}
Expect(roundTripped.ConvertFrom(hub)).To(Succeed())

Expect(roundTripped.ObjectMeta).To(Equal(original.ObjectMeta))
Expect(roundTripped.Spec.Contexts).To(Equal(original.Spec.Contexts))
Expect(roundTripped.Spec.Filter).To(Equal(original.Spec.Filter))
})
})
})
87 changes: 87 additions & 0 deletions api/operator/v1alpha2/dash0spamfilters_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

package v1alpha2

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

dash0common "github.com/dash0hq/dash0-operator/api/operator/common"
)

// Dash0SpamFilter is the Schema for the Dash0SpamFilter API
//
// +kubebuilder:object:root=true
// +groupName=operator.dash0.com
// +kubebuilder:subresource:status
// +kubebuilder:storageversion
// +kubebuilder:conversion:hub
type Dash0SpamFilter struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec Dash0SpamFilterSpec `json:"spec,omitempty"`
Status Dash0SpamFilterStatus `json:"status,omitempty"`
}

// Hub marks this version as the hub for conversions; all other versions are implicitly spokes.
func (*Dash0SpamFilter) Hub() {}

Check failure on line 28 in api/operator/v1alpha2/dash0spamfilters_types.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=dash0hq_dash0-operator&issues=AZ479L9Chsle8uXGhbi3&open=AZ479L9Chsle8uXGhbi3&pullRequest=1044

// Dash0SpamFilterSpec defines the desired state of Dash0SpamFilter
type Dash0SpamFilterSpec struct {
// The signal context this spam filter applies to.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=datapoint;log;span;web_event
Context string `json:"context"`

// The filter conditions that define which telemetry data to drop.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Filter []Dash0SpamFilterCondition `json:"filter"`
}

// Dash0SpamFilterCondition defines a single filter condition for spam filtering,
// matching the Dash0 API's AttributeFilter schema.
type Dash0SpamFilterCondition struct {
// The attribute key to match against.
// +kubebuilder:validation:Required
Key string `json:"key"`

// The comparison operator (e.g., "is", "is_not", "contains", "starts_with").
// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=is;is_not;is_set;is_not_set;is_one_of;is_not_one_of;gt;lt;gte;lte;matches;does_not_match;contains;does_not_contain;starts_with;does_not_start_with;ends_with;does_not_end_with;is_any
Operator string `json:"operator"`

// The value to compare against. Optional for operators like "is_set" and "is_not_set".
// +kubebuilder:validation:Optional
Value *string `json:"value,omitempty"`
}

// Dash0SpamFilterStatus defines the observed state of Dash0SpamFilter
type Dash0SpamFilterStatus struct {
SynchronizationStatus dash0common.Dash0ApiResourceSynchronizationStatus `json:"synchronizationStatus"`
SynchronizedAt metav1.Time `json:"synchronizedAt"`
ValidationIssues []string `json:"validationIssues,omitempty"`
SynchronizationResults []Dash0SpamFilterSynchronizationResultPerEndpointAndDataset `json:"synchronizationResults"`
}

// Dash0SpamFilterSynchronizationResultPerEndpointAndDataset defines the synchronization result per endpoint and dataset
type Dash0SpamFilterSynchronizationResultPerEndpointAndDataset struct {
SynchronizationStatus dash0common.Dash0ApiResourceSynchronizationStatus `json:"synchronizationStatus"`
Dash0ApiEndpoint string `json:"dash0ApiEndpoint,omitempty"`
Dash0Dataset string `json:"dash0Dataset,omitempty"`
// +kubebuilder:validation:Optional
Dash0Id string `json:"dash0Id,omitempty"`
// +kubebuilder:validation:Optional
Dash0Origin string `json:"dash0Origin,omitempty"`
SynchronizationError string `json:"synchronizationError,omitempty"`
}

//+kubebuilder:object:root=true

// Dash0SpamFilterList contains a list of Dash0SpamFilter resources.
type Dash0SpamFilterList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Dash0SpamFilter `json:"items"`
}
105 changes: 105 additions & 0 deletions api/operator/v1alpha2/dash0spamfilters_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
// SPDX-License-Identifier: Apache-2.0

package v1alpha2_test

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/conversion"

dash0v1alpha1 "github.com/dash0hq/dash0-operator/api/operator/v1alpha1"
dash0v1alpha2 "github.com/dash0hq/dash0-operator/api/operator/v1alpha2"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("v1alpha2 Dash0 spam filter CRD", func() {

It("should be marked as the conversion hub", func() {
var _ conversion.Hub = &dash0v1alpha2.Dash0SpamFilter{}
(&dash0v1alpha2.Dash0SpamFilter{}).Hub()
})

Describe("converting to and from the v1alpha1 spoke", func() {

It("should convert v1alpha2 to v1alpha1 (context → contexts)", func() {
src := &dash0v1alpha2.Dash0SpamFilter{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-spam-filter",
},
Spec: dash0v1alpha2.Dash0SpamFilterSpec{
Context: "span",
Filter: []dash0v1alpha2.Dash0SpamFilterCondition{
{
Key: "service.name",
Operator: "is_not",
Value: ptr.To("noisy-service"),
},
},
},
}

dst := &dash0v1alpha1.Dash0SpamFilter{}
Expect(dst.ConvertFrom(src)).To(Succeed())

Expect(dst.ObjectMeta).To(Equal(src.ObjectMeta))
Expect(dst.Spec.Contexts).To(Equal([]string{"span"}))
Expect(dst.Spec.Filter).To(HaveLen(1))
Expect(dst.Spec.Filter[0].Key).To(Equal("service.name"))
Expect(dst.Spec.Filter[0].Operator).To(Equal("is_not"))
Expect(*dst.Spec.Filter[0].Value).To(Equal("noisy-service"))
})

It("should preserve data through a round-trip starting from v1alpha2", func() {
original := &dash0v1alpha2.Dash0SpamFilter{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-spam-filter",
},
Spec: dash0v1alpha2.Dash0SpamFilterSpec{
Context: "log",
Filter: []dash0v1alpha2.Dash0SpamFilterCondition{
{Key: "k8s.namespace.name", Operator: "is", Value: ptr.To("kube-system")},
{Key: "level", Operator: "is_set"},
},
},
}

spoke := &dash0v1alpha1.Dash0SpamFilter{}
Expect(spoke.ConvertFrom(original)).To(Succeed())

roundTripped := &dash0v1alpha2.Dash0SpamFilter{}
Expect(spoke.ConvertTo(roundTripped)).To(Succeed())

Expect(roundTripped.ObjectMeta).To(Equal(original.ObjectMeta))
Expect(roundTripped.Spec.Context).To(Equal(original.Spec.Context))
Expect(roundTripped.Spec.Filter).To(Equal(original.Spec.Filter))
})

It("should handle empty context when round-tripping (no Contexts entry produced)", func() {
original := &dash0v1alpha2.Dash0SpamFilter{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-spam-filter",
},
Spec: dash0v1alpha2.Dash0SpamFilterSpec{
Context: "",
Filter: []dash0v1alpha2.Dash0SpamFilterCondition{
{Key: "level", Operator: "is_set"},
},
},
}

spoke := &dash0v1alpha1.Dash0SpamFilter{}
Expect(spoke.ConvertFrom(original)).To(Succeed())
Expect(spoke.Spec.Contexts).To(BeEmpty())

roundTripped := &dash0v1alpha2.Dash0SpamFilter{}
Expect(spoke.ConvertTo(roundTripped)).To(Succeed())
Expect(roundTripped.Spec.Context).To(BeEmpty())
})
})
})
Loading
Loading