diff --git a/operator/CHANGELOG.md b/operator/CHANGELOG.md index 92ed101eeff0..254d800144b5 100644 --- a/operator/CHANGELOG.md +++ b/operator/CHANGELOG.md @@ -1,5 +1,6 @@ ## Main +- [7295](https://github.com/grafana/loki/pull/7295) **xperimental**: Add extended-validation for rules on OpenShift - [6951](https://github.com/grafana/loki/pull/6951) **Red-GV**: Adding operational Lokistack alerts - [7254](https://github.com/grafana/loki/pull/7254) **periklis**: Expose Loki Ruler API via the lokistack-gateway - [7214](https://github.com/grafana/loki/pull/7214) **periklis**: Fix ruler GRPC tls client configuration diff --git a/operator/apis/config/v1/projectconfig_types.go b/operator/apis/config/v1/projectconfig_types.go index e223d6fd4206..194916493243 100644 --- a/operator/apis/config/v1/projectconfig_types.go +++ b/operator/apis/config/v1/projectconfig_types.go @@ -17,6 +17,10 @@ type OpenShiftFeatureGates struct { // gateway to expose the service to public internet access. // More details: https://docs.openshift.com/container-platform/latest/networking/understanding-networking.html GatewayRoute bool `json:"gatewayRoute,omitempty"` + + // ExtendedRuleValidation enables extended validation of AlertingRule and RecordingRule + // to enforce tenancy in an OpenShift context. + ExtendedRuleValidation bool `json:"ruleExtendedValidation,omitempty"` } // FeatureGates is the supported set of all operator feature gates. diff --git a/operator/apis/loki/v1beta1/alertingrule_types.go b/operator/apis/loki/v1beta1/alertingrule_types.go index 5fd57af1ccd3..8830926da7de 100644 --- a/operator/apis/loki/v1beta1/alertingrule_types.go +++ b/operator/apis/loki/v1beta1/alertingrule_types.go @@ -107,6 +107,7 @@ type AlertingRuleStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:webhook:path=/validate-loki-grafana-com-v1beta1-alertingrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=loki.grafana.com,resources=alertingrules,verbs=create;update,versions=v1beta1,name=valertingrule.loki.grafana.com,admissionReviewVersions=v1 // AlertingRule is the Schema for the alertingrules API // diff --git a/operator/apis/loki/v1beta1/alertingrule_webhook.go b/operator/apis/loki/v1beta1/alertingrule_webhook.go deleted file mode 100644 index 0bfdf87ecdbd..000000000000 --- a/operator/apis/loki/v1beta1/alertingrule_webhook.go +++ /dev/null @@ -1,105 +0,0 @@ -package v1beta1 - -import ( - "github.com/grafana/loki/pkg/logql/syntax" - - "github.com/prometheus/common/model" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation/field" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/webhook" -) - -// SetupWebhookWithManager registers the AlertingRuleWebhook to the controller-runtime manager -// or returns an error. -func (r *AlertingRule) SetupWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr). - For(r). - Complete() -} - -//+kubebuilder:webhook:path=/validate-loki-grafana-com-v1beta1-alertingrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=loki.grafana.com,resources=alertingrules,verbs=create;update,versions=v1beta1,name=valertingrule.kb.io,admissionReviewVersions=v1 - -var _ webhook.Validator = &AlertingRule{} - -// ValidateCreate implements webhook.Validator so a webhook will be registered for the type -func (r *AlertingRule) ValidateCreate() error { - return r.validate() -} - -// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type -func (r *AlertingRule) ValidateUpdate(_ runtime.Object) error { - return r.validate() -} - -// ValidateDelete implements webhook.Validator so a webhook will be registered for the type -func (r *AlertingRule) ValidateDelete() error { - // Do nothing - return nil -} - -func (r *AlertingRule) validate() error { - var allErrs field.ErrorList - - found := make(map[string]bool) - - for i, g := range r.Spec.Groups { - // Check for group name uniqueness - if found[g.Name] { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Name"), - g.Name, - ErrGroupNamesNotUnique.Error(), - )) - } - - found[g.Name] = true - - // Check if rule evaluation period is a valid PromQL duration - _, err := model.ParseDuration(string(g.Interval)) - if err != nil { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Interval"), - g.Interval, - ErrParseEvaluationInterval.Error(), - )) - } - - for j, r := range g.Rules { - // Check if alert for period is a valid PromQL duration - if r.Alert != "" { - _, err := model.ParseDuration(string(r.For)) - if err != nil { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("For"), - r.For, - ErrParseAlertForPeriod.Error(), - )) - } - } - - // Check if the LogQL parser can parse the rule expression - _, err := syntax.ParseExpr(r.Expr) - if err != nil { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), - r.Expr, - ErrParseLogQLExpression.Error(), - )) - } - } - } - - if len(allErrs) == 0 { - return nil - } - - return apierrors.NewInvalid( - schema.GroupKind{Group: "loki.grafana.com", Kind: "AlertingRule"}, - r.Name, - allErrs, - ) -} diff --git a/operator/apis/loki/v1beta1/recordingrule_types.go b/operator/apis/loki/v1beta1/recordingrule_types.go index 49380e6ad845..86376e67ff5c 100644 --- a/operator/apis/loki/v1beta1/recordingrule_types.go +++ b/operator/apis/loki/v1beta1/recordingrule_types.go @@ -85,6 +85,7 @@ type RecordingRuleStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:webhook:path=/validate-loki-grafana-com-v1beta1-recordingrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=loki.grafana.com,resources=recordingrules,verbs=create;update,versions=v1beta1,name=vrecordingrule.loki.grafana.com,admissionReviewVersions=v1 // RecordingRule is the Schema for the recordingrules API // diff --git a/operator/apis/loki/v1beta1/recordingrule_webhook.go b/operator/apis/loki/v1beta1/recordingrule_webhook.go deleted file mode 100644 index b07ff7d36b30..000000000000 --- a/operator/apis/loki/v1beta1/recordingrule_webhook.go +++ /dev/null @@ -1,104 +0,0 @@ -package v1beta1 - -import ( - "github.com/grafana/loki/pkg/logql/syntax" - - "github.com/prometheus/common/model" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation/field" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/webhook" -) - -// SetupWebhookWithManager registers the RecordingRuleWebhook to the controller-runtime manager -// or returns an error. -func (r *RecordingRule) SetupWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr). - For(r). - Complete() -} - -//+kubebuilder:webhook:path=/validate-loki-grafana-com-v1beta1-recordingrule,mutating=false,failurePolicy=fail,sideEffects=None,groups=loki.grafana.com,resources=recordingrules,verbs=create;update,versions=v1beta1,name=vrecordingrule.kb.io,admissionReviewVersions=v1 - -var _ webhook.Validator = &RecordingRule{} - -// ValidateCreate implements webhook.Validator so a webhook will be registered for the type -func (r *RecordingRule) ValidateCreate() error { - return r.validate() -} - -// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type -func (r *RecordingRule) ValidateUpdate(_ runtime.Object) error { - return r.validate() -} - -// ValidateDelete implements webhook.Validator so a webhook will be registered for the type -func (r *RecordingRule) ValidateDelete() error { - // Do nothing - return nil -} - -func (r *RecordingRule) validate() error { - var allErrs field.ErrorList - - found := make(map[string]bool) - - for i, g := range r.Spec.Groups { - // Check for group name uniqueness - if found[g.Name] { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Name"), - g.Name, - ErrGroupNamesNotUnique.Error(), - )) - } - - found[g.Name] = true - - // Check if rule evaluation period is a valid PromQL duration - _, err := model.ParseDuration(string(g.Interval)) - if err != nil { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Interval"), - g.Interval, - ErrParseEvaluationInterval.Error(), - )) - } - - for j, r := range g.Rules { - // Check if recording rule name is a valid PromQL Label Name - if r.Record != "" { - if !model.IsValidMetricName(model.LabelValue(r.Record)) { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Record"), - r.Record, - ErrInvalidRecordMetricName.Error(), - )) - } - } - - // Check if the LogQL parser can parse the rule expression - _, err := syntax.ParseExpr(r.Expr) - if err != nil { - allErrs = append(allErrs, field.Invalid( - field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), - r.Expr, - ErrParseLogQLExpression.Error(), - )) - } - } - } - - if len(allErrs) == 0 { - return nil - } - - return apierrors.NewInvalid( - schema.GroupKind{Group: "loki.grafana.com", Kind: "RecordingRule"}, - r.Name, - allErrs, - ) -} diff --git a/operator/apis/loki/v1beta1/v1beta1.go b/operator/apis/loki/v1beta1/v1beta1.go index c7f89575005f..64409751721f 100644 --- a/operator/apis/loki/v1beta1/v1beta1.go +++ b/operator/apis/loki/v1beta1/v1beta1.go @@ -39,6 +39,8 @@ var ( ErrParseEvaluationInterval = errors.New("Failed to parse evaluation") // ErrParseLogQLExpression when any loki rule expression is not a valid LogQL expression. ErrParseLogQLExpression = errors.New("Failed to parse LogQL expression") + // ErrParseLogQLNotSample when the Loki rule expression does not evaluate to a sample expression. + ErrParseLogQLNotSample = errors.New("LogQL expression is not a sample query") // ErrEffectiveDatesNotUnique when effective dates are not unique. ErrEffectiveDatesNotUnique = errors.New("Effective dates are not unique") // ErrParseEffectiveDates when effective dates cannot be parsed. @@ -51,4 +53,8 @@ var ( ErrSchemaRetroactivelyRemoved = errors.New("Cannot retroactively remove schema(s)") // ErrSchemaRetroactivelyChanged when a schema has been retroactively changed ErrSchemaRetroactivelyChanged = errors.New("Cannot retroactively change schema") + + // ErrRuleMustMatchNamespace indicates that an expression used in an alerting or recording rule is missing + // matchers for a namespace. + ErrRuleMustMatchNamespace = errors.New("rule needs to have a matcher for the namespace") ) diff --git a/operator/apis/loki/v1beta1/zz_generated.deepcopy.go b/operator/apis/loki/v1beta1/zz_generated.deepcopy.go index bb09690a30c4..f9faf48489ac 100644 --- a/operator/apis/loki/v1beta1/zz_generated.deepcopy.go +++ b/operator/apis/loki/v1beta1/zz_generated.deepcopy.go @@ -8,7 +8,7 @@ package v1beta1 import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/operator/bundle/manifests/loki-operator-manager-config_v1_configmap.yaml b/operator/bundle/manifests/loki-operator-manager-config_v1_configmap.yaml index 63a9f20ff7ec..96699b3327f3 100644 --- a/operator/bundle/manifests/loki-operator-manager-config_v1_configmap.yaml +++ b/operator/bundle/manifests/loki-operator-manager-config_v1_configmap.yaml @@ -45,6 +45,7 @@ data: openshift: servingCertsService: true gatewayRoute: true + ruleExtendedValidation: true kind: ConfigMap metadata: labels: diff --git a/operator/bundle/manifests/loki-operator.clusterserviceversion.yaml b/operator/bundle/manifests/loki-operator.clusterserviceversion.yaml index e383cf2284b4..92193f430ad6 100644 --- a/operator/bundle/manifests/loki-operator.clusterserviceversion.yaml +++ b/operator/bundle/manifests/loki-operator.clusterserviceversion.yaml @@ -1346,7 +1346,7 @@ spec: containerPort: 443 deploymentName: loki-operator-controller-manager failurePolicy: Fail - generateName: valertingrule.kb.io + generateName: valertingrule.loki.grafana.com rules: - apiGroups: - loki.grafana.com @@ -1386,7 +1386,7 @@ spec: containerPort: 443 deploymentName: loki-operator-controller-manager failurePolicy: Fail - generateName: vrecordingrule.kb.io + generateName: vrecordingrule.loki.grafana.com rules: - apiGroups: - loki.grafana.com diff --git a/operator/config/overlays/openshift/controller_manager_config.yaml b/operator/config/overlays/openshift/controller_manager_config.yaml index 797f33f6453d..12c0760257ec 100644 --- a/operator/config/overlays/openshift/controller_manager_config.yaml +++ b/operator/config/overlays/openshift/controller_manager_config.yaml @@ -42,3 +42,4 @@ featureGates: openshift: servingCertsService: true gatewayRoute: true + ruleExtendedValidation: true diff --git a/operator/config/webhook/manifests.yaml b/operator/config/webhook/manifests.yaml index c50dd95b5ca1..301dd653d701 100644 --- a/operator/config/webhook/manifests.yaml +++ b/operator/config/webhook/manifests.yaml @@ -33,7 +33,7 @@ webhooks: namespace: system path: /validate-loki-grafana-com-v1beta1-alertingrule failurePolicy: Fail - name: valertingrule.kb.io + name: valertingrule.loki.grafana.com rules: - apiGroups: - loki.grafana.com @@ -53,7 +53,7 @@ webhooks: namespace: system path: /validate-loki-grafana-com-v1beta1-recordingrule failurePolicy: Fail - name: vrecordingrule.kb.io + name: vrecordingrule.loki.grafana.com rules: - apiGroups: - loki.grafana.com diff --git a/operator/go.mod b/operator/go.mod index e29016b2f40c..2cf9739de3ba 100644 --- a/operator/go.mod +++ b/operator/go.mod @@ -26,6 +26,7 @@ require ( github.com/google/go-cmp v0.5.7 github.com/grafana/loki v1.6.2-0.20220708124813-b92f113cb096 github.com/openshift/library-go v0.0.0-20220622115547-84d884f4c9f6 + github.com/prometheus/prometheus v1.8.2-0.20220303173753-edfe657b5405 gopkg.in/yaml.v2 v2.4.0 ) @@ -122,7 +123,6 @@ require ( github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289 // indirect github.com/prometheus/procfs v0.7.3 // indirect - github.com/prometheus/prometheus v1.8.2-0.20220303173753-edfe657b5405 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/sercand/kuberesolver v2.4.0+incompatible // indirect github.com/shopspring/decimal v1.2.0 // indirect diff --git a/operator/internal/validation/alertingrule.go b/operator/internal/validation/alertingrule.go new file mode 100644 index 000000000000..82dce98645cf --- /dev/null +++ b/operator/internal/validation/alertingrule.go @@ -0,0 +1,133 @@ +package validation + +import ( + "context" + "fmt" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "github.com/grafana/loki/pkg/logql/syntax" + "github.com/prometheus/common/model" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ admission.CustomValidator = &AlertingRuleValidator{} + +// AlertingRuleValidator implements a custom validator for AlertingRule resources. +type AlertingRuleValidator struct { + ExtendedValidator func(context.Context, *lokiv1beta1.AlertingRule) field.ErrorList +} + +// SetupWebhookWithManager registers the AlertingRuleValidator as a validating webhook +// with the controller-runtime manager or returns an error. +func (v *AlertingRuleValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&lokiv1beta1.AlertingRule{}). + WithValidator(v). + Complete() +} + +// ValidateCreate implements admission.CustomValidator. +func (v *AlertingRuleValidator) ValidateCreate(ctx context.Context, obj runtime.Object) error { + return v.validate(ctx, obj) +} + +// ValidateUpdate implements admission.CustomValidator. +func (v *AlertingRuleValidator) ValidateUpdate(ctx context.Context, _, newObj runtime.Object) error { + return v.validate(ctx, newObj) +} + +// ValidateDelete implements admission.CustomValidator. +func (v *AlertingRuleValidator) ValidateDelete(_ context.Context, _ runtime.Object) error { + // No validation on delete + return nil +} + +func (v *AlertingRuleValidator) validate(ctx context.Context, obj runtime.Object) error { + alertingRule, ok := obj.(*lokiv1beta1.AlertingRule) + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("object is not of type AlertingRule: %t", obj)) + } + + var allErrs field.ErrorList + + found := make(map[string]bool) + + for i, g := range alertingRule.Spec.Groups { + // Check for group name uniqueness + if found[g.Name] { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Name"), + g.Name, + lokiv1beta1.ErrGroupNamesNotUnique.Error(), + )) + } + + found[g.Name] = true + + // Check if rule evaluation period is a valid PromQL duration + _, err := model.ParseDuration(string(g.Interval)) + if err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Interval"), + g.Interval, + lokiv1beta1.ErrParseEvaluationInterval.Error(), + )) + } + + for j, rule := range g.Rules { + // Check if alert for period is a valid PromQL duration + if rule.Alert != "" { + if _, err := model.ParseDuration(string(rule.For)); err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("For"), + rule.For, + lokiv1beta1.ErrParseAlertForPeriod.Error(), + )) + + continue + } + } + + // Check if the LogQL parser can parse the rule expression + expr, err := syntax.ParseExpr(rule.Expr) + if err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + rule.Expr, + lokiv1beta1.ErrParseLogQLExpression.Error(), + )) + + continue + } + + // Validate that the expression is a sample-expression (metrics as result) and not for logs + if _, ok := expr.(syntax.SampleExpr); !ok { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + rule.Expr, + lokiv1beta1.ErrParseLogQLNotSample.Error(), + )) + } + } + } + + if v.ExtendedValidator != nil { + allErrs = append(allErrs, v.ExtendedValidator(ctx, alertingRule)...) + } + + if len(allErrs) == 0 { + return nil + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: "loki.grafana.com", Kind: "AlertingRule"}, + alertingRule.Name, + allErrs, + ) +} diff --git a/operator/apis/loki/v1beta1/alertingrule_webhook_test.go b/operator/internal/validation/alertingrule_test.go similarity index 83% rename from operator/apis/loki/v1beta1/alertingrule_webhook_test.go rename to operator/internal/validation/alertingrule_test.go index 9d797dfc3406..d0f71e37902b 100644 --- a/operator/apis/loki/v1beta1/alertingrule_webhook_test.go +++ b/operator/internal/validation/alertingrule_test.go @@ -1,11 +1,13 @@ -package v1beta1_test +package validation_test import ( + "context" "testing" "github.com/grafana/loki/operator/apis/loki/v1beta1" - "github.com/stretchr/testify/require" + "github.com/grafana/loki/operator/internal/validation" + "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -186,6 +188,33 @@ var att = []struct { }, ), }, + { + desc: "LogQL not sample-expression", + spec: v1beta1.AlertingRuleSpec{ + Groups: []*v1beta1.AlertingRuleGroup{ + { + Name: "first", + Interval: v1beta1.PrometheusDuration("1m"), + Rules: []*v1beta1.AlertingRuleGroupSpec{ + { + Expr: `{message=~".+"}`, + }, + }, + }, + }, + }, + err: apierrors.NewInvalid( + schema.GroupKind{Group: "loki.grafana.com", Kind: "AlertingRule"}, + "testing-rule", + field.ErrorList{ + field.Invalid( + field.NewPath("Spec").Child("Groups").Index(0).Child("Rules").Index(0).Child("Expr"), + `{message=~".+"}`, + v1beta1.ErrParseLogQLNotSample.Error(), + ), + }, + ), + }, } func TestAlertingRuleValidationWebhook_ValidateCreate(t *testing.T) { @@ -193,14 +222,17 @@ func TestAlertingRuleValidationWebhook_ValidateCreate(t *testing.T) { tc := tc t.Run(tc.desc, func(t *testing.T) { t.Parallel() - l := v1beta1.AlertingRule{ + + l := &v1beta1.AlertingRule{ ObjectMeta: metav1.ObjectMeta{ Name: "testing-rule", }, Spec: tc.spec, } + ctx := context.Background() - err := l.ValidateCreate() + v := &validation.AlertingRuleValidator{} + err := v.ValidateCreate(ctx, l) if err != nil { require.Equal(t, tc.err, err) } else { @@ -215,14 +247,17 @@ func TestAlertingRuleValidationWebhook_ValidateUpdate(t *testing.T) { tc := tc t.Run(tc.desc, func(t *testing.T) { t.Parallel() - l := v1beta1.AlertingRule{ + + l := &v1beta1.AlertingRule{ ObjectMeta: metav1.ObjectMeta{ Name: "testing-rule", }, Spec: tc.spec, } + ctx := context.Background() - err := l.ValidateUpdate(&v1beta1.AlertingRule{}) + v := &validation.AlertingRuleValidator{} + err := v.ValidateUpdate(ctx, &v1beta1.AlertingRule{}, l) if err != nil { require.Equal(t, tc.err, err) } else { diff --git a/operator/internal/validation/openshift/alertingrule.go b/operator/internal/validation/openshift/alertingrule.go new file mode 100644 index 000000000000..effa7aeaf41f --- /dev/null +++ b/operator/internal/validation/openshift/alertingrule.go @@ -0,0 +1,40 @@ +package openshift + +import ( + "context" + "fmt" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/strings/slices" +) + +// AlertingRuleValidator does extended-validation of AlertingRule resources for Openshift-based deployments. +func AlertingRuleValidator(_ context.Context, alertingRule *lokiv1beta1.AlertingRule) field.ErrorList { + var allErrs field.ErrorList + + // Check tenant matches expected value + tenantID := alertingRule.Spec.TenantID + wantTenant := tenantForNamespace(alertingRule.Namespace) + if !slices.Contains(wantTenant, tenantID) { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("TenantID"), + tenantID, + fmt.Sprintf("AlertingRule does not use correct tenant %q", wantTenant))) + } + + for i, g := range alertingRule.Spec.Groups { + for j, rule := range g.Rules { + if err := validateRuleExpression(alertingRule.Namespace, tenantID, rule.Expr); err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + rule.Expr, + err.Error(), + )) + } + } + } + + return allErrs +} diff --git a/operator/internal/validation/openshift/alertingrule_test.go b/operator/internal/validation/openshift/alertingrule_test.go new file mode 100644 index 000000000000..9ff99fd8c9dd --- /dev/null +++ b/operator/internal/validation/openshift/alertingrule_test.go @@ -0,0 +1,221 @@ +package openshift + +import ( + "context" + "testing" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestAlertingRuleValidator(t *testing.T) { + tt := []struct { + desc string + spec *lokiv1beta1.AlertingRule + wantErrors field.ErrorList + }{ + { + desc: "success", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="example", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: nil, + }, + { + desc: "allow audit in openshift-logging", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "openshift-logging", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "audit", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `sum(rate({level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: nil, + }, + { + desc: "wrong tenant", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "openshift-example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="openshift-example", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.TenantID", + BadValue: "application", + Detail: `AlertingRule does not use correct tenant ["infrastructure"]`, + }, + }, + }, + { + desc: "expression does not parse", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: "invalid", + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: "invalid", + Detail: lokiv1beta1.ErrParseLogQLExpression.Error(), + }, + }, + }, + { + desc: "expression does not produce samples", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `{kubernetes_namespace_name="example", level="error"}`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `{kubernetes_namespace_name="example", level="error"}`, + Detail: lokiv1beta1.ErrParseLogQLNotSample.Error(), + }, + }, + }, + { + desc: "no namespace matcher", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `sum(rate({level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `sum(rate({level="error"}[5m])) by (job) > 0.1`, + Detail: lokiv1beta1.ErrRuleMustMatchNamespace.Error(), + }, + }, + }, + { + desc: "matcher does not match AlertingRule namespace", + spec: &lokiv1beta1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "alerting-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.AlertingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.AlertingRuleGroup{ + { + Rules: []*lokiv1beta1.AlertingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="other-ns", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `sum(rate({kubernetes_namespace_name="other-ns", level="error"}[5m])) by (job) > 0.1`, + Detail: lokiv1beta1.ErrRuleMustMatchNamespace.Error(), + }, + }, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + errors := AlertingRuleValidator(ctx, tc.spec) + require.Equal(t, tc.wantErrors, errors) + }) + } +} diff --git a/operator/internal/validation/openshift/common.go b/operator/internal/validation/openshift/common.go new file mode 100644 index 000000000000..66e21b73ad4b --- /dev/null +++ b/operator/internal/validation/openshift/common.go @@ -0,0 +1,63 @@ +package openshift + +import ( + "strings" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "github.com/grafana/loki/pkg/logql/syntax" + "github.com/prometheus/prometheus/model/labels" +) + +const ( + namespaceLabelName = "kubernetes_namespace_name" + namespaceOpenshiftLogging = "openshift-logging" + + tenantAudit = "audit" + tenantApplication = "application" + tenantInfrastructure = "infrastructure" +) + +func validateRuleExpression(namespace, tenantID, rawExpr string) error { + // Check if the LogQL parser can parse the rule expression + expr, err := syntax.ParseExpr(rawExpr) + if err != nil { + return lokiv1beta1.ErrParseLogQLExpression + } + + sampleExpr, ok := expr.(syntax.SampleExpr) + if !ok { + return lokiv1beta1.ErrParseLogQLNotSample + } + + matchers := sampleExpr.Selector().Matchers() + if tenantID != tenantAudit && !validateIncludesNamespace(namespace, matchers) { + return lokiv1beta1.ErrRuleMustMatchNamespace + } + + return nil +} + +func validateIncludesNamespace(namespace string, matchers []*labels.Matcher) bool { + for _, m := range matchers { + if m.Name == namespaceLabelName && m.Type == labels.MatchEqual && m.Value == namespace { + return true + } + } + + return false +} + +func tenantForNamespace(namespace string) []string { + if strings.HasPrefix(namespace, "openshift") || + strings.HasPrefix(namespace, "kube-") || + namespace == "default" { + if namespace == namespaceOpenshiftLogging { + return []string{tenantAudit, tenantInfrastructure} + } + + return []string{tenantInfrastructure} + } + + return []string{tenantApplication} +} diff --git a/operator/internal/validation/openshift/recordingrule.go b/operator/internal/validation/openshift/recordingrule.go new file mode 100644 index 000000000000..7388cb40ae9d --- /dev/null +++ b/operator/internal/validation/openshift/recordingrule.go @@ -0,0 +1,40 @@ +package openshift + +import ( + "context" + "fmt" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/strings/slices" +) + +// RecordingRuleValidator does extended-validation of RecordingRule resources for Openshift-based deployments. +func RecordingRuleValidator(_ context.Context, recordingRule *lokiv1beta1.RecordingRule) field.ErrorList { + var allErrs field.ErrorList + + // Check tenant matches expected value + tenantID := recordingRule.Spec.TenantID + wantTenant := tenantForNamespace(recordingRule.Namespace) + if !slices.Contains(wantTenant, tenantID) { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("TenantID"), + tenantID, + fmt.Sprintf("RecordingRule does not use correct tenant %q", wantTenant))) + } + + for i, g := range recordingRule.Spec.Groups { + for j, rule := range g.Rules { + if err := validateRuleExpression(recordingRule.Namespace, tenantID, rule.Expr); err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + rule.Expr, + err.Error(), + )) + } + } + } + + return allErrs +} diff --git a/operator/internal/validation/openshift/recordingrule_test.go b/operator/internal/validation/openshift/recordingrule_test.go new file mode 100644 index 000000000000..168417a35c2c --- /dev/null +++ b/operator/internal/validation/openshift/recordingrule_test.go @@ -0,0 +1,199 @@ +package openshift + +import ( + "context" + "testing" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestRecordingRuleValidator(t *testing.T) { + tt := []struct { + desc string + spec *lokiv1beta1.RecordingRule + wantErrors field.ErrorList + }{ + { + desc: "success", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="example", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: nil, + }, + { + desc: "wrong tenant", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "openshift-example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="openshift-example", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.TenantID", + BadValue: "application", + Detail: `RecordingRule does not use correct tenant ["infrastructure"]`, + }, + }, + }, + { + desc: "expression does not parse", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: "invalid", + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: "invalid", + Detail: lokiv1beta1.ErrParseLogQLExpression.Error(), + }, + }, + }, + { + desc: "expression does not produce samples", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: `{kubernetes_namespace_name="example", level="error"}`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `{kubernetes_namespace_name="example", level="error"}`, + Detail: lokiv1beta1.ErrParseLogQLNotSample.Error(), + }, + }, + }, + { + desc: "no namespace matcher", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: `sum(rate({level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `sum(rate({level="error"}[5m])) by (job) > 0.1`, + Detail: lokiv1beta1.ErrRuleMustMatchNamespace.Error(), + }, + }, + }, + { + desc: "matcher does not match RecordingRule namespace", + spec: &lokiv1beta1.RecordingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recording-rule", + Namespace: "example", + }, + Spec: lokiv1beta1.RecordingRuleSpec{ + TenantID: "application", + Groups: []*lokiv1beta1.RecordingRuleGroup{ + { + Rules: []*lokiv1beta1.RecordingRuleGroupSpec{ + { + Expr: `sum(rate({kubernetes_namespace_name="other-ns", level="error"}[5m])) by (job) > 0.1`, + }, + }, + }, + }, + }, + }, + wantErrors: []*field.Error{ + { + Type: field.ErrorTypeInvalid, + Field: "Spec.Groups[0].Rules[0].Expr", + BadValue: `sum(rate({kubernetes_namespace_name="other-ns", level="error"}[5m])) by (job) > 0.1`, + Detail: lokiv1beta1.ErrRuleMustMatchNamespace.Error(), + }, + }, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + errors := RecordingRuleValidator(ctx, tc.spec) + require.Equal(t, tc.wantErrors, errors) + }) + } +} diff --git a/operator/internal/validation/recordingrule.go b/operator/internal/validation/recordingrule.go new file mode 100644 index 000000000000..920a6e0d267f --- /dev/null +++ b/operator/internal/validation/recordingrule.go @@ -0,0 +1,131 @@ +package validation + +import ( + "context" + "fmt" + + lokiv1beta1 "github.com/grafana/loki/operator/apis/loki/v1beta1" + + "github.com/grafana/loki/pkg/logql/syntax" + "github.com/prometheus/common/model" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ admission.CustomValidator = &RecordingRuleValidator{} + +// RecordingRuleValidator implements a custom validator for RecordingRule resources. +type RecordingRuleValidator struct { + ExtendedValidator func(context.Context, *lokiv1beta1.RecordingRule) field.ErrorList +} + +// SetupWebhookWithManager registers the RecordingRuleValidator as a validating webhook +// with the controller-runtime manager or returns an error. +func (v *RecordingRuleValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&lokiv1beta1.RecordingRule{}). + WithValidator(v). + Complete() +} + +// ValidateCreate implements admission.CustomValidator. +func (v *RecordingRuleValidator) ValidateCreate(ctx context.Context, obj runtime.Object) error { + return v.validate(ctx, obj) +} + +// ValidateUpdate implements admission.CustomValidator. +func (v *RecordingRuleValidator) ValidateUpdate(ctx context.Context, _, newObj runtime.Object) error { + return v.validate(ctx, newObj) +} + +// ValidateDelete implements admission.CustomValidator. +func (v *RecordingRuleValidator) ValidateDelete(_ context.Context, _ runtime.Object) error { + // No validation on delete + return nil +} + +func (v *RecordingRuleValidator) validate(ctx context.Context, obj runtime.Object) error { + recordingRule, ok := obj.(*lokiv1beta1.RecordingRule) + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("object is not of type RecordingRule: %t", obj)) + } + + var allErrs field.ErrorList + + found := make(map[string]bool) + + for i, g := range recordingRule.Spec.Groups { + // Check for group name uniqueness + if found[g.Name] { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Name"), + g.Name, + lokiv1beta1.ErrGroupNamesNotUnique.Error(), + )) + } + + found[g.Name] = true + + // Check if rule evaluation period is a valid PromQL duration + _, err := model.ParseDuration(string(g.Interval)) + if err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Interval"), + g.Interval, + lokiv1beta1.ErrParseEvaluationInterval.Error(), + )) + } + + for j, r := range g.Rules { + // Check if recording rule name is a valid PromQL Label Name + if r.Record != "" { + if !model.IsValidMetricName(model.LabelValue(r.Record)) { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Record"), + r.Record, + lokiv1beta1.ErrInvalidRecordMetricName.Error(), + )) + } + } + + // Check if the LogQL parser can parse the rule expression + expr, err := syntax.ParseExpr(r.Expr) + if err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + r.Expr, + lokiv1beta1.ErrParseLogQLExpression.Error(), + )) + + continue + } + + // Validate that the expression is a sample-expression (metrics as result) and not for logs + if _, ok := expr.(syntax.SampleExpr); !ok { + allErrs = append(allErrs, field.Invalid( + field.NewPath("Spec").Child("Groups").Index(i).Child("Rules").Index(j).Child("Expr"), + r.Expr, + lokiv1beta1.ErrParseLogQLNotSample.Error(), + )) + } + } + } + + if v.ExtendedValidator != nil { + allErrs = append(allErrs, v.ExtendedValidator(ctx, recordingRule)...) + } + + if len(allErrs) == 0 { + return nil + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: "loki.grafana.com", Kind: "RecordingRule"}, + recordingRule.Name, + allErrs, + ) +} diff --git a/operator/apis/loki/v1beta1/recordingrule_webhook_test.go b/operator/internal/validation/recordingrule_test.go similarity index 81% rename from operator/apis/loki/v1beta1/recordingrule_webhook_test.go rename to operator/internal/validation/recordingrule_test.go index c980eeecc88d..bf4b2165c51c 100644 --- a/operator/apis/loki/v1beta1/recordingrule_webhook_test.go +++ b/operator/internal/validation/recordingrule_test.go @@ -1,11 +1,13 @@ -package v1beta1_test +package validation_test import ( + "context" "testing" "github.com/grafana/loki/operator/apis/loki/v1beta1" - "github.com/stretchr/testify/require" + "github.com/grafana/loki/operator/internal/validation" + "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -155,6 +157,33 @@ var rtt = []struct { }, ), }, + { + desc: "LogQL not sample-expression", + spec: v1beta1.RecordingRuleSpec{ + Groups: []*v1beta1.RecordingRuleGroup{ + { + Name: "first", + Interval: v1beta1.PrometheusDuration("1m"), + Rules: []*v1beta1.RecordingRuleGroupSpec{ + { + Expr: `{message=~".+"}`, + }, + }, + }, + }, + }, + err: apierrors.NewInvalid( + schema.GroupKind{Group: "loki.grafana.com", Kind: "RecordingRule"}, + "testing-rule", + field.ErrorList{ + field.Invalid( + field.NewPath("Spec").Child("Groups").Index(0).Child("Rules").Index(0).Child("Expr"), + `{message=~".+"}`, + v1beta1.ErrParseLogQLNotSample.Error(), + ), + }, + ), + }, } func TestRecordingRuleValidationWebhook_ValidateCreate(t *testing.T) { @@ -162,14 +191,17 @@ func TestRecordingRuleValidationWebhook_ValidateCreate(t *testing.T) { tc := tc t.Run(tc.desc, func(t *testing.T) { t.Parallel() - l := v1beta1.RecordingRule{ + + ctx := context.Background() + l := &v1beta1.RecordingRule{ ObjectMeta: metav1.ObjectMeta{ Name: "testing-rule", }, Spec: tc.spec, } - err := l.ValidateCreate() + v := &validation.RecordingRuleValidator{} + err := v.ValidateCreate(ctx, l) if err != nil { require.Equal(t, tc.err, err) } else { @@ -184,14 +216,17 @@ func TestRecordingRuleValidationWebhook_ValidateUpdate(t *testing.T) { tc := tc t.Run(tc.desc, func(t *testing.T) { t.Parallel() - l := v1beta1.RecordingRule{ + + ctx := context.Background() + l := &v1beta1.RecordingRule{ ObjectMeta: metav1.ObjectMeta{ Name: "testing-rule", }, Spec: tc.spec, } - err := l.ValidateUpdate(&v1beta1.RecordingRule{}) + v := &validation.RecordingRuleValidator{} + err := v.ValidateUpdate(ctx, &v1beta1.RecordingRule{}, l) if err != nil { require.Equal(t, tc.err, err) } else { diff --git a/operator/main.go b/operator/main.go index b26a68b0dba2..4c258abfc8ed 100644 --- a/operator/main.go +++ b/operator/main.go @@ -8,6 +8,9 @@ import ( "github.com/ViaQ/logerr/v2/kverrors" "github.com/ViaQ/logerr/v2/log" + "github.com/grafana/loki/operator/internal/validation" + + "github.com/grafana/loki/operator/internal/validation/openshift" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -121,7 +124,12 @@ func main() { os.Exit(1) } if ctrlCfg.Gates.AlertingRuleWebhook { - if err = (&lokiv1beta1.AlertingRule{}).SetupWebhookWithManager(mgr); err != nil { + v := &validation.AlertingRuleValidator{} + if ctrlCfg.Gates.OpenShift.ExtendedRuleValidation { + v.ExtendedValidator = openshift.AlertingRuleValidator + } + + if err = v.SetupWebhookWithManager(mgr); err != nil { logger.Error(err, "unable to create webhook", "webhook", "AlertingRule") os.Exit(1) } @@ -135,7 +143,12 @@ func main() { os.Exit(1) } if ctrlCfg.Gates.RecordingRuleWebhook { - if err = (&lokiv1beta1.RecordingRule{}).SetupWebhookWithManager(mgr); err != nil { + v := &validation.RecordingRuleValidator{} + if ctrlCfg.Gates.OpenShift.ExtendedRuleValidation { + v.ExtendedValidator = openshift.RecordingRuleValidator + } + + if err = v.SetupWebhookWithManager(mgr); err != nil { logger.Error(err, "unable to create webhook", "webhook", "RecordingRule") os.Exit(1) }