diff --git a/manifest/v1alpha/agent.go b/manifest/v1alpha/agent.go index 87dccaa6..cbb04706 100644 --- a/manifest/v1alpha/agent.go +++ b/manifest/v1alpha/agent.go @@ -251,9 +251,3 @@ type GenericAgentConfig struct { type HoneycombAgentConfig struct { // Honeycomb agent doesn't require any additional parameters. } - -// AgentWithSLOs struct which mapped one to one with kind: agent and slo yaml definition -type AgentWithSLOs struct { - Agent Agent `json:"agent"` - SLOs []SLO `json:"slos"` -} diff --git a/manifest/v1alpha/alert_policy.go b/manifest/v1alpha/alert_policy.go index b1fdd403..b4f363fb 100644 --- a/manifest/v1alpha/alert_policy.go +++ b/manifest/v1alpha/alert_policy.go @@ -45,9 +45,3 @@ type AlertCondition struct { LastsForDuration string `json:"lastsFor,omitempty" validate:"omitempty,validDuration,nonNegativeDuration" example:"15m"` //nolint:lll Operator string `json:"op,omitempty" validate:"omitempty,operator" example:"lt"` } - -// AlertPolicyWithSLOs struct which mapped one to one with kind: alert policy and slo yaml definition -type AlertPolicyWithSLOs struct { - AlertPolicy AlertPolicy `json:"alertPolicy"` - SLOs []SLO `json:"slos"` -} diff --git a/manifest/v1alpha/data_sources.go b/manifest/v1alpha/data_sources.go index 8695a55e..dee822a3 100644 --- a/manifest/v1alpha/data_sources.go +++ b/manifest/v1alpha/data_sources.go @@ -11,38 +11,42 @@ import ( "github.com/nobl9/nobl9-go/manifest" ) -type DataSourceType int +//go:generate ../../bin/go-enum --values --noprefix +// DataSourceType represents the type of data source, either Agent or Direct. +// // Beware that order of these constants is very important // existing integrations are saved in db with type = DataSourceType. // New integrations always have to be added as last item in this list to get new "type id". -const ( - Prometheus DataSourceType = iota + 1 - Datadog - NewRelic - AppDynamics - Splunk - Lightstep - SplunkObservability - Dynatrace - ThousandEyes - Graphite - BigQuery - Elasticsearch - OpenTSDB - GrafanaLoki - CloudWatch - Pingdom - AmazonPrometheus - Redshift - SumoLogic - Instana - InfluxDB - GCM - AzureMonitor - Generic - Honeycomb -) +// +/* ENUM( +Prometheus = 1 +Datadog +NewRelic +AppDynamics +Splunk +Lightstep +SplunkObservability +Dynatrace +ThousandEyes +Graphite +BigQuery +Elasticsearch +OpenTSDB +GrafanaLoki +CloudWatch +Pingdom +AmazonPrometheus +Redshift +SumoLogic +Instana +InfluxDB +GCM +AzureMonitor +Generic +Honeycomb +)*/ +type DataSourceType int const DatasourceStableChannel = "stable" @@ -102,42 +106,6 @@ func IsValidSourceOf(sourceOf string) bool { return ok } -var agentTypeToName = map[DataSourceType]string{ - Prometheus: "Prometheus", - Datadog: "Datadog", - NewRelic: "NewRelic", - AppDynamics: "AppDynamics", - Splunk: "Splunk", - Lightstep: "Lightstep", - SplunkObservability: "SplunkObservability", - Dynatrace: "Dynatrace", - Elasticsearch: "Elasticsearch", - ThousandEyes: "ThousandEyes", - Graphite: "Graphite", - BigQuery: "BigQuery", - OpenTSDB: "OpenTSDB", - GrafanaLoki: "GrafanaLoki", - CloudWatch: "CloudWatch", - Pingdom: "Pingdom", - AmazonPrometheus: "AmazonPrometheus", - Redshift: "Redshift", - SumoLogic: "SumoLogic", - Instana: "Instana", - InfluxDB: "InfluxDB", - GCM: "GoogleCloudMonitoring", - AzureMonitor: "AzureMonitor", - Generic: "Generic", - Honeycomb: "Honeycomb", -} - -func (dst DataSourceType) String() string { - if key, ok := agentTypeToName[dst]; ok { - return key - } - //nolint: goconst - return "Unknown" -} - // HistoricalRetrievalDuration struct was previously called Duration. However, this name was too generic // since we also needed to introduce a Duration struct for QueryDelay, which allowed for different time units. // Time travel is allowed for days/hours/minutes, and query delay can be set to minutes/seconds. Separating those two diff --git a/manifest/v1alpha/data_sources_enum.go b/manifest/v1alpha/data_sources_enum.go new file mode 100644 index 00000000..018a91ec --- /dev/null +++ b/manifest/v1alpha/data_sources_enum.go @@ -0,0 +1,180 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: 0.5.8 +// Revision: 3d844c8ecc59661ed7aa17bfd65727bc06a60ad8 +// Build Date: 2023-09-18T14:55:21Z +// Built By: goreleaser + +package v1alpha + +import ( + "fmt" + + "github.com/pkg/errors" +) + +const ( + // Prometheus is a DataSourceType of type Prometheus. + Prometheus DataSourceType = iota + 1 + // Datadog is a DataSourceType of type Datadog. + Datadog + // NewRelic is a DataSourceType of type NewRelic. + NewRelic + // AppDynamics is a DataSourceType of type AppDynamics. + AppDynamics + // Splunk is a DataSourceType of type Splunk. + Splunk + // Lightstep is a DataSourceType of type Lightstep. + Lightstep + // SplunkObservability is a DataSourceType of type SplunkObservability. + SplunkObservability + // Dynatrace is a DataSourceType of type Dynatrace. + Dynatrace + // ThousandEyes is a DataSourceType of type ThousandEyes. + ThousandEyes + // Graphite is a DataSourceType of type Graphite. + Graphite + // BigQuery is a DataSourceType of type BigQuery. + BigQuery + // Elasticsearch is a DataSourceType of type Elasticsearch. + Elasticsearch + // OpenTSDB is a DataSourceType of type OpenTSDB. + OpenTSDB + // GrafanaLoki is a DataSourceType of type GrafanaLoki. + GrafanaLoki + // CloudWatch is a DataSourceType of type CloudWatch. + CloudWatch + // Pingdom is a DataSourceType of type Pingdom. + Pingdom + // AmazonPrometheus is a DataSourceType of type AmazonPrometheus. + AmazonPrometheus + // Redshift is a DataSourceType of type Redshift. + Redshift + // SumoLogic is a DataSourceType of type SumoLogic. + SumoLogic + // Instana is a DataSourceType of type Instana. + Instana + // InfluxDB is a DataSourceType of type InfluxDB. + InfluxDB + // GCM is a DataSourceType of type GCM. + GCM + // AzureMonitor is a DataSourceType of type AzureMonitor. + AzureMonitor + // Generic is a DataSourceType of type Generic. + Generic + // Honeycomb is a DataSourceType of type Honeycomb. + Honeycomb +) + +var ErrInvalidDataSourceType = errors.New("not a valid DataSourceType") + +const _DataSourceTypeName = "PrometheusDatadogNewRelicAppDynamicsSplunkLightstepSplunkObservabilityDynatraceThousandEyesGraphiteBigQueryElasticsearchOpenTSDBGrafanaLokiCloudWatchPingdomAmazonPrometheusRedshiftSumoLogicInstanaInfluxDBGCMAzureMonitorGenericHoneycomb" + +// DataSourceTypeValues returns a list of the values for DataSourceType +func DataSourceTypeValues() []DataSourceType { + return []DataSourceType{ + Prometheus, + Datadog, + NewRelic, + AppDynamics, + Splunk, + Lightstep, + SplunkObservability, + Dynatrace, + ThousandEyes, + Graphite, + BigQuery, + Elasticsearch, + OpenTSDB, + GrafanaLoki, + CloudWatch, + Pingdom, + AmazonPrometheus, + Redshift, + SumoLogic, + Instana, + InfluxDB, + GCM, + AzureMonitor, + Generic, + Honeycomb, + } +} + +var _DataSourceTypeMap = map[DataSourceType]string{ + Prometheus: _DataSourceTypeName[0:10], + Datadog: _DataSourceTypeName[10:17], + NewRelic: _DataSourceTypeName[17:25], + AppDynamics: _DataSourceTypeName[25:36], + Splunk: _DataSourceTypeName[36:42], + Lightstep: _DataSourceTypeName[42:51], + SplunkObservability: _DataSourceTypeName[51:70], + Dynatrace: _DataSourceTypeName[70:79], + ThousandEyes: _DataSourceTypeName[79:91], + Graphite: _DataSourceTypeName[91:99], + BigQuery: _DataSourceTypeName[99:107], + Elasticsearch: _DataSourceTypeName[107:120], + OpenTSDB: _DataSourceTypeName[120:128], + GrafanaLoki: _DataSourceTypeName[128:139], + CloudWatch: _DataSourceTypeName[139:149], + Pingdom: _DataSourceTypeName[149:156], + AmazonPrometheus: _DataSourceTypeName[156:172], + Redshift: _DataSourceTypeName[172:180], + SumoLogic: _DataSourceTypeName[180:189], + Instana: _DataSourceTypeName[189:196], + InfluxDB: _DataSourceTypeName[196:204], + GCM: _DataSourceTypeName[204:207], + AzureMonitor: _DataSourceTypeName[207:219], + Generic: _DataSourceTypeName[219:226], + Honeycomb: _DataSourceTypeName[226:235], +} + +// String implements the Stringer interface. +func (x DataSourceType) String() string { + if str, ok := _DataSourceTypeMap[x]; ok { + return str + } + return fmt.Sprintf("DataSourceType(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x DataSourceType) IsValid() bool { + _, ok := _DataSourceTypeMap[x] + return ok +} + +var _DataSourceTypeValue = map[string]DataSourceType{ + _DataSourceTypeName[0:10]: Prometheus, + _DataSourceTypeName[10:17]: Datadog, + _DataSourceTypeName[17:25]: NewRelic, + _DataSourceTypeName[25:36]: AppDynamics, + _DataSourceTypeName[36:42]: Splunk, + _DataSourceTypeName[42:51]: Lightstep, + _DataSourceTypeName[51:70]: SplunkObservability, + _DataSourceTypeName[70:79]: Dynatrace, + _DataSourceTypeName[79:91]: ThousandEyes, + _DataSourceTypeName[91:99]: Graphite, + _DataSourceTypeName[99:107]: BigQuery, + _DataSourceTypeName[107:120]: Elasticsearch, + _DataSourceTypeName[120:128]: OpenTSDB, + _DataSourceTypeName[128:139]: GrafanaLoki, + _DataSourceTypeName[139:149]: CloudWatch, + _DataSourceTypeName[149:156]: Pingdom, + _DataSourceTypeName[156:172]: AmazonPrometheus, + _DataSourceTypeName[172:180]: Redshift, + _DataSourceTypeName[180:189]: SumoLogic, + _DataSourceTypeName[189:196]: Instana, + _DataSourceTypeName[196:204]: InfluxDB, + _DataSourceTypeName[204:207]: GCM, + _DataSourceTypeName[207:219]: AzureMonitor, + _DataSourceTypeName[219:226]: Generic, + _DataSourceTypeName[226:235]: Honeycomb, +} + +// ParseDataSourceType attempts to convert a string to a DataSourceType. +func ParseDataSourceType(name string) (DataSourceType, error) { + if x, ok := _DataSourceTypeValue[name]; ok { + return x, nil + } + return DataSourceType(0), fmt.Errorf("%s is %w", name, ErrInvalidDataSourceType) +} diff --git a/manifest/v1alpha/direct.go b/manifest/v1alpha/direct.go index d1b869bd..e32d961f 100644 --- a/manifest/v1alpha/direct.go +++ b/manifest/v1alpha/direct.go @@ -425,12 +425,6 @@ type PublicHoneycombDirectConfig struct { HiddenAPIKey string `json:"apiKey,omitempty"` } -// PublicDirectWithSLOs struct which mapped one to one with kind: direct and slo yaml definition -type PublicDirectWithSLOs struct { - Direct PublicDirect `json:"direct"` - SLOs []SLO `json:"slos"` -} - // AWSIAMRoleAuthExternalIDs struct which is used for exposing AWS IAM role auth data type AWSIAMRoleAuthExternalIDs struct { ExternalID string `json:"externalID"` diff --git a/manifest/v1alpha/metrics.go b/manifest/v1alpha/metrics.go deleted file mode 100644 index e1b87382..00000000 --- a/manifest/v1alpha/metrics.go +++ /dev/null @@ -1,569 +0,0 @@ -// Package v1alpha represents objects available in API n9/v1alpha -package v1alpha - -import "sort" - -// CountMetricsSpec represents set of two time series of good and total counts -type CountMetricsSpec struct { - Incremental *bool `json:"incremental" validate:"required"` - GoodMetric *MetricSpec `json:"good,omitempty"` - BadMetric *MetricSpec `json:"bad,omitempty"` - TotalMetric *MetricSpec `json:"total" validate:"required"` -} - -// RawMetricSpec represents integration with a metric source for a particular objective. -type RawMetricSpec struct { - MetricQuery *MetricSpec `json:"query" validate:"required"` -} - -// MetricSpec defines single time series obtained from data source -type MetricSpec struct { - Prometheus *PrometheusMetric `json:"prometheus,omitempty"` - Datadog *DatadogMetric `json:"datadog,omitempty"` - NewRelic *NewRelicMetric `json:"newRelic,omitempty"` - AppDynamics *AppDynamicsMetric `json:"appDynamics,omitempty"` - Splunk *SplunkMetric `json:"splunk,omitempty"` - Lightstep *LightstepMetric `json:"lightstep,omitempty"` - SplunkObservability *SplunkObservabilityMetric `json:"splunkObservability,omitempty"` - Dynatrace *DynatraceMetric `json:"dynatrace,omitempty"` - Elasticsearch *ElasticsearchMetric `json:"elasticsearch,omitempty"` - ThousandEyes *ThousandEyesMetric `json:"thousandEyes,omitempty"` - Graphite *GraphiteMetric `json:"graphite,omitempty"` - BigQuery *BigQueryMetric `json:"bigQuery,omitempty"` - OpenTSDB *OpenTSDBMetric `json:"opentsdb,omitempty"` - GrafanaLoki *GrafanaLokiMetric `json:"grafanaLoki,omitempty"` - CloudWatch *CloudWatchMetric `json:"cloudWatch,omitempty"` - Pingdom *PingdomMetric `json:"pingdom,omitempty"` - AmazonPrometheus *AmazonPrometheusMetric `json:"amazonPrometheus,omitempty"` - Redshift *RedshiftMetric `json:"redshift,omitempty"` - SumoLogic *SumoLogicMetric `json:"sumoLogic,omitempty"` - Instana *InstanaMetric `json:"instana,omitempty"` - InfluxDB *InfluxDBMetric `json:"influxdb,omitempty"` - GCM *GCMMetric `json:"gcm,omitempty"` - AzureMonitor *AzureMonitorMetric `json:"azureMonitor,omitempty"` - Generic *GenericMetric `json:"generic,omitempty"` - Honeycomb *HoneycombMetric `json:"honeycomb,omitempty"` -} - -// PrometheusMetric represents metric from Prometheus -type PrometheusMetric struct { - PromQL *string `json:"promql" validate:"required" example:"cpu_usage_user{cpu=\"cpu-total\"}"` -} - -// AmazonPrometheusMetric represents metric from Amazon Managed Prometheus -type AmazonPrometheusMetric struct { - PromQL *string `json:"promql" validate:"required" example:"cpu_usage_user{cpu=\"cpu-total\"}"` -} - -// DatadogMetric represents metric from Datadog -type DatadogMetric struct { - Query *string `json:"query" validate:"required"` -} - -// NewRelicMetric represents metric from NewRelic -type NewRelicMetric struct { - NRQL *string `json:"nrql" validate:"required,noSinceOrUntil"` -} - -const ( - ThousandEyesNetLatency = "net-latency" - ThousandEyesNetLoss = "net-loss" - ThousandEyesWebPageLoad = "web-page-load" - ThousandEyesWebDOMLoad = "web-dom-load" - ThousandEyesHTTPResponseTime = "http-response-time" - ThousandEyesServerAvailability = "http-server-availability" - ThousandEyesServerThroughput = "http-server-throughput" - ThousandEyesServerTotalTime = "http-server-total-time" - ThousandEyesDNSServerResolutionTime = "dns-server-resolution-time" - ThousandEyesDNSSECValid = "dns-dnssec-valid" -) - -// ThousandEyesMetric represents metric from ThousandEyes -type ThousandEyesMetric struct { - TestID *int64 `json:"testID" validate:"required,gte=0"` - TestType *string `json:"testType" validate:"supportedThousandEyesTestType"` -} - -// AppDynamicsMetric represents metric from AppDynamics -type AppDynamicsMetric struct { - ApplicationName *string `json:"applicationName" validate:"required,notEmpty"` - MetricPath *string `json:"metricPath" validate:"required,unambiguousAppDynamicMetricPath"` -} - -// SplunkMetric represents metric from Splunk -type SplunkMetric struct { - Query *string `json:"query" validate:"required,notEmpty,splunkQueryValid"` -} - -// LightstepMetric represents metric from Lightstep -type LightstepMetric struct { - StreamID *string `json:"streamId,omitempty"` - TypeOfData *string `json:"typeOfData" validate:"required,oneof=latency error_rate good total metric"` - Percentile *float64 `json:"percentile,omitempty"` - UQL *string `json:"uql,omitempty"` -} - -// SplunkObservabilityMetric represents metric from SplunkObservability -type SplunkObservabilityMetric struct { - Program *string `json:"program" validate:"required"` -} - -// DynatraceMetric represents metric from Dynatrace. -type DynatraceMetric struct { - MetricSelector *string `json:"metricSelector" validate:"required"` -} - -// ElasticsearchMetric represents metric from Elasticsearch. -type ElasticsearchMetric struct { - Index *string `json:"index" validate:"required"` - Query *string `json:"query" validate:"required,elasticsearchBeginEndTimeRequired"` -} - -// CloudWatchMetric represents metric from CloudWatch. -type CloudWatchMetric struct { - AccountID *string `json:"accountId,omitempty"` - Region *string `json:"region" validate:"required,max=255"` - Namespace *string `json:"namespace,omitempty"` - MetricName *string `json:"metricName,omitempty"` - Stat *string `json:"stat,omitempty"` - Dimensions []CloudWatchMetricDimension `json:"dimensions,omitempty" validate:"max=10,uniqueDimensionNames,dive"` - SQL *string `json:"sql,omitempty"` - JSON *string `json:"json,omitempty"` -} - -// RedshiftMetric represents metric from Redshift. -type RedshiftMetric struct { - Region *string `json:"region" validate:"required,max=255"` - ClusterID *string `json:"clusterId" validate:"required"` - DatabaseName *string `json:"databaseName" validate:"required"` - Query *string `json:"query" validate:"required,redshiftRequiredColumns"` -} - -// SumoLogicMetric represents metric from Sumo Logic. -type SumoLogicMetric struct { - Type *string `json:"type" validate:"required"` - Query *string `json:"query" validate:"required"` - Quantization *string `json:"quantization,omitempty"` - Rollup *string `json:"rollup,omitempty"` - // For struct level validation refer to sumoLogicStructValidation in pkg/manifest/v1alpha/validator.go -} - -// InstanaMetric represents metric from Redshift. -type InstanaMetric struct { - MetricType string `json:"metricType" validate:"required,oneof=infrastructure application"` //nolint:lll - Infrastructure *InstanaInfrastructureMetricType `json:"infrastructure,omitempty"` - Application *InstanaApplicationMetricType `json:"application,omitempty"` -} - -// InfluxDBMetric represents metric from InfluxDB -type InfluxDBMetric struct { - Query *string `json:"query" validate:"required,influxDBRequiredPlaceholders"` -} - -// GCMMetric represents metric from GCM -type GCMMetric struct { - Query string `json:"query" validate:"required"` - ProjectID string `json:"projectId" validate:"required"` -} - -type InstanaInfrastructureMetricType struct { - MetricRetrievalMethod string `json:"metricRetrievalMethod" validate:"required,oneof=query snapshot"` - Query *string `json:"query,omitempty"` - SnapshotID *string `json:"snapshotId,omitempty"` - MetricID string `json:"metricId" validate:"required"` - PluginID string `json:"pluginId" validate:"required"` -} - -type InstanaApplicationMetricType struct { - MetricID string `json:"metricId" validate:"required,oneof=calls erroneousCalls errors latency"` //nolint:lll - Aggregation string `json:"aggregation" validate:"required"` - GroupBy InstanaApplicationMetricGroupBy `json:"groupBy" validate:"required"` - APIQuery string `json:"apiQuery" validate:"required,json"` - IncludeInternal bool `json:"includeInternal,omitempty"` - IncludeSynthetic bool `json:"includeSynthetic,omitempty"` -} - -type InstanaApplicationMetricGroupBy struct { - Tag string `json:"tag" validate:"required"` - TagEntity string `json:"tagEntity" validate:"required,oneof=DESTINATION SOURCE NOT_APPLICABLE"` - TagSecondLevelKey *string `json:"tagSecondLevelKey,omitempty"` -} - -// IsStandardConfiguration returns true if the struct represents CloudWatch standard configuration. -func (c CloudWatchMetric) IsStandardConfiguration() bool { - return c.Stat != nil || c.Dimensions != nil || c.MetricName != nil || c.Namespace != nil -} - -// IsSQLConfiguration returns true if the struct represents CloudWatch SQL configuration. -func (c CloudWatchMetric) IsSQLConfiguration() bool { - return c.SQL != nil -} - -// IsJSONConfiguration returns true if the struct represents CloudWatch JSON configuration. -func (c CloudWatchMetric) IsJSONConfiguration() bool { - return c.JSON != nil -} - -// CloudWatchMetricDimension represents name/value pair that is part of the identity of a metric. -type CloudWatchMetricDimension struct { - Name *string `json:"name" validate:"required,max=255,ascii,notBlank"` - Value *string `json:"value" validate:"required,max=255,ascii,notBlank"` -} - -// PingdomMetric represents metric from Pingdom. -type PingdomMetric struct { - CheckID *string `json:"checkId" validate:"required,notBlank,numeric" example:"1234567"` - CheckType *string `json:"checkType" validate:"required,pingdomCheckTypeFieldValid" example:"uptime"` - Status *string `json:"status,omitempty" validate:"omitempty,pingdomStatusValid" example:"up,down"` -} - -// GraphiteMetric represents metric from Graphite. -type GraphiteMetric struct { - MetricPath *string `json:"metricPath" validate:"required,metricPathGraphite"` -} - -// BigQueryMetric represents metric from BigQuery -type BigQueryMetric struct { - Query string `json:"query" validate:"required,bigQueryRequiredColumns"` - ProjectID string `json:"projectId" validate:"required"` - Location string `json:"location" validate:"required"` -} - -// OpenTSDBMetric represents metric from OpenTSDB. -type OpenTSDBMetric struct { - Query *string `json:"query" validate:"required"` -} - -// GrafanaLokiMetric represents metric from GrafanaLokiMetric. -type GrafanaLokiMetric struct { - Logql *string `json:"logql" validate:"required"` -} - -// AzureMonitorMetric represents metric from AzureMonitor -type AzureMonitorMetric struct { - ResourceID string `json:"resourceId" validate:"required,azureResourceID" example:"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"` //nolint:lll - MetricName string `json:"metricName" validate:"required"` - Aggregation string `json:"aggregation" validate:"required"` - Dimensions []AzureMonitorMetricDimension `json:"dimensions,omitempty" validate:"uniqueDimensionNames,dive"` - MetricNamespace string `json:"metricNamespace,omitempty"` -} - -// AzureMonitorMetricDimension represents name/value pair that is part of the identity of a metric. -type AzureMonitorMetricDimension struct { - Name *string `json:"name" validate:"required,max=255,ascii,notBlank"` - Value *string `json:"value" validate:"required,max=255,ascii,notBlank"` -} - -type GenericMetric struct { - Query *string `json:"query" validate:"required"` -} - -// HoneycombMetric represents metric from Honeycomb. -type HoneycombMetric struct { - Dataset string `json:"dataset" validate:"required,max=255,ascii,notBlank"` - Calculation string `json:"calculation" validate:"required,max=30,ascii,notBlank,supportedHoneycombCalculationType"` //nolint:lll - Attribute string `json:"attribute" validate:"required,max=255,ascii,notBlank"` - Filter HoneycombFilter `json:"filter"` -} - -// HoneycombFilter represents filter for Honeycomb metric. It has custom struct validation. -type HoneycombFilter struct { - Operator string `json:"op" validate:"max=30,ascii"` - Conditions []HoneycombFilterCondition `json:"conditions" validate:"max=100,dive"` -} - -// HoneycombFilterCondition represents single condition for Honeycomb filter. -type HoneycombFilterCondition struct { - Attribute string `json:"attribute" validate:"required,max=255,ascii,notBlank"` - Operator string `json:"op" validate:"required,max=30,ascii,notBlank,supportedHoneycombFilterConditionOperator"` - Value string `json:"value" validate:"max=255,ascii"` -} - -func (slo *SLOSpec) containsIndicatorRawMetric() bool { - return slo.Indicator.RawMetric != nil -} - -// IsComposite returns true if SLOSpec contains composite type. -func (slo *SLOSpec) IsComposite() bool { - return slo.Composite != nil -} - -// HasRawMetric returns true if SLOSpec has raw metric. -func (slo *SLOSpec) HasRawMetric() bool { - if slo.containsIndicatorRawMetric() { - return true - } - for _, objective := range slo.Objectives { - if objective.HasRawMetricQuery() { - return true - } - } - return false -} - -// RawMetrics returns raw metric spec. -func (slo *SLOSpec) RawMetrics() []*MetricSpec { - if slo.containsIndicatorRawMetric() { - return []*MetricSpec{slo.Indicator.RawMetric} - } - rawMetrics := make([]*MetricSpec, 0, slo.ObjectivesRawMetricsCount()) - for _, objective := range slo.Objectives { - if objective.RawMetric != nil { - rawMetrics = append(rawMetrics, objective.RawMetric.MetricQuery) - } - } - return rawMetrics -} - -// HasRawMetricQuery returns true if Objective has raw metric with query set. -func (o *Objective) HasRawMetricQuery() bool { - return o.RawMetric != nil && o.RawMetric.MetricQuery != nil -} - -// ObjectivesRawMetricsCount returns total number of all raw metrics defined in this SLO Spec's objectives. -func (slo *SLOSpec) ObjectivesRawMetricsCount() int { - var count int - for _, objective := range slo.Objectives { - if objective.HasRawMetricQuery() { - count++ - } - } - return count -} - -// HasCountMetrics returns true if SLOSpec has count metrics. -func (slo *SLOSpec) HasCountMetrics() bool { - for _, objective := range slo.Objectives { - if objective.HasCountMetrics() { - return true - } - } - return false -} - -// HasCountMetrics returns true if Objective has count metrics. -func (o *Objective) HasCountMetrics() bool { - return o.CountMetrics != nil -} - -// CountMetricsCount returns total number of all count metrics defined in this SLOSpec's objectives. -func (slo *SLOSpec) CountMetricsCount() int { - var count int - for _, objective := range slo.Objectives { - if objective.CountMetrics != nil { - if objective.CountMetrics.GoodMetric != nil { - count++ - } - if objective.CountMetrics.TotalMetric != nil { - count++ - } - if objective.CountMetrics.BadMetric != nil && isBadOverTotalEnabledForDataSourceType(objective) { - count++ - } - } - } - return count -} - -// CountMetrics returns a flat slice of all count metrics defined in this SLOSpec's objectives. -func (slo *SLOSpec) CountMetrics() []*MetricSpec { - countMetrics := make([]*MetricSpec, slo.CountMetricsCount()) - var i int - for _, objective := range slo.Objectives { - if objective.CountMetrics == nil { - continue - } - if objective.CountMetrics.GoodMetric != nil { - countMetrics[i] = objective.CountMetrics.GoodMetric - i++ - } - if objective.CountMetrics.TotalMetric != nil { - countMetrics[i] = objective.CountMetrics.TotalMetric - i++ - } - if objective.CountMetrics.BadMetric != nil && isBadOverTotalEnabledForDataSourceType(objective) { - countMetrics[i] = objective.CountMetrics.BadMetric - i++ - } - } - return countMetrics -} - -// CountMetricPairs returns a slice of all count metrics defined in this SLOSpec's objectives. -func (slo *SLOSpec) CountMetricPairs() []*CountMetricsSpec { - countMetrics := make([]*CountMetricsSpec, slo.CountMetricsCount()) - var i int - for _, objective := range slo.Objectives { - if objective.CountMetrics == nil { - continue - } - if objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.TotalMetric != nil { - countMetrics[i] = objective.CountMetrics - i++ - } - } - return countMetrics -} - -func (slo *SLOSpec) GoodTotalCountMetrics() (good, total []*MetricSpec) { - for _, objective := range slo.Objectives { - if objective.CountMetrics == nil { - continue - } - if objective.CountMetrics.GoodMetric != nil { - good = append(good, objective.CountMetrics.GoodMetric) - } - if objective.CountMetrics.TotalMetric != nil { - total = append(total, objective.CountMetrics.TotalMetric) - } - } - return -} - -// AllMetricSpecs returns slice of all metrics defined in SLO regardless of their type. -func (slo *SLOSpec) AllMetricSpecs() []*MetricSpec { - var metrics []*MetricSpec - metrics = append(metrics, slo.RawMetrics()...) - metrics = append(metrics, slo.CountMetrics()...) - return metrics -} - -// DataSourceType returns a type of data source. -func (m *MetricSpec) DataSourceType() DataSourceType { - switch { - case m.Prometheus != nil: - return Prometheus - case m.Datadog != nil: - return Datadog - case m.NewRelic != nil: - return NewRelic - case m.AppDynamics != nil: - return AppDynamics - case m.Splunk != nil: - return Splunk - case m.Lightstep != nil: - return Lightstep - case m.SplunkObservability != nil: - return SplunkObservability - case m.Dynatrace != nil: - return Dynatrace - case m.Elasticsearch != nil: - return Elasticsearch - case m.ThousandEyes != nil: - return ThousandEyes - case m.Graphite != nil: - return Graphite - case m.BigQuery != nil: - return BigQuery - case m.OpenTSDB != nil: - return OpenTSDB - case m.GrafanaLoki != nil: - return GrafanaLoki - case m.CloudWatch != nil: - return CloudWatch - case m.Pingdom != nil: - return Pingdom - case m.AmazonPrometheus != nil: - return AmazonPrometheus - case m.Redshift != nil: - return Redshift - case m.SumoLogic != nil: - return SumoLogic - case m.Instana != nil: - return Instana - case m.InfluxDB != nil: - return InfluxDB - case m.GCM != nil: - return GCM - case m.AzureMonitor != nil: - return AzureMonitor - case m.Generic != nil: - return Generic - case m.Honeycomb != nil: - return Honeycomb - default: - return 0 - } -} - -// Query returns interface containing metric query for this MetricSpec. -func (m *MetricSpec) Query() interface{} { - switch m.DataSourceType() { - case Prometheus: - return m.Prometheus - case Datadog: - return m.Datadog - case NewRelic: - return m.NewRelic - case AppDynamics: - return m.AppDynamics - case Splunk: - return m.Splunk - case Lightstep: - return m.Lightstep - case SplunkObservability: - return m.SplunkObservability - case Dynatrace: - return m.Dynatrace - case Elasticsearch: - return m.Elasticsearch - case ThousandEyes: - return m.ThousandEyes - case Graphite: - return m.Graphite - case BigQuery: - return m.BigQuery - case OpenTSDB: - return m.OpenTSDB - case GrafanaLoki: - return m.GrafanaLoki - case CloudWatch: - // To be clean, entire metric spec is copied so that original value is not mutated. - var cloudWatchCopy CloudWatchMetric - cloudWatchCopy = *m.CloudWatch - // Dimension list is optional. This is done so that during upsert empty slice and nil slice are treated equally. - if cloudWatchCopy.Dimensions == nil { - cloudWatchCopy.Dimensions = []CloudWatchMetricDimension{} - } - // Dimensions are sorted so that metric_query = '...':jsonb comparison was insensitive to the order in slice. - // It assumes that all dimensions' names are unique (ensured by validation). - sort.Slice(cloudWatchCopy.Dimensions, func(i, j int) bool { - return *cloudWatchCopy.Dimensions[i].Name < *cloudWatchCopy.Dimensions[j].Name - }) - return cloudWatchCopy - case Pingdom: - return m.Pingdom - case AmazonPrometheus: - return m.AmazonPrometheus - case Redshift: - return m.Redshift - case SumoLogic: - return m.SumoLogic - case Instana: - return m.Instana - case InfluxDB: - return m.InfluxDB - case GCM: - return m.GCM - case AzureMonitor: - // To be clean, entire metric spec is copied so that original value is not mutated. - var azureMonitorCopy AzureMonitorMetric - azureMonitorCopy = *m.AzureMonitor - // Dimension list is optional. This is done so that during upsert empty slice and nil slice are treated equally. - if azureMonitorCopy.Dimensions == nil { - azureMonitorCopy.Dimensions = []AzureMonitorMetricDimension{} - } - // Dimensions are sorted so that metric_query = '...':jsonb comparison was insensitive to the order in slice. - // It assumes that all dimensions' names are unique (ensured by validation). - sort.Slice(azureMonitorCopy.Dimensions, func(i, j int) bool { - return *azureMonitorCopy.Dimensions[i].Name < *azureMonitorCopy.Dimensions[j].Name - }) - return azureMonitorCopy - case Generic: - return m.Generic - case Honeycomb: - return m.Honeycomb - default: - return nil - } -} diff --git a/manifest/v1alpha/parser/parser.go b/manifest/v1alpha/parser/parser.go index a7ce846d..86be58ac 100644 --- a/manifest/v1alpha/parser/parser.go +++ b/manifest/v1alpha/parser/parser.go @@ -12,6 +12,7 @@ import ( "github.com/nobl9/nobl9-go/manifest/v1alpha" "github.com/nobl9/nobl9-go/manifest/v1alpha/project" "github.com/nobl9/nobl9-go/manifest/v1alpha/service" + "github.com/nobl9/nobl9-go/manifest/v1alpha/slo" "github.com/nobl9/nobl9-go/manifest/v1alpha/usergroup" ) @@ -49,7 +50,7 @@ func parseObject(kind manifest.Kind, unmarshal unmarshalFunc) (manifest.Object, case manifest.KindService: return genericParseObject[service.Service](unmarshal) case manifest.KindSLO: - return genericParseObject[v1alpha.SLO](unmarshal) + return genericParseObject[slo.SLO](unmarshal) case manifest.KindProject: return genericParseObject[project.Project](unmarshal) case manifest.KindAgent: diff --git a/manifest/v1alpha/slo.go b/manifest/v1alpha/slo.go deleted file mode 100644 index 01db7e9a..00000000 --- a/manifest/v1alpha/slo.go +++ /dev/null @@ -1,150 +0,0 @@ -package v1alpha - -import ( - "github.com/nobl9/nobl9-go/manifest" -) - -//go:generate go run ../../scripts/generate-object-impl.go SLO - -// SLO struct which mapped one to one with kind: slo yaml definition, external usage -type SLO struct { - APIVersion string `json:"apiVersion"` - Kind manifest.Kind `json:"kind"` - Metadata SLOMetadata `json:"metadata"` - Spec SLOSpec `json:"spec"` - Status *SLOStatus `json:"status,omitempty"` - - Organization string `json:"organization,omitempty"` - ManifestSource string `json:"manifestSrc,omitempty"` -} - -type SLOMetadata struct { - Name string `json:"name" validate:"required,objectName"` - DisplayName string `json:"displayName,omitempty" validate:"omitempty,min=0,max=63"` - Project string `json:"project,omitempty" validate:"objectName"` - Labels Labels `json:"labels,omitempty" validate:"omitempty,labels"` -} - -// SLOSpec represents content of Spec typical for SLO Object -type SLOSpec struct { - Description string `json:"description" validate:"description" example:"Total count of server requests"` - Indicator Indicator `json:"indicator"` - BudgetingMethod string `json:"budgetingMethod" validate:"required,budgetingMethod" example:"Occurrences"` - Objectives []Objective `json:"objectives" validate:"required,dive"` - Service string `json:"service" validate:"required,objectName" example:"webapp-service"` - TimeWindows []TimeWindow `json:"timeWindows" validate:"required,len=1,dive"` - AlertPolicies []string `json:"alertPolicies" validate:"omitempty"` - Attachments []Attachment `json:"attachments,omitempty" validate:"omitempty,max=20,dive"` - CreatedAt string `json:"createdAt,omitempty"` - Composite *Composite `json:"composite,omitempty" validate:"omitempty"` - AnomalyConfig *AnomalyConfig `json:"anomalyConfig,omitempty" validate:"omitempty"` -} - -type SLOStatus struct { - ReplayStatus *ReplayStatus `json:"timeTravel,omitempty"` -} - -type ReplayStatus struct { - Status string `json:"status"` - Unit string `json:"unit"` - Value int `json:"value"` - StartTime string `json:"startTime,omitempty"` -} - -// Calendar struct represents calendar time window -type Calendar struct { - StartTime string `json:"startTime" validate:"required,dateWithTime,minDateTime" example:"2020-01-21 12:30:00"` - TimeZone string `json:"timeZone" validate:"required,timeZone" example:"America/New_York"` -} - -// Period represents period of time -type Period struct { - Begin string `json:"begin"` - End string `json:"end"` -} - -// TimeWindow represents content of time window -type TimeWindow struct { - Unit string `json:"unit" validate:"required,timeUnit" example:"Week"` - Count int `json:"count" validate:"required,gt=0" example:"1"` - IsRolling bool `json:"isRolling" example:"true"` - Calendar *Calendar `json:"calendar,omitempty"` - - // Period is only returned in `/get/slo` requests it is ignored for `/apply` - Period *Period `json:"period,omitempty"` -} - -// Attachment represents user defined URL attached to SLO -type Attachment struct { - URL string `json:"url" validate:"required,url"` - DisplayName *string `json:"displayName,omitempty" validate:"max=63"` -} - -// ObjectiveBase base structure representing an objective. -type ObjectiveBase struct { - DisplayName string `json:"displayName" validate:"omitempty,min=0,max=63" example:"Good"` - Value float64 `json:"value" validate:"numeric" example:"100"` - Name string `json:"name" validate:"omitempty,objectName"` - NameChanged bool `json:"-"` -} - -// Objective represents single objective for SLO, for internal usage -type Objective struct { - ObjectiveBase `json:",inline"` - // - BudgetTarget *float64 `json:"target" validate:"required,numeric,gte=0,lt=1" example:"0.9"` - TimeSliceTarget *float64 `json:"timeSliceTarget,omitempty" example:"0.9"` - CountMetrics *CountMetricsSpec `json:"countMetrics,omitempty"` - RawMetric *RawMetricSpec `json:"rawMetric,omitempty"` - Operator *string `json:"op,omitempty" example:"lte"` -} - -// Indicator represents integration with metric source can be. e.g. Prometheus, Datadog, for internal usage -type Indicator struct { - MetricSource MetricSourceSpec `json:"metricSource" validate:"required"` - RawMetric *MetricSpec `json:"rawMetric,omitempty"` -} - -type MetricSourceSpec struct { - Project string `json:"project,omitempty" validate:"omitempty,objectName" example:"default"` - Name string `json:"name" validate:"required,objectName" example:"prometheus-source"` - Kind manifest.Kind `json:"kind,omitempty" validate:"omitempty,metricSourceKind" example:"Agent"` -} - -// Composite represents configuration for Composite SLO. -type Composite struct { - BudgetTarget float64 `json:"target" validate:"required,numeric,gte=0,lt=1" example:"0.9"` - BurnRateCondition *CompositeBurnRateCondition `json:"burnRateCondition,omitempty"` -} - -// CompositeVersion represents composite version history stored for restoring process. -type CompositeVersion struct { - Version int32 - Created string - Dependencies []string -} - -// CompositeBurnRateCondition represents configuration for Composite SLO with occurrences budgeting method. -type CompositeBurnRateCondition struct { - Value float64 `json:"value" validate:"numeric,gte=0,lte=1000" example:"2"` - Operator string `json:"op" validate:"required,oneof=gt" example:"gt"` -} - -// AnomalyConfig represents relationship between anomaly type and selected notification methods. -// This will be removed (moved into Anomaly Policy) in PC-8502 -type AnomalyConfig struct { - NoData *AnomalyConfigNoData `json:"noData" validate:"omitempty"` -} - -// AnomalyConfigNoData contains alertMethods used for No Data anomaly type. -type AnomalyConfigNoData struct { - AlertMethods []AnomalyConfigAlertMethod `json:"alertMethods" validate:"required"` -} - -// AnomalyConfigAlertMethod represents a single alert method used in AnomalyConfig -// defined by name and project. -type AnomalyConfigAlertMethod struct { - Name string `json:"name" validate:"required,objectName" example:"slack-monitoring-channel"` - Project string `json:"project,omitempty" validate:"objectName" example:"default"` -} diff --git a/manifest/v1alpha/method.go b/manifest/v1alpha/slo/budgeting_method.go similarity index 89% rename from manifest/v1alpha/method.go rename to manifest/v1alpha/slo/budgeting_method.go index 086abba5..849f172a 100644 --- a/manifest/v1alpha/method.go +++ b/manifest/v1alpha/slo/budgeting_method.go @@ -1,6 +1,8 @@ -package v1alpha +package slo -import "fmt" +import ( + "fmt" +) // BudgetingMethod indicates algorithm to calculate error budget type BudgetingMethod int @@ -31,7 +33,7 @@ func (m BudgetingMethod) String() string { func ParseBudgetingMethod(value string) (BudgetingMethod, error) { result, ok := getBudgetingMethodNames()[value] if !ok { - return result, fmt.Errorf("'%s' is not valid budgeting method", value) + return result, fmt.Errorf("'%s' is not a valid budgeting method", value) } return result, nil } diff --git a/manifest/v1alpha/slo/doc.go b/manifest/v1alpha/slo/doc.go new file mode 100644 index 00000000..16267bc8 --- /dev/null +++ b/manifest/v1alpha/slo/doc.go @@ -0,0 +1,2 @@ +// Package slo defines SLO object definitions. +package slo diff --git a/manifest/v1alpha/slo/example.yaml b/manifest/v1alpha/slo/example.yaml new file mode 100644 index 00000000..bef05a2e --- /dev/null +++ b/manifest/v1alpha/slo/example.yaml @@ -0,0 +1,39 @@ +apiVersion: n9/v1alpha +kind: SLO +metadata: + name: my-slo + displayName: My SLO + project: default + labels: + team: [ green, orange ] + region: [ eu-central-1 ] +spec: + description: Counts ratio between good and total number of http requests + alertPolicies: [ ] + attachments: + - displayName: Grafana dashboard + url: https://loki.my-org.dev/grafana/d/nd3S__Knz/pod-restarts?orgId=1&from=now-6h&to=now&viewPanel=6 + budgetingMethod: Occurrences + indicator: + metricSource: + kind: Agent + name: prometheus + project: default + objectives: + - countMetrics: + good: + prometheus: + promql: sum(rate(prometheus_http_requests_total{code=~"^2.*"}[1h])) + incremental: false + total: + prometheus: + promql: sum(rate(prometheus_http_requests_total[1h])) + name: good + displayName: Good + target: 0.9 + value: 1 + service: prometheus + timeWindows: + - count: 1 + isRolling: true + unit: Day diff --git a/manifest/v1alpha/slo/example_test.go b/manifest/v1alpha/slo/example_test.go new file mode 100644 index 00000000..3f66b913 --- /dev/null +++ b/manifest/v1alpha/slo/example_test.go @@ -0,0 +1,130 @@ +package slo_test + +import ( + "context" + "log" + + "github.com/nobl9/nobl9-go/internal/examples" + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/manifest/v1alpha/slo" +) + +func ExampleSLO() { + // Create the object: + mySLO := slo.New( + slo.Metadata{ + Name: "my-slo", + DisplayName: "My SLO", + Project: "default", + Labels: v1alpha.Labels{ + "team": []string{"green", "orange"}, + "region": []string{"eu-central-1"}, + }, + }, + slo.Spec{ + Description: "Example slo", + AlertPolicies: []string{"my-policy-name"}, + Attachments: []slo.Attachment{ + { + DisplayName: ptr("Grafana Dashboard"), + URL: "https://loki.my-org.dev/grafana/d/dnd48", + }, + }, + BudgetingMethod: slo.BudgetingMethodOccurrences.String(), + Service: "prometheus", + Indicator: slo.Indicator{ + MetricSource: slo.MetricSourceSpec{ + Name: "prometheus", + Project: "default", + Kind: manifest.KindAgent, + }, + }, + Objectives: []slo.Objective{ + { + ObjectiveBase: slo.ObjectiveBase{ + DisplayName: "Good", + Value: ptr(0.), + Name: "good", + }, + BudgetTarget: ptr(0.9), + CountMetrics: &slo.CountMetricsSpec{ + Incremental: ptr(false), + GoodMetric: &slo.MetricSpec{ + Prometheus: &slo.PrometheusMetric{ + PromQL: ptr(`sum(rate(prometheus_http_requests_total{code=~"^2.*"}[1h]))`), + }, + }, + TotalMetric: &slo.MetricSpec{ + Prometheus: &slo.PrometheusMetric{ + PromQL: ptr(`sum(rate(prometheus_http_requests_total[1h]))`), + }, + }, + }, + }, + }, + TimeWindows: []slo.TimeWindow{ + { + Unit: "Day", + Count: 1, + IsRolling: true, + }, + }, + }, + ) + // Verify the object: + if err := mySLO.Validate(); err != nil { + log.Fatal("slo validation failed, err: %w", err) + } + // Apply the object: + client := examples.GetOfflineEchoClient() + if err := client.ApplyObjects(context.Background(), []manifest.Object{mySLO}); err != nil { + log.Fatal("failed to apply slo, err: %w", err) + } + // Output: + // apiVersion: n9/v1alpha + // kind: SLO + // metadata: + // name: my-slo + // displayName: My SLO + // project: default + // labels: + // region: + // - eu-central-1 + // team: + // - green + // - orange + // spec: + // description: Example slo + // indicator: + // metricSource: + // name: prometheus + // project: default + // kind: Agent + // budgetingMethod: Occurrences + // objectives: + // - displayName: Good + // value: 0.0 + // name: good + // target: 0.9 + // countMetrics: + // incremental: false + // good: + // prometheus: + // promql: sum(rate(prometheus_http_requests_total{code=~"^2.*"}[1h])) + // total: + // prometheus: + // promql: sum(rate(prometheus_http_requests_total[1h])) + // service: prometheus + // timeWindows: + // - unit: Day + // count: 1 + // isRolling: true + // alertPolicies: + // - my-policy-name + // attachments: + // - url: https://loki.my-org.dev/grafana/d/dnd48 + // displayName: Grafana Dashboard +} + +func ptr[T any](v T) *T { return &v } diff --git a/manifest/v1alpha/slo/metrics.go b/manifest/v1alpha/slo/metrics.go new file mode 100644 index 00000000..2126ffb6 --- /dev/null +++ b/manifest/v1alpha/slo/metrics.go @@ -0,0 +1,338 @@ +package slo + +import ( + "sort" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" +) + +// CountMetricsSpec represents set of two time series of good and total counts +type CountMetricsSpec struct { + Incremental *bool `json:"incremental"` + GoodMetric *MetricSpec `json:"good,omitempty"` + BadMetric *MetricSpec `json:"bad,omitempty"` + TotalMetric *MetricSpec `json:"total"` +} + +// RawMetricSpec represents integration with a metric source for a particular objective. +type RawMetricSpec struct { + MetricQuery *MetricSpec `json:"query"` +} + +// MetricSpec defines single time series obtained from data source +type MetricSpec struct { + Prometheus *PrometheusMetric `json:"prometheus,omitempty"` + Datadog *DatadogMetric `json:"datadog,omitempty"` + NewRelic *NewRelicMetric `json:"newRelic,omitempty"` + AppDynamics *AppDynamicsMetric `json:"appDynamics,omitempty"` + Splunk *SplunkMetric `json:"splunk,omitempty"` + Lightstep *LightstepMetric `json:"lightstep,omitempty"` + SplunkObservability *SplunkObservabilityMetric `json:"splunkObservability,omitempty"` + Dynatrace *DynatraceMetric `json:"dynatrace,omitempty"` + Elasticsearch *ElasticsearchMetric `json:"elasticsearch,omitempty"` + ThousandEyes *ThousandEyesMetric `json:"thousandEyes,omitempty"` + Graphite *GraphiteMetric `json:"graphite,omitempty"` + BigQuery *BigQueryMetric `json:"bigQuery,omitempty"` + OpenTSDB *OpenTSDBMetric `json:"opentsdb,omitempty"` + GrafanaLoki *GrafanaLokiMetric `json:"grafanaLoki,omitempty"` + CloudWatch *CloudWatchMetric `json:"cloudWatch,omitempty"` + Pingdom *PingdomMetric `json:"pingdom,omitempty"` + AmazonPrometheus *AmazonPrometheusMetric `json:"amazonPrometheus,omitempty"` + Redshift *RedshiftMetric `json:"redshift,omitempty"` + SumoLogic *SumoLogicMetric `json:"sumoLogic,omitempty"` + Instana *InstanaMetric `json:"instana,omitempty"` + InfluxDB *InfluxDBMetric `json:"influxdb,omitempty"` + GCM *GCMMetric `json:"gcm,omitempty"` + AzureMonitor *AzureMonitorMetric `json:"azureMonitor,omitempty"` + Generic *GenericMetric `json:"generic,omitempty"` + Honeycomb *HoneycombMetric `json:"honeycomb,omitempty"` +} + +func (s *Spec) containsIndicatorRawMetric() bool { + return s.Indicator.RawMetric != nil +} + +// IsComposite returns true if SLOSpec contains composite type. +func (s *Spec) IsComposite() bool { + return s.Composite != nil +} + +// HasRawMetric returns true if SLOSpec has raw metric. +func (s *Spec) HasRawMetric() bool { + if s.containsIndicatorRawMetric() { + return true + } + for _, objective := range s.Objectives { + if objective.HasRawMetricQuery() { + return true + } + } + return false +} + +// RawMetrics returns raw metric spec. +func (s *Spec) RawMetrics() []*MetricSpec { + if s.containsIndicatorRawMetric() { + return []*MetricSpec{s.Indicator.RawMetric} + } + rawMetrics := make([]*MetricSpec, 0, s.ObjectivesRawMetricsCount()) + for _, objective := range s.Objectives { + if objective.RawMetric != nil { + rawMetrics = append(rawMetrics, objective.RawMetric.MetricQuery) + } + } + return rawMetrics +} + +// HasRawMetricQuery returns true if Objective has raw metric with query set. +func (o *Objective) HasRawMetricQuery() bool { + return o.RawMetric != nil && o.RawMetric.MetricQuery != nil +} + +// ObjectivesRawMetricsCount returns total number of all raw metrics defined in this SLO Spec's objectives. +func (s *Spec) ObjectivesRawMetricsCount() int { + var count int + for _, objective := range s.Objectives { + if objective.HasRawMetricQuery() { + count++ + } + } + return count +} + +// HasCountMetrics returns true if SLOSpec has count metrics. +func (s *Spec) HasCountMetrics() bool { + for _, objective := range s.Objectives { + if objective.HasCountMetrics() { + return true + } + } + return false +} + +// HasCountMetrics returns true if Objective has count metrics. +func (o *Objective) HasCountMetrics() bool { + return o.CountMetrics != nil +} + +// CountMetricsCount returns total number of all count metrics defined in this SLOSpec's objectives. +func (s *Spec) CountMetricsCount() int { + var count int + for _, objective := range s.Objectives { + if objective.CountMetrics != nil { + if objective.CountMetrics.GoodMetric != nil { + count++ + } + if objective.CountMetrics.TotalMetric != nil { + count++ + } + if objective.CountMetrics.BadMetric != nil { + count++ + } + } + } + return count +} + +// CountMetrics returns a flat slice of all count metrics defined in this SLOSpec's objectives. +func (s *Spec) CountMetrics() []*MetricSpec { + countMetrics := make([]*MetricSpec, s.CountMetricsCount()) + var i int + for _, objective := range s.Objectives { + if objective.CountMetrics == nil { + continue + } + if objective.CountMetrics.GoodMetric != nil { + countMetrics[i] = objective.CountMetrics.GoodMetric + i++ + } + if objective.CountMetrics.TotalMetric != nil { + countMetrics[i] = objective.CountMetrics.TotalMetric + i++ + } + if objective.CountMetrics.BadMetric != nil { + countMetrics[i] = objective.CountMetrics.BadMetric + i++ + } + } + return countMetrics +} + +// CountMetricPairs returns a slice of all count metrics defined in this SLOSpec's objectives. +func (s *Spec) CountMetricPairs() []*CountMetricsSpec { + countMetrics := make([]*CountMetricsSpec, s.CountMetricsCount()) + var i int + for _, objective := range s.Objectives { + if objective.CountMetrics == nil { + continue + } + if objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.TotalMetric != nil { + countMetrics[i] = objective.CountMetrics + i++ + } + } + return countMetrics +} + +func (s *Spec) GoodTotalCountMetrics() (good, total []*MetricSpec) { + for _, objective := range s.Objectives { + if objective.CountMetrics == nil { + continue + } + if objective.CountMetrics.GoodMetric != nil { + good = append(good, objective.CountMetrics.GoodMetric) + } + if objective.CountMetrics.TotalMetric != nil { + total = append(total, objective.CountMetrics.TotalMetric) + } + } + return +} + +// AllMetricSpecs returns slice of all metrics defined in SLO regardless of their type. +func (s *Spec) AllMetricSpecs() []*MetricSpec { + var metrics []*MetricSpec + metrics = append(metrics, s.RawMetrics()...) + metrics = append(metrics, s.CountMetrics()...) + return metrics +} + +// DataSourceType returns a type of data source. +func (m *MetricSpec) DataSourceType() v1alpha.DataSourceType { + switch { + case m.Prometheus != nil: + return v1alpha.Prometheus + case m.Datadog != nil: + return v1alpha.Datadog + case m.NewRelic != nil: + return v1alpha.NewRelic + case m.AppDynamics != nil: + return v1alpha.AppDynamics + case m.Splunk != nil: + return v1alpha.Splunk + case m.Lightstep != nil: + return v1alpha.Lightstep + case m.SplunkObservability != nil: + return v1alpha.SplunkObservability + case m.Dynatrace != nil: + return v1alpha.Dynatrace + case m.Elasticsearch != nil: + return v1alpha.Elasticsearch + case m.ThousandEyes != nil: + return v1alpha.ThousandEyes + case m.Graphite != nil: + return v1alpha.Graphite + case m.BigQuery != nil: + return v1alpha.BigQuery + case m.OpenTSDB != nil: + return v1alpha.OpenTSDB + case m.GrafanaLoki != nil: + return v1alpha.GrafanaLoki + case m.CloudWatch != nil: + return v1alpha.CloudWatch + case m.Pingdom != nil: + return v1alpha.Pingdom + case m.AmazonPrometheus != nil: + return v1alpha.AmazonPrometheus + case m.Redshift != nil: + return v1alpha.Redshift + case m.SumoLogic != nil: + return v1alpha.SumoLogic + case m.Instana != nil: + return v1alpha.Instana + case m.InfluxDB != nil: + return v1alpha.InfluxDB + case m.GCM != nil: + return v1alpha.GCM + case m.AzureMonitor != nil: + return v1alpha.AzureMonitor + case m.Generic != nil: + return v1alpha.Generic + case m.Honeycomb != nil: + return v1alpha.Honeycomb + default: + return 0 + } +} + +// Query returns interface containing metric query for this MetricSpec. +func (m *MetricSpec) Query() interface{} { + switch m.DataSourceType() { + case v1alpha.Prometheus: + return m.Prometheus + case v1alpha.Datadog: + return m.Datadog + case v1alpha.NewRelic: + return m.NewRelic + case v1alpha.AppDynamics: + return m.AppDynamics + case v1alpha.Splunk: + return m.Splunk + case v1alpha.Lightstep: + return m.Lightstep + case v1alpha.SplunkObservability: + return m.SplunkObservability + case v1alpha.Dynatrace: + return m.Dynatrace + case v1alpha.Elasticsearch: + return m.Elasticsearch + case v1alpha.ThousandEyes: + return m.ThousandEyes + case v1alpha.Graphite: + return m.Graphite + case v1alpha.BigQuery: + return m.BigQuery + case v1alpha.OpenTSDB: + return m.OpenTSDB + case v1alpha.GrafanaLoki: + return m.GrafanaLoki + case v1alpha.CloudWatch: + // To be clean, entire metric spec is copied so that original value is not mutated. + var cloudWatchCopy CloudWatchMetric + cloudWatchCopy = *m.CloudWatch + // Dimension list is optional. This is done so that during upsert empty slice and nil slice are treated equally. + if cloudWatchCopy.Dimensions == nil { + cloudWatchCopy.Dimensions = []CloudWatchMetricDimension{} + } + // Dimensions are sorted so that metric_query = '...':jsonb comparison was insensitive to the order in slice. + // It assumes that all dimensions' names are unique (ensured by validation). + sort.Slice(cloudWatchCopy.Dimensions, func(i, j int) bool { + return *cloudWatchCopy.Dimensions[i].Name < *cloudWatchCopy.Dimensions[j].Name + }) + return cloudWatchCopy + case v1alpha.Pingdom: + return m.Pingdom + case v1alpha.AmazonPrometheus: + return m.AmazonPrometheus + case v1alpha.Redshift: + return m.Redshift + case v1alpha.SumoLogic: + return m.SumoLogic + case v1alpha.Instana: + return m.Instana + case v1alpha.InfluxDB: + return m.InfluxDB + case v1alpha.GCM: + return m.GCM + case v1alpha.AzureMonitor: + // To be clean, entire metric spec is copied so that original value is not mutated. + var azureMonitorCopy AzureMonitorMetric + azureMonitorCopy = *m.AzureMonitor + // Dimension list is optional. This is done so that during upsert empty slice and nil slice are treated equally. + if azureMonitorCopy.Dimensions == nil { + azureMonitorCopy.Dimensions = []AzureMonitorMetricDimension{} + } + // Dimensions are sorted so that metric_query = '...':jsonb comparison was insensitive to the order in slice. + // It assumes that all dimensions' names are unique (ensured by validation). + sort.Slice(azureMonitorCopy.Dimensions, func(i, j int) bool { + return *azureMonitorCopy.Dimensions[i].Name < *azureMonitorCopy.Dimensions[j].Name + }) + return azureMonitorCopy + case v1alpha.Generic: + return m.Generic + case v1alpha.Honeycomb: + return m.Honeycomb + default: + return nil + } +} diff --git a/manifest/v1alpha/slo/metrics_amazon_prometheus.go b/manifest/v1alpha/slo/metrics_amazon_prometheus.go new file mode 100644 index 00000000..4b2599db --- /dev/null +++ b/manifest/v1alpha/slo/metrics_amazon_prometheus.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// AmazonPrometheusMetric represents metric from Amazon Managed Prometheus +type AmazonPrometheusMetric struct { + PromQL *string `json:"promql"` +} + +var amazonPrometheusValidation = validation.New[AmazonPrometheusMetric]( + validation.ForPointer(func(p AmazonPrometheusMetric) *string { return p.PromQL }). + WithName("promql"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_amazon_prometheus_test.go b/manifest/v1alpha/slo/metrics_amazon_prometheus_test.go new file mode 100644 index 00000000..a71efa58 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_amazon_prometheus_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestAmazonPrometheus(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AmazonPrometheus) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AmazonPrometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AmazonPrometheus.PromQL = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.amazonPrometheus.promql", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AmazonPrometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AmazonPrometheus.PromQL = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.amazonPrometheus.promql", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_app_dynamics.go b/manifest/v1alpha/slo/metrics_app_dynamics.go new file mode 100644 index 00000000..7b237f7a --- /dev/null +++ b/manifest/v1alpha/slo/metrics_app_dynamics.go @@ -0,0 +1,71 @@ +package slo + +import ( + "regexp" + + "github.com/pkg/errors" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +const errCodeAppDynamicsWildcardNotSupported = "app_dynamics_wildcard_not_supported" + +// AppDynamicsMetric represents metric from AppDynamics +type AppDynamicsMetric struct { + ApplicationName *string `json:"applicationName"` + MetricPath *string `json:"metricPath"` +} + +var appDynamicsCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()).Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + total := c.TotalMetric + good := c.GoodMetric + bad := c.BadMetric + + if total == nil || total.AppDynamics.ApplicationName == nil { + return nil + } + if good != nil { + // Required properties are validated on a AppDynamicsMetric struct level. + if good.AppDynamics.ApplicationName == nil { + return nil + } + if *good.AppDynamics.ApplicationName != *total.AppDynamics.ApplicationName { + return countMetricsPropertyEqualityError("appDynamics.applicationName", goodMetric) + } + } + if bad != nil { + if bad.AppDynamics.ApplicationName == nil { + return nil + } + if *bad.AppDynamics.ApplicationName != *total.AppDynamics.ApplicationName { + return countMetricsPropertyEqualityError("appDynamics.applicationName", badMetric) + } + } + return nil + }).WithErrorCode(validation.ErrorCodeNotEqualTo)), +).When(whenCountMetricsIs(v1alpha.AppDynamics)) + +var appDynamicsMetricPathWildcardRegex = regexp.MustCompile(`([^\s|]\*)|(\*[^\s|])`) + +var appDynamicsValidation = validation.New[AppDynamicsMetric]( + validation.ForPointer(func(a AppDynamicsMetric) *string { return a.ApplicationName }). + WithName("applicationName"). + Required(). + Rules(validation.StringNotEmpty()), + validation.ForPointer(func(a AppDynamicsMetric) *string { return a.MetricPath }). + WithName("metricPath"). + Required(). + Rules(validation.NewSingleRule(func(s string) error { + if appDynamicsMetricPathWildcardRegex.MatchString(s) { + return errors.Errorf( + "Wildcards like: 'App | MyApp* | Latency' are not supported by AppDynamics," + + " only using '*' as an entire path segment ex: 'App | * | Latency'." + + " Refer to https://docs.appdynamics.com/display/PRO21/Metric+and+Snapshot+API" + + " paragraph 'Using Wildcards'") + } + return nil + }).WithErrorCode(errCodeAppDynamicsWildcardNotSupported)), +) diff --git a/manifest/v1alpha/slo/metrics_app_dynamics_test.go b/manifest/v1alpha/slo/metrics_app_dynamics_test.go new file mode 100644 index 00000000..19f9f57c --- /dev/null +++ b/manifest/v1alpha/slo/metrics_app_dynamics_test.go @@ -0,0 +1,151 @@ +package slo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestValidate_AppDynamics_ObjectiveLevel(t *testing.T) { + t.Run("appDynamics applicationName mismatch for bad over total", func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives[0].CountMetrics.TotalMetric = validMetricSpec(v1alpha.AppDynamics) + slo.Spec.Objectives[0].CountMetrics.GoodMetric = validMetricSpec(v1alpha.AppDynamics) + slo.Spec.Objectives[0].CountMetrics.GoodMetric.AppDynamics.ApplicationName = ptr("different") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeNotEqualTo, + }) + }) + t.Run("appDynamics applicationName mismatch for bad over total", func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives[0].CountMetrics.TotalMetric = validMetricSpec(v1alpha.AppDynamics) + slo.Spec.Objectives[0].CountMetrics.GoodMetric = nil + slo.Spec.Objectives[0].CountMetrics.BadMetric = validMetricSpec(v1alpha.AppDynamics) + slo.Spec.Objectives[0].CountMetrics.BadMetric.AppDynamics.ApplicationName = ptr("different") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeNotEqualTo, + }) + }) +} + +func TestValidate_AppDynamics_Valid(t *testing.T) { + for _, slo := range []SLO{ + validRawMetricSLO(v1alpha.AppDynamics), + validCountMetricSLO(v1alpha.AppDynamics), + func() SLO { + slo := validRawMetricSLO(v1alpha.AppDynamics) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AppDynamics.MetricPath = ptr("App | * | Latency") + return slo + }(), + } { + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } +} + +func TestValidate_AppDynamics_Invalid(t *testing.T) { + for name, test := range map[string]struct { + Spec *AppDynamicsMetric + ExpectedErrors []testutils.ExpectedError + }{ + "required fields": { + Spec: &AppDynamicsMetric{}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "applicationName", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "metricPath", + Code: validation.ErrorCodeRequired, + }, + }, + }, + "application name non empty": { + Spec: &AppDynamicsMetric{ + ApplicationName: ptr(" "), + MetricPath: ptr("path"), + }, + ExpectedErrors: []testutils.ExpectedError{{ + Prop: "applicationName", + Code: validation.ErrorCodeStringNotEmpty, + }}, + }, + "metric path wildcard not supported": { + Spec: &AppDynamicsMetric{ + ApplicationName: ptr("name"), + MetricPath: ptr("App | This* | Latency"), + }, + ExpectedErrors: []testutils.ExpectedError{{ + Prop: "metricPath", + Code: errCodeAppDynamicsWildcardNotSupported, + }}, + }, + } { + t.Run("rawMetric "+name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AppDynamics) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AppDynamics = test.Spec + err := validate(slo) + + raw := make([]testutils.ExpectedError, len(test.ExpectedErrors)) + copy(raw, test.ExpectedErrors) + raw = testutils.PrependPropertyPath(raw, "spec.objectives[0].rawMetric.query.appDynamics") + testutils.AssertContainsErrors(t, slo, err, len(test.ExpectedErrors), raw...) + }) + t.Run("countMetric "+name, func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.AppDynamics) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.AppDynamics = test.Spec + slo.Spec.Objectives[0].CountMetrics.GoodMetric.AppDynamics = test.Spec + err := validate(slo) + + total := make([]testutils.ExpectedError, len(test.ExpectedErrors)) + copy(total, test.ExpectedErrors) + good := make([]testutils.ExpectedError, len(test.ExpectedErrors)) + copy(good, test.ExpectedErrors) + total = testutils.PrependPropertyPath(total, "spec.objectives[0].countMetrics.total.appDynamics") + good = testutils.PrependPropertyPath(good, "spec.objectives[0].countMetrics.good.appDynamics") + testutils.AssertContainsErrors(t, slo, err, len(test.ExpectedErrors)*2, append(total, good...)...) //nolint: makezero + }) + } +} + +func TestValidate_AppDynamics_MetricPathRegex(t *testing.T) { + for _, test := range []struct { + metricPath string + isValid bool + }{ + // Valid + {isValid: true, metricPath: "App | * | Latency"}, + {isValid: true, metricPath: "App |*| Latency"}, + {isValid: true, metricPath: "App|* | Latency"}, + {isValid: true, metricPath: "App | *|Latency"}, + {isValid: true, metricPath: "App|*|Latency"}, + // Invalid + {isValid: false, metricPath: "App*|Latency"}, + {isValid: false, metricPath: "Ap*p|Latency"}, + {isValid: false, metricPath: "*p|Latency"}, + {isValid: false, metricPath: "App|*p|Latency"}, + {isValid: false, metricPath: "App| *p |Latency"}, + {isValid: false, metricPath: "App|Latency|p*"}, + } { + slo := validRawMetricSLO(v1alpha.AppDynamics) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AppDynamics = &AppDynamicsMetric{ + ApplicationName: ptr("name"), + MetricPath: ptr(test.metricPath), + } + err := validate(slo) + if test.isValid { + testutils.AssertNoError(t, slo, err) + } else { + assert.Error(t, err) + } + } +} diff --git a/manifest/v1alpha/slo/metrics_azure_monitor.go b/manifest/v1alpha/slo/metrics_azure_monitor.go new file mode 100644 index 00000000..56b11bb5 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_azure_monitor.go @@ -0,0 +1,105 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// AzureMonitorMetric represents metric from AzureMonitor +type AzureMonitorMetric struct { + ResourceID string `json:"resourceId"` + MetricName string `json:"metricName"` + Aggregation string `json:"aggregation"` + Dimensions []AzureMonitorMetricDimension `json:"dimensions,omitempty"` + MetricNamespace string `json:"metricNamespace,omitempty"` +} + +// AzureMonitorMetricDimension represents name/value pair that is part of the identity of a metric. +type AzureMonitorMetricDimension struct { + Name *string `json:"name"` + Value *string `json:"value"` +} + +var azureMonitorCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()).Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + total := c.TotalMetric + good := c.GoodMetric + bad := c.BadMetric + + if total == nil { + return nil + } + if good != nil { + if good.AzureMonitor.MetricNamespace != total.AzureMonitor.MetricNamespace { + return countMetricsPropertyEqualityError("azureMonitor.metricNamespace", goodMetric) + } + if good.AzureMonitor.ResourceID != total.AzureMonitor.ResourceID { + return countMetricsPropertyEqualityError("azureMonitor.resourceId", goodMetric) + } + } + if bad != nil { + if bad.AzureMonitor.MetricNamespace != total.AzureMonitor.MetricNamespace { + return countMetricsPropertyEqualityError("azureMonitor.metricNamespace", badMetric) + } + if bad.AzureMonitor.ResourceID != total.AzureMonitor.ResourceID { + return countMetricsPropertyEqualityError("azureMonitor.resourceId", badMetric) + } + } + return nil + }).WithErrorCode(validation.ErrorCodeNotEqualTo)), +).When(whenCountMetricsIs(v1alpha.AzureMonitor)) + +var supportedAzureMonitorAggregations = []string{ + "Avg", + "Min", + "Max", + "Count", + "Sum", +} + +var azureMonitorResourceIDRegexp = regexp.MustCompile(`^\/subscriptions\/[a-zA-Z0-9-]+\/resourceGroups\/[a-zA-Z0-9-._()]+\/providers\/[a-zA-Z0-9-.()_]+\/[a-zA-Z0-9-_()]+\/[a-zA-Z0-9-_()]+$`) //nolint:lll + +var azureMonitorValidation = validation.New[AzureMonitorMetric]( + validation.For(func(a AzureMonitorMetric) string { return a.MetricName }). + WithName("metricName"). + Required(), + validation.For(func(a AzureMonitorMetric) string { return a.ResourceID }). + WithName("resourceId"). + Required(). + Rules(validation.StringMatchRegexp(azureMonitorResourceIDRegexp)), + validation.For(func(a AzureMonitorMetric) string { return a.Aggregation }). + WithName("aggregation"). + Required(). + Rules(validation.OneOf(supportedAzureMonitorAggregations...)), + validation.ForEach(func(a AzureMonitorMetric) []AzureMonitorMetricDimension { return a.Dimensions }). + WithName("dimensions"). + IncludeForEach(azureMonitorMetricDimensionValidation). + // We don't want to check names uniqueness if for exsample names are empty. + StopOnError(). + Rules(validation.SliceUnique(func(d AzureMonitorMetricDimension) string { + if d.Name == nil { + return "" + } + return *d.Name + }).WithDetails("dimension 'name' must be unique for all dimensions")), +) + +var azureMonitorMetricDimensionValidation = validation.New[AzureMonitorMetricDimension]( + validation.ForPointer(func(a AzureMonitorMetricDimension) *string { return a.Name }). + WithName("name"). + Required(). + Rules( + validation.StringNotEmpty(), + validation.StringMaxLength(255), + validation.StringASCII()), + validation.ForPointer(func(a AzureMonitorMetricDimension) *string { return a.Value }). + WithName("value"). + Required(). + Rules( + validation.StringNotEmpty(), + validation.StringMaxLength(255), + validation.StringASCII()), +) diff --git a/manifest/v1alpha/slo/metrics_azure_monitor_test.go b/manifest/v1alpha/slo/metrics_azure_monitor_test.go new file mode 100644 index 00000000..509deadb --- /dev/null +++ b/manifest/v1alpha/slo/metrics_azure_monitor_test.go @@ -0,0 +1,265 @@ +package slo + +import ( + "strings" + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestAzureMonitor_CountMetrics(t *testing.T) { + t.Run("metricNamespace must be the same for good/bad and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.AzureMonitor = &AzureMonitorMetric{ + ResourceID: "/subscriptions/1/resourceGroups/azure-monitor-test-sources/providers/Microsoft.Web/sites/app", + MetricName: "HttpResponseTime", + Aggregation: "Avg", + MetricNamespace: "This", + } + // Good. + slo.Spec.Objectives[0].CountMetrics.GoodMetric.AzureMonitor = &AzureMonitorMetric{ + ResourceID: "/subscriptions/1/resourceGroups/azure-monitor-test-sources/providers/Microsoft.Web/sites/app", + MetricName: "HttpResponseTime", + Aggregation: "Avg", + MetricNamespace: "That", + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Message: "'azureMonitor.metricNamespace' must be the same for both 'good' and 'total' metrics", + }) + // Bad. + slo.Spec.Objectives[0].CountMetrics.BadMetric = slo.Spec.Objectives[0].CountMetrics.GoodMetric + slo.Spec.Objectives[0].CountMetrics.GoodMetric = nil + err = validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Message: "'azureMonitor.metricNamespace' must be the same for both 'bad' and 'total' metrics", + }) + }) + t.Run("resourceId must be the same for good/bad and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.AzureMonitor = &AzureMonitorMetric{ + ResourceID: "/subscriptions/123/resourceGroups/azure-monitor-test-sources/providers/Microsoft.Web/sites/app", + MetricName: "HttpResponseTime", + Aggregation: "Avg", + } + // Good. + slo.Spec.Objectives[0].CountMetrics.GoodMetric.AzureMonitor = &AzureMonitorMetric{ + ResourceID: "/subscriptions/333/resourceGroups/azure-monitor-test-sources/providers/Microsoft.Web/sites/app", + MetricName: "HttpResponseTime", + Aggregation: "Avg", + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Message: "'azureMonitor.resourceId' must be the same for both 'good' and 'total' metrics", + }) + // Bad. + slo.Spec.Objectives[0].CountMetrics.BadMetric = slo.Spec.Objectives[0].CountMetrics.GoodMetric + slo.Spec.Objectives[0].CountMetrics.GoodMetric = nil + err = validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Message: "'azureMonitor.resourceId' must be the same for both 'bad' and 'total' metrics", + }) + }) +} + +func TestAzureMonitor(t *testing.T) { + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor = &AzureMonitorMetric{ + ResourceID: "", + MetricName: "", + Aggregation: "", + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.resourceId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.metricName", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.aggregation", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("valid aggregations", func(t *testing.T) { + for _, agg := range supportedAzureMonitorAggregations { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.Aggregation = agg + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid aggregations", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.Aggregation = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.aggregation", + Code: validation.ErrorCodeOneOf, + }) + }) +} + +func TestAzureMonitorDimension(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.Dimensions = []AzureMonitorMetricDimension{ + { + Name: ptr("that"), + Value: ptr("value-1"), + }, + { + Name: ptr("this"), + Value: ptr("value-2"), + }, + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("invalid fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.Dimensions = []AzureMonitorMetricDimension{ + {}, + { + Name: ptr(""), + Value: ptr(""), + }, + { + Name: ptr(strings.Repeat("l", 256)), + Value: ptr(strings.Repeat("l", 256)), + }, + { + Name: ptr("カタカナ"), + Value: ptr("カタカナ"), + }, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 8, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[0].name", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[0].value", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[1].name", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[1].value", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[2].name", + Code: validation.ErrorCodeStringMaxLength, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[2].value", + Code: validation.ErrorCodeStringMaxLength, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[3].name", + Code: validation.ErrorCodeStringASCII, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions[3].value", + Code: validation.ErrorCodeStringASCII, + }, + ) + }) + t.Run("unique names", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.Dimensions = []AzureMonitorMetricDimension{ + { + Name: ptr("this"), + Value: ptr("value"), + }, + { + Name: ptr("this"), + Value: ptr("val"), + }, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.dimensions", + Code: validation.ErrorCodeSliceUnique, + }) + }) +} + +func TestAzureMonitor_ResourceID(t *testing.T) { + testCases := []struct { + desc string + resourceID string + isValid bool + }{ + { + desc: "one letter", + resourceID: "a", + isValid: false, + }, + { + desc: "incomplete resource provider", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/vm", + isValid: false, + }, + { + desc: "missing resource providerNamespace", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/Test-RG1/providers/virtualMachines/vm", //nolint:lll + isValid: false, + }, + { + desc: "missing resource type", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/vm", //nolint:lll + isValid: false, + }, + { + desc: "missing resource name", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines", //nolint:lll + isValid: false, + }, + { + desc: "valid resource id", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm", //nolint:lll + isValid: true, + }, + { + desc: "valid resource id with _", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-123_x", //nolint:lll + isValid: true, + }, + { + desc: "valid resource id with _ in rg", + resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mc_().rg-xxx-01_ups-aks_eu_west/providers/Microsoft.()Network/loadBalancers1_-()/kubernetes", //nolint:lll + isValid: true, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].RawMetric.MetricQuery.AzureMonitor.ResourceID = tC.resourceID + err := validate(slo) + if tC.isValid { + testutils.AssertNoError(t, slo, err) + } else { + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.azureMonitor.resourceId", + Code: validation.ErrorCodeStringMatchRegexp, + }) + } + }) + } +} diff --git a/manifest/v1alpha/slo/metrics_bigquery.go b/manifest/v1alpha/slo/metrics_bigquery.go new file mode 100644 index 00000000..c18ffea1 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_bigquery.go @@ -0,0 +1,54 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// BigQueryMetric represents metric from BigQuery +type BigQueryMetric struct { + Query string `json:"query"` + ProjectID string `json:"projectId"` + Location string `json:"location"` +} + +var bigQueryCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + good := c.GoodMetric + total := c.TotalMetric + + if good.BigQuery.ProjectID != total.BigQuery.ProjectID { + return countMetricsPropertyEqualityError("bigQuery.projectId", goodMetric) + } + if good.BigQuery.Location != total.BigQuery.Location { + return countMetricsPropertyEqualityError("bigQuery.location", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo)), +).When(whenCountMetricsIs(v1alpha.BigQuery)) + +var bigQueryValidation = validation.New[BigQueryMetric]( + validation.For(func(b BigQueryMetric) string { return b.ProjectID }). + WithName("projectId"). + Required(). + Rules(validation.StringMaxLength(255)), + validation.For(func(b BigQueryMetric) string { return b.Location }). + WithName("location"). + Required(), + validation.For(func(b BigQueryMetric) string { return b.Query }). + WithName("query"). + Required(). + Rules( + validation.StringMatchRegexp(regexp.MustCompile(`\bn9date\b`)). + WithDetails("must contain 'n9date'"), + validation.StringMatchRegexp(regexp.MustCompile(`\bn9value\b`)). + WithDetails("must contain 'n9value'"), + validation.StringMatchRegexp(regexp.MustCompile(`DATETIME\(\s*@n9date_from\s*\)`)). + WithDetails("must have DATETIME placeholder with '@n9date_from'"), + validation.StringMatchRegexp(regexp.MustCompile(`DATETIME\(\s*@n9date_to\s*\)`)). + WithDetails("must have DATETIME placeholder with '@n9date_to'")), +) diff --git a/manifest/v1alpha/slo/metrics_bigquery_test.go b/manifest/v1alpha/slo/metrics_bigquery_test.go new file mode 100644 index 00000000..16d06809 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_bigquery_test.go @@ -0,0 +1,87 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestBigQuery_CountMetrics(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.BigQuery) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("projectId must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.BigQuery) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.BigQuery.ProjectID = "1" + slo.Spec.Objectives[0].CountMetrics.GoodMetric.BigQuery.ProjectID = "2" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("location must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.BigQuery) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.BigQuery.Location = "1" + slo.Spec.Objectives[0].CountMetrics.GoodMetric.BigQuery.Location = "2" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) +} + +func TestBigQuery(t *testing.T) { + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.BigQuery) + slo.Spec.Objectives[0].RawMetric.MetricQuery.BigQuery = &BigQueryMetric{} + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.bigQuery.projectId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.bigQuery.location", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.bigQuery.query", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("invalid query", func(t *testing.T) { + for expectedDetails, query := range map[string]string{ + "must contain 'n9date'": ` +SELECT http_code AS n9value +FROM 'bdwtest-256112.metrics.http_response' +WHERE http_code = 200 AND created BETWEEN DATETIME(@n9date_from) AND DATETIME(@n9date_to)`, + "must contain 'n9value'": ` +SELECT created AS n9date +FROM 'bdwtest-256112.metrics.http_response' +WHERE http_code = 200 AND created BETWEEN DATETIME(@n9date_from) AND DATETIME(@n9date_to)`, + "must have DATETIME placeholder with '@n9date_from'": ` +SELECT http_code AS n9value, created AS n9date +FROM 'bdwtest-256112.metrics.http_response' +WHERE http_code = 200 AND created = DATETIME(@n9date_to)`, + "must have DATETIME placeholder with '@n9date_to'": ` +SELECT http_code AS n9value, created AS n9date +FROM 'bdwtest-256112.metrics.http_response' +WHERE http_code = 200 AND created = DATETIME(@n9date_from)`, + } { + slo := validRawMetricSLO(v1alpha.BigQuery) + slo.Spec.Objectives[0].RawMetric.MetricQuery.BigQuery.Query = query + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.bigQuery.query", + ContainsMessage: expectedDetails, + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_cloudwatch.go b/manifest/v1alpha/slo/metrics_cloudwatch.go new file mode 100644 index 00000000..a9c1032e --- /dev/null +++ b/manifest/v1alpha/slo/metrics_cloudwatch.go @@ -0,0 +1,312 @@ +package slo + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/pkg/errors" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// CloudWatchMetric represents metric from CloudWatch. +type CloudWatchMetric struct { + Region *string `json:"region"` + Namespace *string `json:"namespace,omitempty"` + MetricName *string `json:"metricName,omitempty"` + Stat *string `json:"stat,omitempty"` + Dimensions []CloudWatchMetricDimension `json:"dimensions,omitempty"` + AccountID *string `json:"accountId,omitempty"` + SQL *string `json:"sql,omitempty"` + JSON *string `json:"json,omitempty"` +} + +// IsStandardConfiguration returns true if the struct represents CloudWatch standard configuration. +func (c CloudWatchMetric) IsStandardConfiguration() bool { + return c.Stat != nil || c.Dimensions != nil || c.MetricName != nil || c.Namespace != nil +} + +// IsSQLConfiguration returns true if the struct represents CloudWatch SQL configuration. +func (c CloudWatchMetric) IsSQLConfiguration() bool { + return c.SQL != nil +} + +// IsJSONConfiguration returns true if the struct represents CloudWatch JSON configuration. +func (c CloudWatchMetric) IsJSONConfiguration() bool { + return c.JSON != nil +} + +// CloudWatchMetricDimension represents name/value pair that is part of the identity of a metric. +type CloudWatchMetricDimension struct { + Name *string `json:"name"` + Value *string `json:"value"` +} + +var cloudWatchValidation = validation.New[CloudWatchMetric]( + validation.For(validation.GetSelf[CloudWatchMetric]()). + Rules(validation.NewSingleRule(func(c CloudWatchMetric) error { + var configOptions int + if c.IsStandardConfiguration() { + configOptions++ + } + if c.IsSQLConfiguration() { + configOptions++ + } + if c.IsJSONConfiguration() { + configOptions++ + } + if configOptions != 1 { + return errors.New("exactly one configuration type is required," + + " the available types [Standard, JSON, SQL] are represented by the following properties:" + + " Standard{namespace, metricName, stat, dimensions}; JSON{json}; SQL{sql}") + } + return nil + }).WithErrorCode(validation.ErrorCodeOneOf)). + StopOnError(). + Include( + cloudWatchStandardConfigValidation, + cloudWatchSQLConfigValidation, + cloudWatchJSONConfigValidation), + validation.ForPointer(func(c CloudWatchMetric) *string { return c.Region }). + WithName("region"). + Required(). + Rules( + validation.StringMaxLength(255), + validation.OneOf(func() []string { + codes := make([]string, 0, len(v1alpha.AWSRegions())) + for _, region := range v1alpha.AWSRegions() { + codes = append(codes, region.Code) + } + return codes + }()...)), +) + +var cloudWatchSQLConfigValidation = validation.New[CloudWatchMetric]( + validation.ForPointer(func(c CloudWatchMetric) *string { return c.SQL }). + WithName("sql"). + Required(). + Rules(validation.StringNotEmpty()), +).When(func(c CloudWatchMetric) bool { return c.IsSQLConfiguration() }) + +var cloudWatchJSONConfigValidation = validation.New[CloudWatchMetric]( + validation.ForPointer(func(c CloudWatchMetric) *string { return c.JSON }). + WithName("json"). + Required(). + Rules(cloudWatchJSONValidationRule), +).When(func(c CloudWatchMetric) bool { return c.IsJSONConfiguration() }) + +var cloudWatchStandardConfigValidation = validation.New[CloudWatchMetric]( + validation.ForPointer(func(c CloudWatchMetric) *string { return c.Namespace }). + WithName("namespace"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringMatchRegexp(cloudWatchNamespaceRegexp)), + validation.ForPointer(func(c CloudWatchMetric) *string { return c.MetricName }). + WithName("metricName"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringMaxLength(255)), + validation.ForPointer(func(c CloudWatchMetric) *string { return c.Stat }). + WithName("stat"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringMatchRegexp(cloudWatchStatRegexp, cloudWatchExampleValidStats...)), + validation.ForEach(func(c CloudWatchMetric) []CloudWatchMetricDimension { return c.Dimensions }). + WithName("dimensions"). + Rules(validation.SliceMaxLength[[]CloudWatchMetricDimension](10)). + // If the slice is too long, don't proceed with validation. + StopOnError(). + IncludeForEach(cloudwatchMetricDimensionValidation). + StopOnError(). + // We don't want to check names uniqueness if for exsample names are empty. + Rules(validation.SliceUnique(func(c CloudWatchMetricDimension) string { + if c.Name == nil { + return "" + } + return *c.Name + }).WithDetails("dimension 'name' must be unique for all dimensions")), + validation.ForPointer(func(c CloudWatchMetric) *string { return c.AccountID }). + WithName("accountId"). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringMatchRegexp(cloudWatchAccountIDRegexp, "123456789012")), +).When(func(c CloudWatchMetric) bool { return c.IsStandardConfiguration() }) + +var ( + // cloudWatchStatRegex matches valid stat function according to this documentation: + // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Statistics-definitions.html + cloudWatchStatRegexp = buildCloudWatchStatRegexp() + cloudWatchNamespaceRegexp = regexp.MustCompile(`^[0-9A-Za-z.\-_/#:]{1,255}$`) + cloudWatchAccountIDRegexp = regexp.MustCompile(`^\d{12}$`) +) + +var cloudwatchMetricDimensionValidation = validation.New[CloudWatchMetricDimension]( + validation.ForPointer(func(c CloudWatchMetricDimension) *string { return c.Name }). + WithName("name"). + Required(). + Rules( + validation.StringNotEmpty(), + validation.StringMaxLength(255), + validation.StringASCII()), + validation.ForPointer(func(c CloudWatchMetricDimension) *string { return c.Value }). + WithName("value"). + Required(). + Rules( + validation.StringNotEmpty(), + validation.StringMaxLength(255), + validation.StringASCII()), +) + +var cloudWatchJSONValidationRule = validation.NewSingleRule(func(v string) error { + var metricDataQuerySlice []*cloudwatch.MetricDataQuery + if err := json.Unmarshal([]byte(v), &metricDataQuerySlice); err != nil { + return &validation.RuleError{Message: err.Error(), Code: validation.ErrorCodeStringJSON} + } + + returnedData := len(metricDataQuerySlice) + for i, metricData := range metricDataQuerySlice { + if err := metricData.Validate(); err != nil { + return errors.New(strings.TrimSuffix(err.Error(), "\n")) + } + if metricData.ReturnData != nil && !*metricData.ReturnData { + returnedData-- + } + if metricData.MetricStat != nil { + if err := validateCloudwatchJSONPeriod(metricData.MetricStat.Period, "MetricStat.Period", i); err != nil { + return err + } + } else { + if err := validateCloudwatchJSONPeriod(metricData.Period, "Period", i); err != nil { + return err + } + } + } + if returnedData != 1 { + return errors.New("exactly one returned data required," + + " provide '\"ReturnData\": false' to metric data query in order to disable returned data") + } + return nil +}) + +func validateCloudwatchJSONPeriod(period *int64, propName string, index int) error { + indexPropName := func() string { + return validation.SliceElementName(".", index) + "." + propName + } + const queryPeriod = 60 + if period == nil { + return &validation.RuleError{ + Message: fmt.Sprintf("'%s' property is required", indexPropName()), + Code: validation.ErrorCodeRequired, + } + } + if *period != queryPeriod { + return &validation.RuleError{ + Message: fmt.Sprintf("'%s' property should be equal to %d", indexPropName(), queryPeriod), + Code: validation.ErrorCodeEqualTo, + } + } + return nil +} + +var cloudWatchExampleValidStats = []string{ + "SampleCount", + "Sum", + "Average", + "Minimum", + "Maximum", + "IQM", + "p10", + "p99", + "tm98", + "wm99", + "tc10", + "ts30", + "TM(10%:98%)", + "WM(10%:15%)", + "TC(10%:20%)", + "TS(10%:90%)", +} + +func buildCloudWatchStatRegexp() *regexp.Regexp { + simpleFunctions := []string{ + "SampleCount", + "Sum", + "Average", + "Minimum", + "Maximum", + "IQM", + } + + floatFrom0To100 := `(100|(([1-9]\d?)|0))(\.\d{1,10})?` + shortFunctionNames := []string{ + "p", + "tm", + "wm", + "tc", + "ts", + } + shortFunctions := wrapInParenthesis(concatRegexAlternatives(shortFunctionNames)) + wrapInParenthesis(floatFrom0To100) + + percent := wrapInParenthesis(floatFrom0To100 + "%") + floatingPoint := wrapInParenthesis(`-?(([1-9]\d*)|0)(\.\d{1,10})?`) + percentArgumentAlternatives := []string{ + fmt.Sprintf("%s:%s", percent, percent), + fmt.Sprintf("%s:", percent), + fmt.Sprintf(":%s", percent), + } + floatArgumentAlternatives := []string{ + fmt.Sprintf("%s:%s", floatingPoint, floatingPoint), + fmt.Sprintf("%s:", floatingPoint), + fmt.Sprintf(":%s", floatingPoint), + } + var allArgumentAlternatives []string + allArgumentAlternatives = append(allArgumentAlternatives, percentArgumentAlternatives...) + allArgumentAlternatives = append(allArgumentAlternatives, floatArgumentAlternatives...) + + valueOrPercentFunctionNames := []string{ + "TM", + "WM", + "TC", + "TS", + } + valueOrPercentFunctions := wrapInParenthesis(concatRegexAlternatives(valueOrPercentFunctionNames)) + + fmt.Sprintf(`\(%s\)`, concatRegexAlternatives(allArgumentAlternatives)) + + valueOnlyFunctionNames := []string{ + "PR", + } + valueOnlyFunctions := wrapInParenthesis(concatRegexAlternatives(valueOnlyFunctionNames)) + + fmt.Sprintf(`\(%s\)`, concatRegexAlternatives(floatArgumentAlternatives)) + + var allFunctions []string + allFunctions = append(allFunctions, simpleFunctions...) + allFunctions = append(allFunctions, shortFunctions) + allFunctions = append(allFunctions, valueOrPercentFunctions) + allFunctions = append(allFunctions, valueOnlyFunctions) + + finalRegexStr := fmt.Sprintf("^%s$", concatRegexAlternatives(allFunctions)) + finalRegex := regexp.MustCompile(finalRegexStr) + return finalRegex +} + +func wrapInParenthesis(regex string) string { + return fmt.Sprintf("(%s)", regex) +} + +func concatRegexAlternatives(alternatives []string) string { + var result strings.Builder + for i, alternative := range alternatives { + result.WriteString(wrapInParenthesis(alternative)) + if i < len(alternatives)-1 { + result.WriteString("|") + } + } + return wrapInParenthesis(result.String()) +} diff --git a/manifest/v1alpha/slo/metrics_cloudwatch_test.go b/manifest/v1alpha/slo/metrics_cloudwatch_test.go new file mode 100644 index 00000000..f5b59ca2 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_cloudwatch_test.go @@ -0,0 +1,389 @@ +package slo + +import ( + "embed" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestCloudWatch(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("invalid configuration", func(t *testing.T) { + for name, metric := range map[string]*CloudWatchMetric{ + "no configuration": { + Region: ptr("eu-central-1"), + }, + "sql and json": { + Region: ptr("eu-central-1"), + SQL: ptr("SELECT * FROM table"), + JSON: getCloudWatchJSON(t, "cloudwatch_valid_json"), + }, + "standard and json": { + Region: ptr("eu-central-1"), + Namespace: ptr("namespace"), + JSON: getCloudWatchJSON(t, "cloudwatch_valid_json"), + }, + "standard and sql": { + Region: ptr("eu-central-1"), + Namespace: ptr("namespace"), + SQL: ptr("SELECT * FROM table"), + }, + "all": { + Region: ptr("eu-central-1"), + Namespace: ptr("namespace"), + SQL: ptr("SELECT * FROM table"), + JSON: getCloudWatchJSON(t, "cloudwatch_valid_json"), + }, + } { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = metric + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch", + Code: validation.ErrorCodeOneOf, + }) + }) + } + }) + t.Run("invalid region", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.Region = ptr("invalid") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.region", + Code: validation.ErrorCodeOneOf, + }) + }) +} + +func TestCloudWatchStandard(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + Namespace: ptr("namespace"), + MetricName: ptr("my-name"), + Stat: ptr("SampleCount"), + Dimensions: []CloudWatchMetricDimension{ + { + Name: ptr("my-name"), + Value: ptr("value"), + }, + }, + AccountID: nil, // Optional + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + Namespace: ptr(""), + MetricName: ptr(""), + Stat: ptr(""), + AccountID: ptr(""), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 4, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.metricName", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.namespace", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.stat", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.accountId", + Code: validation.ErrorCodeStringNotEmpty, + }, + ) + }) + t.Run("invalid fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + Namespace: ptr("?"), + MetricName: ptr(strings.Repeat("l", 256)), + Stat: ptr("invalid"), + AccountID: ptr("invalid"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 4, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.namespace", + Code: validation.ErrorCodeStringMatchRegexp, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.metricName", + Code: validation.ErrorCodeStringMaxLength, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.stat", + Code: validation.ErrorCodeStringMatchRegexp, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.accountId", + Code: validation.ErrorCodeStringMatchRegexp, + }, + ) + }) + t.Run("valid stat", func(t *testing.T) { + for _, stat := range cloudWatchExampleValidStats { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + Namespace: ptr("my-ns"), + MetricName: ptr("my-metric"), + Stat: ptr(stat), + AccountID: ptr("123456789012"), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid accountId", func(t *testing.T) { + for _, accountID := range []string{ + "1234", + "0918203481029481092478109", + "notAnAccountID", + "neither123", + "this123that", + } { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.AccountID = ptr(accountID) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.accountId", + Code: validation.ErrorCodeStringMatchRegexp, + }) + } + }) +} + +func TestCloudWatchJSON(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + JSON: getCloudWatchJSON(t, "cloudwatch_valid_json"), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + tests := map[string]struct { + JSON *string + ContainsMessage string + Message string + Code string + }{ + "invalid JSON": { + JSON: ptr("{]}"), + Code: validation.ErrorCodeStringJSON, + }, + "invalid metric data": { + JSON: ptr("[{}]"), + // Returned by AWS SDK validation. + ContainsMessage: "missing required field", + }, + "no returned data": { + JSON: getCloudWatchJSON(t, "cloudwatch_no_returned_data_json"), + ContainsMessage: "exactly one returned data required", + }, + "more than one returned data": { + JSON: getCloudWatchJSON(t, "cloudwatch_more_than_one_returned_data_json"), + ContainsMessage: "exactly one returned data required", + }, + "missing Period": { + JSON: getCloudWatchJSON(t, "cloudwatch_missing_period_json"), + Message: "'.[0].Period' property is required", + }, + "missing MetricStat.Period": { + JSON: getCloudWatchJSON(t, "cloudwatch_missing_metric_stat_period_json"), + // Returned by AWS SDK validation. + ContainsMessage: "missing required field, MetricDataQuery.MetricStat.Period", + }, + "invalid Period": { + JSON: getCloudWatchJSON(t, "cloudwatch_invalid_period_json"), + Message: "'.[0].Period' property should be equal to 60", + }, + "invalid MetricStat.Period": { + JSON: getCloudWatchJSON(t, "cloudwatch_invalid_metric_stat_period_json"), + Message: "'.[1].MetricStat.Period' property should be equal to 60", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + JSON: test.JSON, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.json", + Code: test.Code, + Message: test.Message, + ContainsMessage: test.ContainsMessage, + }) + }) + } +} + +func TestCloudWatchSQL(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + SQL: ptr("SELECT * FROM table"), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("no empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch = &CloudWatchMetric{ + Region: ptr("eu-central-1"), + SQL: ptr(""), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.sql", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} + +func TestCloudWatch_Dimensions(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.Dimensions = []CloudWatchMetricDimension{ + { + Name: ptr("that"), + Value: ptr("value-1"), + }, + { + Name: ptr("this"), + Value: ptr("value-2"), + }, + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("slice too long", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + var dims []CloudWatchMetricDimension + for i := 0; i < 11; i++ { + dims = append(dims, CloudWatchMetricDimension{ + Name: ptr(strconv.Itoa(i)), + Value: ptr("value"), + }) + } + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.Dimensions = dims + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions", + Code: validation.ErrorCodeSliceMaxLength, + }) + }) + t.Run("invalid fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.Dimensions = []CloudWatchMetricDimension{ + {}, + { + Name: ptr(""), + Value: ptr(""), + }, + { + Name: ptr(strings.Repeat("l", 256)), + Value: ptr(strings.Repeat("l", 256)), + }, + { + Name: ptr("カタカナ"), + Value: ptr("カタカナ"), + }, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 8, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[0].name", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[0].value", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[1].name", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[1].value", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[2].name", + Code: validation.ErrorCodeStringMaxLength, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[2].value", + Code: validation.ErrorCodeStringMaxLength, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[3].name", + Code: validation.ErrorCodeStringASCII, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions[3].value", + Code: validation.ErrorCodeStringASCII, + }, + ) + }) + t.Run("unique names", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.CloudWatch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.CloudWatch.Dimensions = []CloudWatchMetricDimension{ + { + Name: ptr("this"), + Value: ptr("value"), + }, + { + Name: ptr("this"), + Value: ptr("val"), + }, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.cloudWatch.dimensions", + Code: validation.ErrorCodeSliceUnique, + }) + }) +} + +//go:embed test_data +var testData embed.FS + +func getCloudWatchJSON(t *testing.T, name string) *string { + t.Helper() + data, err := testData.ReadFile(filepath.Join("test_data", name+".json")) + require.NoError(t, err) + s := string(data) + return &s +} diff --git a/manifest/v1alpha/slo/metrics_datadog.go b/manifest/v1alpha/slo/metrics_datadog.go new file mode 100644 index 00000000..f733b743 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_datadog.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// DatadogMetric represents metric from Datadog +type DatadogMetric struct { + Query *string `json:"query"` +} + +var datadogValidation = validation.New[DatadogMetric]( + validation.ForPointer(func(d DatadogMetric) *string { return d.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_datadog_test.go b/manifest/v1alpha/slo/metrics_datadog_test.go new file mode 100644 index 00000000..60ae8b2d --- /dev/null +++ b/manifest/v1alpha/slo/metrics_datadog_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestDatadog(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Datadog) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Datadog) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Datadog.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.datadog.query", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Datadog) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Datadog.Query = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.datadog.query", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_dynatrace.go b/manifest/v1alpha/slo/metrics_dynatrace.go new file mode 100644 index 00000000..4b2b6f77 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_dynatrace.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// DynatraceMetric represents metric from Dynatrace. +type DynatraceMetric struct { + MetricSelector *string `json:"metricSelector"` +} + +var dynatraceValidation = validation.New[DynatraceMetric]( + validation.ForPointer(func(d DynatraceMetric) *string { return d.MetricSelector }). + WithName("metricSelector"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_dynatrace_test.go b/manifest/v1alpha/slo/metrics_dynatrace_test.go new file mode 100644 index 00000000..30e2b031 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_dynatrace_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestDynatrace(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Dynatrace) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Dynatrace) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Dynatrace.MetricSelector = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.dynatrace.metricSelector", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Dynatrace) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Dynatrace.MetricSelector = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.dynatrace.metricSelector", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_elasticsearch.go b/manifest/v1alpha/slo/metrics_elasticsearch.go new file mode 100644 index 00000000..4558520e --- /dev/null +++ b/manifest/v1alpha/slo/metrics_elasticsearch.go @@ -0,0 +1,22 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// ElasticsearchMetric represents metric from Elasticsearch. +type ElasticsearchMetric struct { + Index *string `json:"index"` + Query *string `json:"query"` +} + +var elasticsearchValidation = validation.New[ElasticsearchMetric]( + validation.ForPointer(func(e ElasticsearchMetric) *string { return e.Index }). + WithName("index"). + Required(). + Rules(validation.StringNotEmpty()), + validation.ForPointer(func(e ElasticsearchMetric) *string { return e.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringContains("{{.BeginTime}}", "{{.EndTime}}")), +) diff --git a/manifest/v1alpha/slo/metrics_elasticsearch_test.go b/manifest/v1alpha/slo/metrics_elasticsearch_test.go new file mode 100644 index 00000000..9ca114f9 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_elasticsearch_test.go @@ -0,0 +1,71 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestElasticsearch(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Elasticsearch) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Elasticsearch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Elasticsearch = &ElasticsearchMetric{ + Index: nil, + Query: nil, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.elasticsearch.index", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.elasticsearch.query", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Elasticsearch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Elasticsearch = &ElasticsearchMetric{ + Index: ptr(""), + Query: ptr(""), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.elasticsearch.index", + Code: validation.ErrorCodeStringNotEmpty, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.elasticsearch.query", + Code: validation.ErrorCodeStringNotEmpty, + }, + ) + }) + t.Run("invalid query", func(t *testing.T) { + for _, query := range []string{ + "invalid", + "{{.EndTime}} got that", + "{{.BeginTime}} got that", + } { + slo := validRawMetricSLO(v1alpha.Elasticsearch) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Elasticsearch = &ElasticsearchMetric{ + Index: ptr("index"), + Query: ptr(query), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.elasticsearch.query", + Code: validation.ErrorCodeStringContains, + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_gcm.go b/manifest/v1alpha/slo/metrics_gcm.go new file mode 100644 index 00000000..8234e713 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_gcm.go @@ -0,0 +1,18 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// GCMMetric represents metric from GCM +type GCMMetric struct { + Query string `json:"query"` + ProjectID string `json:"projectId"` +} + +var gcmValidation = validation.New[GCMMetric]( + validation.For(func(e GCMMetric) string { return e.Query }). + WithName("query"). + Required(), + validation.For(func(e GCMMetric) string { return e.ProjectID }). + WithName("projectId"). + Required(), +) diff --git a/manifest/v1alpha/slo/metrics_gcm_test.go b/manifest/v1alpha/slo/metrics_gcm_test.go new file mode 100644 index 00000000..8d83e7dd --- /dev/null +++ b/manifest/v1alpha/slo/metrics_gcm_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestGCM(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.GCM) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.GCM) + slo.Spec.Objectives[0].RawMetric.MetricQuery.GCM = &GCMMetric{ + Query: "", + ProjectID: "", + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.gcm.query", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.gcm.projectId", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} diff --git a/manifest/v1alpha/slo/metrics_generic.go b/manifest/v1alpha/slo/metrics_generic.go new file mode 100644 index 00000000..592e988e --- /dev/null +++ b/manifest/v1alpha/slo/metrics_generic.go @@ -0,0 +1,14 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +type GenericMetric struct { + Query *string `json:"query"` +} + +var genericValidation = validation.New[GenericMetric]( + validation.ForPointer(func(e GenericMetric) *string { return e.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_generic_test.go b/manifest/v1alpha/slo/metrics_generic_test.go new file mode 100644 index 00000000..ac0df474 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_generic_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestGeneric(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Generic) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Generic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Generic.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.generic.query", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Generic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Generic.Query = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.generic.query", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_grafana_loki.go b/manifest/v1alpha/slo/metrics_grafana_loki.go new file mode 100644 index 00000000..98212e12 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_grafana_loki.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// GrafanaLokiMetric represents metric from GrafanaLokiMetric. +type GrafanaLokiMetric struct { + Logql *string `json:"logql"` +} + +var grafanaLokiValidation = validation.New[GrafanaLokiMetric]( + validation.ForPointer(func(g GrafanaLokiMetric) *string { return g.Logql }). + WithName("logql"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_grafana_loki_test.go b/manifest/v1alpha/slo/metrics_grafana_loki_test.go new file mode 100644 index 00000000..6b34a992 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_grafana_loki_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestGrafanaLoki(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.GrafanaLoki) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.GrafanaLoki) + slo.Spec.Objectives[0].RawMetric.MetricQuery.GrafanaLoki.Logql = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.grafanaLoki.logql", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.GrafanaLoki) + slo.Spec.Objectives[0].RawMetric.MetricQuery.GrafanaLoki.Logql = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.grafanaLoki.logql", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_graphite.go b/manifest/v1alpha/slo/metrics_graphite.go new file mode 100644 index 00000000..d5cda86f --- /dev/null +++ b/manifest/v1alpha/slo/metrics_graphite.go @@ -0,0 +1,29 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/validation" +) + +// GraphiteMetric represents metric from Graphite. +type GraphiteMetric struct { + MetricPath *string `json:"metricPath"` +} + +var graphiteValidation = validation.New[GraphiteMetric]( + validation.ForPointer(func(g GraphiteMetric) *string { return g.MetricPath }). + WithName("metricPath"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules( + // Graphite allows the use of wildcards in metric paths, but we decided not to support it for our MVP. + // https://graphite.readthedocs.io/en/latest/render_api.html#paths-and-wildcards + validation.StringDenyRegexp(regexp.MustCompile(`\*`)). + WithDetails("wildcards are not allowed"), + validation.StringDenyRegexp(regexp.MustCompile(`\[[^.]*\]`), "[a-z0-9]"). + WithDetails("character list or range is not allowed"), + validation.StringDenyRegexp(regexp.MustCompile(`{[^.]*}`), "{user,system,iowait}"). + WithDetails("value list is not allowed")), +) diff --git a/manifest/v1alpha/slo/metrics_graphite_test.go b/manifest/v1alpha/slo/metrics_graphite_test.go new file mode 100644 index 00000000..20dfd2c0 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_graphite_test.go @@ -0,0 +1,50 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestGraphite(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Graphite) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Graphite) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Graphite.MetricPath = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.graphite.metricPath", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Graphite) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Graphite.MetricPath = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.graphite.metricPath", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) + t.Run("invalid metricPath", func(t *testing.T) { + for containsMessage, path := range map[string]string{ + "wildcards are not allowed": "foo.*.bar", + "character list or range is not allowed": "foo[a-z]bar.baz", + "value list is not allowed": "foo.{user,system}.bar", + } { + slo := validRawMetricSLO(v1alpha.Graphite) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Graphite.MetricPath = ptr(path) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.graphite.metricPath", + ContainsMessage: containsMessage, + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_honeycomb.go b/manifest/v1alpha/slo/metrics_honeycomb.go new file mode 100644 index 00000000..a5d5d899 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_honeycomb.go @@ -0,0 +1,106 @@ +package slo + +import ( + "github.com/nobl9/nobl9-go/validation" +) + +// HoneycombMetric represents metric from Honeycomb. +type HoneycombMetric struct { + Dataset string `json:"dataset"` + Calculation string `json:"calculation"` + Attribute string `json:"attribute"` + Filter HoneycombFilter `json:"filter"` +} + +// HoneycombFilter represents filter for Honeycomb metric. It has custom struct validation. +type HoneycombFilter struct { + Operator string `json:"op"` + Conditions []HoneycombFilterCondition `json:"conditions"` +} + +// HoneycombFilterCondition represents single condition for Honeycomb filter. +type HoneycombFilterCondition struct { + Attribute string `json:"attribute"` + Operator string `json:"op"` + Value string `json:"value"` +} + +var honeycombValidation = validation.New[HoneycombMetric]( + validation.For(func(h HoneycombMetric) string { return h.Dataset }). + WithName("dataset"). + Required(). + Rules( + validation.StringMaxLength(255), + validation.StringNotEmpty(), + validation.StringASCII()), + validation.For(func(h HoneycombMetric) string { return h.Calculation }). + WithName("calculation"). + Required(). + Rules(validation.OneOf(supportedHoneycombCalculationTypes...)), + validation.For(func(h HoneycombMetric) string { return h.Attribute }). + WithName("attribute"). + Required(). + Rules( + validation.StringMaxLength(255), + validation.StringNotEmpty(), + validation.StringASCII()), + validation.For(func(h HoneycombMetric) HoneycombFilter { return h.Filter }). + WithName("filter"). + Omitempty(). + Include(honeycombFilterValidation), +) + +var honeycombFilterValidation = validation.New[HoneycombFilter]( + validation.For(validation.GetSelf[HoneycombFilter]()). + Rules(validation.NewSingleRule(func(h HoneycombFilter) error { + if len(h.Conditions) > 1 && h.Operator == "" { + return validation.NewPropertyError("op", h.Operator, validation.NewRequiredError()) + } + return nil + })), + validation.For(func(h HoneycombFilter) string { return h.Operator }). + WithName("op"). + Omitempty(). + Rules(validation.OneOf(supportedHoneycombFilterOperators...)). + Include(), + validation.ForEach(func(h HoneycombFilter) []HoneycombFilterCondition { return h.Conditions }). + WithName("conditions"). + Rules(validation.SliceMaxLength[[]HoneycombFilterCondition](100)). + // We don't want to spend too much time here if someone is spamming us with more than 100 conditions. + StopOnError(). + Rules(validation.SliceUnique(validation.SelfHashFunc[HoneycombFilterCondition]())). + IncludeForEach(honeycombFilterConditionValidation), +) + +var honeycombFilterConditionValidation = validation.New[HoneycombFilterCondition]( + validation.For(func(h HoneycombFilterCondition) string { return h.Attribute }). + WithName("attribute"). + Required(). + Rules( + validation.StringMaxLength(255), + validation.StringNotEmpty(), + validation.StringASCII()), + validation.For(func(h HoneycombFilterCondition) string { return h.Operator }). + WithName("op"). + Required(). + Rules(validation.OneOf(supportedHoneycombFilterConditionOperators...)), + validation.For(func(h HoneycombFilterCondition) string { return h.Value }). + WithName("value"). + Rules( + validation.StringMaxLength(255), + validation.StringASCII()), +) + +var supportedHoneycombFilterOperators = []string{"AND", "OR"} + +var supportedHoneycombCalculationTypes = []string{ + "COUNT", "SUM", "AVG", "COUNT_DISTINCT", "MAX", "MIN", + "P001", "P01", "P05", "P10", "P25", "P50", "P75", "P90", "P95", "P99", "P999", + "RATE_AVG", "RATE_SUM", "RATE_MAX", +} + +var supportedHoneycombFilterConditionOperators = []string{ + "=", "!=", ">", ">=", "<", "<=", + "starts-with", "does-not-start-with", "exists", "does-not-exist", + "contains", "does-not-contain", "in", "not-in", +} diff --git a/manifest/v1alpha/slo/metrics_honeycomb_test.go b/manifest/v1alpha/slo/metrics_honeycomb_test.go new file mode 100644 index 00000000..2a4492d3 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_honeycomb_test.go @@ -0,0 +1,177 @@ +package slo + +import ( + "fmt" + "strings" + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestHoneycomb(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("string properties", func(t *testing.T) { + for _, test := range []struct { + Metric *HoneycombMetric + ErrorsCount int + Errors []testutils.ExpectedError + }{ + { + Metric: &HoneycombMetric{ + Dataset: " ", + Calculation: "MAX", + Attribute: " ", + Filter: HoneycombFilter{ + Conditions: []HoneycombFilterCondition{ + { + Attribute: " ", + Operator: "<", + }, + }, + }, + }, ErrorsCount: 3, + Errors: []testutils.ExpectedError{ + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.dataset", + Code: validation.ErrorCodeStringNotEmpty, + }, + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.attribute", + Code: validation.ErrorCodeStringNotEmpty, + }, + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.conditions[0].attribute", + Code: validation.ErrorCodeStringNotEmpty, + }, + }, + }, + { + Metric: &HoneycombMetric{ + Dataset: strings.Repeat("l", 256), + Calculation: "MAX", + Attribute: strings.Repeat("l", 256), + Filter: HoneycombFilter{ + Conditions: []HoneycombFilterCondition{ + { + Attribute: strings.Repeat("l", 256), + Operator: "<", + Value: strings.Repeat("l", 256), + }, + }, + }, + }, ErrorsCount: 4, + Errors: []testutils.ExpectedError{ + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.dataset", + Code: validation.ErrorCodeStringMaxLength, + }, + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.attribute", + Code: validation.ErrorCodeStringMaxLength, + }, + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.conditions[0].attribute", + Code: validation.ErrorCodeStringMaxLength, + }, + { + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.conditions[0].value", + Code: validation.ErrorCodeStringMaxLength, + }, + }, + }, + } { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb = test.Metric + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, test.ErrorsCount, test.Errors...) + } + }) + t.Run("valid calculation type", func(t *testing.T) { + for _, typ := range supportedHoneycombCalculationTypes { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Calculation = typ + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid calculation type", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Calculation = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.honeycomb.calculation", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("valid filter operator", func(t *testing.T) { + for _, op := range supportedHoneycombFilterOperators { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Operator = op + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid filter operator", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Operator = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.op", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("valid filter condition operator", func(t *testing.T) { + for _, op := range supportedHoneycombFilterConditionOperators { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Conditions[0].Operator = op + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid filter condition operator", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Conditions[0].Operator = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.conditions[0].op", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("too many conditions", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Conditions = createTooManyHoneycombConditions(t) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.conditions", + Code: validation.ErrorCodeSliceMaxLength, + }) + }) + t.Run("operator is required for more than one condition", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Honeycomb) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Operator = "" + slo.Spec.Objectives[0].RawMetric.MetricQuery.Honeycomb.Filter.Conditions = createTooManyHoneycombConditions(t)[:2] + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.honeycomb.filter.op", + Code: validation.ErrorCodeRequired, + }) + }) +} + +func createTooManyHoneycombConditions(t *testing.T) []HoneycombFilterCondition { + t.Helper() + tooManyHoneycombConditions := make([]HoneycombFilterCondition, 101) + for i := 0; i < 101; i++ { + tooManyHoneycombConditions[i] = HoneycombFilterCondition{ + Attribute: fmt.Sprintf("attr%d", i), + Operator: ">", + } + } + return tooManyHoneycombConditions +} diff --git a/manifest/v1alpha/slo/metrics_influxdb.go b/manifest/v1alpha/slo/metrics_influxdb.go new file mode 100644 index 00000000..900b9e20 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_influxdb.go @@ -0,0 +1,26 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/validation" +) + +// InfluxDBMetric represents metric from InfluxDB +type InfluxDBMetric struct { + Query *string `json:"query"` +} + +var influxdbValidation = validation.New[InfluxDBMetric]( + validation.ForPointer(func(i InfluxDBMetric) *string { return i.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules( + validation.StringMatchRegexp(regexp.MustCompile(`\s*bucket\s*:\s*".+"\s*`)). + WithDetails("must contain a bucket name"), + //nolint: lll + validation.StringMatchRegexp(regexp.MustCompile(`\s*range\s*\(\s*start\s*:\s*time\s*\(\s*v\s*:\s*params\.n9time_start\s*\)\s*,\s*stop\s*:\s*time\s*\(\s*v\s*:\s*params\.n9time_stop\s*\)\s*\)`)). + WithDetails("must contain both 'params.n9time_start' and 'params.n9time_stop'")), +) diff --git a/manifest/v1alpha/slo/metrics_influxdb_test.go b/manifest/v1alpha/slo/metrics_influxdb_test.go new file mode 100644 index 00000000..0b94d383 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_influxdb_test.go @@ -0,0 +1,108 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestInfluxDB(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.InfluxDB) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.InfluxDB) + slo.Spec.Objectives[0].RawMetric.MetricQuery.InfluxDB.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.influxdb.query", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.InfluxDB) + slo.Spec.Objectives[0].RawMetric.MetricQuery.InfluxDB.Query = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.influxdb.query", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} + +func TestInfluxDB_Query(t *testing.T) { + tests := []struct { + name string + query string + isValid bool + }{ + { + name: "basic good query", + query: `from(bucket: "influxdb-integration-samples") + |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, + isValid: true, + }, + { + name: "Query should contain name 'params.n9time_start", + query: `from(bucket: "influxdb-integration-samples") + |> range(start: time(v: params.n9time_definitely_not_start), stop: time(v: params.n9time_stop))`, + isValid: false, + }, + { + name: "Query should contain name 'params.n9time_stop", + query: `from(bucket: "influxdb-integration-samples") + |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_bad_stop))`, + isValid: false, + }, + { + name: "User can add whitespaces", + query: `from(bucket: "influxdb-integration-samples") + |> range ( start : time ( v : params.n9time_start ) +, stop : time ( v : params.n9time_stop ) )`, + isValid: true, + }, + { + name: "User cannot add whitespaces inside words", + query: `from(bucket: "influxdb-integration-samples") + |> range(start: time(v: par ams.n9time_start), stop: time(v: params.n9time_stop))`, + isValid: false, + }, + { + name: "User cannot split variables connected by .", + query: `from(bucket: "influxdb-integration-samples") + |> range(start: time(v: params. n9time_start), stop: time(v: params.n9time_stop))`, + isValid: false, + }, + { + name: "Query need to have bucket value", + query: `from(et: "influxdb-integration-samples") + |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, + isValid: false, + }, + { + name: "Bucket name need to be present", + query: `from(bucket: "") + |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, + isValid: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.InfluxDB) + slo.Spec.Objectives[0].RawMetric.MetricQuery.InfluxDB.Query = ptr(tc.query) + err := validate(slo) + if tc.isValid { + testutils.AssertNoError(t, slo, err) + } else { + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.influxdb.query", + Code: validation.ErrorCodeStringMatchRegexp, + }) + } + }) + } +} diff --git a/manifest/v1alpha/slo/metrics_instana.go b/manifest/v1alpha/slo/metrics_instana.go new file mode 100644 index 00000000..18f132d3 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_instana.go @@ -0,0 +1,204 @@ +package slo + +import ( + "github.com/pkg/errors" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// InstanaMetric represents metric from Redshift. +type InstanaMetric struct { + MetricType string `json:"metricType"` + Infrastructure *InstanaInfrastructureMetricType `json:"infrastructure,omitempty"` + Application *InstanaApplicationMetricType `json:"application,omitempty"` +} + +type InstanaInfrastructureMetricType struct { + MetricRetrievalMethod string `json:"metricRetrievalMethod"` + Query *string `json:"query,omitempty"` + SnapshotID *string `json:"snapshotId,omitempty"` + MetricID string `json:"metricId"` + PluginID string `json:"pluginId"` +} + +type InstanaApplicationMetricType struct { + MetricID string `json:"metricId"` + Aggregation string `json:"aggregation"` + GroupBy InstanaApplicationMetricGroupBy `json:"groupBy"` + APIQuery string `json:"apiQuery"` + IncludeInternal bool `json:"includeInternal,omitempty"` + IncludeSynthetic bool `json:"includeSynthetic,omitempty"` +} + +type InstanaApplicationMetricGroupBy struct { + Tag string `json:"tag"` + TagEntity string `json:"tagEntity"` + TagSecondLevelKey *string `json:"tagSecondLevelKey,omitempty"` +} + +const ( + instanaMetricTypeInfrastructure = "infrastructure" + instanaMetricTypeApplication = "application" + + instanaMetricRetrievalMethodQuery = "query" + instanaMetricRetrievalMethodSnapshot = "snapshot" +) + +var instanaCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + if c.GoodMetric.Instana.MetricType != c.TotalMetric.Instana.MetricType { + return countMetricsPropertyEqualityError("instana.metricType", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo)), +).When(whenCountMetricsIs(v1alpha.Instana)) + +var instanaValidation = validation.ForPointer(func(m MetricSpec) *InstanaMetric { return m.Instana }). + WithName("instana"). + Rules(validation.NewSingleRule[InstanaMetric](func(v InstanaMetric) error { + if v.Application != nil && v.Infrastructure != nil { + return errors.New("cannot use both 'instana.application' and 'instana.infrastructure'") + } + switch v.MetricType { + case instanaMetricTypeInfrastructure: + if v.Infrastructure == nil { + return errors.Errorf( + "when 'metricType' is '%s', 'instana.infrastructure' is required", + instanaMetricTypeInfrastructure) + } + case instanaMetricTypeApplication: + if v.Application == nil { + return errors.Errorf( + "when 'metricType' is '%s', 'instana.application' is required", + instanaMetricTypeApplication) + } + } + return nil + })). + StopOnError() + +var instanaCountMetricsValidation = validation.New[MetricSpec]( + instanaValidation. + Include(validation.New[InstanaMetric]( + validation.For(func(i InstanaMetric) string { return i.MetricType }). + WithName("metricType"). + Required(). + Rules(validation.EqualTo(instanaMetricTypeInfrastructure)), + validation.ForPointer(func(i InstanaMetric) *InstanaInfrastructureMetricType { return i.Infrastructure }). + WithName("infrastructure"). + Required(). + Include(instanaInfrastructureMetricValidation), + validation.ForPointer(func(i InstanaMetric) *InstanaApplicationMetricType { return i.Application }). + WithName("application"). + Rules(validation.Forbidden[InstanaApplicationMetricType]()), + )), +) + +var instanaRawMetricValidation = validation.New[MetricSpec]( + instanaValidation. + Include(validation.New[InstanaMetric]( + validation.For(func(i InstanaMetric) string { return i.MetricType }). + WithName("metricType"). + Required(). + Rules(validation.OneOf(instanaMetricTypeInfrastructure, instanaMetricTypeApplication)), + validation.ForPointer(func(i InstanaMetric) *InstanaInfrastructureMetricType { return i.Infrastructure }). + WithName("infrastructure"). + Include(instanaInfrastructureMetricValidation), + validation.ForPointer(func(i InstanaMetric) *InstanaApplicationMetricType { return i.Application }). + WithName("application"). + Include(instanaApplicationMetricValidation), + )), +) + +var instanaInfrastructureMetricValidation = validation.New[InstanaInfrastructureMetricType]( + validation.For(validation.GetSelf[InstanaInfrastructureMetricType]()). + Rules(validation.NewSingleRule(func(i InstanaInfrastructureMetricType) error { + switch i.MetricRetrievalMethod { + case instanaMetricRetrievalMethodQuery: + if i.Query == nil { + return errors.New("when 'metricRetrievalMethod' is 'query', 'query' property must be provided") + } + if i.SnapshotID != nil { + return errors.New("when 'metricRetrievalMethod' is 'query', 'snapshotId' property is not allowed") + } + case instanaMetricRetrievalMethodSnapshot: + if i.SnapshotID == nil { + return errors.New("when 'metricRetrievalMethod' is 'snapshot', 'snapshotId' property must be provided") + } + if i.Query != nil { + return errors.New("when 'metricRetrievalMethod' is 'snapshot', 'query' property is not allowed") + } + } + return nil + })), + validation.For(func(i InstanaInfrastructureMetricType) string { return i.MetricRetrievalMethod }). + WithName("metricRetrievalMethod"). + Required(). + Rules(validation.OneOf(instanaMetricRetrievalMethodQuery, instanaMetricRetrievalMethodSnapshot)), + validation.For(func(i InstanaInfrastructureMetricType) string { return i.MetricID }). + WithName("metricId"). + Required(), + validation.For(func(i InstanaInfrastructureMetricType) string { return i.PluginID }). + WithName("pluginId"). + Required(), +) + +var validInstanaLatencyAggregations = []string{ + "sum", "mean", "min", "max", "p25", + "p50", "p75", "p90", "p95", "p98", "p99", +} + +var instanaApplicationMetricValidation = validation.New[InstanaApplicationMetricType]( + validation.For(validation.GetSelf[InstanaApplicationMetricType]()). + Rules(validation.NewSingleRule(func(i InstanaApplicationMetricType) error { + switch i.MetricID { + case "calls", "erroneousCalls": + if i.Aggregation != "sum" { + return &validation.RuleError{ + Message: "'aggregation' must be 'sum' when 'metricId' is 'calls' or 'erroneousCalls'", + Code: validation.ErrorCodeEqualTo, + } + } + case "errors": + if i.Aggregation != "mean" { + return &validation.RuleError{ + Message: "'aggregation' must be 'mean' when 'metricId' is 'errors'", + Code: validation.ErrorCodeEqualTo, + } + } + case "latency": + if err := validation.OneOf(validInstanaLatencyAggregations...). + WithDetails("when 'aggregation' is 'latency'"). + Validate(i.Aggregation); err != nil { + return err + } + } + return nil + })), + validation.For(func(i InstanaApplicationMetricType) string { return i.MetricID }). + WithName("metricId"). + Required(). + Rules(validation.OneOf("calls", "erroneousCalls", "errors", "latency")), + validation.For(func(i InstanaApplicationMetricType) string { return i.Aggregation }). + WithName("aggregation"). + Required(), + validation.For(func(i InstanaApplicationMetricType) InstanaApplicationMetricGroupBy { return i.GroupBy }). + WithName("groupBy"). + Required(). + Include(validation.New[InstanaApplicationMetricGroupBy]( + validation.For(func(i InstanaApplicationMetricGroupBy) string { return i.Tag }). + WithName("tag"). + Required(), + validation.For(func(i InstanaApplicationMetricGroupBy) string { return i.TagEntity }). + WithName("tagEntity"). + Required(). + Rules(validation.OneOf("DESTINATION", "SOURCE", "NOT_APPLICABLE")), + )), + validation.For(func(i InstanaApplicationMetricType) string { return i.APIQuery }). + WithName("apiQuery"). + Required(). + Rules(validation.StringJSON()), +) diff --git a/manifest/v1alpha/slo/metrics_instana_test.go b/manifest/v1alpha/slo/metrics_instana_test.go new file mode 100644 index 00000000..10e821e9 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_instana_test.go @@ -0,0 +1,428 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestInstana_CountMetrics(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Instana) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("metricType must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Instana.MetricType = instanaMetricTypeApplication + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Instana.MetricType = instanaMetricTypeInfrastructure + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("application metrics are not allowed", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Instana = validInstanaApplicationMetric() + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 6, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.instana.application", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.instana.metricType", + Code: validation.ErrorCodeEqualTo, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.instana.infrastructure", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.instana.application", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.instana.metricType", + Code: validation.ErrorCodeEqualTo, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.instana.infrastructure", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("metricType required", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Instana.MetricType = "" + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Instana.MetricType = "" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.instana.metricType", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.instana.metricType", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} + +func TestInstana_RawMetrics(t *testing.T) { + t.Run("valid application metric", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("both application and infrastructure provided", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application = &InstanaApplicationMetricType{} + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure = &InstanaInfrastructureMetricType{} + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana", + Message: "cannot use both 'instana.application' and 'instana.infrastructure'", + }) + }) + t.Run("application missing for metricType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.MetricType = instanaMetricTypeApplication + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application = nil + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana", + Message: "when 'metricType' is 'application', 'instana.application' is required", + }) + }) + t.Run("infrastructure missing for metricType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.MetricType = instanaMetricTypeInfrastructure + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application = nil + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana", + Message: "when 'metricType' is 'infrastructure', 'instana.infrastructure' is required", + }) + }) + t.Run("invalid metricType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.MetricType = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.metricType", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("metricType required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.MetricType = "" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.metricType", + Code: validation.ErrorCodeRequired, + }) + }) +} + +func TestInstana_Infrastructure(t *testing.T) { + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = &InstanaMetric{ + MetricType: instanaMetricTypeInfrastructure, + Infrastructure: &InstanaInfrastructureMetricType{}, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.infrastructure.metricRetrievalMethod", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.infrastructure.metricId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.infrastructure.pluginId", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("invalid metricRetrievalMethod", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure.MetricRetrievalMethod = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.infrastructure.metricRetrievalMethod", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("required query retrieval method", func(t *testing.T) { + for name, test := range map[string]struct { + Method string + Query *string + SnapshotID *string + ErrorMessage string + }{ + "required query": { + Method: instanaMetricRetrievalMethodQuery, + Query: nil, + SnapshotID: nil, + ErrorMessage: "when 'metricRetrievalMethod' is 'query', 'query' property must be provided", + }, + "forbidden snapshot": { + Method: instanaMetricRetrievalMethodQuery, + Query: ptr("query"), + SnapshotID: ptr("123"), + ErrorMessage: "when 'metricRetrievalMethod' is 'query', 'snapshotId' property is not allowed", + }, + "required snapshot": { + Method: instanaMetricRetrievalMethodSnapshot, + Query: nil, + SnapshotID: nil, + ErrorMessage: "when 'metricRetrievalMethod' is 'snapshot', 'snapshotId' property must be provided", + }, + "forbidden query": { + Method: instanaMetricRetrievalMethodSnapshot, + Query: ptr("query"), + SnapshotID: ptr("123"), + ErrorMessage: "when 'metricRetrievalMethod' is 'snapshot', 'query' property is not allowed", + }, + } { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure.MetricRetrievalMethod = test.Method + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure.Query = test.Query + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Infrastructure.SnapshotID = test.SnapshotID + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.infrastructure", + Message: test.ErrorMessage, + }) + }) + } + }) +} + +func TestInstana_Application(t *testing.T) { + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = &InstanaMetric{ + MetricType: instanaMetricTypeApplication, + Infrastructure: nil, + Application: &InstanaApplicationMetricType{}, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 4, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.metricId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.aggregation", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.groupBy", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.apiQuery", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("valid metricId", func(t *testing.T) { + for metricID, aggregation := range map[string]string{ + "calls": "sum", + "erroneousCalls": "sum", + "errors": "mean", + "latency": "sum", + } { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.MetricID = metricID + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.Aggregation = aggregation + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid metricId", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.MetricID = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.metricId", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("invalid apiQuery", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.APIQuery = "{]}" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.apiQuery", + Code: validation.ErrorCodeStringJSON, + }) + }) + t.Run("missing fields for groupBy", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.GroupBy = InstanaApplicationMetricGroupBy{ + TagSecondLevelKey: ptr(""), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.groupBy.tag", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.groupBy.tagEntity", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("valid tagEntity", func(t *testing.T) { + for _, tagEntity := range []string{ + "DESTINATION", + "SOURCE", + "NOT_APPLICABLE", + } { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.GroupBy.TagEntity = tagEntity + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid tagEntity", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.GroupBy.TagEntity = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application.groupBy.tagEntity", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("metricId", func(t *testing.T) { + for _, test := range []struct { + MetricID string + Aggregation string + IsValid bool + }{ + { + MetricID: "calls", + Aggregation: "sum", + IsValid: true, + }, + { + MetricID: "calls", + Aggregation: "mean", + IsValid: false, + }, + { + MetricID: "erroneousCalls", + Aggregation: "sum", + IsValid: true, + }, + { + MetricID: "erroneousCalls", + Aggregation: "mean", + IsValid: false, + }, + { + MetricID: "errors", + Aggregation: "mean", + IsValid: true, + }, + { + MetricID: "errors", + Aggregation: "sum", + IsValid: false, + }, + } { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.MetricID = test.MetricID + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.Aggregation = test.Aggregation + err := validate(slo) + if test.IsValid { + testutils.AssertNoError(t, slo, err) + } else { + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application", + Code: validation.ErrorCodeEqualTo, + }) + } + } + }) + t.Run("metricId - valid latency", func(t *testing.T) { + for _, agg := range validInstanaLatencyAggregations { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.MetricID = "latency" + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.Aggregation = agg + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("metricId - invalid latency", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Instana) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana = validInstanaApplicationMetric() + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.MetricID = "latency" + slo.Spec.Objectives[0].RawMetric.MetricQuery.Instana.Application.Aggregation = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.instana.application", + Code: validation.ErrorCodeOneOf, + }) + }) +} + +func validInstanaApplicationMetric() *InstanaMetric { + return &InstanaMetric{ + MetricType: instanaMetricTypeApplication, + Application: &InstanaApplicationMetricType{ + MetricID: "latency", + Aggregation: "p99", + GroupBy: InstanaApplicationMetricGroupBy{ + Tag: "endpoint.name", + TagEntity: "DESTINATION", + }, + APIQuery: ` +{ + "type": "EXPRESSION", + "logicalOperator": "AND", + "elements": [ + { + "type": "TAG_FILTER", + "name": "service.name", + "operator": "EQUALS", + "entity": "DESTINATION", + "value": "master" + }, + { + "type": "TAG_FILTER", + "name": "call.type", + "operator": "EQUALS", + "entity": "NOT_APPLICABLE", + "value": "HTTP" + } + ] +} +`, + }, + } +} diff --git a/manifest/v1alpha/slo/metrics_lightstep.go b/manifest/v1alpha/slo/metrics_lightstep.go new file mode 100644 index 00000000..99859385 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_lightstep.go @@ -0,0 +1,149 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +const ( + LightstepMetricDataType = "metric" + LightstepLatencyDataType = "latency" + LightstepErrorRateDataType = "error_rate" + LightstepTotalCountDataType = "total" + LightstepGoodCountDataType = "good" +) + +// LightstepMetric represents metric from Lightstep +type LightstepMetric struct { + StreamID *string `json:"streamId,omitempty"` + TypeOfData *string `json:"typeOfData"` + Percentile *float64 `json:"percentile,omitempty"` + UQL *string `json:"uql,omitempty"` +} + +var lightstepCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules(validation.NewSingleRule(func(c CountMetricsSpec) error { + if c.GoodMetric.Lightstep.StreamID == nil || c.TotalMetric.Lightstep.StreamID == nil { + return nil + } + if *c.GoodMetric.Lightstep.StreamID != *c.TotalMetric.Lightstep.StreamID { + return countMetricsPropertyEqualityError("lightstep.streamId", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo)), + validation.ForPointer(func(c CountMetricsSpec) *bool { return c.Incremental }). + WithName("incremental"). + Rules(validation.EqualTo(false)), +).When(whenCountMetricsIs(v1alpha.Lightstep)) + +// createLightstepMetricSpecValidation constructs a new MetriSpec level validation for Lightstep. +func createLightstepMetricSpecValidation( + include validation.Validator[LightstepMetric], +) validation.Validator[MetricSpec] { + return validation.New[MetricSpec]( + validation.ForPointer(func(m MetricSpec) *LightstepMetric { return m.Lightstep }). + WithName("lightstep"). + Include(include)) +} + +var lightstepRawMetricValidation = createLightstepMetricSpecValidation(validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.TypeOfData }). + WithName("typeOfData"). + Required(). + Rules(validation.OneOf( + LightstepErrorRateDataType, + LightstepLatencyDataType, + LightstepMetricDataType, + )), +)) + +var lightstepTotalCountMetricValidation = createLightstepMetricSpecValidation(validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.TypeOfData }). + WithName("typeOfData"). + Required(). + Rules(validation.OneOf(LightstepTotalCountDataType, LightstepMetricDataType)), +)) + +var lightstepGoodCountMetricValidation = createLightstepMetricSpecValidation(validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.TypeOfData }). + WithName("typeOfData"). + Required(). + Rules(validation.OneOf(LightstepGoodCountDataType, LightstepMetricDataType)), +)) + +var lightstepValidation = validation.New[LightstepMetric]( + validation.For(validation.GetSelf[LightstepMetric]()). + Include(lightstepLatencyDataTypeValidation). + Include(lightstepMetricDataTypeValidation). + Include(lightstepGoodAndTotalDataTypeValidation). + Include(lightstepErrorRateDataTypeValidation), +) + +var lightstepLatencyDataTypeValidation = validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.StreamID }). + WithName("streamId"). + Required(), + validation.ForPointer(func(l LightstepMetric) *float64 { return l.Percentile }). + WithName("percentile"). + Required(). + Rules(validation.GreaterThan(0.0), validation.LessThanOrEqualTo(99.99)), + validation.ForPointer(func(l LightstepMetric) *string { return l.UQL }). + WithName("uql"). + Rules(validation.Forbidden[string]()), +). + When(func(m LightstepMetric) bool { + return m.TypeOfData != nil && *m.TypeOfData == LightstepLatencyDataType + }) + +var ligstepUQLRegexp = regexp.MustCompile(`((constant|spans_sample|assemble)\s+[a-z\d.])`) + +var lightstepMetricDataTypeValidation = validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.StreamID }). + WithName("streamId"). + Rules(validation.Forbidden[string]()), + validation.ForPointer(func(l LightstepMetric) *float64 { return l.Percentile }). + WithName("percentile"). + Rules(validation.Forbidden[float64]()), + validation.ForPointer(func(l LightstepMetric) *string { return l.UQL }). + WithName("uql"). + Required(). + Rules(validation.StringDenyRegexp(ligstepUQLRegexp)), +). + When(func(m LightstepMetric) bool { + return m.TypeOfData != nil && *m.TypeOfData == LightstepMetricDataType + }) + +var lightstepGoodAndTotalDataTypeValidation = validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.StreamID }). + WithName("streamId"). + Required(), + validation.ForPointer(func(l LightstepMetric) *float64 { return l.Percentile }). + WithName("percentile"). + Rules(validation.Forbidden[float64]()), + validation.ForPointer(func(l LightstepMetric) *string { return l.UQL }). + WithName("uql"). + Rules(validation.Forbidden[string]()), +). + When(func(m LightstepMetric) bool { + return m.TypeOfData != nil && + (*m.TypeOfData == LightstepGoodCountDataType || + *m.TypeOfData == LightstepTotalCountDataType) + }) + +var lightstepErrorRateDataTypeValidation = validation.New[LightstepMetric]( + validation.ForPointer(func(l LightstepMetric) *string { return l.StreamID }). + WithName("streamId"). + Required(), + validation.ForPointer(func(l LightstepMetric) *float64 { return l.Percentile }). + WithName("percentile"). + Rules(validation.Forbidden[float64]()), + validation.ForPointer(func(l LightstepMetric) *string { return l.UQL }). + WithName("uql"). + Rules(validation.Forbidden[string]()), +). + When(func(m LightstepMetric) bool { + return m.TypeOfData != nil && *m.TypeOfData == LightstepErrorRateDataType + }) diff --git a/manifest/v1alpha/slo/metrics_lightstep_test.go b/manifest/v1alpha/slo/metrics_lightstep_test.go new file mode 100644 index 00000000..5464a7d9 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_lightstep_test.go @@ -0,0 +1,400 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestLightstep_CountMetricLevel(t *testing.T) { + t.Run("streamId must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].CountMetrics.Incremental = ptr(false) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Lightstep = &LightstepMetric{ + StreamID: ptr("streamId"), + TypeOfData: ptr(LightstepTotalCountDataType), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Lightstep = &LightstepMetric{ + StreamID: ptr("different"), + TypeOfData: ptr(LightstepGoodCountDataType), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("incremental must be set to false", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].CountMetrics.Incremental = ptr(true) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.incremental", + Code: validation.ErrorCodeEqualTo, + }) + }) +} + +func TestLightstep_RawMetricLevel(t *testing.T) { + t.Run("valid typeOfData", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepErrorRateDataType), + }, + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepLatencyDataType), + Percentile: ptr(92.1), + }, + { + TypeOfData: ptr(LightstepMetricDataType), + UQL: ptr("metric"), + }, + } { + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = metric + testutils.AssertNoError(t, slo, validate(slo)) + } + }) + t.Run("invalid typeOfData", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepTotalCountDataType), + }, + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepGoodCountDataType), + }, + } { + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = metric + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.typeOfData", + Code: validation.ErrorCodeOneOf, + }) + } + }) +} + +func TestLightstep_TotalMetricLevel(t *testing.T) { + t.Run("valid typeOfData", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepTotalCountDataType), + }, + { + UQL: ptr("metric"), + TypeOfData: ptr(LightstepMetricDataType), + }, + } { + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Lightstep = metric + testutils.AssertNoError(t, slo, validate(slo)) + } + }) + t.Run("invalid typeOfData", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepErrorRateDataType), + }, + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepLatencyDataType), + Percentile: ptr(92.1), + }, + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepGoodCountDataType), + }, + } { + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Lightstep = metric + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.lightstep.typeOfData", + Code: validation.ErrorCodeOneOf, + }) + } + }) +} + +func TestLightstep_GoodMetricLevel(t *testing.T) { + t.Run("valid typeOfData", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepGoodCountDataType), + }, + { + UQL: ptr("metric"), + TypeOfData: ptr(LightstepMetricDataType), + }, + } { + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Lightstep = metric + testutils.AssertNoError(t, slo, validate(slo)) + } + }) + t.Run("invalid typeOfData", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + for _, metric := range []*LightstepMetric{ + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepErrorRateDataType), + }, + { + StreamID: ptr("123"), + TypeOfData: ptr(LightstepLatencyDataType), + Percentile: ptr(92.1), + }, + { + TypeOfData: ptr(LightstepTotalCountDataType), + StreamID: ptr("123"), + }, + } { + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Lightstep = metric + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.lightstep.typeOfData", + Code: validation.ErrorCodeOneOf, + }) + } + }) +} + +func TestLightstepLatencyTypeOfData(t *testing.T) { + t.Run("valid", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepLatencyDataType), + StreamID: ptr("123"), + Percentile: ptr(99.99), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives = []Objective{ + { + ObjectiveBase: ObjectiveBase{Name: "test", Value: ptr(10.0)}, + BudgetTarget: ptr(0.9), + + RawMetric: &RawMetricSpec{MetricQuery: &MetricSpec{Lightstep: &LightstepMetric{ + StreamID: ptr("123"), + TypeOfData: ptr(LightstepLatencyDataType), + UQL: ptr("metric"), + }}}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + { + ObjectiveBase: ObjectiveBase{Name: "test1", Value: ptr(11.0)}, + BudgetTarget: ptr(0.8), + RawMetric: &RawMetricSpec{MetricQuery: &MetricSpec{Lightstep: &LightstepMetric{ + StreamID: nil, + TypeOfData: ptr(LightstepLatencyDataType), + Percentile: ptr(0.0), + }}}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + { + ObjectiveBase: ObjectiveBase{Name: "test2", Value: ptr(12.0)}, + BudgetTarget: ptr(0.7), + RawMetric: &RawMetricSpec{MetricQuery: &MetricSpec{Lightstep: &LightstepMetric{ + StreamID: ptr("123"), + TypeOfData: ptr(LightstepLatencyDataType), + Percentile: ptr(100.0), + }}}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 5, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.percentile", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.uql", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[1].rawMetric.query.lightstep.streamId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[1].rawMetric.query.lightstep.percentile", + Code: validation.ErrorCodeGreaterThan, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[2].rawMetric.query.lightstep.percentile", + Code: validation.ErrorCodeLessThanOrEqualTo, + }, + ) + }) +} + +func TestLightstepErrorRateTypeOfData(t *testing.T) { + t.Run("valid", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepErrorRateDataType), + StreamID: ptr("123"), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepErrorRateDataType), + StreamID: nil, + Percentile: ptr(0.1), + UQL: ptr("this"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.percentile", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.uql", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.streamId", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} + +func TestLightstepMetricTypeOfData(t *testing.T) { + t.Run("valid", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepMetricDataType), + UQL: ptr(`( +metric cpu.utilization | rate | filter error == true && service == spans_sample | group_by [], min; +spans count | rate | group_by [], sum +) | join left/right * 100`), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepMetricDataType), + UQL: nil, + Percentile: ptr(0.1), + StreamID: ptr("this"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.uql", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.percentile", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.streamId", + Code: validation.ErrorCodeForbidden, + }, + ) + }) + t.Run("invalid metrics", func(t *testing.T) { + for name, uql := range map[string]string{ + "spans_sample joined UQL": `( +spans_sample count | delta | filter error == true && service == android | group_by [], sum; +spans_sample count | delta | filter service == android | group_by [], sum) | join left/right * 100`, + "constant UQL": "constant .5", + "spans_sample UQL": "spans_sample span filter", + "assemble UQL": "assemble span", + } { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepMetricDataType), + UQL: ptr(uql), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.lightstep.uql", + Code: validation.ErrorCodeStringDenyRegexp, + }, + ) + }) + } + }) +} + +func TestLightstepGoodTotalTypeOfData(t *testing.T) { + t.Run("valid", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepTotalCountDataType), + StreamID: ptr("123"), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepGoodCountDataType), + StreamID: ptr("123"), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Lightstep) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepTotalCountDataType), + StreamID: nil, + Percentile: ptr(0.1), + UQL: ptr("this"), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Lightstep = &LightstepMetric{ + TypeOfData: ptr(LightstepGoodCountDataType), + StreamID: nil, + Percentile: ptr(0.1), + UQL: ptr("this"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 6, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.lightstep.percentile", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.lightstep.uql", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.lightstep.streamId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.lightstep.percentile", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.lightstep.uql", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.lightstep.streamId", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} diff --git a/manifest/v1alpha/slo/metrics_newrelic.go b/manifest/v1alpha/slo/metrics_newrelic.go new file mode 100644 index 00000000..eaa592a7 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_newrelic.go @@ -0,0 +1,22 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/validation" +) + +// NewRelicMetric represents metric from NewRelic +type NewRelicMetric struct { + NRQL *string `json:"nrql"` +} + +var newRelicValidation = validation.New[NewRelicMetric]( + validation.ForPointer(func(n NewRelicMetric) *string { return n.NRQL }). + WithName("nrql"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules(validation.StringDenyRegexp(regexp.MustCompile(`(?i)[\n\s](since|until)([\n\s]|$)`)). + WithDetails("query must not contain 'since' or 'until' keywords (case insensitive)")), +) diff --git a/manifest/v1alpha/slo/metrics_newrelic_test.go b/manifest/v1alpha/slo/metrics_newrelic_test.go new file mode 100644 index 00000000..16733e48 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_newrelic_test.go @@ -0,0 +1,102 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestNewRelic(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.NewRelic) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.NewRelic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.NewRelic.NRQL = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.newRelic.nrql", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.NewRelic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.NewRelic.NRQL = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.newRelic.nrql", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} + +func TestNewRelic_Query(t *testing.T) { + tests := []struct { + name string + query string + isValid bool + }{ + { + name: "basic good query", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric + WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, + isValid: true, + }, + { + name: "query with since in quotation marks", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric 'SINCE' + WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, + isValid: true, + }, + { + name: "query with until in quotation marks", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric "UNTIL" + WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, + isValid: true, + }, + { + name: "query with 'since' in a word", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric + WHERE (entity.guid = 'somekey') AND (transactionType = 'sinceThis')`, + isValid: true, + }, + { + name: "query with case insensitive since", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric + WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX SiNCE`, + isValid: false, + }, + { + name: "query with case insensitive until", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric + WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') uNtIL LIMIT MAX TIMESERIES`, + isValid: false, + }, + { + name: "until at new line", + query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric +WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') +uNtIL LIMIT MAX TIMESERIES`, + isValid: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.NewRelic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.NewRelic.NRQL = ptr(test.query) + err := validate(slo) + if test.isValid { + testutils.AssertNoError(t, slo, err) + } else { + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.newRelic.nrql", + Code: validation.ErrorCodeStringDenyRegexp, + }) + } + }) + } +} diff --git a/manifest/v1alpha/slo/metrics_opentsdb.go b/manifest/v1alpha/slo/metrics_opentsdb.go new file mode 100644 index 00000000..fb6d5da2 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_opentsdb.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// OpenTSDBMetric represents metric from OpenTSDB. +type OpenTSDBMetric struct { + Query *string `json:"query"` +} + +var openTSDBValidation = validation.New[OpenTSDBMetric]( + validation.ForPointer(func(o OpenTSDBMetric) *string { return o.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_opentsdb_test.go b/manifest/v1alpha/slo/metrics_opentsdb_test.go new file mode 100644 index 00000000..284a3a33 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_opentsdb_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestOpenTSDB(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.OpenTSDB) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.OpenTSDB) + slo.Spec.Objectives[0].RawMetric.MetricQuery.OpenTSDB.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.opentsdb.query", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.OpenTSDB) + slo.Spec.Objectives[0].RawMetric.MetricQuery.OpenTSDB.Query = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.opentsdb.query", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_pingdom.go b/manifest/v1alpha/slo/metrics_pingdom.go new file mode 100644 index 00000000..539d24ff --- /dev/null +++ b/manifest/v1alpha/slo/metrics_pingdom.go @@ -0,0 +1,124 @@ +package slo + +import ( + "regexp" + "strings" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// PingdomMetric represents metric from Pingdom. +type PingdomMetric struct { + CheckID *string `json:"checkId"` + CheckType *string `json:"checkType"` + Status *string `json:"status,omitempty"` +} + +const ( + PingdomTypeUptime = "uptime" + PingdomTypeTransaction = "transaction" +) + +const ( + pingdomStatusUp = "up" + pingdomStatusDown = "down" + pingdomStatusUnconfirmed = "unconfirmed" + pingdomStatusUnknown = "unknown" +) + +var pingdomCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + if c.GoodMetric.Pingdom.CheckID == nil || c.TotalMetric.Pingdom.CheckID == nil { + return nil + } + if *c.GoodMetric.Pingdom.CheckID != *c.TotalMetric.Pingdom.CheckID { + return countMetricsPropertyEqualityError("pingdom.checkId", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo), + validation.NewSingleRule(func(c CountMetricsSpec) error { + if c.GoodMetric.Pingdom.CheckType == nil || c.TotalMetric.Pingdom.CheckType == nil { + return nil + } + if *c.GoodMetric.Pingdom.CheckType != *c.TotalMetric.Pingdom.CheckType { + return countMetricsPropertyEqualityError("pingdom.checkType", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo), + ), +).When(whenCountMetricsIs(v1alpha.Pingdom)) + +// createPingdomMetricSpecValidation constructs a new MetriSpec level validation for Pingdom. +func createPingdomMetricSpecValidation( + include validation.Validator[PingdomMetric], +) validation.Validator[MetricSpec] { + return validation.New[MetricSpec]( + validation.ForPointer(func(m MetricSpec) *PingdomMetric { return m.Pingdom }). + WithName("pingdom"). + Include(include)) +} + +var pingdomRawMetricValidation = createPingdomMetricSpecValidation(validation.New[PingdomMetric]( + validation.ForPointer(func(p PingdomMetric) *string { return p.CheckType }). + WithName("checkType"). + Required(). + Rules(validation.EqualTo(PingdomTypeUptime)), +)) + +var pingdomCountMetricsValidation = createPingdomMetricSpecValidation(validation.New[PingdomMetric]( + validation.ForPointer(func(p PingdomMetric) *string { return p.CheckType }). + WithName("checkType"). + Required(). + Rules(validation.OneOf(PingdomTypeUptime, PingdomTypeTransaction)), + validation.For(func(p PingdomMetric) *string { return p.Status }). + WithName("status"). + When(func(metric PingdomMetric) bool { + return metric.CheckType != nil && *metric.CheckType == PingdomTypeUptime + }). + Rules(validation.Required[*string]()), +)) + +var pingdomValidation = validation.New[PingdomMetric]( + validation.For(validation.GetSelf[PingdomMetric]()). + Include(pingdomUptimeCheckTypeValidation). + Include(pingdomTransactionCheckTypeValidation), + validation.ForPointer(func(p PingdomMetric) *string { return p.CheckID }). + WithName("checkId"). + Required(). + Rules( + validation.StringNotEmpty(), + // This regexp is crafted in order to not interweave with StringNotEmpty validation. + validation.StringMatchRegexp(regexp.MustCompile(`^(?:|\d+)$`))), // nolint: gocritic +) + +var pingdomUptimeCheckTypeValidation = validation.New[PingdomMetric]( + validation.ForPointer(func(m PingdomMetric) *string { return m.Status }). + WithName("status"). + Rules(validation.NewSingleRule(func(s string) error { + for _, status := range strings.Split(s, ",") { + if err := validation.OneOf( + pingdomStatusUp, + pingdomStatusDown, + pingdomStatusUnconfirmed, + pingdomStatusUnknown).Validate(status); err != nil { + return err + } + } + return nil + })), +). + When(func(m PingdomMetric) bool { + return m.CheckType != nil && *m.CheckType == PingdomTypeUptime + }) + +var pingdomTransactionCheckTypeValidation = validation.New[PingdomMetric]( + validation.ForPointer(func(m PingdomMetric) *string { return m.Status }). + WithName("status"). + Rules(validation.Forbidden[string]()), +). + When(func(m PingdomMetric) bool { + return m.CheckType != nil && *m.CheckType == PingdomTypeTransaction + }) diff --git a/manifest/v1alpha/slo/metrics_pingdom_test.go b/manifest/v1alpha/slo/metrics_pingdom_test.go new file mode 100644 index 00000000..41ee6167 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_pingdom_test.go @@ -0,0 +1,180 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestPingdom_CountMetricsLevel(t *testing.T) { + t.Run("checkId must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].CountMetrics.Incremental = ptr(false) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom = &PingdomMetric{ + CheckID: ptr("123"), + CheckType: ptr(PingdomTypeTransaction), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom = &PingdomMetric{ + CheckID: ptr("333"), + CheckType: ptr(PingdomTypeTransaction), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("checkType must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].CountMetrics.Incremental = ptr(false) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom = &PingdomMetric{ + CheckID: ptr("123"), + CheckType: ptr(PingdomTypeUptime), + Status: ptr(pingdomStatusDown), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom = &PingdomMetric{ + CheckID: ptr("123"), + CheckType: ptr(PingdomTypeTransaction), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("required status", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom.CheckType = ptr(PingdomTypeUptime) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom.Status = nil + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom.CheckType = ptr(PingdomTypeUptime) + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom.Status = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.pingdom.status", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.pingdom.status", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} + +func TestPingdom_RawMetricLevel(t *testing.T) { + t.Run("valid checkType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = ptr(PingdomTypeUptime) + testutils.AssertNoError(t, slo, validate(slo)) + }) + t.Run("invalid checkType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = ptr(PingdomTypeTransaction) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.Status = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.checkType", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("omit empty status", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = ptr(PingdomTypeUptime) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.Status = nil + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) +} + +func TestPingdom(t *testing.T) { + t.Run("required checkType", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.checkType", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("required checkId", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckID = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.checkId", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("missing checkId", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckID = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.checkId", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) + t.Run("invalid checkId", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckID = ptr("a12393") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.checkId", + Code: validation.ErrorCodeStringMatchRegexp, + }) + }) +} + +func TestPingdom_CheckTypeTransaction(t *testing.T) { + t.Run("forbidden status", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom.CheckType = ptr(PingdomTypeTransaction) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Pingdom.Status = ptr(pingdomStatusDown) + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom.CheckType = ptr(PingdomTypeTransaction) + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Pingdom.Status = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.pingdom.status", + Code: validation.ErrorCodeForbidden, + }) + }) +} + +func TestPingdom_CheckTypeUptime(t *testing.T) { + t.Run("valid status", func(t *testing.T) { + for _, status := range []string{ + pingdomStatusUp, + pingdomStatusDown, + pingdomStatusUnconfirmed, + pingdomStatusUnknown, + pingdomStatusDown + "," + pingdomStatusUnconfirmed, + } { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = ptr(PingdomTypeUptime) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.Status = ptr(status) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid status", func(t *testing.T) { + for _, status := range []string{ + ",", + "", + "", + pingdomStatusDown + "," + "invalid", + "invalid" + "," + pingdomStatusUp, + } { + slo := validRawMetricSLO(v1alpha.Pingdom) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.CheckType = ptr(PingdomTypeUptime) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Pingdom.Status = ptr(status) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.pingdom.status", + Code: validation.ErrorCodeOneOf, + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_prometheus.go b/manifest/v1alpha/slo/metrics_prometheus.go new file mode 100644 index 00000000..a850e64b --- /dev/null +++ b/manifest/v1alpha/slo/metrics_prometheus.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// PrometheusMetric represents metric from Prometheus +type PrometheusMetric struct { + PromQL *string `json:"promql"` +} + +var prometheusValidation = validation.New[PrometheusMetric]( + validation.ForPointer(func(p PrometheusMetric) *string { return p.PromQL }). + WithName("promql"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_prometheus_test.go b/manifest/v1alpha/slo/metrics_prometheus_test.go new file mode 100644 index 00000000..765ab351 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_prometheus_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestPrometheus(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Prometheus.PromQL = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.prometheus.promql", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Prometheus.PromQL = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.prometheus.promql", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_redshift.go b/manifest/v1alpha/slo/metrics_redshift.go new file mode 100644 index 00000000..bcd02934 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_redshift.go @@ -0,0 +1,61 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// RedshiftMetric represents metric from Redshift. +type RedshiftMetric struct { + Region *string `json:"region"` + ClusterID *string `json:"clusterId"` + DatabaseName *string `json:"databaseName"` + Query *string `json:"query"` +} + +var redshiftCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules( + validation.NewSingleRule(func(c CountMetricsSpec) error { + good := c.GoodMetric + total := c.TotalMetric + + if !arePointerValuesEqual(good.Redshift.Region, total.Redshift.Region) { + return countMetricsPropertyEqualityError("redshift.region", goodMetric) + } + if !arePointerValuesEqual(good.Redshift.ClusterID, total.Redshift.ClusterID) { + return countMetricsPropertyEqualityError("redshift.clusterId", goodMetric) + } + if !arePointerValuesEqual(good.Redshift.DatabaseName, total.Redshift.DatabaseName) { + return countMetricsPropertyEqualityError("redshift.databaseName", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo)), +).When(whenCountMetricsIs(v1alpha.Redshift)) + +var redshiftValidation = validation.New[RedshiftMetric]( + validation.ForPointer(func(r RedshiftMetric) *string { return r.Region }). + WithName("region"). + Required(). + Rules(validation.StringMaxLength(255)), + validation.ForPointer(func(r RedshiftMetric) *string { return r.ClusterID }). + WithName("clusterId"). + Required(), + validation.ForPointer(func(r RedshiftMetric) *string { return r.DatabaseName }). + WithName("databaseName"). + Required(), + validation.ForPointer(func(r RedshiftMetric) *string { return r.Query }). + WithName("query"). + Required(). + Rules( + validation.StringMatchRegexp(regexp.MustCompile(`^SELECT[\s\S]*\bn9date\b[\s\S]*FROM`)). + WithDetails("must contain 'n9date' column"), + validation.StringMatchRegexp(regexp.MustCompile(`^SELECT\s[\s\S]*\bn9value\b[\s\S]*\sFROM`)). + WithDetails("must contain 'n9value' column"), + validation.StringMatchRegexp(regexp.MustCompile(`WHERE[\s\S]*\W:n9date_from\b[\s\S]*`)). + WithDetails("must filter by ':n9date_from' column"), + validation.StringMatchRegexp(regexp.MustCompile(`WHERE[\s\S]*\W:n9date_to\b[\s\S]*`)). + WithDetails("must filter by ':n9date_to' column")), +) diff --git a/manifest/v1alpha/slo/metrics_redshift_test.go b/manifest/v1alpha/slo/metrics_redshift_test.go new file mode 100644 index 00000000..95f78b3e --- /dev/null +++ b/manifest/v1alpha/slo/metrics_redshift_test.go @@ -0,0 +1,100 @@ +package slo + +import ( + "strings" + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestRedshift_CountMetrics(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Redshift) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("region must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Redshift.Region = ptr("region-1") + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Redshift.Region = ptr("region-2") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("clusterId must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Redshift.ClusterID = ptr("1") + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Redshift.ClusterID = ptr("2") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("databaseName must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Redshift.DatabaseName = ptr("dev-db") + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Redshift.DatabaseName = ptr("prod-db") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) +} + +func TestRedshift(t *testing.T) { + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Redshift = &RedshiftMetric{} + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 4, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.region", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.clusterId", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.databaseName", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.query", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("invalid region", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Redshift.Region = ptr(strings.Repeat("a", 256)) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.region", + Code: validation.ErrorCodeStringMaxLength, + }) + }) + //nolint: lll + t.Run("invalid query", func(t *testing.T) { + for expectedDetails, query := range map[string]string{ + "must contain 'n9date' column": "SELECT value as n9value FROM sinusoid WHERE timestamp BETWEEN :n9date_from AND :n9date_to", + "must contain 'n9value' column": "SELECT timestamp as n9date FROM sinusoid WHERE timestamp BETWEEN :n9date_from AND :n9date_to", + "must filter by ':n9date_from' column": "SELECT value as n9value, timestamp as n9date FROM sinusoid WHERE timestamp = :n9date_to", + "must filter by ':n9date_to' column": "SELECT value as n9value, timestamp as n9date FROM sinusoid WHERE timestamp = :n9date_from", + } { + slo := validRawMetricSLO(v1alpha.Redshift) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Redshift.Query = ptr(query) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.redshift.query", + ContainsMessage: expectedDetails, + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_splunk.go b/manifest/v1alpha/slo/metrics_splunk.go new file mode 100644 index 00000000..368defcb --- /dev/null +++ b/manifest/v1alpha/slo/metrics_splunk.go @@ -0,0 +1,26 @@ +package slo + +import ( + "regexp" + + "github.com/nobl9/nobl9-go/validation" +) + +// SplunkMetric represents metric from Splunk +type SplunkMetric struct { + Query *string `json:"query"` +} + +var splunkValidation = validation.New[SplunkMetric]( + validation.ForPointer(func(s SplunkMetric) *string { return s.Query }). + WithName("query"). + Required(). + Rules(validation.StringNotEmpty()). + StopOnError(). + Rules( + validation.StringContains("n9time", "n9value"), + validation.StringMatchRegexp( + regexp.MustCompile(`(\bindex\s*=.+)|("\bindex"\s*=.+)`), + "index=svc-events", `"index"=svc-events`). + WithDetails(`query has to contain index= or "index"=`)), +) diff --git a/manifest/v1alpha/slo/metrics_splunk_observability.go b/manifest/v1alpha/slo/metrics_splunk_observability.go new file mode 100644 index 00000000..81f615f2 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_splunk_observability.go @@ -0,0 +1,15 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// SplunkObservabilityMetric represents metric from SplunkObservability +type SplunkObservabilityMetric struct { + Program *string `json:"program"` +} + +var splunkObservabilityValidation = validation.New[SplunkObservabilityMetric]( + validation.ForPointer(func(s SplunkObservabilityMetric) *string { return s.Program }). + WithName("program"). + Required(). + Rules(validation.StringNotEmpty()), +) diff --git a/manifest/v1alpha/slo/metrics_splunk_observability_test.go b/manifest/v1alpha/slo/metrics_splunk_observability_test.go new file mode 100644 index 00000000..cd572afa --- /dev/null +++ b/manifest/v1alpha/slo/metrics_splunk_observability_test.go @@ -0,0 +1,35 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestSplunkObservability(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SplunkObservability) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SplunkObservability) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SplunkObservability.Program = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.splunkObservability.program", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SplunkObservability) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SplunkObservability.Program = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.splunkObservability.program", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) +} diff --git a/manifest/v1alpha/slo/metrics_splunk_test.go b/manifest/v1alpha/slo/metrics_splunk_test.go new file mode 100644 index 00000000..27445816 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_splunk_test.go @@ -0,0 +1,79 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestSplunk(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Splunk) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Splunk) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Splunk.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.splunk.query", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("empty", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Splunk) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Splunk.Query = ptr("") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.splunk.query", + Code: validation.ErrorCodeStringNotEmpty, + }) + }) + t.Run("invalid query", func(t *testing.T) { + tests := map[string]struct { + Query string + ExpectedCode string + }{ + "missing n9time": { + Query: ` +search index=svc-events source=udp:5072 sourcetype=syslog status<400 | +bucket _time span=1m | +stats avg(response_time) as n9value by _time | +fields _time n9value`, + ExpectedCode: validation.ErrorCodeStringContains, + }, + "missing n9value": { + Query: ` +search index=svc-events source=udp:5072 sourcetype=syslog status<400 | +bucket _time span=1m | +stats avg(response_time) as value by _time | +rename _time as n9time | +fields n9time value`, + ExpectedCode: validation.ErrorCodeStringContains, + }, + "missing index": { + Query: ` +search source=udp:5072 sourcetype=syslog status<400 | +bucket _time span=1m | +stats avg(response_time) as n9value by _time | +rename _time as n9time | +fields n9time n9value`, + ExpectedCode: validation.ErrorCodeStringMatchRegexp, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Splunk) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Splunk.Query = ptr(test.Query) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.splunk.query", + Code: test.ExpectedCode, + }) + }) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_sumo_logic.go b/manifest/v1alpha/slo/metrics_sumo_logic.go new file mode 100644 index 00000000..d63f62ce --- /dev/null +++ b/manifest/v1alpha/slo/metrics_sumo_logic.go @@ -0,0 +1,156 @@ +package slo + +import ( + "fmt" + "regexp" + "time" + + "github.com/pkg/errors" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +// SumoLogicMetric represents metric from Sumo Logic. +type SumoLogicMetric struct { + Type *string `json:"type"` + Query *string `json:"query"` + Quantization *string `json:"quantization,omitempty"` + Rollup *string `json:"rollup,omitempty"` +} + +const ( + sumoLogicTypeMetric = "metrics" + sumoLogicTypeLogs = "logs" +) + +var sumoLogicCountMetricsLevelValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Rules( + // Quantization must be equal for good and total. + validation.NewSingleRule(func(c CountMetricsSpec) error { + if c.GoodMetric.SumoLogic.Quantization == nil || c.TotalMetric.SumoLogic.Quantization == nil { + return nil + } + if *c.GoodMetric.SumoLogic.Quantization != *c.TotalMetric.SumoLogic.Quantization { + return countMetricsPropertyEqualityError("sumologic.quantization", goodMetric) + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo), + // Query segment with timeslice declaration must have the same duration for good and total. + validation.NewSingleRule(func(c CountMetricsSpec) error { + good := c.GoodMetric.SumoLogic + total := c.TotalMetric.SumoLogic + if *good.Type != "logs" || *total.Type != "logs" { + return nil + } + goodTS, err := getTimeSliceFromSumoLogicQuery(*good.Query) + if err != nil { + return nil + } + totalTS, err := getTimeSliceFromSumoLogicQuery(*total.Query) + if err != nil { + return nil + } + if goodTS != totalTS { + return errors.Errorf( + "'sumologic.query' with segment 'timeslice ${duration}', " + + "${duration} must be the same for both 'good' and 'total' metrics") + } + return nil + }).WithErrorCode(validation.ErrorCodeEqualTo), + ), +).When(whenCountMetricsIs(v1alpha.SumoLogic)) + +var sumoLogicValidation = validation.New[SumoLogicMetric]( + validation.For(validation.GetSelf[SumoLogicMetric]()). + Include(sumoLogicMetricTypeValidation). + Include(sumoLogicLogsTypeValidation), + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Type }). + WithName("type"). + Required(). + Rules(validation.OneOf(sumoLogicTypeLogs, sumoLogicTypeMetric)), +) + +var sumoLogicValidRollups = []string{"Avg", "Sum", "Min", "Max", "Count", "None"} + +var sumoLogicMetricTypeValidation = validation.New[SumoLogicMetric]( + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Query }). + WithName("query"). + Required(), + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Quantization }). + WithName("quantization"). + Required(). + Rules(validation.NewSingleRule(func(s string) error { + const minQuantizationSeconds = 15 + quantization, err := time.ParseDuration(s) + if err != nil { + return errors.Errorf("error parsing quantization string to duration - %v", err) + } + if quantization.Seconds() < minQuantizationSeconds { + return errors.Errorf("minimum quantization value is [%ds], got: [%vs]", + minQuantizationSeconds, quantization.Seconds()) + } + return nil + })), + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Rollup }). + WithName("rollup"). + Required(). + Rules(validation.OneOf(sumoLogicValidRollups...)), +). + When(func(m SumoLogicMetric) bool { + return m.Type != nil && *m.Type == sumoLogicTypeMetric + }) + +var sumoLogicLogsTypeValidation = validation.New[SumoLogicMetric]( + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Query }). + WithName("query"). + Required(). + Rules( + validation.NewSingleRule(func(s string) error { + const minTimeSliceSeconds = 15 + timeslice, err := getTimeSliceFromSumoLogicQuery(s) + if err != nil { + return err + } + if timeslice.Seconds() < minTimeSliceSeconds { + return errors.Errorf("minimum timeslice value is [%ds], got: [%s]", minTimeSliceSeconds, timeslice) + } + return nil + }), + validation.StringMatchRegexp(regexp.MustCompile(`(?m)\bn9_value\b`)). + WithDetails("n9_value is required"), + validation.StringMatchRegexp(regexp.MustCompile(`(?m)\bn9_time\b`)). + WithDetails("n9_time is required"), + validation.StringMatchRegexp(regexp.MustCompile(`(?m)\bby\b`)). + WithDetails("aggregation function is required"), + ), + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Quantization }). + WithName("quantization"). + Rules(validation.Forbidden[string]()), + validation.ForPointer(func(p SumoLogicMetric) *string { return p.Rollup }). + WithName("rollup"). + Rules(validation.Forbidden[string]()), +). + When(func(m SumoLogicMetric) bool { + return m.Type != nil && *m.Type == sumoLogicTypeLogs + }) + +var sumoLogicTimeSliceRegexp = regexp.MustCompile(`(?m)\stimeslice\s(\d+\w+)\s`) + +func getTimeSliceFromSumoLogicQuery(query string) (time.Duration, error) { + submatches := sumoLogicTimeSliceRegexp.FindAllStringSubmatch(query, 2) + if len(submatches) != 1 { + return 0, fmt.Errorf("exactly one timeslice declaration is required in the query") + } + submatch := submatches[0] + if len(submatch) != 2 { + return 0, fmt.Errorf("timeslice declaration must matche regular expression: %s", sumoLogicTimeSliceRegexp) + } + // https://help.sumologic.com/05Search/Search-Query-Language/Search-Operators/timeslice#syntax + timeslice, err := time.ParseDuration(submatch[1]) + if err != nil { + return 0, fmt.Errorf("error parsing timeslice duration: %s", err.Error()) + } + return timeslice, nil +} diff --git a/manifest/v1alpha/slo/metrics_sumo_logic_test.go b/manifest/v1alpha/slo/metrics_sumo_logic_test.go new file mode 100644 index 00000000..010c8378 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_sumo_logic_test.go @@ -0,0 +1,289 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestSumoLogic_CountMetricsLevel(t *testing.T) { + t.Run("quantization must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeMetric), + Query: ptr("kube_node_status_condition | min"), + Quantization: ptr("20s"), + Rollup: ptr("None"), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeMetric), + Query: ptr("kube_node_status_condition | min"), + Quantization: ptr("25s"), + Rollup: ptr("None"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) + t.Run("query timeslice duration must be the same for good and total", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeLogs), + Query: ptr(` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`), + } + slo.Spec.Objectives[0].CountMetrics.GoodMetric.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeLogs), + Query: ptr(` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 25s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: validation.ErrorCodeEqualTo, + }) + }) +} + +func TestSumoLogic(t *testing.T) { + t.Run("missing type", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Type = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.type", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("invalid type", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Type = ptr("invalid") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.type", + Code: validation.ErrorCodeOneOf, + }) + }) + t.Run("missing query", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Query = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + Code: validation.ErrorCodeRequired, + }) + }) +} + +func TestSumoLogic_MetricType(t *testing.T) { + t.Run("required values", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Quantization = nil + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Rollup = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.quantization", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.rollup", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("invalid quantization", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Quantization = ptr("invalid") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.quantization", + Message: `error parsing quantization string to duration - time: invalid duration "invalid"`, + }) + }) + t.Run("minimum quantization", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Quantization = ptr("14s") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.quantization", + Message: "minimum quantization value is [15s], got: [14s]", + }) + }) + t.Run("valid rollups", func(t *testing.T) { + for _, rollup := range sumoLogicValidRollups { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Rollup = ptr(rollup) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("invalid rollup", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic.Rollup = ptr("invalid") + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.rollup", + Code: validation.ErrorCodeOneOf, + }) + }) +} + +func TestSumoLogic_LogsType(t *testing.T) { + t.Run("forbidden values", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeLogs), + Query: ptr(` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`), + Quantization: ptr("20s"), + Rollup: ptr("None"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.quantization", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.rollup", + Code: validation.ErrorCodeForbidden, + }, + ) + }) + tests := map[string]struct { + Query string + Error testutils.ExpectedError + }{ + "no timeslice segment": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + Message: "exactly one timeslice declaration is required in the query", + }, + }, + "two timeslice segments": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 30s + | timeslice 20s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + Message: "exactly one timeslice declaration is required in the query", + }, + }, + "invalid timeslice segment": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20x as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + Message: `error parsing timeslice duration: time: unknown unit "x" in duration "20x"`, + }, + }, + "minimum timeslice value": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 14s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by n9_time + | sort by n9_time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + Message: `minimum timeslice value is [15s], got: [14s]`, + }, + }, + "missing n9_value": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) by n9_time + | sort by n9_time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + ContainsMessage: "n9_value is required", + }, + }, + "missing n9_time": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20s + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value by time + | sort by time asc`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + ContainsMessage: "n9_time is required", + }, + }, + "missing aggregation function": { + Query: ` +_collector="n9-dev-tooling-cluster" _source="logs" + | json "log" + | timeslice 20s as n9_time + | parse "level=* *" as (log_level, tail) + | if (log_level matches "error" ,0,1) as log_level_not_error + | sum(log_level_not_error) as n9_value`, + Error: testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.sumoLogic.query", + ContainsMessage: "aggregation function is required", + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.SumoLogic) + slo.Spec.Objectives[0].RawMetric.MetricQuery.SumoLogic = &SumoLogicMetric{ + Type: ptr(sumoLogicTypeLogs), + Query: ptr(test.Query), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, test.Error) + }) + } +} diff --git a/manifest/v1alpha/slo/metrics_test.go b/manifest/v1alpha/slo/metrics_test.go new file mode 100644 index 00000000..3acf833c --- /dev/null +++ b/manifest/v1alpha/slo/metrics_test.go @@ -0,0 +1,23 @@ +package slo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" +) + +func TestDataSourceType(t *testing.T) { + for _, src := range v1alpha.DataSourceTypeValues() { + typ := validMetricSpec(src).DataSourceType() + assert.Equal(t, src.String(), typ.String()) + } +} + +func TestQuery(t *testing.T) { + for _, src := range v1alpha.DataSourceTypeValues() { + spec := validMetricSpec(src).Query() + assert.NotEmpty(t, spec) + } +} diff --git a/manifest/v1alpha/slo/metrics_thousand_eyes.go b/manifest/v1alpha/slo/metrics_thousand_eyes.go new file mode 100644 index 00000000..ffde480c --- /dev/null +++ b/manifest/v1alpha/slo/metrics_thousand_eyes.go @@ -0,0 +1,58 @@ +package slo + +import "github.com/nobl9/nobl9-go/validation" + +// ThousandEyesMetric represents metric from ThousandEyes +type ThousandEyesMetric struct { + TestID *int64 `json:"testID"` + TestType *string `json:"testType"` +} + +const ( + ThousandEyesNetLatency = "net-latency" + ThousandEyesNetLoss = "net-loss" + ThousandEyesWebPageLoad = "web-page-load" + ThousandEyesWebDOMLoad = "web-dom-load" + ThousandEyesHTTPResponseTime = "http-response-time" + ThousandEyesServerAvailability = "http-server-availability" + ThousandEyesServerThroughput = "http-server-throughput" + ThousandEyesServerTotalTime = "http-server-total-time" + ThousandEyesDNSServerResolutionTime = "dns-server-resolution-time" + ThousandEyesDNSSECValid = "dns-dnssec-valid" +) + +var thousandEyesCountMetricsValidation = validation.New[MetricSpec]( + validation.ForPointer(func(m MetricSpec) *ThousandEyesMetric { return m.ThousandEyes }). + WithName("thousandEyes"). + Rules(validation.Forbidden[ThousandEyesMetric]()), +) + +var thousandEyesRawMetricValidation = validation.New[MetricSpec]( + validation.ForPointer(func(m MetricSpec) *ThousandEyesMetric { return m.ThousandEyes }). + WithName("thousandEyes"). + Include(thousandEyesValidation), +) + +var supportedThousandEeyesTestTypes = []string{ + ThousandEyesNetLatency, + ThousandEyesNetLoss, + ThousandEyesWebPageLoad, + ThousandEyesWebDOMLoad, + ThousandEyesHTTPResponseTime, + ThousandEyesServerAvailability, + ThousandEyesServerThroughput, + ThousandEyesServerTotalTime, + ThousandEyesDNSServerResolutionTime, + ThousandEyesDNSSECValid, +} + +var thousandEyesValidation = validation.New[ThousandEyesMetric]( + validation.ForPointer(func(m ThousandEyesMetric) *int64 { return m.TestID }). + WithName("testID"). + Required(). + Rules(validation.GreaterThanOrEqualTo[int64](0)), + validation.ForPointer(func(m ThousandEyesMetric) *string { return m.TestType }). + WithName("testType"). + Required(). + Rules(validation.OneOf(supportedThousandEeyesTestTypes...)), +) diff --git a/manifest/v1alpha/slo/metrics_thousand_eyes_test.go b/manifest/v1alpha/slo/metrics_thousand_eyes_test.go new file mode 100644 index 00000000..c262facf --- /dev/null +++ b/manifest/v1alpha/slo/metrics_thousand_eyes_test.go @@ -0,0 +1,72 @@ +package slo + +import ( + "testing" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +func TestThousandEyes(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.ThousandEyes) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("forbidden for count metrics", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.ThousandEyes) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.total.thousandEyes", + Code: validation.ErrorCodeForbidden, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.good.thousandEyes", + Code: validation.ErrorCodeForbidden, + }, + ) + }) + t.Run("required fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.ThousandEyes) + slo.Spec.Objectives[0].RawMetric.MetricQuery.ThousandEyes = &ThousandEyesMetric{} + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.thousandEyes.testID", + Code: validation.ErrorCodeRequired, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.thousandEyes.testType", + Code: validation.ErrorCodeRequired, + }, + ) + }) + t.Run("invalid fields", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.ThousandEyes) + slo.Spec.Objectives[0].RawMetric.MetricQuery.ThousandEyes = &ThousandEyesMetric{ + TestID: ptr[int64](-1), + TestType: ptr("invalid"), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.thousandEyes.testID", + Code: validation.ErrorCodeGreaterThanOrEqualTo, + }, + testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query.thousandEyes.testType", + Code: validation.ErrorCodeOneOf, + }, + ) + }) + t.Run("valid testType", func(t *testing.T) { + for _, testType := range supportedThousandEeyesTestTypes { + slo := validRawMetricSLO(v1alpha.ThousandEyes) + slo.Spec.Objectives[0].RawMetric.MetricQuery.ThousandEyes.TestType = ptr(testType) + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) +} diff --git a/manifest/v1alpha/slo/metrics_validation.go b/manifest/v1alpha/slo/metrics_validation.go new file mode 100644 index 00000000..056d6984 --- /dev/null +++ b/manifest/v1alpha/slo/metrics_validation.go @@ -0,0 +1,423 @@ +package slo + +import ( + "fmt" + + "github.com/pkg/errors" + "golang.org/x/exp/slices" + + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +const ( + errCodeExactlyOneMetricType = "exactly_one_metric_type" + errCodeBadOverTotalDisabled = "bad_over_total_disabled" + errCodeExactlyOneMetricSpecType = "exactly_one_metric_spec_type" + errCodeEitherBadOrGoodCountMetric = "either_bad_or_good_count_metric" + errCodeTimeSliceTarget = "time_slice_target" +) + +var specMetricsValidation = validation.New[Spec]( + validation.For(validation.GetSelf[Spec]()). + Rules(validation.NewSingleRule(func(s Spec) error { + if s.HasRawMetric() == s.HasCountMetrics() { + return errors.New("must have exactly one metric type, either 'rawMetric' or 'countMetrics'") + } + return nil + }).WithErrorCode(errCodeExactlyOneMetricType)). + StopOnError(). + Rules(exactlyOneMetricSpecTypeValidationRule). + StopOnError(). + // Each objective should have exactly two count metrics. + Rules(validation.NewSingleRule(func(s Spec) error { + for i, objective := range s.Objectives { + if objective.CountMetrics == nil { + return nil + } + if objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.BadMetric != nil { + return validation.NewPropertyError( + "countMetrics", + nil, + &validation.RuleError{ + Message: "cannot have both 'bad' and 'good' metrics defined", + Code: errCodeEitherBadOrGoodCountMetric, + }).PrependPropertyName(validation.SliceElementName("objectives", i)) + } + } + return nil + })). + StopOnError(). + Rules( + timeSliceTargetsValidationRule, + objectiveOperatorRequiredForRawMetricValidationRule, + ), +) + +var countMetricsSpecValidation = validation.New[CountMetricsSpec]( + validation.For(validation.GetSelf[CountMetricsSpec]()). + Include( + azureMonitorCountMetricsLevelValidation, + appDynamicsCountMetricsLevelValidation, + lightstepCountMetricsLevelValidation, + pingdomCountMetricsLevelValidation, + sumoLogicCountMetricsLevelValidation, + instanaCountMetricsLevelValidation, + redshiftCountMetricsLevelValidation, + bigQueryCountMetricsLevelValidation), + validation.ForPointer(func(c CountMetricsSpec) *bool { return c.Incremental }). + WithName("incremental"). + Required(), + validation.ForPointer(func(c CountMetricsSpec) *MetricSpec { return c.TotalMetric }). + WithName("total"). + Required(). + Include( + metricSpecValidation, + countMetricsValidation, + lightstepTotalCountMetricValidation), + validation.ForPointer(func(c CountMetricsSpec) *MetricSpec { return c.GoodMetric }). + WithName("good"). + Include( + metricSpecValidation, + countMetricsValidation, + lightstepGoodCountMetricValidation), + validation.ForPointer(func(c CountMetricsSpec) *MetricSpec { return c.BadMetric }). + WithName("bad"). + Rules(oneOfBadOverTotalValidationRule). + Include( + countMetricsValidation, + metricSpecValidation), +) + +var rawMetricsValidation = validation.New[RawMetricSpec]( + validation.ForPointer(func(r RawMetricSpec) *MetricSpec { return r.MetricQuery }). + WithName("query"). + Required(). + Include( + metricSpecValidation, + lightstepRawMetricValidation, + pingdomRawMetricValidation, + thousandEyesRawMetricValidation, + instanaRawMetricValidation), +) + +var countMetricsValidation = validation.New[MetricSpec]( + validation.For(validation.GetSelf[MetricSpec]()). + Include( + pingdomCountMetricsValidation, + thousandEyesCountMetricsValidation, + instanaCountMetricsValidation), +) + +var metricSpecValidation = validation.New[MetricSpec]( + validation.ForPointer(func(m MetricSpec) *AppDynamicsMetric { return m.AppDynamics }). + WithName("appDynamics"). + Include(appDynamicsValidation), + validation.ForPointer(func(m MetricSpec) *LightstepMetric { return m.Lightstep }). + WithName("lightstep"). + Include(lightstepValidation), + validation.ForPointer(func(m MetricSpec) *PingdomMetric { return m.Pingdom }). + WithName("pingdom"). + Include(pingdomValidation), + validation.ForPointer(func(m MetricSpec) *SumoLogicMetric { return m.SumoLogic }). + WithName("sumoLogic"). + Include(sumoLogicValidation), + validation.ForPointer(func(m MetricSpec) *AzureMonitorMetric { return m.AzureMonitor }). + WithName("azureMonitor"). + Include(azureMonitorValidation), + validation.ForPointer(func(m MetricSpec) *RedshiftMetric { return m.Redshift }). + WithName("redshift"). + Include(redshiftValidation), + validation.ForPointer(func(m MetricSpec) *BigQueryMetric { return m.BigQuery }). + WithName("bigQuery"). + Include(bigQueryValidation), + validation.ForPointer(func(m MetricSpec) *CloudWatchMetric { return m.CloudWatch }). + WithName("cloudWatch"). + Include(cloudWatchValidation), + validation.ForPointer(func(m MetricSpec) *PrometheusMetric { return m.Prometheus }). + WithName("prometheus"). + Include(prometheusValidation), + validation.ForPointer(func(m MetricSpec) *AmazonPrometheusMetric { return m.AmazonPrometheus }). + WithName("amazonPrometheus"). + Include(amazonPrometheusValidation), + validation.ForPointer(func(m MetricSpec) *DatadogMetric { return m.Datadog }). + WithName("datadog"). + Include(datadogValidation), + validation.ForPointer(func(m MetricSpec) *DynatraceMetric { return m.Dynatrace }). + WithName("dynatrace"). + Include(dynatraceValidation), + validation.ForPointer(func(m MetricSpec) *ElasticsearchMetric { return m.Elasticsearch }). + WithName("elasticsearch"). + Include(elasticsearchValidation), + validation.ForPointer(func(m MetricSpec) *GCMMetric { return m.GCM }). + WithName("gcm"). + Include(gcmValidation), + validation.ForPointer(func(m MetricSpec) *GraphiteMetric { return m.Graphite }). + WithName("graphite"). + Include(graphiteValidation), + validation.ForPointer(func(m MetricSpec) *InfluxDBMetric { return m.InfluxDB }). + WithName("influxdb"). + Include(influxdbValidation), + validation.ForPointer(func(m MetricSpec) *GrafanaLokiMetric { return m.GrafanaLoki }). + WithName("grafanaLoki"). + Include(grafanaLokiValidation), + validation.ForPointer(func(m MetricSpec) *OpenTSDBMetric { return m.OpenTSDB }). + WithName("opentsdb"). + Include(openTSDBValidation), + validation.ForPointer(func(m MetricSpec) *SplunkMetric { return m.Splunk }). + WithName("splunk"). + Include(splunkValidation), + validation.ForPointer(func(m MetricSpec) *SplunkObservabilityMetric { return m.SplunkObservability }). + WithName("splunkObservability"). + Include(splunkObservabilityValidation), + validation.ForPointer(func(m MetricSpec) *NewRelicMetric { return m.NewRelic }). + WithName("newRelic"). + Include(newRelicValidation), + validation.ForPointer(func(m MetricSpec) *GenericMetric { return m.Generic }). + WithName("generic"). + Include(genericValidation), + validation.ForPointer(func(m MetricSpec) *HoneycombMetric { return m.Honeycomb }). + WithName("honeycomb"). + Include(honeycombValidation), +) + +var badOverTotalEnabledSources = []v1alpha.DataSourceType{ + v1alpha.CloudWatch, + v1alpha.AppDynamics, + v1alpha.AzureMonitor, +} + +// Support for bad/total metrics will be enabled gradually. +// CloudWatch is first delivered datasource integration - extend the list while adding support for next integrations. +var oneOfBadOverTotalValidationRule = validation.NewSingleRule(func(v MetricSpec) error { + return validation.OneOf(badOverTotalEnabledSources...).Validate(v.DataSourceType()) +}).WithErrorCode(errCodeBadOverTotalDisabled) + +var exactlyOneMetricSpecTypeValidationRule = validation.NewSingleRule(func(v Spec) error { + if v.HasRawMetric() { + return validateExactlyOneMetricSpecType(v.RawMetrics()...) + } + return validateExactlyOneMetricSpecType(v.CountMetrics()...) +}).WithErrorCode(errCodeExactlyOneMetricSpecType) + +// nolint: gocognit, gocyclo +func validateExactlyOneMetricSpecType(metrics ...*MetricSpec) error { + var onlyType v1alpha.DataSourceType + typesMatch := func(typ v1alpha.DataSourceType) error { + if onlyType == 0 { + onlyType = typ + } + if onlyType != typ { + return errors.Errorf( + "must have exactly one metric spec type, detected both %s and %s", + onlyType, typ) + } + return nil + } + for _, metric := range metrics { + if metric == nil { + continue + } + if metric.Prometheus != nil { + if err := typesMatch(v1alpha.Prometheus); err != nil { + return err + } + } + if metric.Datadog != nil { + if err := typesMatch(v1alpha.Datadog); err != nil { + return err + } + } + if metric.NewRelic != nil { + if err := typesMatch(v1alpha.NewRelic); err != nil { + return err + } + } + if metric.AppDynamics != nil { + if err := typesMatch(v1alpha.AppDynamics); err != nil { + return err + } + } + if metric.Splunk != nil { + if err := typesMatch(v1alpha.Splunk); err != nil { + return err + } + } + if metric.Lightstep != nil { + if err := typesMatch(v1alpha.Lightstep); err != nil { + return err + } + } + if metric.SplunkObservability != nil { + if err := typesMatch(v1alpha.SplunkObservability); err != nil { + return err + } + } + if metric.ThousandEyes != nil { + if err := typesMatch(v1alpha.ThousandEyes); err != nil { + return err + } + } + if metric.Dynatrace != nil { + if err := typesMatch(v1alpha.Dynatrace); err != nil { + return err + } + } + if metric.Elasticsearch != nil { + if err := typesMatch(v1alpha.Elasticsearch); err != nil { + return err + } + } + if metric.Graphite != nil { + if err := typesMatch(v1alpha.Graphite); err != nil { + return err + } + } + if metric.BigQuery != nil { + if err := typesMatch(v1alpha.BigQuery); err != nil { + return err + } + } + if metric.OpenTSDB != nil { + if err := typesMatch(v1alpha.OpenTSDB); err != nil { + return err + } + } + if metric.GrafanaLoki != nil { + if err := typesMatch(v1alpha.GrafanaLoki); err != nil { + return err + } + } + if metric.CloudWatch != nil { + if err := typesMatch(v1alpha.CloudWatch); err != nil { + return err + } + } + if metric.Pingdom != nil { + if err := typesMatch(v1alpha.Pingdom); err != nil { + return err + } + } + if metric.AmazonPrometheus != nil { + if err := typesMatch(v1alpha.AmazonPrometheus); err != nil { + return err + } + } + if metric.Redshift != nil { + if err := typesMatch(v1alpha.Redshift); err != nil { + return err + } + } + if metric.SumoLogic != nil { + if err := typesMatch(v1alpha.SumoLogic); err != nil { + return err + } + } + if metric.Instana != nil { + if err := typesMatch(v1alpha.Instana); err != nil { + return err + } + } + if metric.InfluxDB != nil { + if err := typesMatch(v1alpha.InfluxDB); err != nil { + return err + } + } + if metric.GCM != nil { + if err := typesMatch(v1alpha.GCM); err != nil { + return err + } + } + if metric.AzureMonitor != nil { + if err := typesMatch(v1alpha.AzureMonitor); err != nil { + return err + } + } + if metric.Generic != nil { + if err := typesMatch(v1alpha.Generic); err != nil { + return err + } + } + if metric.Honeycomb != nil { + if err := typesMatch(v1alpha.Honeycomb); err != nil { + return err + } + } + } + if onlyType == 0 { + return errors.New("must have exactly one metric spec type, none were provided") + } + return nil +} + +var timeSliceTargetsValidationRule = validation.NewSingleRule[Spec](func(s Spec) error { + for i, objective := range s.Objectives { + switch s.BudgetingMethod { + case BudgetingMethodTimeslices.String(): + if objective.TimeSliceTarget == nil { + return validation.NewPropertyError( + "timeSliceTarget", + objective.TimeSliceTarget, validation.NewRequiredError()). + PrependPropertyName(validation.SliceElementName("objectives", i)) + } + case BudgetingMethodOccurrences.String(): + if objective.TimeSliceTarget != nil { + return validation.NewPropertyError( + "timeSliceTarget", + objective.TimeSliceTarget, + &validation.RuleError{ + Message: fmt.Sprintf( + "property may only be used with budgetingMethod == '%s'", + BudgetingMethodTimeslices), + Code: validation.ErrorCodeForbidden}). + PrependPropertyName(validation.SliceElementName("objectives", i)) + } + } + } + return nil +}).WithErrorCode(errCodeTimeSliceTarget) + +var objectiveOperatorRequiredForRawMetricValidationRule = validation.NewSingleRule[Spec](func(s Spec) error { + if !s.HasRawMetric() { + return nil + } + for i, objective := range s.Objectives { + if objective.Operator == nil { + return validation.NewPropertyError( + "op", + objective.Operator, + validation.NewRequiredError()). + PrependPropertyName(validation.SliceElementName("objectives", i)) + } + } + return nil +}) + +// whenCountMetricsIs is a helper function that returns a validation.Predicate which will only pass if +// the count metrics is of the given type. +func whenCountMetricsIs(typ v1alpha.DataSourceType) func(c CountMetricsSpec) bool { + return func(c CountMetricsSpec) bool { + if c.TotalMetric == nil { + return false + } + if c.GoodMetric != nil && typ != c.GoodMetric.DataSourceType() { + return false + } + if slices.Contains(badOverTotalEnabledSources, typ) { + if c.BadMetric != nil && typ != c.BadMetric.DataSourceType() { + return false + } + return c.BadMetric != nil || c.GoodMetric != nil + } + return c.GoodMetric != nil + } +} + +const ( + goodMetric = "good" + badMetric = "bad" +) + +func countMetricsPropertyEqualityError(propName, metric string) error { + return errors.Errorf("'%s' must be the same for both '%s' and 'total' metrics", propName, metric) +} diff --git a/manifest/v1alpha/slo/slo.go b/manifest/v1alpha/slo/slo.go new file mode 100644 index 00000000..12aa306f --- /dev/null +++ b/manifest/v1alpha/slo/slo.go @@ -0,0 +1,157 @@ +package slo + +import ( + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" +) + +//go:generate go run ../../scripts/generate-object-impl.go SLO + +// New creates a new SLO based on provided Metadata nad Spec. +func New(metadata Metadata, spec Spec) SLO { + return SLO{ + APIVersion: manifest.VersionV1alpha.String(), + Kind: manifest.KindSLO, + Metadata: metadata, + Spec: spec, + } +} + +// SLO struct which mapped one to one with kind: slo yaml definition, external usage +type SLO struct { + APIVersion string `json:"apiVersion"` + Kind manifest.Kind `json:"kind"` + Metadata Metadata `json:"metadata"` + Spec Spec `json:"spec"` + Status *Status `json:"status,omitempty"` + + Organization string `json:"organization,omitempty"` + ManifestSource string `json:"manifestSrc,omitempty"` +} + +// Metadata provides identity information for SLO. +type Metadata struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Project string `json:"project,omitempty"` + Labels v1alpha.Labels `json:"labels,omitempty"` +} + +// Spec holds detailed information specific to SLO. +type Spec struct { + Description string `json:"description"` + Indicator Indicator `json:"indicator"` + BudgetingMethod string `json:"budgetingMethod"` + Objectives []Objective `json:"objectives"` + Service string `json:"service"` + TimeWindows []TimeWindow `json:"timeWindows"` + AlertPolicies []string `json:"alertPolicies"` + Attachments []Attachment `json:"attachments,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + Composite *Composite `json:"composite,omitempty"` + AnomalyConfig *AnomalyConfig `json:"anomalyConfig,omitempty"` +} + +// Attachment represents user defined URL attached to SLO +type Attachment struct { + URL string `json:"url"` + DisplayName *string `json:"displayName,omitempty"` +} + +// ObjectiveBase base structure representing an objective. +type ObjectiveBase struct { + DisplayName string `json:"displayName"` + Value *float64 `json:"value"` + Name string `json:"name"` + NameChanged bool `json:"-"` +} + +func (o ObjectiveBase) GetValue() float64 { + var v float64 + if o.Value != nil { + v = *o.Value + } + return v +} + +// Objective represents single objective for SLO, for internal usage +type Objective struct { + ObjectiveBase `json:",inline"` + // + BudgetTarget *float64 `json:"target"` + TimeSliceTarget *float64 `json:"timeSliceTarget,omitempty"` + CountMetrics *CountMetricsSpec `json:"countMetrics,omitempty"` + RawMetric *RawMetricSpec `json:"rawMetric,omitempty"` + Operator *string `json:"op,omitempty"` +} + +func (o Objective) GetBudgetTarget() float64 { + var v float64 + if o.BudgetTarget != nil { + v = *o.BudgetTarget + } + return v +} + +// Indicator represents integration with metric source can be. e.g. Prometheus, Datadog, for internal usage +type Indicator struct { + MetricSource MetricSourceSpec `json:"metricSource"` + RawMetric *MetricSpec `json:"rawMetric,omitempty"` +} + +type MetricSourceSpec struct { + Name string `json:"name"` + Project string `json:"project,omitempty"` + Kind manifest.Kind `json:"kind,omitempty"` +} + +// Composite represents configuration for Composite SLO. +type Composite struct { + BudgetTarget *float64 `json:"target"` + BurnRateCondition *CompositeBurnRateCondition `json:"burnRateCondition,omitempty"` +} + +// CompositeVersion represents composite version history stored for restoring process. +type CompositeVersion struct { + Version int32 + Created string + Dependencies []string +} + +// CompositeBurnRateCondition represents configuration for Composite SLO with occurrences budgeting method. +type CompositeBurnRateCondition struct { + Value float64 `json:"value"` + Operator string `json:"op"` +} + +// AnomalyConfig represents relationship between anomaly type and selected notification methods. +// This will be removed (moved into Anomaly Policy) in PC-8502 +type AnomalyConfig struct { + NoData *AnomalyConfigNoData `json:"noData"` +} + +// AnomalyConfigNoData contains alertMethods used for No Data anomaly type. +type AnomalyConfigNoData struct { + AlertMethods []AnomalyConfigAlertMethod `json:"alertMethods"` +} + +// AnomalyConfigAlertMethod represents a single alert method used in AnomalyConfig +// defined by name and project. +type AnomalyConfigAlertMethod struct { + Name string `json:"name"` + Project string `json:"project,omitempty"` +} + +// Status holds dynamic fields returned when the Service is fetched from Nobl9 platform. +// Status is not part of the static object definition. +type Status struct { + ReplayStatus *ReplayStatus `json:"timeTravel,omitempty"` +} + +type ReplayStatus struct { + Status string `json:"status"` + Unit string `json:"unit"` + Value int `json:"value"` + StartTime string `json:"startTime,omitempty"` +} diff --git a/manifest/v1alpha/slo_object.go b/manifest/v1alpha/slo/slo_object.go similarity index 75% rename from manifest/v1alpha/slo_object.go rename to manifest/v1alpha/slo/slo_object.go index d855f29a..9eaa566f 100644 --- a/manifest/v1alpha/slo_object.go +++ b/manifest/v1alpha/slo/slo_object.go @@ -1,13 +1,16 @@ -// Code generated by "generate-object-impl SLO"; DO NOT EDIT. +// Codes generated by "generate-object-impl SLO"; DO NOT EDIT. -package v1alpha +package slo -import "github.com/nobl9/nobl9-go/manifest" +import ( + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" +) // Ensure interfaces are implemented. var _ manifest.Object = SLO{} var _ manifest.ProjectScopedObject = SLO{} -var _ ObjectContext = SLO{} +var _ v1alpha.ObjectContext = SLO{} func (s SLO) GetVersion() string { return s.APIVersion @@ -22,7 +25,10 @@ func (s SLO) GetName() string { } func (s SLO) Validate() error { - return validator.Check(s) + if err := validate(s); err != nil { + return err + } + return nil } func (s SLO) GetManifestSource() string { diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_invalid_metric_stat_period_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_invalid_metric_stat_period_json.json new file mode 100644 index 00000000..f4d07725 --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_invalid_metric_stat_period_json.json @@ -0,0 +1,43 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 60 + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 90, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_invalid_period_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_invalid_period_json.json new file mode 100644 index 00000000..3f7781a9 --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_invalid_period_json.json @@ -0,0 +1,43 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 62 + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_missing_metric_stat_period_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_missing_metric_stat_period_json.json new file mode 100644 index 00000000..777beedb --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_missing_metric_stat_period_json.json @@ -0,0 +1,42 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 60 + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_missing_period_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_missing_period_json.json new file mode 100644 index 00000000..ec73b0d4 --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_missing_period_json.json @@ -0,0 +1,42 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2" + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_more_than_one_returned_data_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_more_than_one_returned_data_json.json new file mode 100644 index 00000000..119aa8d8 --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_more_than_one_returned_data_json.json @@ -0,0 +1,42 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 60 + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + } + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_no_returned_data_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_no_returned_data_json.json new file mode 100644 index 00000000..8c9953ed --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_no_returned_data_json.json @@ -0,0 +1,44 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 60, + "ReturnData": false + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/cloudwatch_valid_json.json b/manifest/v1alpha/slo/test_data/cloudwatch_valid_json.json new file mode 100644 index 00000000..136d9f8c --- /dev/null +++ b/manifest/v1alpha/slo/test_data/cloudwatch_valid_json.json @@ -0,0 +1,43 @@ +[ + { + "Id": "e1", + "Expression": "m1 / m2", + "Period": 60 + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "HTTPCode_Target_2XX_Count", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + }, + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": "AWS/ApplicationELB", + "MetricName": "RequestCount", + "Dimensions": [ + { + "Name": "LoadBalancer", + "Value": "app/main-default-appingress-350b/904311bedb964754" + } + ] + }, + "Period": 60, + "Stat": "SampleCount" + }, + "ReturnData": false + } +] diff --git a/manifest/v1alpha/slo/test_data/expected_metadata_error.txt b/manifest/v1alpha/slo/test_data/expected_metadata_error.txt new file mode 100644 index 00000000..d92d9e29 --- /dev/null +++ b/manifest/v1alpha/slo/test_data/expected_metadata_error.txt @@ -0,0 +1,12 @@ +Validation for SLO 'MY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLO' in project 'MY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECT' has failed for the following fields: + - 'metadata.name' with value 'MY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY SLOMY S...': + - length must be between 1 and 63 + - string does not match regular expression: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' (e.g. 'my-name', '123-abc'); a DNS-1123 compliant name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character + - 'metadata.project' with value 'MY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECTMY PROJECT...': + - length must be between 1 and 63 + - string does not match regular expression: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' (e.g. 'my-name', '123-abc'); a DNS-1123 compliant name must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character + - 'metadata.labels' with value '{"L O L":["dip","dip"]}': + - label key 'L O L' does not match the regex: ^\p{L}([_\-0-9\p{L}]*[0-9\p{L}])?$ + - 'spec.description' with value 'llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll...': + - length must be between 0 and 1050 +Manifest source: /home/me/slo.yaml diff --git a/manifest/v1alpha/slo/time_window.go b/manifest/v1alpha/slo/time_window.go new file mode 100644 index 00000000..ac892766 --- /dev/null +++ b/manifest/v1alpha/slo/time_window.go @@ -0,0 +1,185 @@ +package slo + +import ( + "time" + + "github.com/pkg/errors" + + "github.com/nobl9/nobl9-go/manifest/v1alpha/twindow" + "github.com/nobl9/nobl9-go/validation" +) + +// TimeWindow represents content of time window +type TimeWindow struct { + Unit string `json:"unit"` + Count int `json:"count"` + IsRolling bool `json:"isRolling"` + Calendar *Calendar `json:"calendar,omitempty"` + + // Period is only returned in `/get/slo` requests it is ignored for `/apply` + Period *Period `json:"period,omitempty"` +} + +// GetType returns value of twindow.TimeWindowTypeEnum for given time window> +func (tw TimeWindow) GetType() twindow.TimeWindowTypeEnum { + if tw.isCalendar() { + return twindow.Calendar + } + return twindow.Rolling +} + +func (tw TimeWindow) isCalendar() bool { + return tw.Calendar != nil +} + +// Calendar struct represents calendar time window +type Calendar struct { + StartTime string `json:"startTime"` + TimeZone string `json:"timeZone"` +} + +// Period represents period of time +type Period struct { + Begin string `json:"begin"` + End string `json:"end"` +} + +// Values used to validate time window size +const ( + minimumRollingTimeWindowSize = 5 * time.Minute + maximumRollingTimeWindowSize = 31 * 24 * time.Hour // 31 days + maximumCalendarTimeWindowSize = 366 * 24 * time.Hour // 366 days +) + +var timeWindowsValidation = validation.New[TimeWindow]( + validation.For(func(t TimeWindow) string { return t.Unit }). + WithName("unit"). + Required(). + Rules(twindow.ValidationRuleTimeUnit()), + validation.For(func(t TimeWindow) int { return t.Count }). + WithName("count"). + Rules(validation.GreaterThan(0)), + validation.ForPointer(func(t TimeWindow) *Calendar { return t.Calendar }). + WithName("calendar"). + Include(validation.New[Calendar]( + validation.For(func(c Calendar) string { return c.StartTime }). + WithName("startTime"). + Required(). + Rules(calendarStartTimeValidationRule()), + validation.For(func(c Calendar) string { return c.TimeZone }). + WithName("timeZone"). + Required(). + Rules(validation.NewSingleRule(func(v string) error { + if _, err := time.LoadLocation(v); err != nil { + return errors.Wrap(err, "not a valid time zone") + } + return nil + }))), + ), +) + +func timeWindowValidationRule() validation.SingleRule[TimeWindow] { + return validation.NewSingleRule(func(v TimeWindow) error { + if err := validateTimeWindowAmbiguity(v); err != nil { + return err + } + if err := validateTimeUnitForTimeWindowType(v); err != nil { + return err + } + switch v.GetType() { + case twindow.Rolling: + return rollingWindowSizeValidation(v) + case twindow.Calendar: + return calendarWindowSizeValidation(v) + } + return nil + }) +} + +func rollingWindowSizeValidation(timeWindow TimeWindow) error { + rollingWindowTimeUnitEnum := twindow.GetTimeUnitEnum(twindow.Rolling, timeWindow.Unit) + var timeWindowSize time.Duration + switch rollingWindowTimeUnitEnum { + case twindow.Minute: + timeWindowSize = time.Duration(timeWindow.Count) * time.Minute + case twindow.Hour: + timeWindowSize = time.Duration(timeWindow.Count) * time.Hour + case twindow.Day: + timeWindowSize = time.Duration(timeWindow.Count) * 24 * time.Hour + default: + return errors.New("valid window type for time unit required") + } + switch { + case timeWindowSize > maximumRollingTimeWindowSize: + return errors.Errorf( + "rolling time window size must be less than or equal to %s", + maximumRollingTimeWindowSize) + case timeWindowSize < minimumRollingTimeWindowSize: + return errors.Errorf( + "rolling time window size must be greater than or equal to %s", + minimumRollingTimeWindowSize) + } + return nil +} + +func calendarWindowSizeValidation(timeWindow TimeWindow) error { + tw, err := twindow.NewCalendarTimeWindow( + twindow.MustParseTimeUnit(timeWindow.Unit), + uint32(timeWindow.Count), + time.UTC, + time.Now().UTC(), + ) + if err != nil { + return err + } + timeWindowSize := tw.GetTimePeriod(time.Now().UTC()).Duration() + if timeWindowSize > maximumCalendarTimeWindowSize { + return errors.Errorf("calendar time window size must be less than %s", maximumCalendarTimeWindowSize) + } + return nil +} + +func validateTimeWindowAmbiguity(timeWindow TimeWindow) error { + if timeWindow.IsRolling && timeWindow.isCalendar() { + return errors.New( + "if 'isRolling' property is true, 'calendar' property must be omitted") + } + if !timeWindow.IsRolling && !timeWindow.isCalendar() { + return errors.New( + "if 'isRolling' property is false or not set, 'calendar' property must be provided") + } + return nil +} + +func validateTimeUnitForTimeWindowType(tw TimeWindow) error { + var err error + typ := tw.GetType() + switch typ { + case twindow.Rolling: + err = twindow.ValidateRollingWindowTimeUnit(tw.Unit) + case twindow.Calendar: + err = twindow.ValidateCalendarAlignedTimeUnit(tw.Unit) + } + if err != nil { + return errors.Wrapf(err, "invalid time window unit for %s window type", typ) + } + return nil +} + +func calendarStartTimeValidationRule() validation.SingleRule[string] { + return validation.NewSingleRule(func(v string) error { + date, err := twindow.ParseStartDate(v) + if err != nil { + return err + } + minStartDate := twindow.GetMinStartDate() + if date.Before(minStartDate) { + return errors.Errorf("date must be after or equal to %s", minStartDate.Format(time.RFC3339)) + } + if date.Nanosecond() != 0 { + return errors.New( + "setting nanoseconds or milliseconds in time are forbidden to be set") + } + return nil + }) +} diff --git a/manifest/v1alpha/slo/validation.go b/manifest/v1alpha/slo/validation.go new file mode 100644 index 00000000..978e286f --- /dev/null +++ b/manifest/v1alpha/slo/validation.go @@ -0,0 +1,235 @@ +package slo + +import ( + "fmt" + + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +var sloValidation = validation.New[SLO]( + validation.For(func(s SLO) Metadata { return s.Metadata }). + Include(metadataValidation), + validation.For(func(s SLO) Spec { return s.Spec }). + WithName("spec"). + Include(specValidation), +) + +var metadataValidation = validation.New[Metadata]( + v1alpha.FieldRuleMetadataName(func(m Metadata) string { return m.Name }), + v1alpha.FieldRuleMetadataDisplayName(func(m Metadata) string { return m.DisplayName }), + v1alpha.FieldRuleMetadataProject(func(m Metadata) string { return m.Project }), + v1alpha.FieldRuleMetadataLabels(func(m Metadata) v1alpha.Labels { return m.Labels }), +) + +var specValidation = validation.New[Spec]( + validation.For(validation.GetSelf[Spec]()). + Include(specMetricsValidation), + validation.For(validation.GetSelf[Spec]()). + WithName("composite"). + When(func(s Spec) bool { return s.Composite != nil }). + Rules(specCompositeValidationRule), + validation.For(func(s Spec) string { return s.Description }). + WithName("description"). + Rules(validation.StringDescription()), + validation.For(func(s Spec) string { return s.BudgetingMethod }). + WithName("budgetingMethod"). + Required(). + Rules(validation.NewSingleRule(func(v string) error { + _, err := ParseBudgetingMethod(v) + return err + })), + validation.For(func(s Spec) string { return s.Service }). + WithName("service"). + Required(). + Rules(validation.StringIsDNSSubdomain()), + validation.ForEach(func(s Spec) []string { return s.AlertPolicies }). + WithName("alertPolicies"). + RulesForEach(validation.StringIsDNSSubdomain()), + validation.ForEach(func(s Spec) []Attachment { return s.Attachments }). + WithName("attachments"). + Rules(validation.SliceLength[[]Attachment](0, 20)). + StopOnError(). + IncludeForEach(attachmentValidation), + validation.ForPointer(func(s Spec) *Composite { return s.Composite }). + WithName("composite"). + Include(compositeValidation), + validation.ForPointer(func(s Spec) *AnomalyConfig { return s.AnomalyConfig }). + WithName("anomalyConfig"). + Include(anomalyConfigValidation), + validation.ForEach(func(s Spec) []TimeWindow { return s.TimeWindows }). + WithName("timeWindows"). + Rules(validation.SliceLength[[]TimeWindow](1, 1)). + StopOnError(). + IncludeForEach(timeWindowsValidation). + StopOnError(). + RulesForEach(timeWindowValidationRule()), + validation.For(func(s Spec) Indicator { return s.Indicator }). + WithName("indicator"). + Required(). + Include(indicatorValidation), + validation.ForEach(func(s Spec) []Objective { return s.Objectives }). + WithName("objectives"). + Rules(validation.SliceMinLength[[]Objective](1)). + StopOnError(). + Rules(validation.SliceUnique(func(v Objective) float64 { + if v.Value == nil { + return 0 + } + return *v.Value + }, "objectives[*].value must be different for each objective")). + IncludeForEach(objectiveValidation), +) + +var attachmentValidation = validation.New[Attachment]( + validation.For(func(a Attachment) string { return a.URL }). + WithName("url"). + Required(). + Rules(validation.StringURL()), + validation.ForPointer(func(a Attachment) *string { return a.DisplayName }). + WithName("displayName"). + Rules(validation.StringLength(0, 63)), +) + +var compositeValidation = validation.New[Composite]( + validation.ForPointer(func(c Composite) *float64 { return c.BudgetTarget }). + WithName("target"). + Required(). + Rules(validation.GreaterThan(0.0), validation.LessThan(1.0)), + validation.ForPointer(func(c Composite) *CompositeBurnRateCondition { return c.BurnRateCondition }). + WithName("burnRateCondition"). + Include(validation.New[CompositeBurnRateCondition]( + validation.For(func(b CompositeBurnRateCondition) float64 { return b.Value }). + WithName("value"). + Rules(validation.GreaterThanOrEqualTo(0.0), validation.LessThanOrEqualTo(1000.0)), + validation.For(func(b CompositeBurnRateCondition) string { return b.Operator }). + WithName("op"). + Required(). + Rules(validation.OneOf("gt")), + )), +) + +var specCompositeValidationRule = validation.NewSingleRule(func(s Spec) error { + switch s.BudgetingMethod { + case BudgetingMethodOccurrences.String(): + if s.Composite.BurnRateCondition == nil { + return validation.NewPropertyError( + "burnRateCondition", + s.Composite.BurnRateCondition, + validation.NewRequiredError(), + ) + } + case BudgetingMethodTimeslices.String(): + if s.Composite.BurnRateCondition != nil { + return validation.NewPropertyError( + "burnRateCondition", + s.Composite.BurnRateCondition, + &validation.RuleError{ + Message: fmt.Sprintf( + "burnRateCondition may only be used with budgetingMethod == '%s'", + BudgetingMethodOccurrences), + Code: validation.ErrorCodeForbidden, + }, + ) + } + } + return nil +}) + +var anomalyConfigValidation = validation.New[AnomalyConfig]( + validation.ForPointer(func(a AnomalyConfig) *AnomalyConfigNoData { return a.NoData }). + WithName("noData"). + Include(validation.New[AnomalyConfigNoData]( + validation.ForEach(func(a AnomalyConfigNoData) []AnomalyConfigAlertMethod { return a.AlertMethods }). + WithName("alertMethods"). + Rules(validation.SliceMinLength[[]AnomalyConfigAlertMethod](1)). + StopOnError(). + Rules(validation.SliceUnique(validation.SelfHashFunc[AnomalyConfigAlertMethod]())). + StopOnError(). + IncludeForEach(validation.New[AnomalyConfigAlertMethod]( + validation.For(func(a AnomalyConfigAlertMethod) string { return a.Name }). + WithName("name"). + Required(). + Rules(validation.StringIsDNSSubdomain()), + validation.For(func(a AnomalyConfigAlertMethod) string { return a.Project }). + WithName("project"). + Rules(validation.StringIsDNSSubdomain()), + )), + )), +) + +var indicatorValidation = validation.New[Indicator]( + validation.For(func(i Indicator) MetricSourceSpec { return i.MetricSource }). + WithName("metricSource"). + Include(validation.New[MetricSourceSpec]( + validation.For(func(m MetricSourceSpec) string { return m.Name }). + WithName("name"). + Required(). + Rules(validation.StringIsDNSSubdomain()), + validation.For(func(m MetricSourceSpec) string { return m.Project }). + WithName("project"). + Omitempty(). + Rules(validation.StringIsDNSSubdomain()), + validation.For(func(m MetricSourceSpec) manifest.Kind { return m.Kind }). + WithName("kind"). + Omitempty(). + Rules(validation.OneOf(manifest.KindAgent, manifest.KindDirect)), + )), + validation.ForPointer(func(i Indicator) *MetricSpec { return i.RawMetric }). + WithName("rawMetric"). + Include(metricSpecValidation), +) + +var objectiveValidation = validation.New[Objective]( + validation.For(func(o Objective) ObjectiveBase { return o.ObjectiveBase }). + Include(objectiveBaseValidation), + validation.ForPointer(func(o Objective) *float64 { return o.BudgetTarget }). + WithName("target"). + Required(). + Rules(validation.GreaterThanOrEqualTo(0.0), validation.LessThan(1.0)), + validation.ForPointer(func(o Objective) *float64 { return o.TimeSliceTarget }). + WithName("timeSliceTarget"). + Rules(validation.GreaterThan(0.0), validation.LessThanOrEqualTo(1.0)), + validation.ForPointer(func(o Objective) *string { return o.Operator }). + WithName("op"). + Rules(validation.OneOf(v1alpha.OperatorNames()...)), + validation.ForPointer(func(o Objective) *CountMetricsSpec { return o.CountMetrics }). + WithName("countMetrics"). + Include(countMetricsSpecValidation), + validation.ForPointer(func(o Objective) *RawMetricSpec { return o.RawMetric }). + WithName("rawMetric"). + Include(rawMetricsValidation), +) + +var objectiveBaseValidation = validation.New[ObjectiveBase]( + validation.For(func(o ObjectiveBase) string { return o.Name }). + WithName("name"). + Omitempty(). + Rules(validation.StringIsDNSSubdomain()), + validation.For(func(o ObjectiveBase) string { return o.DisplayName }). + WithName("displayName"). + Omitempty(). + Rules(validation.StringMaxLength(63)), + validation.ForPointer(func(o ObjectiveBase) *float64 { return o.Value }). + WithName("value"). + Required(), +) + +func validate(s SLO) *v1alpha.ObjectError { + if s.Spec.AnomalyConfig != nil && s.Spec.AnomalyConfig.NoData != nil { + for i := range s.Spec.AnomalyConfig.NoData.AlertMethods { + if s.Spec.AnomalyConfig.NoData.AlertMethods[i].Project == "" { + s.Spec.AnomalyConfig.NoData.AlertMethods[i].Project = s.Metadata.Project + } + } + } + return v1alpha.ValidateObject(sloValidation, s) +} + +func arePointerValuesEqual[T comparable](p1, p2 *T) bool { + if p1 == nil || p2 == nil { + return true + } + return *p1 == *p2 +} diff --git a/manifest/v1alpha/slo/validation_test.go b/manifest/v1alpha/slo/validation_test.go new file mode 100644 index 00000000..9b79468f --- /dev/null +++ b/manifest/v1alpha/slo/validation_test.go @@ -0,0 +1,1432 @@ +package slo + +import ( + _ "embed" + "encoding/json" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nobl9/nobl9-go/internal/testutils" + "github.com/nobl9/nobl9-go/manifest" + "github.com/nobl9/nobl9-go/manifest/v1alpha" + "github.com/nobl9/nobl9-go/validation" +) + +//go:embed test_data/expected_metadata_error.txt +var expectedMetadataError string + +func TestValidate_Metadata(t *testing.T) { + slo := validSLO() + slo.Metadata = Metadata{ + Name: strings.Repeat("MY SLO", 20), + DisplayName: strings.Repeat("my-slo", 10), + Project: strings.Repeat("MY PROJECT", 20), + Labels: v1alpha.Labels{ + "L O L": []string{"dip", "dip"}, + }, + } + slo.Spec.Description = strings.Repeat("l", 2000) + slo.ManifestSource = "/home/me/slo.yaml" + err := validate(slo) + require.Error(t, err) + assert.Equal(t, strings.TrimSuffix(expectedMetadataError, "\n"), err.Error()) +} + +func TestValidate_Spec_BudgetingMethod(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = BudgetingMethodOccurrences.String() + testutils.AssertNoError(t, slo, validate(slo)) + slo.Spec.BudgetingMethod = BudgetingMethodTimeslices.String() + slo.Spec.Objectives[0].TimeSliceTarget = ptr(0.1) + testutils.AssertNoError(t, slo, validate(slo)) + }) + t.Run("empty method", func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = "" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.budgetingMethod", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("invalid method", func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = "invalid" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.budgetingMethod", + Message: "'invalid' is not a valid budgeting method", + }) + }) +} + +func TestValidate_Spec_Service(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validSLO() + slo.Spec.Service = "my-service" + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validSLO() + slo.Spec.Service = "MY SERVICE" + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.service", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }) + }) +} + +func TestValidate_Spec_AlertPolicies(t *testing.T) { + t.Run("passes", func(t *testing.T) { + slo := validSLO() + slo.Spec.AlertPolicies = []string{"my-policy"} + err := validate(slo) + testutils.AssertNoError(t, slo, err) + }) + t.Run("fails", func(t *testing.T) { + slo := validSLO() + slo.Spec.AlertPolicies = []string{"my-policy", "MY POLICY", "ok-policy"} + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.alertPolicies[1]", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }) + }) +} + +func TestValidate_Spec_Attachments(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, attachments := range [][]Attachment{ + {}, + {{URL: "https://my-url.com"}}, + {{URL: "https://my-url.com"}, {URL: "http://insecure-url.pl", DisplayName: ptr("Dashboard")}}, + } { + slo := validSLO() + slo.Spec.Attachments = attachments + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails, too many attachments", func(t *testing.T) { + slo := validSLO() + var attachments []Attachment + for i := 0; i < 21; i++ { + attachments = append(attachments, Attachment{}) + } + slo.Spec.Attachments = attachments + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.attachments", + Code: validation.ErrorCodeSliceLength, + }) + }) + t.Run("fails, invalid attachment", func(t *testing.T) { + slo := validSLO() + slo.Spec.Attachments = []Attachment{ + {URL: "https://this.com"}, + {URL: ".com"}, + {URL: "", DisplayName: ptr(strings.Repeat("l", 64))}, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 3, + testutils.ExpectedError{ + Prop: "spec.attachments[1].url", + Code: validation.ErrorCodeStringURL, + }, + testutils.ExpectedError{ + Prop: "spec.attachments[2].displayName", + Code: validation.ErrorCodeStringLength, + }, + testutils.ExpectedError{ + Prop: "spec.attachments[2].url", + Code: validation.ErrorCodeRequired, + }, + ) + }) +} + +func TestValidate_Spec_Composite(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, composite := range []*Composite{ + nil, + { + BudgetTarget: ptr(0.001), + BurnRateCondition: &CompositeBurnRateCondition{Value: 1000, Operator: "gt"}, + }, + { + BudgetTarget: ptr(0.9999), + BurnRateCondition: &CompositeBurnRateCondition{Value: 1000, Operator: "gt"}, + }, + } { + slo := validSLO() + slo.Spec.Composite = composite + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails", func(t *testing.T) { + for name, test := range map[string]struct { + Composite *Composite + ExpectedError testutils.ExpectedError + }{ + "target required": { + Composite: &Composite{ + BudgetTarget: nil, + BurnRateCondition: &CompositeBurnRateCondition{Value: 1000, Operator: "gt"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.target", + Code: validation.ErrorCodeRequired, + }, + }, + "target too small": { + Composite: &Composite{ + BudgetTarget: ptr(0.), + BurnRateCondition: &CompositeBurnRateCondition{Value: 1000, Operator: "gt"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.target", + Code: validation.ErrorCodeGreaterThan, + }, + }, + "target too large": { + Composite: &Composite{ + BudgetTarget: ptr(1.0), + BurnRateCondition: &CompositeBurnRateCondition{Value: 1000, Operator: "gt"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.target", + Code: validation.ErrorCodeLessThan, + }, + }, + "burn rate value too small": { + Composite: &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: &CompositeBurnRateCondition{Value: -1, Operator: "gt"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition.value", + Code: validation.ErrorCodeGreaterThanOrEqualTo, + }, + }, + "burn rate value too large": { + Composite: &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: &CompositeBurnRateCondition{Value: 1001, Operator: "gt"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition.value", + Code: validation.ErrorCodeLessThanOrEqualTo, + }, + }, + "missing operator": { + Composite: &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: &CompositeBurnRateCondition{Value: 10}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition.op", + Code: validation.ErrorCodeRequired, + }, + }, + "invalid operator": { + Composite: &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: &CompositeBurnRateCondition{Value: 10, Operator: "lte"}, + }, + ExpectedError: testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition.op", + Code: validation.ErrorCodeOneOf, + }, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.Composite = test.Composite + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, test.ExpectedError) + }) + } + }) + t.Run("missing burnRateCondition for occurrences", func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = BudgetingMethodOccurrences.String() + slo.Spec.Composite = &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: nil, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition", + Code: validation.ErrorCodeRequired, + }) + }) + t.Run("burnRateCondition forbidden for timeslices", func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = BudgetingMethodTimeslices.String() + slo.Spec.Objectives[0].TimeSliceTarget = ptr(0.9) + slo.Spec.Composite = &Composite{ + BudgetTarget: ptr(0.9), + BurnRateCondition: &CompositeBurnRateCondition{Value: 10, Operator: "gt"}, + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.composite.burnRateCondition", + Code: validation.ErrorCodeForbidden, + }) + }) +} + +func TestValidate_Spec_AnomalyConfig(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, config := range []*AnomalyConfig{ + nil, + {NoData: nil}, + {NoData: &AnomalyConfigNoData{AlertMethods: []AnomalyConfigAlertMethod{{ + Name: "my-name", + }}}}, + {NoData: &AnomalyConfigNoData{AlertMethods: []AnomalyConfigAlertMethod{{ + Name: "my-name", + Project: "default", + }}}}, + } { + slo := validSLO() + slo.Spec.AnomalyConfig = config + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails", func(t *testing.T) { + for name, test := range map[string]struct { + Config *AnomalyConfig + ExpectedErrors []testutils.ExpectedError + ExpectedErrorsCount int + }{ + "no alert methods": { + Config: &AnomalyConfig{NoData: &AnomalyConfigNoData{ + AlertMethods: make([]AnomalyConfigAlertMethod, 0), + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.anomalyConfig.noData.alertMethods", + Code: validation.ErrorCodeSliceMinLength, + }, + }, + ExpectedErrorsCount: 1, + }, + "invalid name and project": { + Config: &AnomalyConfig{NoData: &AnomalyConfigNoData{AlertMethods: []AnomalyConfigAlertMethod{ + { + Name: "", + Project: "this-project", + }, + { + Name: "MY NAME", + Project: "THIS PROJECT", + }, + { + Name: "MY NAME", + }, + }}}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.anomalyConfig.noData.alertMethods[0].name", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "spec.anomalyConfig.noData.alertMethods[1].name", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + { + Prop: "spec.anomalyConfig.noData.alertMethods[1].project", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + { + Prop: "spec.anomalyConfig.noData.alertMethods[2].name", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + }, + ExpectedErrorsCount: 4, + }, + "not unique alert methods": { + Config: &AnomalyConfig{NoData: &AnomalyConfigNoData{AlertMethods: []AnomalyConfigAlertMethod{ + { + Name: "my-name", + Project: "default", + }, + { + Name: "my-name", + Project: "", // Will be filled with default. + }, + }}}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.anomalyConfig.noData.alertMethods", + Code: validation.ErrorCodeSliceUnique, + }, + }, + ExpectedErrorsCount: 1, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.AnomalyConfig = test.Config + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, test.ExpectedErrorsCount, test.ExpectedErrors...) + }) + } + }) +} + +// nolint: lll +func TestValidate_Spec_TimeWindows(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, tw := range [][]TimeWindow{ + {TimeWindow{ + Unit: "Day", + Count: 1, + IsRolling: true, + }}, + {TimeWindow{ + Unit: "Month", + Count: 1, + IsRolling: false, + Calendar: &Calendar{ + StartTime: "2022-01-21 12:30:00", + TimeZone: "America/New_York", + }, + }}, + } { + slo := validSLO() + slo.Spec.TimeWindows = tw + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails", func(t *testing.T) { + for name, test := range map[string]struct { + TimeWindows []TimeWindow + ExpectedErrors []testutils.ExpectedError + ExpectedErrorsCount int + }{ + "no time windows": { + TimeWindows: []TimeWindow{}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows", + Code: validation.ErrorCodeSliceLength, + }, + }, + ExpectedErrorsCount: 1, + }, + "too many time windows": { + TimeWindows: []TimeWindow{{}, {}}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows", + Code: validation.ErrorCodeSliceLength, + }, + }, + ExpectedErrorsCount: 1, + }, + "missing unit and count": { + TimeWindows: []TimeWindow{{ + Unit: "", + Count: 0, + IsRolling: true, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0].unit", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "spec.timeWindows[0].count", + Code: validation.ErrorCodeGreaterThan, + }, + }, + ExpectedErrorsCount: 2, + }, + "invalid unit": { + TimeWindows: []TimeWindow{{ + Unit: "dayz", + Count: 1, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0].unit", + Code: validation.ErrorCodeOneOf, + }, + }, + ExpectedErrorsCount: 1, + }, + "invalid calendar - missing fields": { + TimeWindows: []TimeWindow{{ + Unit: "Day", + Count: 1, + Calendar: &Calendar{}, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0].calendar.startTime", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "spec.timeWindows[0].calendar.timeZone", + Code: validation.ErrorCodeRequired, + }, + }, + ExpectedErrorsCount: 2, + }, + "invalid calendar - invalid fields": { + TimeWindows: []TimeWindow{{ + Unit: "Day", + Count: 1, + Calendar: &Calendar{ + StartTime: "asd", + TimeZone: "asd", + }, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0].calendar.startTime", + Message: `error parsing date: parsing time "asd" as "2006-01-02 15:04:05": cannot parse "asd" as "2006"`, + }, + { + Prop: "spec.timeWindows[0].calendar.timeZone", + Message: "not a valid time zone: unknown time zone asd", + }, + }, + ExpectedErrorsCount: 2, + }, + "isRolling and calendar are both set": { + TimeWindows: []TimeWindow{{ + Unit: "Day", + Count: 1, + IsRolling: true, + Calendar: &Calendar{ + StartTime: "2022-01-21 12:30:00", + TimeZone: "America/New_York", + }, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: "if 'isRolling' property is true, 'calendar' property must be omitted", + }, + }, + ExpectedErrorsCount: 1, + }, + "isRolling and calendar are both not set": { + TimeWindows: []TimeWindow{{ + Unit: "Day", + Count: 1, + IsRolling: false, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: "if 'isRolling' property is false or not set, 'calendar' property must be provided", + }, + }, + ExpectedErrorsCount: 1, + }, + "invalid rolling time window unit": { + TimeWindows: []TimeWindow{{ + Unit: "Year", + Count: 1, + IsRolling: true, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: "invalid time window unit for Rolling window type: must be one of [Minute, Hour, Day]", + }, + }, + ExpectedErrorsCount: 1, + }, + "invalid calendar time window unit": { + TimeWindows: []TimeWindow{{ + Unit: "Second", + Count: 1, + IsRolling: false, + Calendar: &Calendar{ + StartTime: "2022-01-21 12:30:00", + TimeZone: "America/New_York", + }, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: "invalid time window unit for Calendar window type: must be one of [Day, Week, Month, Quarter, Year]", + }, + }, + ExpectedErrorsCount: 1, + }, + "rolling time window size is less than defined min": { + TimeWindows: []TimeWindow{{ + Unit: "Minute", + Count: 4, + IsRolling: true, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: fmt.Sprintf( + "rolling time window size must be greater than or equal to %s", + minimumRollingTimeWindowSize), + }, + }, + ExpectedErrorsCount: 1, + }, + "rolling time window size is greater than defined max": { + TimeWindows: []TimeWindow{{ + Unit: "Day", + Count: 32, + IsRolling: true, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: fmt.Sprintf( + "rolling time window size must be less than or equal to %s", + maximumRollingTimeWindowSize), + }, + }, + ExpectedErrorsCount: 1, + }, + "calendar time window size is greater than defined max": { + TimeWindows: []TimeWindow{{ + Unit: "Year", + Count: 2, + IsRolling: false, + Calendar: &Calendar{ + StartTime: "2022-01-21 12:30:00", + TimeZone: "America/New_York", + }, + }}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.timeWindows[0]", + Message: fmt.Sprintf( + "calendar time window size must be less than %s", + maximumCalendarTimeWindowSize), + }, + }, + ExpectedErrorsCount: 1, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.TimeWindows = test.TimeWindows + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, test.ExpectedErrorsCount, test.ExpectedErrors...) + }) + } + }) +} + +func TestValidate_Spec_Indicator(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, ind := range []Indicator{ + { + MetricSource: MetricSourceSpec{Name: "name-only"}, + }, + { + MetricSource: MetricSourceSpec{ + Name: "name", + Project: "default", + Kind: manifest.KindAgent, + }, + }, + { + MetricSource: MetricSourceSpec{ + Name: "name", + Project: "default", + Kind: manifest.KindDirect, + }, + }, + } { + slo := validSLO() + slo.Spec.Indicator = ind + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails", func(t *testing.T) { + for name, test := range map[string]struct { + Indicator Indicator + ExpectedErrors []testutils.ExpectedError + ExpectedErrorsCount int + }{ + "empty indicator": { + Indicator: Indicator{}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.indicator", + Code: validation.ErrorCodeRequired, + }, + }, + ExpectedErrorsCount: 1, + }, + "empty metric source name": { + Indicator: Indicator{MetricSource: MetricSourceSpec{Name: "", Project: "default"}}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.indicator.metricSource.name", + Code: validation.ErrorCodeRequired, + }, + }, + ExpectedErrorsCount: 1, + }, + "invalid metric source": { + Indicator: Indicator{ + MetricSource: MetricSourceSpec{ + Name: "MY NAME", + Project: "MY PROJECT", + Kind: manifest.KindSLO, + }, + }, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.indicator.metricSource.name", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + { + Prop: "spec.indicator.metricSource.project", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + { + Prop: "spec.indicator.metricSource.kind", + Code: validation.ErrorCodeOneOf, + }, + }, + ExpectedErrorsCount: 3, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.Indicator = test.Indicator + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, test.ExpectedErrorsCount, test.ExpectedErrors...) + }) + } + }) +} + +func TestValidate_Spec_Objectives(t *testing.T) { + t.Run("passes", func(t *testing.T) { + for _, objectives := range [][]Objective{ + {{ + ObjectiveBase: ObjectiveBase{ + Name: "name", + Value: ptr(9.2), + DisplayName: strings.Repeat("l", 63), + }, + BudgetTarget: ptr(0.9), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }}, + } { + slo := validSLO() + slo.Spec.Objectives = objectives + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("fails", func(t *testing.T) { + for name, test := range map[string]struct { + Objectives []Objective + ExpectedErrors []testutils.ExpectedError + ExpectedErrorsCount int + }{ + "not enough objectives": { + Objectives: []Objective{}, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.objectives", + Code: validation.ErrorCodeSliceMinLength, + }, + }, + ExpectedErrorsCount: 2, + }, + "objective base errors": { + Objectives: []Objective{ + { + ObjectiveBase: ObjectiveBase{ + DisplayName: strings.Repeat("l", 64), + Value: ptr(2.), + Name: "MY NAME", + }, + BudgetTarget: ptr(2.0), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + { + ObjectiveBase: ObjectiveBase{Name: ""}, + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + { + ObjectiveBase: ObjectiveBase{Value: ptr(1.)}, + BudgetTarget: ptr(-1.0), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + }, + ExpectedErrors: []testutils.ExpectedError{ + { + Prop: "spec.objectives[0].displayName", + Code: validation.ErrorCodeStringMaxLength, + }, + { + Prop: "spec.objectives[0].name", + Code: validation.ErrorCodeStringIsDNSSubdomain, + }, + { + Prop: "spec.objectives[0].target", + Code: validation.ErrorCodeLessThan, + }, + { + Prop: "spec.objectives[1].value", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "spec.objectives[1].target", + Code: validation.ErrorCodeRequired, + }, + { + Prop: "spec.objectives[2].target", + Code: validation.ErrorCodeGreaterThanOrEqualTo, + }, + }, + ExpectedErrorsCount: 6, + }, + "all objectives have unique values": { + Objectives: []Objective{ + { + ObjectiveBase: ObjectiveBase{Value: ptr(10.)}, + BudgetTarget: ptr(0.9), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + { + ObjectiveBase: ObjectiveBase{Value: ptr(10.)}, + BudgetTarget: ptr(0.8), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr(v1alpha.GreaterThan.String()), + }, + }, + ExpectedErrors: []testutils.ExpectedError{{ + Prop: "spec.objectives", + Code: validation.ErrorCodeSliceUnique, + }}, + ExpectedErrorsCount: 1, + }, + "invalid operator": { + Objectives: []Objective{{ + ObjectiveBase: ObjectiveBase{Value: ptr(10.)}, + BudgetTarget: ptr(0.9), + RawMetric: &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)}, + Operator: ptr("invalid"), + }}, + ExpectedErrors: []testutils.ExpectedError{{ + Prop: "spec.objectives[0].op", + Code: validation.ErrorCodeOneOf, + }}, + ExpectedErrorsCount: 1, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives = test.Objectives + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, test.ExpectedErrorsCount, test.ExpectedErrors...) + }) + } + }) +} + +func TestValidate_Spec_Objectives_RawMetric(t *testing.T) { + for name, test := range map[string]struct { + Code string + InputValue float64 + }{ + "timeSliceTarget too low": { + Code: validation.ErrorCodeGreaterThan, + InputValue: 0.0, + }, + "timeSliceTarget too high": { + Code: validation.ErrorCodeLessThanOrEqualTo, + InputValue: 1.1, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.BudgetingMethod = BudgetingMethodTimeslices.String() + slo.Spec.Objectives[0].TimeSliceTarget = ptr(test.InputValue) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].timeSliceTarget", + Code: test.Code, + }) + }) + } +} + +func TestValidate_Spec(t *testing.T) { + t.Run("exactly one metric type - both provided", func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives[0].RawMetric = &RawMetricSpec{MetricQuery: validMetricSpec(v1alpha.Prometheus)} + slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{ + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + GoodMetric: validMetricSpec(v1alpha.Prometheus), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Code: errCodeExactlyOneMetricType, + }) + }) + t.Run("exactly one metric type - both missing", func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives[0].RawMetric = nil + slo.Spec.Objectives[0].CountMetrics = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Code: errCodeExactlyOneMetricType, + }) + }) + t.Run("required time slice target for budgeting method", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.BudgetingMethod = BudgetingMethodTimeslices.String() + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].timeSliceTarget", + Code: joinErrorCodes(errCodeTimeSliceTarget, validation.ErrorCodeRequired), + }) + }) + t.Run("invalid time slice target for budgeting method", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.BudgetingMethod = BudgetingMethodOccurrences.String() + slo.Spec.Objectives[0].TimeSliceTarget = ptr(0.1) + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].timeSliceTarget", + Code: joinErrorCodes(errCodeTimeSliceTarget, validation.ErrorCodeForbidden), + }) + }) + t.Run("missing operator for raw metric", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].Operator = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].op", + Code: validation.ErrorCodeRequired, + }) + }) +} + +func TestValidate_Spec_RawMetrics(t *testing.T) { + t.Run("no metric spec provided", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery.Prometheus = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Message: "must have exactly one metric spec type, none were provided", + }) + }) + t.Run("exactly one metric spec type", func(t *testing.T) { + for name, metrics := range map[string][]*MetricSpec{ + "single objective": { + { + Prometheus: validMetricSpec(v1alpha.Prometheus).Prometheus, + Lightstep: validMetricSpec(v1alpha.Lightstep).Lightstep, + }, + }, + "two objectives": { + validMetricSpec(v1alpha.Prometheus), + validMetricSpec(v1alpha.Lightstep), + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives = nil + for i, m := range metrics { + slo.Spec.Objectives = append(slo.Spec.Objectives, Objective{ + ObjectiveBase: ObjectiveBase{Value: ptr(10. + float64(i)), Name: strconv.Itoa(i)}, + BudgetTarget: ptr(0.9), + RawMetric: &RawMetricSpec{MetricQuery: m}, + }) + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Code: errCodeExactlyOneMetricSpecType, + }) + }) + } + }) + t.Run("query required", func(t *testing.T) { + slo := validRawMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].RawMetric.MetricQuery = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 2, testutils.ExpectedError{ + Prop: "spec.objectives[0].rawMetric.query", + Code: validation.ErrorCodeRequired, + }) + }) +} + +func TestValidate_Spec_CountMetrics(t *testing.T) { + t.Run("no metric spec provided", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.Prometheus) + slo.Spec.Objectives[0].CountMetrics.TotalMetric.Prometheus = nil + slo.Spec.Objectives[0].CountMetrics.GoodMetric.Prometheus = nil + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Message: "must have exactly one metric spec type, none were provided", + }) + }) + t.Run("bad over total enabled", func(t *testing.T) { + for _, typ := range badOverTotalEnabledSources { + slo := validSLO() + slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{ + Incremental: ptr(true), + TotalMetric: validMetricSpec(typ), + BadMetric: validMetricSpec(typ), + } + err := validate(slo) + testutils.AssertNoError(t, slo, err) + } + }) + t.Run("bad provided with good", func(t *testing.T) { + slo := validCountMetricSLO(v1alpha.AzureMonitor) + slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{ + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.AzureMonitor), + GoodMetric: validMetricSpec(v1alpha.AzureMonitor), + BadMetric: validMetricSpec(v1alpha.AzureMonitor), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics", + Code: errCodeEitherBadOrGoodCountMetric, + }) + }) + t.Run("exactly one metric spec type", func(t *testing.T) { + for name, metrics := range map[string][]*CountMetricsSpec{ + "single objective - total": { + { + Incremental: ptr(true), + TotalMetric: &MetricSpec{ + Prometheus: validMetricSpec(v1alpha.Prometheus).Prometheus, + Dynatrace: validMetricSpec(v1alpha.Dynatrace).Dynatrace, + }, + GoodMetric: validMetricSpec(v1alpha.Prometheus), + }, + }, + "single objective - good": { + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + GoodMetric: &MetricSpec{ + Prometheus: validMetricSpec(v1alpha.Prometheus).Prometheus, + Dynatrace: validMetricSpec(v1alpha.Dynatrace).Dynatrace, + }, + }, + }, + "single objective - bad": { + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.CloudWatch), + BadMetric: &MetricSpec{ + CloudWatch: validMetricSpec(v1alpha.CloudWatch).CloudWatch, + AzureMonitor: validMetricSpec(v1alpha.AzureMonitor).AzureMonitor, + }, + }, + }, + "single objective - good/total": { + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + GoodMetric: validMetricSpec(v1alpha.Dynatrace), + }, + }, + "single objective - bad/total": { + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + BadMetric: validMetricSpec(v1alpha.CloudWatch), + }, + }, + "two objectives - mix": { + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + GoodMetric: validMetricSpec(v1alpha.Prometheus), + }, + { + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + BadMetric: validMetricSpec(v1alpha.CloudWatch), + }, + }, + } { + t.Run(name, func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives = nil + for i, m := range metrics { + slo.Spec.Objectives = append(slo.Spec.Objectives, Objective{ + ObjectiveBase: ObjectiveBase{Value: ptr(10. + float64(i)), Name: strconv.Itoa(i)}, + BudgetTarget: ptr(0.9), + CountMetrics: m, + }) + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec", + Code: errCodeExactlyOneMetricSpecType, + }) + }) + } + }) + t.Run("bad over total disabled", func(t *testing.T) { + slo := validSLO() + slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{ + Incremental: ptr(true), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + BadMetric: validMetricSpec(v1alpha.Prometheus), + } + err := validate(slo) + testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{ + Prop: "spec.objectives[0].countMetrics.bad", + Code: joinErrorCodes(errCodeBadOverTotalDisabled, validation.ErrorCodeOneOf), + }) + }) +} + +func validRawMetricSLO(metricType v1alpha.DataSourceType) SLO { + s := validSLO() + s.Spec.Objectives[0].CountMetrics = nil + s.Spec.Objectives[0].RawMetric = &RawMetricSpec{MetricQuery: validMetricSpec(metricType)} + s.Spec.Objectives[0].TimeSliceTarget = nil + s.Spec.Objectives[0].Operator = ptr(v1alpha.GreaterThan.String()) + return s +} + +func validCountMetricSLO(metricType v1alpha.DataSourceType) SLO { + s := validSLO() + s.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{ + Incremental: ptr(false), + TotalMetric: validMetricSpec(metricType), + GoodMetric: validMetricSpec(metricType), + } + return s +} + +func validSLO() SLO { + return New( + Metadata{ + Name: "my-slo", + DisplayName: "My SLO", + Project: "default", + Labels: v1alpha.Labels{ + "team": []string{"green", "orange"}, + "region": []string{"eu-central-1"}, + }, + }, + Spec{ + Description: "Example slo", + AlertPolicies: []string{"my-policy-name"}, + Attachments: []Attachment{ + { + DisplayName: ptr("Grafana Dashboard"), + URL: "https://loki.my-org.dev/grafana/d/dnd48", + }, + }, + BudgetingMethod: BudgetingMethodOccurrences.String(), + Service: "prometheus", + Indicator: Indicator{ + MetricSource: MetricSourceSpec{ + Project: "default", + Name: "prometheus", + Kind: manifest.KindAgent, + }, + }, + Objectives: []Objective{ + { + ObjectiveBase: ObjectiveBase{ + DisplayName: "Good", + Value: ptr(120.), + Name: "good", + }, + BudgetTarget: ptr(0.9), + CountMetrics: &CountMetricsSpec{ + Incremental: ptr(false), + TotalMetric: validMetricSpec(v1alpha.Prometheus), + GoodMetric: validMetricSpec(v1alpha.Prometheus), + }, + }, + }, + TimeWindows: []TimeWindow{ + { + Unit: "Day", + Count: 1, + IsRolling: true, + }, + }, + }, + ) +} + +// Ensure that validateExactlyOneMetricSpecType function handles all possible data source types. +func TestValidateExactlyOneMetricSpecType(t *testing.T) { + for _, s1 := range v1alpha.DataSourceTypeValues() { + for _, s2 := range v1alpha.DataSourceTypeValues() { + err := validateExactlyOneMetricSpecType(validMetricSpec(s1), validMetricSpec(s2)) + if s1 == s2 { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + } + } +} + +func validMetricSpec(typ v1alpha.DataSourceType) *MetricSpec { + ms := validMetricSpecs[typ] + var clone MetricSpec + data, _ := json.Marshal(ms) + _ = json.Unmarshal(data, &clone) + return &clone +} + +var validMetricSpecs = map[v1alpha.DataSourceType]MetricSpec{ + v1alpha.Prometheus: {Prometheus: &PrometheusMetric{ + PromQL: ptr(`sum(rate(prometheus_http_req`), + }}, + v1alpha.Datadog: {Datadog: &DatadogMetric{ + Query: ptr(`avg:trace.http.request.duration{*}`), + }}, + v1alpha.NewRelic: {NewRelic: &NewRelicMetric{ + NRQL: ptr(`SELECT average(duration*1000) FROM Transaction WHERE app Name='production' TIMESERIES`), + }}, + v1alpha.AppDynamics: {AppDynamics: &AppDynamicsMetric{ + ApplicationName: ptr("my-app"), + MetricPath: ptr(`End User Experience|App|Slow Requests`), + }}, + v1alpha.Splunk: {Splunk: &SplunkMetric{ + Query: ptr(` +search index=svc-events source=udp:5072 sourcetype=syslog status<400 | +bucket _time span=1m | +stats avg(response_time) as n9value by _time | +rename _time as n9time | +fields n9time n9value`), + }}, + v1alpha.Lightstep: {Lightstep: &LightstepMetric{ + TypeOfData: ptr(LightstepMetricDataType), + UQL: ptr(`metric cpu.utilization | rate | group_by [], mean`), + }}, + v1alpha.SplunkObservability: {SplunkObservability: &SplunkObservabilityMetric{ + Program: ptr(`data('demo.trans.count', filter=filter('demo_datacenter', 'Tokyo'), rollup='rate').mean().publish()`), + }}, + v1alpha.Dynatrace: {Dynatrace: &DynatraceMetric{ + MetricSelector: ptr(` +builtin:synthetic.http.duration.geo +:filter(and( + in("dt.entity.http_check",entitySelector("type(http_check),entityName(~"API Sample~")")), + in("dt.entity.synthetic_location",entitySelector("type(synthetic_location),entityName(~"N. California~")")))) +:splitBy("dt.entity.http_check","dt.entity.synthetic_location") +:avg:auto:sort(value(avg,descending)) +:limit(20)`), + }}, + v1alpha.ThousandEyes: {ThousandEyes: &ThousandEyesMetric{ + TestID: ptr(int64(2024796)), + TestType: ptr("net-latency"), + }}, + v1alpha.Graphite: {Graphite: &GraphiteMetric{ + MetricPath: ptr("stats.response.200"), + }}, + v1alpha.BigQuery: {BigQuery: &BigQueryMetric{ + ProjectID: "svc-256112", + Location: "EU", + Query: ` +SELECT http_code AS n9value, created AS n9date +FROM 'bdwtest-256112.metrics.http_response' +WHERE http_code = 200 AND created BETWEEN DATETIME(@n9date_from) AND DATETIME(@n9date_to)`, + }}, + v1alpha.Elasticsearch: {Elasticsearch: &ElasticsearchMetric{ + Index: ptr("apm-7.13.3-transaction"), + Query: ptr(` +{ + "query": { + "bool": { + "must": [ + { + "match": { + "service.name": "weloveourpets_xyz" + } + }, + { + "match": { + "transaction.result": "HTTP 2xx" + } + } + ], + "filter": [ + { + "range": { + "@timestamp": { + "gte": "{{.BeginTime}}", + "lte": "{{.EndTime}}" + } + } + } + ] + } + }, + "size": 0, + "aggs": { + "resolution": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "{{.Resolution}}", + "min_doc_count": 0, + "extended_bounds": { + "min": "{{.BeginTime}}", + "max": "{{.EndTime}}" + } + }, + "aggs": { + "n9-val": { + "avg": { + "field": "transaction.duration.us" + } + } + } + } + } +} +`), + }}, + v1alpha.OpenTSDB: {OpenTSDB: &OpenTSDBMetric{ + Query: ptr(`m=avg:{{.N9RESOLUTION}}-avg:main_kafka_prometheus_go_memstats_alloc_bytes`), + }}, + v1alpha.GrafanaLoki: {GrafanaLoki: &GrafanaLokiMetric{ + Logql: ptr(` +sum(sum_over_time({topic="error-budgets-out", consumergroup="alerts", cluster="main"} |= +"kafka_consumergroup_lag" | +logfmt | +kafka_consumergroup_lag!="" | +line_format "{{.kafka_consumergroup_lag}}" | +unwrap kafka_consumergroup_lag [1m]))`), + }}, + v1alpha.CloudWatch: {CloudWatch: &CloudWatchMetric{ + Region: ptr("eu-central-1"), + Namespace: ptr("AWS/Prometheus"), + MetricName: ptr("CPUUtilization"), + Stat: ptr("Average"), + Dimensions: []CloudWatchMetricDimension{ + { + Name: ptr("DBInstanceIdentifier"), + Value: ptr("my-db-instance"), + }, + }, + AccountID: ptr("123456789012"), + }}, + v1alpha.Pingdom: {Pingdom: &PingdomMetric{ + CheckID: ptr("8745322"), + CheckType: ptr("uptime"), + Status: ptr("up"), + }}, + v1alpha.AmazonPrometheus: {AmazonPrometheus: &AmazonPrometheusMetric{ + PromQL: ptr("sum(rate(prometheus_http_requests_total[1h]))"), + }}, + v1alpha.Redshift: {Redshift: &RedshiftMetric{ + Region: ptr("eu-central-1"), + ClusterID: ptr("my-redshift-cluster"), + DatabaseName: ptr("my-database"), + Query: ptr("SELECT value as n9value, timestamp as n9date FROM sinusoid" + + " WHERE timestamp BETWEEN :n9date_from AND :n9date_to"), + }}, + v1alpha.SumoLogic: {SumoLogic: &SumoLogicMetric{ + Type: ptr("metrics"), + Query: ptr("kube_node_status_condition | min"), + Quantization: ptr("1m"), + Rollup: ptr("Min"), + }}, + v1alpha.Instana: {Instana: &InstanaMetric{ + MetricType: instanaMetricTypeInfrastructure, + Infrastructure: &InstanaInfrastructureMetricType{ + MetricID: "availableReplicas", + PluginID: "kubernetesDeployment", + MetricRetrievalMethod: "query", + Query: ptr("entity.kubernetes.namespace:kube-system AND entity.kubernetes.deployment.name:aws-load-balancer-controller"), //nolint:lll + }, + }}, + v1alpha.InfluxDB: {InfluxDB: &InfluxDBMetric{ + Query: ptr(` +from(bucket: "integrations") + |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop)) + |> aggregateWindow(every: 15s, fn: mean, createEmpty: false) + |> filter(fn: (r) => r["_measurement"] == "internal_write") + |> filter(fn: (r) => r["_field"] == "write_time_ns") +`), + }}, + v1alpha.GCM: {GCM: &GCMMetric{ + Query: ` +fetch consumed_api + | metric 'serviceruntime.googleapis.com/api/request_count' + | filter + (resource.service == 'monitoring.googleapis.com') + && (metric.response_code == '200') + | align rate(1m) + | every 1m + | group_by [resource.service], + [value_request_count_aggregate: aggregate(value.request_count)] +`, + ProjectID: "svc-256112", + }}, + v1alpha.AzureMonitor: {AzureMonitor: &AzureMonitorMetric{ + ResourceID: "/subscriptions/9c26f90e/resourceGroups/azure-monitor-test-sources/providers/Microsoft.Web/sites/app", + MetricName: "HttpResponseTime", + Aggregation: "Avg", + }}, + v1alpha.Generic: {Generic: &GenericMetric{ + Query: ptr("anything is valid"), + }}, + v1alpha.Honeycomb: {Honeycomb: &HoneycombMetric{ + Dataset: "sequence-of-numbers", + Calculation: "SUM", + Attribute: "http.status_code", + Filter: HoneycombFilter{ + Operator: "AND", + Conditions: []HoneycombFilterCondition{ + { + Attribute: "http.status_code", + Operator: "=", + Value: "200", + }, + }, + }, + }}, +} + +func ptr[T any](v T) *T { return &v } + +func joinErrorCodes(codes ...string) string { + return strings.Join(codes, validation.ErrorCodeSeparator) +} diff --git a/manifest/v1alpha/twindow/twindow.go b/manifest/v1alpha/twindow/twindow.go index 48fb1035..199e2a3e 100644 --- a/manifest/v1alpha/twindow/twindow.go +++ b/manifest/v1alpha/twindow/twindow.go @@ -6,6 +6,8 @@ import ( "fmt" "strconv" "time" + + "github.com/nobl9/nobl9-go/validation" ) const ( @@ -385,6 +387,10 @@ var ( quarter: Quarter, year: Year, } + + timeUnitsList = []string{"Second", "Minute", "Hour", "Day", "Week", "Month", "Quarter", "Year"} + rollingWindowTimeUnitsList = []string{"Minute", "Hour", "Day"} + calendarWindowTimeUnitsList = []string{"Day", "Week", "Month", "Quarter", "Year"} ) // containsTimeUnitEnum checks if time unit is contained in a provided enum string map @@ -412,14 +418,12 @@ func IsTimeUnit(timeUnit string) bool { return ok } -func IsCalendarAlignedTimeUnit(timeUnit string) bool { - _, ok := calendarWindowTimeUnits[timeUnit] - return ok +func ValidateCalendarAlignedTimeUnit(timeUnit string) error { + return validation.OneOf[string](calendarWindowTimeUnitsList...).Validate(timeUnit) } -func IsRollingWindowTimeUnit(timeUnit string) bool { - _, ok := rollingWindowTimeUnits[timeUnit] - return ok +func ValidateRollingWindowTimeUnit(timeUnit string) error { + return validation.OneOf[string](rollingWindowTimeUnitsList...).Validate(timeUnit) } func (tu TimeUnitEnum) String() string { @@ -452,3 +456,7 @@ func ParseStartDate(startDateStr string) (time.Time, error) { } return startDate, nil } + +func ValidationRuleTimeUnit() validation.SingleRule[string] { + return validation.OneOf[string](timeUnitsList...) +} diff --git a/manifest/v1alpha/validator.go b/manifest/v1alpha/validator.go index 8ae38f7e..7053770b 100644 --- a/manifest/v1alpha/validator.go +++ b/manifest/v1alpha/validator.go @@ -12,12 +12,7 @@ import ( "time" "unicode/utf8" - "github.com/aws/aws-sdk-go/service/cloudwatch" v "github.com/go-playground/validator/v10" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" - "golang.org/x/text/cases" - "golang.org/x/text/language" "github.com/nobl9/nobl9-go/manifest" "github.com/nobl9/nobl9-go/manifest/v1alpha/twindow" @@ -45,23 +40,8 @@ const ( S3BucketNameRegex string = `^[a-z0-9][a-z0-9\-.]{1,61}[a-z0-9]$` GCSNonDomainNameBucketNameRegex string = `^[a-z0-9][a-z0-9-_]{1,61}[a-z0-9]$` GCSNonDomainNameBucketMaxLength int = 63 - CloudWatchNamespaceRegex string = `^[0-9A-Za-z.\-_/#:]{1,255}$` HeaderNameRegex string = `^([a-zA-Z0-9]+[_-]?)+$` - AzureResourceIDRegex string = `^\/subscriptions\/[a-zA-Z0-9-]+\/resourceGroups\/[a-zA-Z0-9-._()]+\/providers\/[a-zA-Z0-9-.()_]+\/[a-zA-Z0-9-_()]+\/[a-zA-Z0-9-_()]+$` //nolint:lll -) - -// Values used to validate time window size -const ( - minimumRollingTimeWindowSize = 5 * time.Minute - maximumRollingTimeWindowSizeDaysNumber = 31 - // 31 days converted to hours, because time.Hour is the biggest unit of time.Duration type. - maximumRollingTimeWindowSize = time.Duration(maximumRollingTimeWindowSizeDaysNumber) * - time.Duration(twindow.HoursInDay) * - time.Hour - maximumCalendarTimeWindowSizeDaysNumber = 366 - maximumCalendarTimeWindowSize = time.Duration(maximumCalendarTimeWindowSizeDaysNumber) * - time.Duration(twindow.HoursInDay) * - time.Hour + AzureResourceIDRegex string = `^\/subscriptions\/[a-zA-Z0-9-]+\/resourceGroups\/[a-zA-Z0-9-]+\/providers\/[a-zA-Z0-9-\._]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$` //nolint:lll ) const ( @@ -89,16 +69,6 @@ var ( ErrAlertMethodTypeChanged = fmt.Errorf("cannot change alert method type") ) -var ( - // cloudWatchStatRegex matches valid stat function according to this documentation: - // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Statistics-definitions.html - cloudWatchStatRegex = buildCloudWatchStatRegex() - validInstanaLatencyAggregations = map[string]struct{}{ - "sum": {}, "mean": {}, "min": {}, "max": {}, "p25": {}, - "p50": {}, "p75": {}, "p90": {}, "p95": {}, "p98": {}, "p99": {}, - } -) - type ErrInvalidPayload struct { Msg string } @@ -131,34 +101,25 @@ func NewValidator() *Validate { return name }) - val.RegisterStructValidation(timeWindowStructLevelValidation, TimeWindow{}) val.RegisterStructValidation(queryDelayDurationValidation, QueryDelayDuration{}) val.RegisterStructValidation(agentSpecStructLevelValidation, AgentSpec{}) - val.RegisterStructValidation(sloSpecStructLevelValidation, SLOSpec{}) - val.RegisterStructValidation(metricSpecStructLevelValidation, MetricSpec{}) val.RegisterStructValidation(alertPolicyConditionStructLevelValidation, AlertCondition{}) val.RegisterStructValidation(alertMethodSpecStructLevelValidation, AlertMethodSpec{}) val.RegisterStructValidation(directSpecStructLevelValidation, DirectSpec{}) val.RegisterStructValidation(webhookAlertMethodValidation, WebhookAlertMethod{}) val.RegisterStructValidation(emailAlertMethodValidation, EmailAlertMethod{}) - val.RegisterStructValidation(countMetricsSpecValidation, CountMetricsSpec{}) - val.RegisterStructValidation(cloudWatchMetricStructValidation, CloudWatchMetric{}) val.RegisterStructValidation(annotationSpecStructDatesValidation, AnnotationSpec{}) - val.RegisterStructValidation(sumoLogicStructValidation, SumoLogicMetric{}) val.RegisterStructValidation(alertSilencePeriodValidation, AlertSilencePeriod{}) val.RegisterStructValidation(alertSilenceAlertPolicyProjectValidation, AlertSilenceAlertPolicySource{}) val.RegisterStructValidation(agentSpecHistoricalRetrievalValidation, Agent{}) val.RegisterStructValidation(directSpecHistoricalRetrievalValidation, Direct{}) val.RegisterStructValidation(historicalDataRetrievalValidation, HistoricalDataRetrieval{}) val.RegisterStructValidation(historicalDataRetrievalDurationValidation, HistoricalRetrievalDuration{}) - val.RegisterStructValidation(validateAzureMonitorMetricsConfiguration, AzureMonitorMetric{}) - val.RegisterStructValidation(validateHoneycombFilter, HoneycombFilter{}) _ = val.RegisterValidation("timeUnit", isTimeUnitValid) _ = val.RegisterValidation("dateWithTime", isDateWithTimeValid) _ = val.RegisterValidation("minDateTime", isMinDateTime) _ = val.RegisterValidation("timeZone", isTimeZoneValid) - _ = val.RegisterValidation("budgetingMethod", isBudgetingMethod) _ = val.RegisterValidation("site", isSite) _ = val.RegisterValidation("notEmpty", isNotEmpty) _ = val.RegisterValidation("objectName", isValidObjectName) @@ -190,26 +151,15 @@ func NewValidator() *Validate { _ = val.RegisterValidation("roleARN", isValidRoleARN) _ = val.RegisterValidation("gcsBucketName", isValidGCSBucketName) _ = val.RegisterValidation("metricSourceKind", isValidMetricSourceKind) - _ = val.RegisterValidation("metricPathGraphite", isValidMetricPathGraphite) - _ = val.RegisterValidation("bigQueryRequiredColumns", isValidBigQueryQuery) - _ = val.RegisterValidation("splunkQueryValid", splunkQueryValid) _ = val.RegisterValidation("emails", hasValidEmails) - _ = val.RegisterValidation("uniqueDimensionNames", areDimensionNamesUnique) _ = val.RegisterValidation("notBlank", notBlank) - _ = val.RegisterValidation("supportedThousandEyesTestType", supportedThousandEyesTestType) _ = val.RegisterValidation("headerName", isValidHeaderName) _ = val.RegisterValidation("pingdomCheckTypeFieldValid", pingdomCheckTypeFieldValid) _ = val.RegisterValidation("pingdomStatusValid", pingdomStatusValid) - _ = val.RegisterValidation("redshiftRequiredColumns", isValidRedshiftQuery) _ = val.RegisterValidation("urlAllowedSchemes", hasValidURLScheme) - _ = val.RegisterValidation("influxDBRequiredPlaceholders", isValidInfluxDBQuery) - _ = val.RegisterValidation("noSinceOrUntil", isValidNewRelicQuery) - _ = val.RegisterValidation("elasticsearchBeginEndTimeRequired", isValidElasticsearchQuery) _ = val.RegisterValidation("json", isValidJSON) _ = val.RegisterValidation("newRelicApiKey", isValidNewRelicInsightsAPIKey) _ = val.RegisterValidation("azureResourceID", isValidAzureResourceID) - _ = val.RegisterValidation("supportedHoneycombCalculationType", supportedHoneycombCalculationType) - _ = val.RegisterValidation("supportedHoneycombFilterConditionOperator", supportedHoneycombFilterConditionOperator) return &Validate{ validate: val, @@ -278,33 +228,6 @@ func annotationSpecStructDatesValidation(sl v.StructLevel) { } } -func areDimensionNamesUnique(fl v.FieldLevel) bool { - usedNames := make(map[string]struct{}) - for i := 0; i < fl.Field().Len(); i++ { - if !fl.Field().CanInterface() { - return false - } - var name string - switch dimension := fl.Field().Index(i).Interface().(type) { - case CloudWatchMetricDimension: - if dimension.Name != nil { - name = *dimension.Name - } - case AzureMonitorMetricDimension: - if dimension.Name != nil { - name = *dimension.Name - } - default: - return false - } - if _, used := usedNames[name]; used { - return false - } - usedNames[name] = struct{}{} - } - return true -} - func isValidWebhookTemplate(fl v.FieldLevel) bool { return hasValidTemplateFields(fl, notificationTemplateAllowedFields) } @@ -385,1358 +308,185 @@ func hasValidEmails(fl v.FieldLevel) bool { return true } -func timeWindowStructLevelValidation(sl v.StructLevel) { - timeWindow := sl.Current().Interface().(TimeWindow) - - if !isTimeWindowTypeUnambiguous(timeWindow) { - sl.ReportError(timeWindow, "timeWindow", "TimeWindow", "ambiguousTimeWindowType", "") - } - - if !isTimeUnitValidForTimeWindowType(timeWindow, timeWindow.Unit) { - sl.ReportError(timeWindow, "timeWindow", "TimeWindow", "validWindowTypeForTimeUnitRequired", "") - } - windowSizeValidation(timeWindow, sl) -} - // isValidObjectName maintains convention for naming objects from // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names func isValidObjectName(fl v.FieldLevel) bool { return len(IsDNS1123Label(fl.Field().String())) == 0 } -// nolint: lll -func sloSpecStructLevelValidation(sl v.StructLevel) { - sloSpec := sl.Current().Interface().(SLOSpec) - - if !hasExactlyOneMetricType(sloSpec) { - sl.ReportError(sloSpec.Indicator.RawMetric, "indicator.rawMetric", "RawMetric", "exactlyOneMetricType", "") - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "exactlyOneMetricType", "") - } - - if !hasOnlyOneRawMetricDefinitionTypeOrNone(sloSpec) { - sl.ReportError( - sloSpec.Indicator.RawMetric, "indicator.rawMetric", "RawMetrics", "multipleRawMetricDefinitionTypes", "", - ) - sl.ReportError( - sloSpec.Objectives, "objectives", "Objectives", "multipleRawMetricDefinitionTypes", "", - ) - } - - if !isBadOverTotalEnabledForDataSource(sloSpec) { - sl.ReportError( - sloSpec.Indicator.MetricSource, - "indicator.metricSource", - "MetricSource", - "isBadOverTotalEnabledForDataSource", - "", - ) - } - - if !areAllMetricSpecsOfTheSameType(sloSpec) { - sl.ReportError(sloSpec.Indicator.RawMetric, "indicator.rawMetric", "RawMetrics", "allMetricsOfTheSameType", "") - } - - if !areRawMetricsSetForAllObjectivesOrNone(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "rawMetricsSetForAllObjectivesOrNone", "") - } - if !areCountMetricsSetForAllObjectivesOrNone(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "countMetricsSetForAllObjectivesOrNone", "") - } - if !isBadOverTotalEnabledForDataSource(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "badOverTotalEnabledForDataSource", "") - } - // if !doAllObjectivesHaveUniqueNames(sloSpec) { - // sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "valuesForEachObjectiveMustBeUniqueWithinOneSLO", "") - // } - // TODO: Replace doAllObjectivesHaveUniqueValues with doAllObjectivesHaveUniqueNames when dropping value uniqueness - if !doAllObjectivesHaveUniqueValues(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "valuesForEachObjectiveMustBeUniqueWithinOneSLO", "") - } - if !areTimeSliceTargetsRequiredAndSet(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "timeSliceTargetRequiredForTimeslices", "") - } - - if !isValidObjectiveOperatorForRawMetric(sloSpec) { - sl.ReportError(sloSpec.Objectives, "objectives", "Objectives", "validObjectiveOperatorForRawMetric", "") - } - - if sloSpec.Composite != nil { - if !isBurnRateSetForCompositeWithOccurrences(sloSpec) { - sl.ReportError( - sloSpec.Composite.BurnRateCondition, - "burnRateCondition", - "composite", - "compositeBurnRateRequiredForOccurrences", - "", - ) - } +func isTimeUnitValid(fl v.FieldLevel) bool { + return twindow.IsTimeUnit(fl.Field().String()) +} - if !isValidBudgetingMethodForCompositeWithBurnRate(sloSpec) { - sl.ReportError( - sloSpec.Composite.BurnRateCondition, - "burnRateCondition", - "composite", - "wrongBudgetingMethodForCompositeWithBurnRate", - "", - ) +func isTimeZoneValid(fl v.FieldLevel) bool { + if fl.Field().String() != "" { + _, err := time.LoadLocation(fl.Field().String()) + if err != nil { + return false } } - - sloSpecStructLevelAppDynamicsValidation(sl, sloSpec) - sloSpecStructLevelLightstepValidation(sl, sloSpec) - sloSpecStructLevelPingdomValidation(sl, sloSpec) - sloSpecStructLevelSumoLogicValidation(sl, sloSpec) - sloSpecStructLevelThousandEyesValidation(sl, sloSpec) - sloSpecStructLevelAzureMonitorValidation(sl, sloSpec) - - // AnomalyConfig will be moved into Anomaly Rules in PC-8502 - sloSpecStructLevelAnomalyConfigValidation(sl, sloSpec) -} - -func isBurnRateSetForCompositeWithOccurrences(spec SLOSpec) bool { - return !isBudgetingMethodOccurrences(spec) || spec.Composite.BurnRateCondition != nil -} - -func isValidBudgetingMethodForCompositeWithBurnRate(spec SLOSpec) bool { - return spec.Composite.BurnRateCondition == nil || isBudgetingMethodOccurrences(spec) -} - -func isBudgetingMethodOccurrences(sloSpec SLOSpec) bool { - return sloSpec.BudgetingMethod == BudgetingMethodOccurrences.String() + return true } -func sloSpecStructLevelAppDynamicsValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !haveCountMetricsTheSameAppDynamicsApplicationNames(sloSpec) { - sl.ReportError( - sloSpec.Objectives, - "objectives", - "Objectives", - "countMetricsHaveTheSameAppDynamicsApplicationNames", - "", - ) +func isDateWithTimeValid(fl v.FieldLevel) bool { + if fl.Field().String() != "" { + t, err := time.Parse(twindow.IsoDateTimeOnlyLayout, fl.Field().String()) + // Nanoseconds (thus milliseconds too) in time struct are forbidden to be set. + if err != nil || t.Nanosecond() != 0 { + return false + } } + return true } -func sloSpecStructLevelLightstepValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !haveCountMetricsTheSameLightstepStreamID(sloSpec) { - sl.ReportError( - sloSpec.Objectives, - "objectives", - "Objectives", - "countMetricsHaveTheSameLightstepStreamID", - "", - ) - } - - if !isValidLightstepTypeOfDataForRawMetric(sloSpec) { - if sloSpec.containsIndicatorRawMetric() { - sl.ReportError( - sloSpec.Indicator.RawMetric, - "indicator.rawMetric", - "RawMetric", - "validLightstepTypeOfDataForRawMetric", - "", - ) - } else { - sl.ReportError( - sloSpec.Objectives, - "objectives[].rawMetric.query", - "RawMetric", - "validLightstepTypeOfDataForRawMetric", - "", - ) +func isMinDateTime(fl v.FieldLevel) bool { + if fl.Field().String() != "" { + date, err := twindow.ParseStartDate(fl.Field().String()) + if err != nil { + return false } + minStartDate := twindow.GetMinStartDate() + return date.After(minStartDate) || date.Equal(minStartDate) } - - if !isValidLightstepTypeOfDataForCountMetrics(sloSpec) { - sl.ReportError( - sloSpec.Objectives, - "objectives", - "Objectives", - "validLightstepTypeOfDataForCountMetrics", - "", - ) - } - if !areLightstepCountMetricsNonIncremental(sloSpec) { - sl.ReportError( - sloSpec.Objectives, - "objectives", - "Objectives", - "lightstepCountMetricsAreNonIncremental", - "", - ) - } + return true } -func sloSpecStructLevelPingdomValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !havePingdomCountMetricsGoodTotalTheSameCheckID(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "pingdomCountMetricsGoodTotalHaveDifferentCheckID", - "", - ) - } +func agentSpecStructLevelValidation(sl v.StructLevel) { + sa := sl.Current().Interface().(AgentSpec) - if !havePingdomRawMetricCheckTypeUptime(sloSpec) { - if sloSpec.containsIndicatorRawMetric() { - sl.ReportError( - sloSpec.Indicator.RawMetric, - "indicator.rawMetric", - "RawMetric", - "validPingdomCheckTypeForRawMetric", - "", - ) - } else { - sl.ReportError( - sloSpec.Objectives, - "objectives[].rawMetric.query", - "RawMetric", - "validPingdomCheckTypeForRawMetric", - "", - ) - } + agentTypeValidation(sa, sl) + if sa.Prometheus != nil { + prometheusConfigValidation(sa.Prometheus, sl) } + agentQueryDelayValidation(sa, sl) + sourceOfValidation(sa.SourceOf, sl) - if !havePingdomMetricsTheSameCheckType(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "pingdomMetricsHaveDifferentCheckType", - "", - ) + if !isValidReleaseChannel(sa.ReleaseChannel) { + sl.ReportError(sa, "ReleaseChannel", "ReleaseChannel", "unknownReleaseChannel", "") } +} - if !havePingdomCorrectStatusForCountMetricsCheckType(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "pingdomCountMetricsIncorrectStatusForCheckType", - "", - ) +func agentQueryDelayValidation(sa AgentSpec, sl v.StructLevel) { + at, err := sa.GetType() + if err != nil { + sl.ReportError(sa, "", "", "unknownAgentType", "") + return } - - if !havePingdomCorrectStatusForRawMetrics(sloSpec) { - if sloSpec.containsIndicatorRawMetric() { + if sa.QueryDelay != nil { + agentDefault := GetQueryDelayDefaults()[at.String()] + if sa.QueryDelay.QueryDelayDuration.LesserThan(agentDefault) { sl.ReportError( - sloSpec.Indicator.RawMetric, - "indicator.rawMetric", - "RawMetric", - "pingdomCorrectCheckTypeForRawMetrics", + sa, + "QueryDelayDuration", + "QueryDelayDuration", + "queryDelayDurationLesserThanDefaultDataSourceQueryDelay", "", ) - } else { + } + if sa.QueryDelay.QueryDelayDuration.BiggerThanMax() { sl.ReportError( - sloSpec.Objectives, - "objectives[].rawMetric.query", - "RawMetric", - "pingdomCorrectCheckTypeForRawMetrics", + sa, + "QueryDelayDuration", + "QueryDelayDuration", + "queryDelayDurationBiggerThanMaximumAllowed", "", ) } } } -func sloSpecStructLevelSumoLogicValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !areSumoLogicQuantizationValuesEqual(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "sumoLogicCountMetricsEqualQuantization", - "", - ) - } +func isValidURL(fl v.FieldLevel) bool { + return validateURL(fl.Field().String()) +} - if !areSumoLogicTimesliceValuesEqual(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "sumoLogicCountMetricsEqualTimeslice", - "", - ) - } +func isEmptyOrValidURL(fl v.FieldLevel) bool { + value := fl.Field().String() + return value == "" || value == HiddenValue || validateURL(value) } -func sloSpecStructLevelThousandEyesValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !doesNotHaveCountMetricsThousandEyes(sloSpec) { - sl.ReportError(sloSpec.Indicator.RawMetric, "indicator.rawMetric", "RawMetrics", "onlyRawMetricsThousandEyes", "") - } +func isValidURLDynatrace(fl v.FieldLevel) bool { + return validateURLDynatrace(fl.Field().String()) } -func sloSpecStructLevelAzureMonitorValidation(sl v.StructLevel, sloSpec SLOSpec) { - if !haveAzureMonitorCountMetricSpecTheSameResourceIDAndMetricNamespace(sloSpec) { - sl.ReportError( - sloSpec.CountMetrics, - "objectives", - "Objectives", - "azureMonitorCountMetricsEqualResourceIDAndMetricNamespace", - "", - ) +func isValidURLDiscord(fl v.FieldLevel) bool { + key := fl.Field().String() + if strings.HasSuffix(strings.ToLower(key), "/slack") || strings.HasSuffix(strings.ToLower(key), "/github") { + return false } + return isEmptyOrValidURL(fl) } -func sloSpecStructLevelAnomalyConfigValidation(sl v.StructLevel, sloSpec SLOSpec) { - sloProject := sl.Parent().Interface().(SLO).Metadata.Project - - if sloSpec.AnomalyConfig != nil { - if sloSpec.AnomalyConfig.NoData == nil { - return - } +func isValidOpsgenieAPIKey(fl v.FieldLevel) bool { + key := fl.Field().String() + return key == "" || + key == HiddenValue || + (strings.HasPrefix(key, "Basic") || + strings.HasPrefix(key, "GenieKey")) +} - if len(sloSpec.AnomalyConfig.NoData.AlertMethods) == 0 { - sl.ReportError( - sloSpec.AnomalyConfig.NoData, - "anomalyConfig.noData.alertMethods", - "AlertMethods", - "expectedNotEmptyAlertMethodList", - "", - ) - } +func isValidPagerDutyIntegrationKey(fl v.FieldLevel) bool { + key := fl.Field().String() + return key == "" || key == HiddenValue || len(key) == 32 +} - nameToProjectMap := make(map[string]string, len(sloSpec.AnomalyConfig.NoData.AlertMethods)) - for _, alertMethod := range sloSpec.AnomalyConfig.NoData.AlertMethods { - project := alertMethod.Project - if project == "" { - project = sloProject - } - if nameToProjectMap[alertMethod.Name] == project { - sl.ReportError( - sloSpec.AnomalyConfig.NoData.AlertMethods, - "anomalyConfig.noData.alertMethods", - "AlertMethods", - fmt.Sprintf("duplicateAlertMethhod(name=%s,project=%s)", alertMethod.Name, project), - "", - ) - } - nameToProjectMap[alertMethod.Name] = project - } - } +func validateURL(validateURL string) bool { + validURLRegex := regexp.MustCompile(URLRegex) + return validURLRegex.MatchString(validateURL) } -func isBadOverTotalEnabledForDataSource(spec SLOSpec) bool { - if spec.HasCountMetrics() { - for _, objectives := range spec.Objectives { - if objectives.CountMetrics != nil { - if objectives.CountMetrics.BadMetric != nil && - !isBadOverTotalEnabledForDataSourceType(objectives) { - return false - } - } +func validateURLDynatrace(validateURL string) bool { + u, err := url.Parse(validateURL) + if err != nil { + return false + } + // For SaaS type enforce https and land lack of path. + // Join instead of Clean (to avoid getting . for empty path), Trim to get rid of root. + pathURL := strings.Trim(path.Join(u.Path), "/") + if strings.HasSuffix(u.Host, "live.dynatrace.com") { + if u.Scheme != "https" || pathURL != "" { + return false } } return true } -func hasOnlyOneRawMetricDefinitionTypeOrNone(spec SLOSpec) bool { - indicatorHasRawMetric := spec.containsIndicatorRawMetric() - if indicatorHasRawMetric { - for _, objective := range spec.Objectives { - if !objective.HasRawMetricQuery() { - continue - } - if !reflect.DeepEqual(objective.RawMetric.MetricQuery, spec.Indicator.RawMetric) { - return false - } - } - } - return true +func areLabelsValid(fl v.FieldLevel) bool { + lbl := fl.Field().Interface().(Labels) + return lbl.Validate() == nil } -func areRawMetricsSetForAllObjectivesOrNone(spec SLOSpec) bool { - if spec.containsIndicatorRawMetric() { +func isHTTPS(fl v.FieldLevel) bool { + if !isNotEmpty(fl) || fl.Field().String() == HiddenValue { return true } - count := spec.ObjectivesRawMetricsCount() - return count == 0 || count == len(spec.Objectives) + val, err := url.Parse(fl.Field().String()) + if err != nil || val.Scheme != "https" { + return false + } + return true } -func doAllObjectivesHaveUniqueValues(spec SLOSpec) bool { - values := make(map[float64]struct{}) - for _, objective := range spec.Objectives { - values[objective.Value] = struct{}{} +func prometheusConfigValidation(pc *PrometheusAgentConfig, sl v.StructLevel) { + switch { + case pc.URL == nil: + sl.ReportError(pc.URL, "url", "URL", "integrationUrlRequired", "") + case !validateURL(*pc.URL): + sl.ReportError(pc.URL, "url", "URL", "integrationUrlNotValid", "") } - return len(values) == len(spec.Objectives) } -func areLightstepCountMetricsNonIncremental(sloSpec SLOSpec) bool { - if !sloSpec.HasCountMetrics() { - return true +// nolint added because of detected duplicate with metricTypeValidation variant of this function +func agentTypeValidation(sa AgentSpec, sl v.StructLevel) { + const expectedNumberOfAgentTypes = 1 + var agentTypesCount int + if sa.Prometheus != nil { + agentTypesCount++ } - for _, objective := range sloSpec.Objectives { - if objective.CountMetrics == nil { - continue - } - if (objective.CountMetrics.GoodMetric == nil || objective.CountMetrics.GoodMetric.Lightstep == nil) && - (objective.CountMetrics.TotalMetric == nil || objective.CountMetrics.TotalMetric.Lightstep == nil) { - continue - } - if objective.CountMetrics.Incremental == nil || !*objective.CountMetrics.Incremental { - continue - } - return false + if sa.Datadog != nil { + agentTypesCount++ } - return true -} - -func isValidLightstepTypeOfDataForCountMetrics(sloSpec SLOSpec) bool { - if !sloSpec.HasCountMetrics() { - return true - } - goodCounts, totalCounts := sloSpec.GoodTotalCountMetrics() - for _, goodCount := range goodCounts { - if goodCount.Lightstep == nil { - continue - } - if goodCount.Lightstep.TypeOfData == nil { - return false - } - if *goodCount.Lightstep.TypeOfData != LightstepGoodCountDataType && - *goodCount.Lightstep.TypeOfData != LightstepMetricDataType { - return false - } - } - for _, totalCount := range totalCounts { - if totalCount.Lightstep == nil { - continue - } - if totalCount.Lightstep.TypeOfData == nil { - return false - } - if *totalCount.Lightstep.TypeOfData != LightstepTotalCountDataType && - *totalCount.Lightstep.TypeOfData != LightstepMetricDataType { - return false - } - } - return true -} - -func isValidLightstepTypeOfDataForRawMetric(sloSpec SLOSpec) bool { - if !sloSpec.HasRawMetric() { - return true - } - metrics := sloSpec.RawMetrics() - for _, metric := range metrics { - if metric.Lightstep == nil { - continue - } - if metric.Lightstep.TypeOfData == nil { - return false - } - if *metric.Lightstep.TypeOfData != LightstepErrorRateDataType && - *metric.Lightstep.TypeOfData != LightstepLatencyDataType && - *metric.Lightstep.TypeOfData != LightstepMetricDataType { - return false - } - } - return true -} - -func areTimeSliceTargetsRequiredAndSet(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - if sloSpec.BudgetingMethod == BudgetingMethodTimeslices.String() && - !(objective.TimeSliceTarget != nil && isValidTimeSliceTargetValue(*objective.TimeSliceTarget)) || - sloSpec.BudgetingMethod == BudgetingMethodOccurrences.String() && objective.TimeSliceTarget != nil { - return false - } - } - return true -} - -func metricSpecStructLevelValidation(sl v.StructLevel) { - metricSpec := sl.Current().Interface().(MetricSpec) - - metricTypeValidation(metricSpec, sl) - if metricSpec.Lightstep != nil { - lightstepMetricValidation(metricSpec.Lightstep, sl) - } - if metricSpec.Instana != nil { - instanaMetricValidation(metricSpec.Instana, sl) - } -} - -func lightstepMetricValidation(metric *LightstepMetric, sl v.StructLevel) { - if metric.TypeOfData == nil { - return - } - - switch *metric.TypeOfData { - case LightstepLatencyDataType: - lightstepLatencyMetricValidation(metric, sl) - case LightstepMetricDataType: - lightstepUQLMetricValidation(metric, sl) - case LightstepGoodCountDataType, LightstepTotalCountDataType: - lightstepGoodTotalMetricValidation(metric, sl) - case LightstepErrorRateDataType: - lightstepErrorRateMetricValidation(metric, sl) - } -} - -func lightstepLatencyMetricValidation(metric *LightstepMetric, sl v.StructLevel) { - if metric.Percentile == nil { - sl.ReportError(metric.Percentile, "percentile", "Percentile", "percentileRequired", "") - } else if *metric.Percentile <= 0 || *metric.Percentile > 99.99 { - sl.ReportError(metric.Percentile, "percentile", "Percentile", "invalidPercentile", "") - } - if metric.StreamID == nil { - sl.ReportError(metric.StreamID, "streamID", "StreamID", "streamIDRequired", "") - } - if metric.UQL != nil { - sl.ReportError(metric.UQL, "uql", "UQL", "uqlNotAllowed", "") - } -} - -func lightstepUQLMetricValidation(metric *LightstepMetric, sl v.StructLevel) { - if metric.UQL == nil { - sl.ReportError(metric.UQL, "uql", "UQL", "uqlRequired", "") - } else { - if len(*metric.UQL) == 0 { - sl.ReportError(metric.UQL, "uql", "UQL", "uqlRequired", "") - } - // Only UQL `metric` and `spans` inputs type are supported. https://docs.lightstep.com/docs/uql-reference - r := regexp.MustCompile(`((constant|spans_sample|assemble)\s+[a-z\d.])`) - if r.MatchString(*metric.UQL) { - sl.ReportError(metric.UQL, "uql", "UQL", "onlyMetricAndSpansUQLQueriesAllowed", "") - } - } - - if metric.Percentile != nil { - sl.ReportError(metric.Percentile, "percentile", "Percentile", "percentileNotAllowed", "") - } - - if metric.StreamID != nil { - sl.ReportError(metric.StreamID, "streamID", "StreamID", "streamIDNotAllowed", "") - } -} - -func lightstepGoodTotalMetricValidation(metric *LightstepMetric, sl v.StructLevel) { - if metric.StreamID == nil { - sl.ReportError(metric.StreamID, "streamID", "StreamID", "streamIDRequired", "") - } - if metric.UQL != nil { - sl.ReportError(metric.UQL, "uql", "UQL", "uqlNotAllowed", "") - } - if metric.Percentile != nil { - sl.ReportError(metric.Percentile, "percentile", "Percentile", "percentileNotAllowed", "") - } -} - -func lightstepErrorRateMetricValidation(metric *LightstepMetric, sl v.StructLevel) { - if metric.StreamID == nil { - sl.ReportError(metric.StreamID, "streamID", "StreamID", "streamIDRequired", "") - } - if metric.Percentile != nil { - sl.ReportError(metric.Percentile, "percentile", "Percentile", "percentileNotAllowed", "") - } - if metric.UQL != nil { - sl.ReportError(metric.UQL, "uql", "UQL", "uqlNotAllowed", "") - } -} - -const ( - instanaMetricTypeInfrastructure = "infrastructure" - instanaMetricTypeApplication = "application" - - instanaMetricRetrievalMethodQuery = "query" - instanaMetricRetrievalMethodSnapshot = "snapshot" -) - -func instanaMetricValidation(metric *InstanaMetric, sl v.StructLevel) { - if metric.Infrastructure != nil && metric.Application != nil { - if metric.MetricType == instanaMetricTypeInfrastructure { - sl.ReportError(metric.Infrastructure, instanaMetricTypeInfrastructure, - cases.Title(language.Und). - String(instanaMetricTypeInfrastructure), "infrastructureObjectOnlyRequired", "") - } - if metric.MetricType == instanaMetricTypeApplication { - sl.ReportError(metric.Application, instanaMetricTypeApplication, - cases.Title(language.Und). - String(instanaMetricTypeApplication), "applicationObjectOnlyRequired", "") - } - return - } - - switch metric.MetricType { - case instanaMetricTypeInfrastructure: - if metric.Infrastructure == nil { - sl.ReportError(metric.Infrastructure, instanaMetricTypeInfrastructure, - cases.Title(language.Und). - String(instanaMetricTypeInfrastructure), "infrastructureRequired", "") - } else { - instanaMetricTypeInfrastructureValidation(metric.Infrastructure, sl) - } - case instanaMetricTypeApplication: - if metric.Application == nil { - sl.ReportError(metric.Application, instanaMetricTypeApplication, - cases.Title(language.Und). - String(instanaMetricTypeApplication), "applicationRequired", "") - } else { - instanaMetricTypeApplicationValidation(metric.Application, sl) - } - } -} - -func instanaMetricTypeInfrastructureValidation(infrastructure *InstanaInfrastructureMetricType, sl v.StructLevel) { - if infrastructure.Query != nil && infrastructure.SnapshotID != nil { - switch infrastructure.MetricRetrievalMethod { - case instanaMetricRetrievalMethodQuery: - sl.ReportError(infrastructure.Query, instanaMetricRetrievalMethodQuery, - cases.Title(language.Und). - String(instanaMetricRetrievalMethodQuery), "queryOnlyRequired", "") - case instanaMetricRetrievalMethodSnapshot: - sl.ReportError(infrastructure.Query, instanaMetricRetrievalMethodQuery, - cases.Title(language.Und). - String(instanaMetricRetrievalMethodQuery), "snapshotIDOnlyRequired", "") - } - return - } - - switch infrastructure.MetricRetrievalMethod { - case instanaMetricRetrievalMethodQuery: - if infrastructure.Query == nil { - sl.ReportError(infrastructure.Query, instanaMetricRetrievalMethodQuery, - cases.Title(language.Und). - String(instanaMetricRetrievalMethodQuery), "queryRequired", "") - } - case instanaMetricRetrievalMethodSnapshot: - if infrastructure.SnapshotID == nil { - sl.ReportError(infrastructure.SnapshotID, instanaMetricRetrievalMethodSnapshot+"Id", - cases.Title(language.Und). - String(instanaMetricRetrievalMethodSnapshot+"Id"), "snapshotIdRequired", "") - } - } -} - -func instanaMetricTypeApplicationValidation(application *InstanaApplicationMetricType, sl v.StructLevel) { - const aggregation = "aggregation" - switch application.MetricID { - case "calls", "erroneousCalls": - if application.Aggregation == "sum" { - return - } - case "errors": - if application.Aggregation == "mean" { - return - } - case "latency": - if _, isValid := validInstanaLatencyAggregations[application.Aggregation]; isValid { - return - } - } - sl.ReportError(application.Aggregation, aggregation, - cases.Title(language.Und).String(aggregation), "wrongAggregationValueForMetricID", "") -} - -func hasExactlyOneMetricType(sloSpec SLOSpec) bool { - return sloSpec.HasRawMetric() != sloSpec.HasCountMetrics() -} - -func doesNotHaveCountMetricsThousandEyes(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - if objective.CountMetrics == nil { - continue - } - if (objective.CountMetrics.TotalMetric != nil && objective.CountMetrics.TotalMetric.ThousandEyes != nil) || - (objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.GoodMetric.ThousandEyes != nil) { - return false - } - } - return true -} - -//nolint:gocognit,gocyclo -func areAllMetricSpecsOfTheSameType(sloSpec SLOSpec) bool { - var ( - metricCount int - prometheusCount int - datadogCount int - newRelicCount int - appDynamicsCount int - splunkCount int - lightstepCount int - splunkObservabilityCount int - dynatraceCount int - elasticsearchCount int - bigQueryCount int - thousandEyesCount int - graphiteCount int - openTSDBCount int - grafanaLokiCount int - cloudWatchCount int - pingdomCount int - amazonPrometheusCount int - redshiftCount int - sumoLogicCount int - instanaCount int - influxDBCount int - gcmCount int - azureMonitorCount int - genericCount int - honeycombCount int - ) - for _, metric := range sloSpec.AllMetricSpecs() { - if metric == nil { - continue - } - if metric.Prometheus != nil { - prometheusCount++ - } - if metric.Datadog != nil { - datadogCount++ - } - if metric.NewRelic != nil { - newRelicCount++ - } - if metric.AppDynamics != nil { - appDynamicsCount++ - } - if metric.Splunk != nil { - splunkCount++ - } - if metric.Lightstep != nil { - lightstepCount++ - } - if metric.SplunkObservability != nil { - splunkObservabilityCount++ - } - if metric.ThousandEyes != nil { - thousandEyesCount++ - } - if metric.Dynatrace != nil { - dynatraceCount++ - } - if metric.Elasticsearch != nil { - elasticsearchCount++ - } - if metric.Graphite != nil { - graphiteCount++ - } - if metric.BigQuery != nil { - bigQueryCount++ - } - if metric.OpenTSDB != nil { - openTSDBCount++ - } - if metric.GrafanaLoki != nil { - grafanaLokiCount++ - } - if metric.CloudWatch != nil { - cloudWatchCount++ - } - if metric.Pingdom != nil { - pingdomCount++ - } - if metric.AmazonPrometheus != nil { - amazonPrometheusCount++ - } - if metric.Redshift != nil { - redshiftCount++ - } - if metric.SumoLogic != nil { - sumoLogicCount++ - } - if metric.Instana != nil { - instanaCount++ - } - if metric.InfluxDB != nil { - influxDBCount++ - } - if metric.GCM != nil { - gcmCount++ - } - if metric.AzureMonitor != nil { - azureMonitorCount++ - } - if metric.Generic != nil { - genericCount++ - } - if metric.Honeycomb != nil { - honeycombCount++ - } - } - if prometheusCount > 0 { - metricCount++ - } - if datadogCount > 0 { - metricCount++ - } - if newRelicCount > 0 { - metricCount++ - } - if appDynamicsCount > 0 { - metricCount++ - } - if splunkCount > 0 { - metricCount++ - } - if lightstepCount > 0 { - metricCount++ - } - if splunkObservabilityCount > 0 { - metricCount++ - } - if thousandEyesCount > 0 { - metricCount++ - } - if dynatraceCount > 0 { - metricCount++ - } - if elasticsearchCount > 0 { - metricCount++ - } - if graphiteCount > 0 { - metricCount++ - } - if bigQueryCount > 0 { - metricCount++ - } - if openTSDBCount > 0 { - metricCount++ - } - if grafanaLokiCount > 0 { - metricCount++ - } - if cloudWatchCount > 0 { - metricCount++ - } - if pingdomCount > 0 { - metricCount++ - } - if amazonPrometheusCount > 0 { - metricCount++ - } - if redshiftCount > 0 { - metricCount++ - } - if instanaCount > 0 { - metricCount++ - } - if sumoLogicCount > 0 { - metricCount++ - } - if influxDBCount > 0 { - metricCount++ - } - if gcmCount > 0 { - metricCount++ - } - if azureMonitorCount > 0 { - metricCount++ - } - if genericCount > 0 { - metricCount++ - } - if honeycombCount > 0 { - metricCount++ - } - // exactly one exists - return metricCount == 1 -} - -func haveCountMetricsTheSameAppDynamicsApplicationNames(sloSpec SLOSpec) bool { - for _, metricSpec := range sloSpec.CountMetricPairs() { - if metricSpec == nil || metricSpec.GoodMetric.AppDynamics == nil || metricSpec.TotalMetric.AppDynamics == nil { - continue - } - if metricSpec.GoodMetric.AppDynamics.ApplicationName == nil || - metricSpec.TotalMetric.AppDynamics.ApplicationName == nil { - return false - } - if *metricSpec.GoodMetric.AppDynamics.ApplicationName != *metricSpec.TotalMetric.AppDynamics.ApplicationName { - return false - } - } - return true -} - -func haveCountMetricsTheSameLightstepStreamID(sloSpec SLOSpec) bool { - for _, metricSpec := range sloSpec.CountMetricPairs() { - if metricSpec == nil || metricSpec.GoodMetric.Lightstep == nil || metricSpec.TotalMetric.Lightstep == nil { - continue - } - if metricSpec.GoodMetric.Lightstep.StreamID == nil && metricSpec.TotalMetric.Lightstep.StreamID == nil { - continue - } - if (metricSpec.GoodMetric.Lightstep.StreamID == nil && metricSpec.TotalMetric.Lightstep.StreamID != nil) || - (metricSpec.GoodMetric.Lightstep.StreamID != nil && metricSpec.TotalMetric.Lightstep.StreamID == nil) { - return false - } - if *metricSpec.GoodMetric.Lightstep.StreamID != *metricSpec.TotalMetric.Lightstep.StreamID { - return false - } - } - return true -} - -func havePingdomCountMetricsGoodTotalTheSameCheckID(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - if objective.CountMetrics == nil { - continue - } - if objective.CountMetrics.TotalMetric != nil && objective.CountMetrics.TotalMetric.Pingdom != nil && - objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.GoodMetric.Pingdom != nil && - objective.CountMetrics.GoodMetric.Pingdom.CheckID != nil && - objective.CountMetrics.TotalMetric.Pingdom.CheckID != nil && - *objective.CountMetrics.GoodMetric.Pingdom.CheckID != *objective.CountMetrics.TotalMetric.Pingdom.CheckID { - return false - } - } - return true -} - -func havePingdomRawMetricCheckTypeUptime(sloSpec SLOSpec) bool { - if !sloSpec.HasRawMetric() { - return true - } - - for _, metricSpec := range sloSpec.RawMetrics() { - if metricSpec == nil || metricSpec.Pingdom == nil { - continue - } - - if metricSpec.Pingdom.CheckType != nil && - pingdomCheckTypeValid(*metricSpec.Pingdom.CheckType) && - *metricSpec.Pingdom.CheckType != PingdomTypeUptime { - return false - } - } - - return true -} - -func havePingdomMetricsTheSameCheckType(sloSpec SLOSpec) bool { - types := make(map[string]bool) - for _, objective := range sloSpec.Objectives { - if objective.CountMetrics == nil { - continue - } - if objective.CountMetrics.TotalMetric != nil && objective.CountMetrics.TotalMetric.Pingdom != nil && - objective.CountMetrics.TotalMetric.Pingdom.CheckType != nil && - pingdomCheckTypeValid(*objective.CountMetrics.TotalMetric.Pingdom.CheckType) { - types[*objective.CountMetrics.TotalMetric.Pingdom.CheckType] = true - } - if objective.CountMetrics.GoodMetric != nil && objective.CountMetrics.GoodMetric.Pingdom != nil && - objective.CountMetrics.GoodMetric.Pingdom.CheckType != nil && - pingdomCheckTypeValid(*objective.CountMetrics.GoodMetric.Pingdom.CheckType) { - types[*objective.CountMetrics.GoodMetric.Pingdom.CheckType] = true - } - } - return len(types) < 2 -} - -func havePingdomCorrectStatusForRawMetrics(sloSpec SLOSpec) bool { - if !sloSpec.HasRawMetric() { - return true - } - - for _, metricSpec := range sloSpec.RawMetrics() { - if metricSpec.Pingdom != nil && - metricSpec.Pingdom.CheckType != nil && - *metricSpec.Pingdom.CheckType == PingdomTypeTransaction { - return metricSpec.Pingdom.Status == nil - } - } - - return true -} - -func havePingdomCorrectStatusForCountMetricsCheckType(sloSpec SLOSpec) bool { - for _, metricSpec := range sloSpec.CountMetrics() { - if metricSpec == nil || metricSpec.Pingdom == nil || metricSpec.Pingdom.CheckType == nil { - continue - } - switch *metricSpec.Pingdom.CheckType { - case PingdomTypeTransaction: - if metricSpec.Pingdom.Status != nil { - return false - } - case PingdomTypeUptime: - if metricSpec.Pingdom.Status == nil { - return false - } - } - } - return true -} - -func areSumoLogicQuantizationValuesEqual(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - countMetrics := objective.CountMetrics - if countMetrics == nil { - continue - } - if countMetrics.GoodMetric == nil || countMetrics.TotalMetric == nil { - continue - } - if countMetrics.GoodMetric.SumoLogic == nil && countMetrics.TotalMetric.SumoLogic == nil { - continue - } - if countMetrics.GoodMetric.SumoLogic.Quantization == nil || countMetrics.TotalMetric.SumoLogic.Quantization == nil { - continue - } - if *countMetrics.GoodMetric.SumoLogic.Quantization != *countMetrics.TotalMetric.SumoLogic.Quantization { - return false - } - } - return true -} - -func areSumoLogicTimesliceValuesEqual(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - countMetrics := objective.CountMetrics - if countMetrics == nil { - continue - } - if countMetrics.GoodMetric == nil || countMetrics.TotalMetric == nil { - continue - } - if countMetrics.GoodMetric.SumoLogic == nil && countMetrics.TotalMetric.SumoLogic == nil { - continue - } - - good := countMetrics.GoodMetric.SumoLogic - total := countMetrics.TotalMetric.SumoLogic - if *good.Type == "logs" && *total.Type == "logs" { - goodTS, err := getTimeSliceFromSumoLogicQuery(*good.Query) - if err != nil { - continue - } - - totalTS, err := getTimeSliceFromSumoLogicQuery(*total.Query) - if err != nil { - continue - } - - if goodTS != totalTS { - return false - } - } - } - return true -} - -// haveAzureMonitorCountMetricSpecTheSameResourceIDAndMetricNamespace checks if good/bad query has the same resourceID -// and metricNamespace as total query -// nolint: gocognit -func haveAzureMonitorCountMetricSpecTheSameResourceIDAndMetricNamespace(sloSpec SLOSpec) bool { - for _, objective := range sloSpec.Objectives { - if objective.CountMetrics == nil { - continue - } - total := objective.CountMetrics.TotalMetric - good := objective.CountMetrics.GoodMetric - bad := objective.CountMetrics.BadMetric - - if total != nil && total.AzureMonitor != nil { - if good != nil && good.AzureMonitor != nil { - if good.AzureMonitor.MetricNamespace != total.AzureMonitor.MetricNamespace || - good.AzureMonitor.ResourceID != total.AzureMonitor.ResourceID { - return false - } - } - - if bad != nil && bad.AzureMonitor != nil { - if bad.AzureMonitor.MetricNamespace != total.AzureMonitor.MetricNamespace || - bad.AzureMonitor.ResourceID != total.AzureMonitor.ResourceID { - return false - } - } - } - } - - return true -} - -// Support for bad/total metrics will be enabled gradually. -// CloudWatch is first delivered datasource integration - extend the list while adding support for next integrations. -func isBadOverTotalEnabledForDataSourceType(objective Objective) bool { - enabledDataSources := []DataSourceType{CloudWatch, AppDynamics, AzureMonitor, Honeycomb} - if objective.CountMetrics != nil { - if objective.CountMetrics.BadMetric == nil { - return false - } - return slices.Contains(enabledDataSources, objective.CountMetrics.BadMetric.DataSourceType()) - } - return true -} - -func areCountMetricsSetForAllObjectivesOrNone(sloSpec SLOSpec) bool { - count := sloSpec.CountMetricsCount() - const countMetricsPerObjective int = 2 - return count == 0 || count == len(sloSpec.Objectives)*countMetricsPerObjective -} - -func isTimeWindowTypeUnambiguous(timeWindow TimeWindow) bool { - return (timeWindow.isCalendar() && !timeWindow.IsRolling) || (!timeWindow.isCalendar() && timeWindow.IsRolling) -} - -func isTimeUnitValidForTimeWindowType(timeWindow TimeWindow, timeUnit string) bool { - timeWindowType := GetTimeWindowType(timeWindow) - - switch timeWindowType { - case twindow.Rolling: - return twindow.IsRollingWindowTimeUnit(timeUnit) - case twindow.Calendar: - return twindow.IsCalendarAlignedTimeUnit(timeUnit) - } - return false -} - -func windowSizeValidation(timeWindow TimeWindow, sl v.StructLevel) { - switch GetTimeWindowType(timeWindow) { - case twindow.Rolling: - rollingWindowSizeValidation(timeWindow, sl) - case twindow.Calendar: - calendarWindowSizeValidation(timeWindow, sl) - } -} - -func rollingWindowSizeValidation(timeWindow TimeWindow, sl v.StructLevel) { - rollingWindowTimeUnitEnum := twindow.GetTimeUnitEnum(twindow.Rolling, timeWindow.Unit) - var timeWindowSize time.Duration - switch rollingWindowTimeUnitEnum { - case twindow.Minute: - timeWindowSize = time.Duration(timeWindow.Count) * time.Minute - case twindow.Hour: - timeWindowSize = time.Duration(timeWindow.Count) * time.Hour - case twindow.Day: - timeWindowSize = time.Duration(timeWindow.Count) * time.Duration(twindow.HoursInDay) * time.Hour - default: - sl.ReportError(timeWindow, "timeWindow", "TimeWindow", "validWindowTypeForTimeUnitRequired", "") - return - } - switch { - case timeWindowSize > maximumRollingTimeWindowSize: - sl.ReportError( - timeWindow, - "timeWindow", - "TimeWindow", - "rollingTimeWindowSizeLessThanOrEqualsTo31DaysRequired", - "", - ) - case timeWindowSize < minimumRollingTimeWindowSize: - sl.ReportError( - timeWindow, - "timeWindow", - "TimeWindow", - "rollingTimeWindowSizeGreaterThanOrEqualTo5MinutesRequired", - "", - ) - } -} - -// nolint: gomnd -func calendarWindowSizeValidation(timeWindow TimeWindow, sl v.StructLevel) { - var timeWindowSize time.Duration - if isTimeUnitValidForTimeWindowType(timeWindow, timeWindow.Unit) { - tw, _ := twindow.NewCalendarTimeWindow( - twindow.MustParseTimeUnit(timeWindow.Unit), - uint32(timeWindow.Count), - time.UTC, - time.Now().UTC(), - ) - timeWindowSize = tw.GetTimePeriod(time.Now().UTC()).Duration() - if timeWindowSize > maximumCalendarTimeWindowSize { - sl.ReportError( - timeWindow, - "timeWindow", - "TimeWindow", - "calendarTimeWindowSizeLessThan1YearRequired", - "", - ) - } - } -} - -// GetTimeWindowType function returns value of TimeWindowTypeEnum for given time window -func GetTimeWindowType(timeWindow TimeWindow) twindow.TimeWindowTypeEnum { - if timeWindow.isCalendar() { - return twindow.Calendar - } - return twindow.Rolling -} - -func (tw *TimeWindow) isCalendar() bool { - return tw.Calendar != nil -} - -func isTimeUnitValid(fl v.FieldLevel) bool { - return twindow.IsTimeUnit(fl.Field().String()) -} - -func isTimeZoneValid(fl v.FieldLevel) bool { - if fl.Field().String() != "" { - _, err := time.LoadLocation(fl.Field().String()) - if err != nil { - return false - } - } - return true -} - -func isDateWithTimeValid(fl v.FieldLevel) bool { - if fl.Field().String() != "" { - t, err := time.Parse(twindow.IsoDateTimeOnlyLayout, fl.Field().String()) - // Nanoseconds (thus milliseconds too) in time struct are forbidden to be set. - if err != nil || t.Nanosecond() != 0 { - return false - } - } - return true -} - -func isMinDateTime(fl v.FieldLevel) bool { - if fl.Field().String() != "" { - date, err := twindow.ParseStartDate(fl.Field().String()) - if err != nil { - return false - } - minStartDate := twindow.GetMinStartDate() - return date.After(minStartDate) || date.Equal(minStartDate) - } - return true -} - -func agentSpecStructLevelValidation(sl v.StructLevel) { - sa := sl.Current().Interface().(AgentSpec) - - agentTypeValidation(sa, sl) - if sa.Prometheus != nil { - prometheusConfigValidation(sa.Prometheus, sl) - } - agentQueryDelayValidation(sa, sl) - sourceOfValidation(sa.SourceOf, sl) - - if !isValidReleaseChannel(sa.ReleaseChannel) { - sl.ReportError(sa, "ReleaseChannel", "ReleaseChannel", "unknownReleaseChannel", "") - } -} - -func agentQueryDelayValidation(sa AgentSpec, sl v.StructLevel) { - at, err := sa.GetType() - if err != nil { - sl.ReportError(sa, "", "", "unknownAgentType", "") - return - } - if sa.QueryDelay != nil { - agentDefault := GetQueryDelayDefaults()[at.String()] - if sa.QueryDelay.QueryDelayDuration.LesserThan(agentDefault) { - sl.ReportError( - sa, - "QueryDelayDuration", - "QueryDelayDuration", - "queryDelayDurationLesserThanDefaultDataSourceQueryDelay", - "", - ) - } - if sa.QueryDelay.QueryDelayDuration.BiggerThanMax() { - sl.ReportError( - sa, - "QueryDelayDuration", - "QueryDelayDuration", - "queryDelayDurationBiggerThanMaximumAllowed", - "", - ) - } - } -} - -func isValidURL(fl v.FieldLevel) bool { - return validateURL(fl.Field().String()) -} - -func isEmptyOrValidURL(fl v.FieldLevel) bool { - value := fl.Field().String() - return value == "" || value == HiddenValue || validateURL(value) -} - -func isValidURLDynatrace(fl v.FieldLevel) bool { - return validateURLDynatrace(fl.Field().String()) -} - -func isValidURLDiscord(fl v.FieldLevel) bool { - key := fl.Field().String() - if strings.HasSuffix(strings.ToLower(key), "/slack") || strings.HasSuffix(strings.ToLower(key), "/github") { - return false - } - return isEmptyOrValidURL(fl) -} - -func isValidOpsgenieAPIKey(fl v.FieldLevel) bool { - key := fl.Field().String() - return key == "" || - key == HiddenValue || - (strings.HasPrefix(key, "Basic") || - strings.HasPrefix(key, "GenieKey")) -} - -func isValidPagerDutyIntegrationKey(fl v.FieldLevel) bool { - key := fl.Field().String() - return key == "" || key == HiddenValue || len(key) == 32 -} - -func validateURL(validateURL string) bool { - validURLRegex := regexp.MustCompile(URLRegex) - return validURLRegex.MatchString(validateURL) -} - -func validateURLDynatrace(validateURL string) bool { - u, err := url.Parse(validateURL) - if err != nil { - return false - } - // For SaaS type enforce https and land lack of path. - // Join instead of Clean (to avoid getting . for empty path), Trim to get rid of root. - pathURL := strings.Trim(path.Join(u.Path), "/") - if strings.HasSuffix(u.Host, "live.dynatrace.com") { - if u.Scheme != "https" || pathURL != "" { - return false - } - } - return true -} - -func areLabelsValid(fl v.FieldLevel) bool { - lbl := fl.Field().Interface().(Labels) - return lbl.Validate() == nil -} - -func isHTTPS(fl v.FieldLevel) bool { - if !isNotEmpty(fl) || fl.Field().String() == HiddenValue { - return true - } - val, err := url.Parse(fl.Field().String()) - if err != nil || val.Scheme != "https" { - return false - } - return true -} - -func prometheusConfigValidation(pc *PrometheusAgentConfig, sl v.StructLevel) { - switch { - case pc.URL == nil: - sl.ReportError(pc.URL, "url", "URL", "integrationUrlRequired", "") - case !validateURL(*pc.URL): - sl.ReportError(pc.URL, "url", "URL", "integrationUrlNotValid", "") - } -} - -// nolint added because of detected duplicate with metricTypeValidation variant of this function -func agentTypeValidation(sa AgentSpec, sl v.StructLevel) { - const expectedNumberOfAgentTypes = 1 - var agentTypesCount int - if sa.Prometheus != nil { - agentTypesCount++ - } - if sa.Datadog != nil { - agentTypesCount++ - } - if sa.NewRelic != nil { - agentTypesCount++ + if sa.NewRelic != nil { + agentTypesCount++ } if sa.AppDynamics != nil { agentTypesCount++ @@ -1833,114 +583,6 @@ func agentTypeValidation(sa AgentSpec, sl v.StructLevel) { } } -// nolint added because of detected duplicate with agentTypeValidation variant of this function -func metricTypeValidation(ms MetricSpec, sl v.StructLevel) { - const expectedCountOfMetricTypes = 1 - var metricTypesCount int - if ms.Prometheus != nil { - metricTypesCount++ - } - if ms.Datadog != nil { - metricTypesCount++ - } - if ms.NewRelic != nil { - metricTypesCount++ - } - if ms.AppDynamics != nil { - metricTypesCount++ - } - if ms.Splunk != nil { - metricTypesCount++ - } - if ms.Lightstep != nil { - metricTypesCount++ - } - if ms.SplunkObservability != nil { - metricTypesCount++ - } - if ms.Dynatrace != nil { - metricTypesCount++ - } - if ms.Elasticsearch != nil { - metricTypesCount++ - } - if ms.BigQuery != nil { - metricTypesCount++ - } - if ms.ThousandEyes != nil { - metricTypesCount++ - } - if ms.Graphite != nil { - metricTypesCount++ - } - if ms.OpenTSDB != nil { - metricTypesCount++ - } - if ms.GrafanaLoki != nil { - metricTypesCount++ - } - if ms.CloudWatch != nil { - metricTypesCount++ - } - if ms.Pingdom != nil { - metricTypesCount++ - } - if ms.AmazonPrometheus != nil { - metricTypesCount++ - } - if ms.Redshift != nil { - metricTypesCount++ - } - if ms.SumoLogic != nil { - metricTypesCount++ - } - if ms.Instana != nil { - metricTypesCount++ - } - if ms.InfluxDB != nil { - metricTypesCount++ - } - if ms.GCM != nil { - metricTypesCount++ - } - if ms.AzureMonitor != nil { - metricTypesCount++ - } - if ms.Generic != nil { - metricTypesCount++ - } - if ms.Honeycomb != nil { - metricTypesCount++ - } - if metricTypesCount != expectedCountOfMetricTypes { - sl.ReportError(ms, "prometheus", "Prometheus", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "datadog", "Datadog", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "newRelic", "NewRelic", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "appDynamics", "AppDynamics", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "splunk", "Splunk", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "lightstep", "Lightstep", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "splunkObservability", "SplunkObservability", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "dynatrace", "Dynatrace", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "elasticsearch", "Elasticsearch", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "bigQuery", "bigQuery", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "thousandEyes", "ThousandEyes", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "graphite", "Graphite", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "opentsdb", "OpenTSDB", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "grafanaLoki", "GrafanaLoki", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "cloudWatch", "CloudWatch", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "pingdom", "Pingdom", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "amazonPrometheus", "AmazonPrometheus", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "redshift", "Redshift", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "sumoLogic", "SumoLogic", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "instana", "Instana", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "influxdb", "InfluxDB", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "gcm", "GCM", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "azuremonitor", "AzureMonitor", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "genericMetric", "Generic", "exactlyOneMetricTypeRequired", "") - sl.ReportError(ms, "honeycomb", "Honeycomb", "exactlyOneMetricTypeRequired", "") - } -} - func directSpecStructLevelValidation(sl v.StructLevel) { sa := sl.Current().Interface().(DirectSpec) @@ -2086,11 +728,6 @@ func isValidReleaseChannel(releaseChannel ReleaseChannel) bool { return releaseChannel.IsValid() && releaseChannel != ReleaseChannelAlpha } -func isBudgetingMethod(fl v.FieldLevel) bool { - _, err := ParseBudgetingMethod(fl.Field().String()) - return err == nil -} - func isSite(fl v.FieldLevel) bool { value := fl.Field().String() return isValidDatadogAPIUrl(value) || value == "eu" || value == "com" @@ -2174,21 +811,6 @@ func isUnambiguousAppDynamicMetricPath(fl v.FieldLevel) bool { return true } -func isValidObjectiveOperatorForRawMetric(sloSpec SLOSpec) bool { - if !sloSpec.HasRawMetric() { - return true - } - for _, objective := range sloSpec.Objectives { - if objective.Operator == nil { - return false - } - if _, err := ParseOperator(*objective.Operator); err != nil { - return false - } - } - return true -} - func isValidAlertPolicyMeasurement(fl v.FieldLevel) bool { _, err := ParseMeasurement(fl.Field().String()) return err == nil @@ -2314,10 +936,6 @@ func alertPolicyConditionOperatorLimitsValidation(sl v.StructLevel) { } } -func isValidTimeSliceTargetValue(tsv float64) bool { - return tsv > 0.0 && tsv <= 1.00 -} - // stringInterpolationPlaceholder common symbol to use in strings for interpolation e.g. "My amazing {} Service" const stringInterpolationPlaceholder = "{}" @@ -2440,234 +1058,30 @@ func isValidMetricSourceKind(fl v.FieldLevel) bool { } } -func isValidMetricPathGraphite(fl v.FieldLevel) bool { - // Graphite allows the use of wildcards in metric paths, but we decided not to support it for our MVP. - // https://graphite.readthedocs.io/en/latest/render_api.html#paths-and-wildcards - segments := strings.Split(fl.Field().String(), ".") - for _, segment := range segments { - // asterisk - if strings.Contains(segment, "*") { - return false - } - // character list of range - if strings.Contains(segment, "[") || strings.Contains(segment, "]") { - return false - } - // value list - if strings.Contains(segment, "{") || strings.Contains(segment, "}") { - return false - } - } - return true -} - -func isValidBigQueryQuery(fl v.FieldLevel) bool { - query := fl.Field().String() - return validateBigQueryQuery(query) -} - -func validateBigQueryQuery(query string) bool { - dateInProjection := regexp.MustCompile(`\bn9date\b`) - valueInProjection := regexp.MustCompile(`\bn9value\b`) - dateFromInWhere := regexp.MustCompile(`DATETIME\(\s*@n9date_from\s*\)`) - dateToInWhere := regexp.MustCompile(`DATETIME\(\s*@n9date_to\s*\)`) - - return dateInProjection.MatchString(query) && - valueInProjection.MatchString(query) && - dateFromInWhere.MatchString(query) && - dateToInWhere.MatchString(query) -} - -func isValidRedshiftQuery(fl v.FieldLevel) bool { - query := fl.Field().String() - dateInProjection := regexp.MustCompile(`^SELECT[\s\S]*\bn9date\b[\s\S]*FROM`) - valueInProjection := regexp.MustCompile(`^SELECT\s[\s\S]*\bn9value\b[\s\S]*\sFROM`) - dateFromInWhere := regexp.MustCompile(`WHERE[\s\S]*\W:n9date_from\b[\s\S]*`) - dateToInWhere := regexp.MustCompile(`WHERE[\s\S]*\W:n9date_to\b[\s\S]*`) - - return dateInProjection.MatchString(query) && - valueInProjection.MatchString(query) && - dateFromInWhere.MatchString(query) && - dateToInWhere.MatchString(query) -} - -func isValidInfluxDBQuery(fl v.FieldLevel) bool { - query := fl.Field().String() - - return validateInfluxDBQuery(query) -} - -func validateInfluxDBQuery(query string) bool { - bucketRegex := regexp.MustCompile("\\s*bucket\\s*:\\s*\".+\"\\s*") - queryRegex := regexp.MustCompile("\\s*range\\s*\\(\\s*start\\s*:\\s*time\\s*" + - "\\(\\s*v\\s*:\\s*" + - "params\\.n9time_start\\s*\\)\\s*,\\s*stop\\s*:\\s*time\\s*\\(\\s*v\\s*:\\s*" + - "params\\.n9time_stop" + - "\\s*\\)\\s*\\)") - - return queryRegex.MatchString(query) && bucketRegex.MatchString(query) -} - -func isValidNewRelicQuery(fl v.FieldLevel) bool { - query := fl.Field().String() - return validateNewRelicQuery(query) -} - -// validateNewRelicQuery checks if SINCE and UNTIL are absent in a query. -func validateNewRelicQuery(query string) bool { - split := regexp.MustCompile(`\s`).Split(query, -1) - for _, s := range split { - lowerCase := strings.ToLower(s) - if lowerCase == "since" || lowerCase == "until" { - return false - } - } - return true -} - func isValidNewRelicInsightsAPIKey(fl v.FieldLevel) bool { apiKey := fl.Field().String() return strings.HasPrefix(apiKey, "NRIQ-") || apiKey == "" } -func isValidElasticsearchQuery(fl v.FieldLevel) bool { - query := fl.Field().String() - - return strings.Contains(query, "{{.BeginTime}}") && strings.Contains(query, "{{.EndTime}}") -} - func hasValidURLScheme(fl v.FieldLevel) bool { u, err := url.Parse(fl.Field().String()) if err != nil { return false } schemes := strings.Split(fl.Param(), ",") - for _, scheme := range schemes { - if u.Scheme == scheme { - return true - } - } - return false -} - -func isValidJSON(fl v.FieldLevel) bool { - jsonString := fl.Field().String() - var object interface{} - err := json.Unmarshal([]byte(jsonString), &object) - return err == nil -} - -func splunkQueryValid(fl v.FieldLevel) bool { - query := fl.Field().String() - wordToRegex := [3]string{ - "\\bn9time\\b", // the query has to contain a word "n9time" - "\\bn9value\\b", // the query has to contain a word "n9value" - "(\\bindex\\s*=.+)|(\"\\bindex\"\\s*=.+)", // the query has to contain index=something or "index"=something - } - - for _, regex := range wordToRegex { - if isMatch := regexp.MustCompile(regex).MatchString(query); !isMatch { - return false - } - } - - return true -} - -func wrapInParenthesis(regex string) string { - return fmt.Sprintf("(%s)", regex) -} - -func concatRegexAlternatives(alternatives []string) string { - var result strings.Builder - for i, alternative := range alternatives { - result.WriteString(wrapInParenthesis(alternative)) - if i < len(alternatives)-1 { - result.WriteString("|") - } - } - return wrapInParenthesis(result.String()) -} - -func buildCloudWatchStatRegex() *regexp.Regexp { - simpleFunctions := []string{ - "SampleCount", - "Sum", - "Average", - "Minimum", - "Maximum", - "IQM", - } - - floatFrom0To100 := `(100|(([1-9]\d?)|0))(\.\d{1,10})?` - shortFunctionNames := []string{ - "p", - "tm", - "wm", - "tc", - "ts", - } - shortFunctions := wrapInParenthesis(concatRegexAlternatives(shortFunctionNames)) + wrapInParenthesis(floatFrom0To100) - - percent := wrapInParenthesis(floatFrom0To100 + "%") - floatingPoint := wrapInParenthesis(`-?(([1-9]\d*)|0)(\.\d{1,10})?`) - percentArgumentAlternatives := []string{ - fmt.Sprintf("%s:%s", percent, percent), - fmt.Sprintf("%s:", percent), - fmt.Sprintf(":%s", percent), - } - floatArgumentAlternatives := []string{ - fmt.Sprintf("%s:%s", floatingPoint, floatingPoint), - fmt.Sprintf("%s:", floatingPoint), - fmt.Sprintf(":%s", floatingPoint), - } - var allArgumentAlternatives []string - allArgumentAlternatives = append(allArgumentAlternatives, percentArgumentAlternatives...) - allArgumentAlternatives = append(allArgumentAlternatives, floatArgumentAlternatives...) - - valueOrPercentFunctionNames := []string{ - "TM", - "WM", - "TC", - "TS", - } - valueOrPercentFunctions := wrapInParenthesis(concatRegexAlternatives(valueOrPercentFunctionNames)) + - fmt.Sprintf(`\(%s\)`, concatRegexAlternatives(allArgumentAlternatives)) - - valueOnlyFunctionNames := []string{ - "PR", + for _, scheme := range schemes { + if u.Scheme == scheme { + return true + } } - valueOnlyFunctions := wrapInParenthesis(concatRegexAlternatives(valueOnlyFunctionNames)) + - fmt.Sprintf(`\(%s\)`, concatRegexAlternatives(floatArgumentAlternatives)) - - var allFunctions []string - allFunctions = append(allFunctions, simpleFunctions...) - allFunctions = append(allFunctions, shortFunctions) - allFunctions = append(allFunctions, valueOrPercentFunctions) - allFunctions = append(allFunctions, valueOnlyFunctions) - - finalRegexStr := fmt.Sprintf("^%s$", concatRegexAlternatives(allFunctions)) - finalRegex := regexp.MustCompile(finalRegexStr) - return finalRegex + return false } -func supportedThousandEyesTestType(fl v.FieldLevel) bool { - value := fl.Field().String() - switch value { - case - ThousandEyesNetLatency, - ThousandEyesNetLoss, - ThousandEyesWebPageLoad, - ThousandEyesWebDOMLoad, - ThousandEyesHTTPResponseTime, - ThousandEyesServerAvailability, - ThousandEyesServerThroughput, - ThousandEyesServerTotalTime, - ThousandEyesDNSServerResolutionTime, - ThousandEyesDNSSECValid: - return true - } - return false +func isValidJSON(fl v.FieldLevel) bool { + jsonString := fl.Field().String() + var object interface{} + err := json.Unmarshal([]byte(jsonString), &object) + return err == nil } func pingdomCheckTypeFieldValid(fl v.FieldLevel) bool { @@ -2706,229 +1120,6 @@ func pingdomStatusValid(fl v.FieldLevel) bool { return true } -func countMetricsSpecValidation(sl v.StructLevel) { - countMetrics := sl.Current().Interface().(CountMetricsSpec) - if countMetrics.TotalMetric == nil { - return - } - - totalDatasourceMetricType := countMetrics.TotalMetric.DataSourceType() - - if countMetrics.GoodMetric != nil { - if countMetrics.GoodMetric.DataSourceType() != totalDatasourceMetricType { - sl.ReportError(countMetrics.GoodMetric, "goodMetrics", "GoodMetric", "metricsOfTheSameType", "") - reportCountMetricsSpecMessageForTotalMetric(sl, countMetrics) - } - } - - if countMetrics.BadMetric != nil { - if countMetrics.BadMetric.DataSourceType() != totalDatasourceMetricType { - sl.ReportError(countMetrics.BadMetric, "badMetrics", "BadMetric", "metricsOfTheSameType", "") - reportCountMetricsSpecMessageForTotalMetric(sl, countMetrics) - } - } - - redshiftCountMetricsSpecValidation(sl) - bigQueryCountMetricsSpecValidation(sl) - instanaCountMetricsSpecValidation(sl) -} - -func reportCountMetricsSpecMessageForTotalMetric(sl v.StructLevel, countMetrics CountMetricsSpec) { - sl.ReportError(countMetrics.TotalMetric, "totalMetrics", "TotalMetric", "metricsOfTheSameType", "") -} - -func cloudWatchMetricStructValidation(sl v.StructLevel) { - cloudWatchMetric, ok := sl.Current().Interface().(CloudWatchMetric) - if !ok { - sl.ReportError(cloudWatchMetric, "", "", "couldNotConverse", "") - return - } - - isConfiguration := cloudWatchMetric.IsStandardConfiguration() - isSQL := cloudWatchMetric.IsSQLConfiguration() - isJSON := cloudWatchMetric.IsJSONConfiguration() - - var configOptions int - if isConfiguration { - configOptions++ - } - if isSQL { - configOptions++ - } - if isJSON { - configOptions++ - } - if configOptions != 1 { - sl.ReportError(cloudWatchMetric.Stat, "stat", "Stat", "exactlyOneConfigType", "") - sl.ReportError(cloudWatchMetric.SQL, "sql", "SQL", "exactlyOneConfigType", "") - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "exactlyOneConfigType", "") - return - } - - switch { - case isJSON: - validateCloudWatchJSONQuery(sl, cloudWatchMetric) - case isConfiguration: - validateCloudWatchConfiguration(sl, cloudWatchMetric) - } - - if isJSON && cloudWatchMetric.AccountID != nil && len(*cloudWatchMetric.AccountID) > 0 { - sl.ReportError(cloudWatchMetric.AccountID, "accountId", "AccountID", "accountIdMustBeEmpty", "") - } - - if isSQL && cloudWatchMetric.AccountID != nil && len(*cloudWatchMetric.AccountID) > 0 { - sl.ReportError(cloudWatchMetric.AccountID, "accountId", "AccountID", "accountIdForSQLNotSupported", "") - } - - if isConfiguration && cloudWatchMetric.AccountID != nil && !isValidAWSAccountID(*cloudWatchMetric.AccountID) { - sl.ReportError(cloudWatchMetric.AccountID, "accountId", "AccountID", "accountIdInvalid", "") - } - - if cloudWatchMetric.Region != nil && !IsValidRegion(*cloudWatchMetric.Region, AWSRegions()) { - sl.ReportError(cloudWatchMetric.Region, "region", "Region", "regionNotAvailable", "") - } -} - -// isValidAWSAccountID checks if the provided string is a valid AWS account ID. -// An AWS account ID is a 12-digit number, or it can be an empty string. -func isValidAWSAccountID(accountID string) bool { - if len(accountID) == 0 { - return true - } - if match, _ := regexp.MatchString(`^[0-9]{12}$`, accountID); match { - return true - } - return false -} - -func redshiftCountMetricsSpecValidation(sl v.StructLevel) { - countMetrics, ok := sl.Current().Interface().(CountMetricsSpec) - if !ok { - sl.ReportError(countMetrics, "", "", "structConversion", "") - return - } - if countMetrics.TotalMetric == nil || countMetrics.GoodMetric == nil { - return - } - if countMetrics.TotalMetric.Redshift == nil || countMetrics.GoodMetric.Redshift == nil { - return - } - if countMetrics.GoodMetric.Redshift.Region == nil || countMetrics.GoodMetric.Redshift.ClusterID == nil || - countMetrics.GoodMetric.Redshift.DatabaseName == nil { - return - } - if countMetrics.TotalMetric.Redshift.Region == nil || countMetrics.TotalMetric.Redshift.ClusterID == nil || - countMetrics.TotalMetric.Redshift.DatabaseName == nil { - return - } - if *countMetrics.GoodMetric.Redshift.Region != *countMetrics.TotalMetric.Redshift.Region { - sl.ReportError( - countMetrics.GoodMetric.Redshift.Region, - "goodMetric.redshift.region", "", - "regionIsNotEqual", "", - ) - sl.ReportError( - countMetrics.TotalMetric.Redshift.Region, - "totalMetric.redshift.region", "", - "regionIsNotEqual", "", - ) - } - if *countMetrics.GoodMetric.Redshift.ClusterID != *countMetrics.TotalMetric.Redshift.ClusterID { - sl.ReportError( - countMetrics.GoodMetric.Redshift.ClusterID, - "goodMetric.redshift.clusterId", "", - "clusterIdIsNotEqual", "", - ) - sl.ReportError( - countMetrics.TotalMetric.Redshift.ClusterID, - "totalMetric.redshift.clusterId", "", - "clusterIdIsNotEqual", "", - ) - } - if *countMetrics.GoodMetric.Redshift.DatabaseName != *countMetrics.TotalMetric.Redshift.DatabaseName { - sl.ReportError( - countMetrics.GoodMetric.Redshift.DatabaseName, - "goodMetric.redshift.databaseName", "", - "databaseNameIsNotEqual", "", - ) - sl.ReportError( - countMetrics.TotalMetric.Redshift.DatabaseName, - "totalMetric.redshift.databaseName", "", - "databaseNameIsNotEqual", "", - ) - } -} - -func instanaCountMetricsSpecValidation(sl v.StructLevel) { - countMetrics, ok := sl.Current().Interface().(CountMetricsSpec) - if !ok { - sl.ReportError(countMetrics, "", "", "structConversion", "") - return - } - if countMetrics.TotalMetric == nil || countMetrics.GoodMetric == nil { - return - } - if countMetrics.TotalMetric.Instana == nil || countMetrics.GoodMetric.Instana == nil { - return - } - - if countMetrics.TotalMetric.Instana.MetricType == instanaMetricTypeApplication { - sl.ReportError( - countMetrics.TotalMetric.Instana.MetricType, - "totalMetric.instana.metricType", "", - "instanaApplicationTypeNotAllowed", "", - ) - } - - if countMetrics.GoodMetric.Instana.MetricType == instanaMetricTypeApplication { - sl.ReportError( - countMetrics.GoodMetric.Instana.MetricType, - "goodMetric.instana.metricType", "", - "instanaApplicationTypeNotAllowed", "", - ) - } -} - -func bigQueryCountMetricsSpecValidation(sl v.StructLevel) { - countMetrics, ok := sl.Current().Interface().(CountMetricsSpec) - if !ok { - sl.ReportError(countMetrics, "", "", "structConversion", "") - return - } - if countMetrics.TotalMetric == nil || countMetrics.GoodMetric == nil { - return - } - if countMetrics.TotalMetric.BigQuery == nil || countMetrics.GoodMetric.BigQuery == nil { - return - } - - if countMetrics.GoodMetric.BigQuery.Location != countMetrics.TotalMetric.BigQuery.Location { - sl.ReportError( - countMetrics.GoodMetric.BigQuery.Location, - "goodMetric.bigQuery.location", "", - "locationNameIsNotEqual", "", - ) - sl.ReportError( - countMetrics.TotalMetric.BigQuery.Location, - "totalMetric.bigQuery.location", "", - "locationNameIsNotEqual", "", - ) - } - - if countMetrics.GoodMetric.BigQuery.ProjectID != countMetrics.TotalMetric.BigQuery.ProjectID { - sl.ReportError( - countMetrics.GoodMetric.BigQuery.ProjectID, - "goodMetric.bigQuery.projectId", "", - "projectIdIsNotEqual", "", - ) - sl.ReportError( - countMetrics.TotalMetric.BigQuery.ProjectID, - "totalMetric.bigQuery.projectId", "", - "projectIdIsNotEqual", "", - ) - } -} - func agentSpecHistoricalRetrievalValidation(sl v.StructLevel) { validatedAgent, ok := sl.Current().Interface().(Agent) if !ok { @@ -3078,102 +1269,6 @@ func queryDelayDurationValidation(sl v.StructLevel) { } } -// validateCloudWatchConfigurationRequiredFields checks if all required fields for standard configuration exist. -func validateCloudWatchConfigurationRequiredFields(sl v.StructLevel, cloudWatchMetric CloudWatchMetric) bool { - i := 0 - if cloudWatchMetric.Namespace == nil { - sl.ReportError(cloudWatchMetric.Namespace, "namespace", "Namespace", "required", "") - i++ - } - if cloudWatchMetric.MetricName == nil { - sl.ReportError(cloudWatchMetric.MetricName, "metricName", "MetricName", "required", "") - i++ - } - if cloudWatchMetric.Stat == nil { - sl.ReportError(cloudWatchMetric.Stat, "stat", "Stat", "required", "") - i++ - } - if cloudWatchMetric.Dimensions == nil { - sl.ReportError(cloudWatchMetric.Dimensions, "dimensions", "Dimensions", "required", "") - i++ - } - return i == 0 -} - -// validateCloudWatchConfiguration validates standard configuration and data necessary for further data retrieval. -func validateCloudWatchConfiguration(sl v.StructLevel, cloudWatchMetric CloudWatchMetric) { - if !validateCloudWatchConfigurationRequiredFields(sl, cloudWatchMetric) { - return - } - - const maxLength = 255 - if len(*cloudWatchMetric.Namespace) > maxLength { - sl.ReportError(cloudWatchMetric.Namespace, "namespace", "Namespace", "maxLength", "") - } - if len(*cloudWatchMetric.MetricName) > maxLength { - sl.ReportError(cloudWatchMetric.MetricName, "metricName", "MetricName", "maxLength", "") - } - - if !isValidCloudWatchNamespace(*cloudWatchMetric.Namespace) { - sl.ReportError(cloudWatchMetric.Namespace, "namespace", "Namespace", "cloudWatchNamespaceRegex", "") - } - if !cloudWatchStatRegex.MatchString(*cloudWatchMetric.Stat) { - sl.ReportError(cloudWatchMetric.Stat, "stat", "Stat", "invalidCloudWatchStat", "") - } -} - -// validateCloudWatchJSONQuery validates JSON query and data necessary for further data retrieval. -func validateCloudWatchJSONQuery(sl v.StructLevel, cloudWatchMetric CloudWatchMetric) { - const queryPeriod = 60 - if cloudWatchMetric.JSON == nil { - return - } - var metricDataQuerySlice []*cloudwatch.MetricDataQuery - if err := json.Unmarshal([]byte(*cloudWatchMetric.JSON), &metricDataQuerySlice); err != nil { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "invalidJSONQuery", "") - return - } - - returnedValues := len(metricDataQuerySlice) - for _, metricData := range metricDataQuerySlice { - if err := metricData.Validate(); err != nil { - msg := fmt.Sprintf("\n%s", strings.TrimSuffix(err.Error(), "\n")) - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", msg, "") - continue - } - if metricData.ReturnData != nil && !*metricData.ReturnData { - returnedValues-- - } - if metricData.AccountId != nil && metricData.Expression != nil { - sl.ReportError(cloudWatchMetric.AccountID, "json", "JSON", "accountIdForSQLNotSupported", "") - } - if metricData.MetricStat != nil { - if metricData.MetricStat.Period == nil { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "requiredPeriod", "") - } else if *metricData.MetricStat.Period != queryPeriod { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "invalidPeriodValue", "") - } - } else { - if metricData.Period == nil { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "requiredPeriod", "") - } else if *metricData.Period != queryPeriod { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "invalidPeriodValue", "") - } - } - if metricData.AccountId != nil && !isValidAWSAccountID(*metricData.AccountId) { - sl.ReportError(cloudWatchMetric.AccountID, "accountId", "AccountID", "accountIdInvalid", "") - } - } - if returnedValues != 1 { - sl.ReportError(cloudWatchMetric.JSON, "json", "JSON", "onlyOneReturnValueRequired", "") - } -} - -func isValidCloudWatchNamespace(namespace string) bool { - validNamespace := regexp.MustCompile(CloudWatchNamespaceRegex) - return validNamespace.MatchString(namespace) -} - func notBlank(fl v.FieldLevel) bool { field := fl.Field() @@ -3196,29 +1291,6 @@ func isValidHeaderName(fl v.FieldLevel) bool { return validHeaderNameRegex.MatchString(headerName) } -func sumoLogicStructValidation(sl v.StructLevel) { - const ( - metricType = "metrics" - logsType = "logs" - ) - - sumoLogicMetric, ok := sl.Current().Interface().(SumoLogicMetric) - if !ok { - sl.ReportError(sumoLogicMetric, "", "", "couldNotConverse", "") - return - } - - switch *sumoLogicMetric.Type { - case metricType: - validateSumoLogicMetricsConfiguration(sl, sumoLogicMetric) - case logsType: - validateSumoLogicLogsConfiguration(sl, sumoLogicMetric) - default: - msg := fmt.Sprintf("type [%s] is invalid, use one of: [%s|%s]", *sumoLogicMetric.Type, metricType, logsType) - sl.ReportError(sumoLogicMetric.Type, "type", "Type", msg, "") - } -} - func alertSilencePeriodValidation(sl v.StructLevel) { period, ok := sl.Current().Interface().(AlertSilencePeriod) if !ok { @@ -3284,194 +1356,3 @@ func alertSilenceAlertPolicyProjectValidation(sl v.StructLevel) { return } } - -// validateSumoLogicMetricsConfiguration validates configuration of Sumo Logic SLOs with metrics type. -func validateSumoLogicMetricsConfiguration(sl v.StructLevel, sumoLogicMetric SumoLogicMetric) { - const minQuantizationSeconds = 15 - - shouldReturn := false - if sumoLogicMetric.Quantization == nil { - msg := "quantization is required when using metrics type" - sl.ReportError(sumoLogicMetric.Quantization, "quantization", "Quantization", msg, "") - shouldReturn = true - } - - if sumoLogicMetric.Rollup == nil { - msg := "rollup is required when using metrics type" - sl.ReportError(sumoLogicMetric.Rollup, "rollup", "Rollup", msg, "") - shouldReturn = true - } - - if shouldReturn { - return - } - - quantization, err := time.ParseDuration(*sumoLogicMetric.Quantization) - if err != nil { - msg := fmt.Sprintf("error parsing quantization string to duration - %v", err) - sl.ReportError(sumoLogicMetric.Quantization, "quantization", "Quantization", msg, "") - } - - if quantization.Seconds() < minQuantizationSeconds { - msg := fmt.Sprintf("minimum quantization value is [15s], got: [%vs]", quantization.Seconds()) - sl.ReportError(sumoLogicMetric.Quantization, "quantization", "Quantization", msg, "") - } - - var availableRollups = []string{"Avg", "Sum", "Min", "Max", "Count", "None"} - isRollupValid := false - rollup := *sumoLogicMetric.Rollup - for _, availableRollup := range availableRollups { - if rollup == availableRollup { - isRollupValid = true - break - } - } - - if !isRollupValid { - msg := fmt.Sprintf("rollup [%s] is invalid, use one of: [%s]", rollup, strings.Join(availableRollups, "|")) - sl.ReportError(sumoLogicMetric.Rollup, "rollup", "Rollup", msg, "") - } -} - -// validateSumoLogicLogsConfiguration validates configuration of Sumo Logic SLOs with logs type. -func validateSumoLogicLogsConfiguration(sl v.StructLevel, metric SumoLogicMetric) { - if metric.Query == nil { - return - } - - validateSumoLogicTimeslice(sl, metric) - validateSumoLogicN9Fields(sl, metric) -} - -func validateSumoLogicTimeslice(sl v.StructLevel, metric SumoLogicMetric) { - const minTimeSliceSeconds = 15 - - timeslice, err := getTimeSliceFromSumoLogicQuery(*metric.Query) - if err != nil { - sl.ReportError(metric.Query, "query", "Query", err.Error(), "") - return - } - - if timeslice.Seconds() < minTimeSliceSeconds { - msg := fmt.Sprintf("minimum timeslice value is [15s], got: [%s]", timeslice) - sl.ReportError(metric.Query, "query", "Query", msg, "") - } -} - -func getTimeSliceFromSumoLogicQuery(query string) (time.Duration, error) { - r := regexp.MustCompile(`(?m).*\stimeslice\s(\d+\w+)\s.*`) - matchResults := r.FindStringSubmatch(query) - - if len(matchResults) != 2 { - return 0, fmt.Errorf("exactly one timeslice declaration is required in the query") - } - - // https://help.sumologic.com/05Search/Search-Query-Language/Search-Operators/timeslice#syntax - timeslice, err := time.ParseDuration(matchResults[1]) - if err != nil { - return 0, fmt.Errorf("error parsing timeslice duration: %s", err.Error()) - } - - return timeslice, nil -} - -func validateSumoLogicN9Fields(sl v.StructLevel, metric SumoLogicMetric) { - if matched, _ := regexp.MatchString(`(?m).*\bn9_value\b.*`, *metric.Query); !matched { - sl.ReportError(metric.Query, "query", "Query", "n9_value is required", "") - } - - if matched, _ := regexp.MatchString(`(?m).*\bn9_time\b`, *metric.Query); !matched { - sl.ReportError(metric.Query, "query", "Query", "n9_time is required", "") - } - - if matched, _ := regexp.MatchString(`(?m).*\bby\b.*`, *metric.Query); !matched { - sl.ReportError(metric.Query, "query", "Query", "aggregation function is required", "") - } -} - -func validateAzureMonitorMetricsConfiguration(sl v.StructLevel) { - metric, ok := sl.Current().Interface().(AzureMonitorMetric) - if !ok { - sl.ReportError(metric, "", "", "structConversion", "") - return - } - - isValidAzureMonitorAggregation(sl, metric) -} - -func isValidAzureMonitorAggregation(sl v.StructLevel, metric AzureMonitorMetric) { - availableAggregations := map[string]struct{}{ - "Avg": {}, - "Min": {}, - "Max": {}, - "Count": {}, - "Sum": {}, - } - if _, ok := availableAggregations[metric.Aggregation]; !ok { - msg := fmt.Sprintf( - "aggregation [%s] is invalid, use one of: [%s]", - metric.Aggregation, strings.Join(maps.Keys(availableAggregations), "|"), - ) - sl.ReportError(metric.Aggregation, "aggregation", "Aggregation", msg, "") - } -} - -func supportedHoneycombCalculationType(fl v.FieldLevel) bool { - value := fl.Field().String() - switch value { - case "COUNT", "SUM", "AVG", "COUNT_DISTINCT", "MAX", "MIN", - "P001", "P01", "P05", "P10", "P25", "P50", "P75", "P90", "P95", "P99", "P999", - "RATE_AVG", "RATE_SUM", "RATE_MAX": - return true - } - return false -} - -func supportedHoneycombFilterConditionOperator(fl v.FieldLevel) bool { - value := fl.Field().String() - switch value { - case "=", "!=", ">", ">=", "<", "<=", - "starts-with", "does-not-start-with", "exists", "does-not-exist", - "contains", "does-not-contain", "in", "not-in": - return true - } - return false -} - -func validateHoneycombFilter(sl v.StructLevel) { - hf := sl.Current().Interface().(HoneycombFilter) - - if len(hf.Conditions) <= 1 { - return - } - - validOperators := map[string]struct{}{ - "AND": {}, - "OR": {}, - } - if hf.Operator == "" { - sl.ReportError( - hf.Operator, "Operator", "Operator", - "Operator is required if there is more than one condition", "", - ) - } else if _, ok := validOperators[hf.Operator]; !ok { - msg := fmt.Sprintf( - "Operator is invalid, use one of: [%s]", strings.Join(maps.Keys(validOperators), "|"), - ) - sl.ReportError(hf.Operator, "Operator", "Operator", msg, "") - } - - // Validate for duplicate conditions. - conditions := make(map[string]struct{}) - for _, condition := range hf.Conditions { - key := fmt.Sprintf("%s|%s|%s", condition.Attribute, condition.Operator, condition.Value) - if _, exists := conditions[key]; exists { - sl.ReportError( - hf.Conditions, "Conditions", "conditions", - "Conditions must be unique", "", - ) - break - } - conditions[key] = struct{}{} - } -} diff --git a/manifest/v1alpha/validator_test.go b/manifest/v1alpha/validator_test.go index d973ae16..24f4cf7b 100644 --- a/manifest/v1alpha/validator_test.go +++ b/manifest/v1alpha/validator_test.go @@ -1,12 +1,8 @@ package v1alpha import ( - "fmt" - "reflect" - "sort" "testing" - "github.com/aws/aws-sdk-go/aws" v "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" "golang.org/x/exp/slices" @@ -234,296 +230,6 @@ func TestAnnotationSpecStructDatesValidation(t *testing.T) { } } -func TestInfluxDBQueryValidation(t *testing.T) { - tests := []struct { - name string - query string - isValid bool - }{ - { - name: "basic good query", - query: `from(bucket: "influxdb-integration-samples") - |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, - isValid: true, - }, - { - name: "Query should contain name 'params.n9time_start", - query: `from(bucket: "influxdb-integration-samples") - |> range(start: time(v: params.n9time_definitely_not_start), stop: time(v: params.n9time_stop))`, - isValid: false, - }, - { - name: "Query should contain name 'params.n9time_stop", - query: `from(bucket: "influxdb-integration-samples") - |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_bad_stop))`, - isValid: false, - }, - { - name: "Query cannot be empty", - query: ``, - isValid: false, - }, - { - name: "User can add whitespaces", - query: `from(bucket: "influxdb-integration-samples") - |> range ( start : time ( v : params.n9time_start ) -, stop : time ( v : params.n9time_stop ) )`, - isValid: true, - }, - { - name: "User cannot add whitespaces inside words", - query: `from(bucket: "influxdb-integration-samples") - |> range(start: time(v: par ams.n9time_start), stop: time(v: params.n9time_stop))`, - isValid: false, - }, - { - name: "User cannot split variables connected by .", - query: `from(bucket: "influxdb-integration-samples") - |> range(start: time(v: params. n9time_start), stop: time(v: params.n9time_stop))`, - isValid: false, - }, - { - name: "Query need to have bucket value", - query: `from(et: "influxdb-integration-samples") - |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, - isValid: false, - }, - { - name: "Bucket name need to be present", - query: `from(bucket: "") - |> range(start: time(v: params.n9time_start), stop: time(v: params.n9time_stop))`, - isValid: false, - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.isValid, validateInfluxDBQuery(testCase.query)) - }) - } -} - -func TestNewRelicQueryValidation(t *testing.T) { - tests := []struct { - name string - query string - isValid bool - }{ - { - name: "basic good query", - query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric - WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, - isValid: true, - }, - { - name: "query with case insensitive since", - query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric - WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX SiNCE`, - isValid: false, - }, - { - name: "query with case insensitive until", - query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric - WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') uNtIL LIMIT MAX TIMESERIES`, - isValid: false, - }, - { - name: "query with since in quotation marks", - query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric 'SINCE' - WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, - isValid: true, - }, - { - name: "query with until in quotation marks", - query: `SELECT average(test.duration)*1000 AS 'Response time' FROM Metric "UNTIL" - WHERE (entity.guid = 'somekey') AND (transactionType = 'Other') LIMIT MAX TIMESERIES`, - isValid: true, - }, - } - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.isValid, validateNewRelicQuery(testCase.query)) - }) - } -} - -func TestBigQueryQueryValidation(t *testing.T) { - tests := []struct { - name string - query string - isValid bool - }{ - { - name: "basic good query", - query: `SELECT val_col AS n9value, - DATETIME(date_col) AS n9date - FROM - project.dataset.table - WHERE - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: true, - }, - { - name: "All lowercase good query", - query: `select val_col AS n9value, - DATETIME(date_col) AS n9date - from - project.dataset.table - where - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: true, - }, - { - name: "Good query mixed case", - query: `SeLeCt val_col AS n9value, - DATETIME(date_col) AS n9date - FroM - project.dataset.table - wherE - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: true, - }, - { - name: "Missing query", - query: ``, - isValid: false, - }, - { - name: "Missing n9value", - query: `SeLeCt val_col AS abc, - DATETIME(date_col) AS n9date - FroM - project.dataset.table - wherE - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: false, - }, - { - name: "Missing n9date", - query: `SeLeCt val_col AS n9value, - DATETIME(date_col) AS abc - FroM - project.dataset.table - wherE - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: false, - }, - { - name: "Missing n9date_from", - query: `SeLeCt val_col AS n9value, - DATETIME(date_col) AS n9date - FroM - project.dataset.table - wherE - date_col BETWEEN - DATETIME(@abc) - AND DATETIME(@n9date_to)`, - isValid: false, - }, - { - name: "Missing n9date_to", - query: `eLeCt val_col AS n9value, - DATETIME(date_col) AS n9date - FroM - project.dataset.table - wherE - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@abc)`, - isValid: false, - }, - { - name: "n9value shouldn't have uppercase letters", - query: `select val_col AS n9Value, - DATETIME(date_col) AS n9date - FroM, - project.dataset.table - where - date_col BETWEEN - DATETIME(@n9date_from) - AND DATETIME(@n9date_to)`, - isValid: false, - }, - } - - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.isValid, validateBigQueryQuery(testCase.query)) - }) - } -} - -func TestElasticsearchQueryValidation(t *testing.T) { - validate := v.New() - index := "apm-7.13.3-transaction" - err := validate.RegisterValidation("elasticsearchBeginEndTimeRequired", isValidElasticsearchQuery) - if err != nil { - assert.FailNow(t, "Cannot register elasticsearch validator") - } - for _, testCase := range []struct { - desc string - query string - isValid bool - }{ - { - desc: "empty query", - query: "", - isValid: false, - }, - { - desc: "query has no placeholders", - query: `"@timestamp": { - "gte": "now-30m/m", - "lte": "now/m" - }`, - isValid: false, - }, - { - desc: "query has only {{.BeginTime}} placeholder", - query: `"@timestamp": { - "gte": "{{.BeginTime}}", - "lte": "now/m" - }`, - isValid: false, - }, - { - desc: "query has only {{.EndTime}} placeholder", - query: `"@timestamp": { - "gte": "now-30m/m", - "lte": "{{.EndTime}}" - }`, - isValid: false, - }, - { - desc: "query have all the required placeholders", - query: `"@timestamp": { - "gte": "{{.BeginTime}}", - "lte": "{{.EndTime}}" - }`, - isValid: true, - }, - } { - t.Run(testCase.desc, func(t *testing.T) { - metric := ElasticsearchMetric{Query: &testCase.query, Index: &index} - err := validate.Struct(metric) - if testCase.isValid { - assert.NoError(t, err) - } else { - assert.Error(t, err) - } - }) - } -} - func TestAlertSilencePeriodValidation(t *testing.T) { validate := v.New() validate.RegisterStructValidation(alertSilencePeriodValidation, AlertSilencePeriod{}) @@ -600,385 +306,6 @@ func TestAlertSilencePeriodValidation(t *testing.T) { } } -func TestSupportedThousandEyesTestType(t *testing.T) { - var testID int64 = 1 - validate := v.New() - err := validate.RegisterValidation("supportedThousandEyesTestType", supportedThousandEyesTestType) - if err != nil { - assert.FailNow(t, "cannot register supportedThousandEyesTestType validator") - } - testCases := []struct { - testType string - isSupported bool - }{ - { - "net-latency", - true, - }, - { - "net-loss", - true, - }, - { - "web-page-load", - true, - }, - { - "web-dom-load", - true, - }, - { - "http-response-time", - true, - }, - { - "http-server-availability", - true, - }, - { - "http-server-throughput", - true, - }, - { - "http-server-total-time", - true, - }, - { - "dns-server-resolution-time", - true, - }, - { - "dns-dnssec-valid", - true, - }, - { - "", - false, - }, - { - "none", - false, - }, - } - for _, tC := range testCases { - t.Run(tC.testType, func(t *testing.T) { - err := validate.Struct(ThousandEyesMetric{TestID: &testID, TestType: &tC.testType}) - if tC.isSupported { - assert.Nil(t, err) - } else { - assert.Error(t, err) - } - }) - } -} - -func TestLightstepMetric(t *testing.T) { - negativePercentile := -1.0 - zeroPercentile := 0.0 - positivePercentile := 95.0 - overflowPercentile := 100.0 - streamID := "123" - validUQL := `( - metric cpu.utilization | rate | filter error == true && service == spans_sample | group_by [], min; - spans count | rate | group_by [], sum - ) | join left/right * 100` - forbiddenSpanSampleJoinedUQL := `( - spans_sample count | delta | filter error == true && service == android | group_by [], sum; - spans_sample count | delta | filter service == android | group_by [], sum - ) | join left/right * 100 - ` - forbiddenConstantUQL := "constant .5" - forbiddenSpansSampleUQL := "spans_sample span filter" - forbiddenAssembleUQL := "assemble span" - createSpec := func(uql, streamID, dataType *string, percentile *float64) *MetricSpec { - return &MetricSpec{ - Lightstep: &LightstepMetric{ - UQL: uql, - StreamID: streamID, - TypeOfData: dataType, - Percentile: percentile, - }, - } - } - getStringPointer := func(s string) *string { return &s } - validate := v.New() - validate.RegisterStructValidation(metricSpecStructLevelValidation, MetricSpec{}) - - testCases := []struct { - description string - spec *MetricSpec - errors []string - }{ - { - description: "Valid latency type spec", - spec: createSpec(nil, &streamID, getStringPointer(LightstepLatencyDataType), &positivePercentile), - errors: nil, - }, - { - description: "Invalid latency type spec", - spec: createSpec(&validUQL, nil, getStringPointer(LightstepLatencyDataType), nil), - errors: []string{"percentileRequired", "streamIDRequired", "uqlNotAllowed"}, - }, - { - description: "Invalid latency type spec - negative percentile", - spec: createSpec(nil, &streamID, getStringPointer(LightstepLatencyDataType), &negativePercentile), - errors: []string{"invalidPercentile"}, - }, - { - description: "Invalid latency type spec - zero percentile", - spec: createSpec(nil, &streamID, getStringPointer(LightstepLatencyDataType), &zeroPercentile), - errors: []string{"invalidPercentile"}, - }, - { - description: "Invalid latency type spec - overflow percentile", - spec: createSpec(nil, &streamID, getStringPointer(LightstepLatencyDataType), &overflowPercentile), - errors: []string{"invalidPercentile"}, - }, - { - description: "Valid error rate type spec", - spec: createSpec(nil, &streamID, getStringPointer(LightstepErrorRateDataType), nil), - errors: nil, - }, - { - description: "Invalid error rate type spec", - spec: createSpec(&validUQL, nil, getStringPointer(LightstepErrorRateDataType), &positivePercentile), - errors: []string{"streamIDRequired", "percentileNotAllowed", "uqlNotAllowed"}, - }, - { - description: "Valid total count type spec", - spec: createSpec(nil, &streamID, getStringPointer(LightstepTotalCountDataType), nil), - errors: nil, - }, - { - description: "Invalid total count type spec", - spec: createSpec(&validUQL, nil, getStringPointer(LightstepTotalCountDataType), &positivePercentile), - errors: []string{"streamIDRequired", "uqlNotAllowed", "percentileNotAllowed"}, - }, - { - description: "Valid good count type spec", - spec: createSpec(nil, &streamID, getStringPointer(LightstepGoodCountDataType), nil), - errors: nil, - }, - { - description: "Invalid good count type spec", - spec: createSpec(&validUQL, nil, getStringPointer(LightstepGoodCountDataType), &positivePercentile), - errors: []string{"streamIDRequired", "uqlNotAllowed", "percentileNotAllowed"}, - }, - { - description: "Valid metric type spec", - spec: createSpec(&validUQL, nil, getStringPointer(LightstepMetricDataType), nil), - errors: nil, - }, - { - description: "Invalid metric type spec", - spec: createSpec(nil, &streamID, getStringPointer(LightstepMetricDataType), &positivePercentile), - errors: []string{"uqlRequired", "percentileNotAllowed", "streamIDNotAllowed"}, - }, - { - description: "Invalid metric type spec - empty UQL", - spec: createSpec(getStringPointer(""), nil, getStringPointer(LightstepMetricDataType), nil), - errors: []string{"uqlRequired"}, - }, - { - description: "Invalid metric type spec - not supported UQL", - spec: createSpec(&forbiddenSpanSampleJoinedUQL, nil, getStringPointer(LightstepMetricDataType), nil), - errors: []string{"onlyMetricAndSpansUQLQueriesAllowed"}, - }, - { - description: "Invalid metric type spec - not supported UQL", - spec: createSpec(&forbiddenConstantUQL, nil, getStringPointer(LightstepMetricDataType), nil), - errors: []string{"onlyMetricAndSpansUQLQueriesAllowed"}, - }, - { - description: "Invalid metric type spec - not supported UQL", - spec: createSpec(&forbiddenSpansSampleUQL, nil, getStringPointer(LightstepMetricDataType), nil), - errors: []string{"onlyMetricAndSpansUQLQueriesAllowed"}, - }, - { - description: "Invalid metric type spec - not supported UQL", - spec: createSpec(&forbiddenAssembleUQL, nil, getStringPointer(LightstepMetricDataType), nil), - errors: []string{"onlyMetricAndSpansUQLQueriesAllowed"}, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - err := validate.Struct(tc.spec) - if len(tc.errors) == 0 { - assert.Nil(t, err) - - return - } - - validationErrors, ok := err.(v.ValidationErrors) - if !ok { - assert.FailNow(t, "cannot cast error to validator.ValidatorErrors") - } - var errors []string - for _, ve := range validationErrors { - errors = append(errors, ve.Tag()) - } - sort.Strings(tc.errors) - sort.Strings(errors) - assert.True(t, reflect.DeepEqual(tc.errors, errors)) - }) - } -} - -func TestIsBadOverTotalEnabledForDataSource_appd(t *testing.T) { - slo := SLOSpec{ - Objectives: []Objective{{CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{AppDynamics: &AppDynamicsMetric{}}, - TotalMetric: &MetricSpec{AppDynamics: &AppDynamicsMetric{}}, - }}}, - } - r := isBadOverTotalEnabledForDataSource(slo) - assert.True(t, r) -} - -func TestIsBadOverTotalEnabledForDataSource_cloudwatch(t *testing.T) { - slo := SLOSpec{ - Objectives: []Objective{{CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{CloudWatch: &CloudWatchMetric{}}, - TotalMetric: &MetricSpec{CloudWatch: &CloudWatchMetric{}}, - }}}, - } - r := isBadOverTotalEnabledForDataSource(slo) - assert.True(t, r) -} - -func TestValidateAzureResourceID(t *testing.T) { - testCases := []struct { - desc string - resourceID string - isValid bool - }{ - { - desc: "empty", - resourceID: "", - isValid: false, - }, - { - desc: "one letter", - resourceID: "a", - isValid: false, - }, - { - desc: "incomplete resource provider", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/vm", - isValid: false, - }, - { - desc: "missing resource providerNamespace", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/Test-RG1/providers/virtualMachines/vm", //nolint:lll - isValid: false, - }, - { - desc: "missing resource type", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/vm", //nolint:lll - isValid: false, - }, - { - desc: "missing resource name", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines", //nolint:lll - isValid: false, - }, - { - desc: "valid resource id", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm", //nolint:lll - isValid: true, - }, - { - desc: "valid resource id with _", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-123_x", //nolint:lll - isValid: true, - }, - { - desc: "valid resource id with _ in rg", - resourceID: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mc_().rg-xxx-01_ups-aks_eu_west/providers/Microsoft.()Network/loadBalancers1_-()/kubernetes", //nolint:lll - isValid: true, - }, - } - - val := v.New() - err := val.RegisterValidation("azureResourceID", isValidAzureResourceID) - if err != nil { - assert.FailNow(t, "Cannot register validator") - } - - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - err := val.Var(tC.resourceID, "azureResourceID") - if tC.isValid { - assert.Nil(t, err) - } else { - assert.Error(t, err) - } - }) - } -} - -func TestIsBadOverTotalEnabledForDataSource_azuremonitor(t *testing.T) { - slo := SLOSpec{ - Objectives: []Objective{{CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{}}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{}}, - }}}, - } - r := isBadOverTotalEnabledForDataSource(slo) - assert.True(t, r) -} - -func TestIsBadOverTotalEnabledForDataSource_honeycomb(t *testing.T) { - slo := SLOSpec{ - Objectives: []Objective{{CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{Honeycomb: &HoneycombMetric{}}, - TotalMetric: &MetricSpec{Honeycomb: &HoneycombMetric{}}, - }}}, - } - r := isBadOverTotalEnabledForDataSource(slo) - assert.True(t, r) -} - -func TestAlertConditionOnlyMeasurementAverageBurnRateIsAllowedToUseAlertingWindow(t *testing.T) { - validate := NewValidator() - for condition, isValid := range map[AlertCondition]bool{ - { - Measurement: MeasurementTimeToBurnEntireBudget.String(), - AlertingWindow: "10m", - Value: "30m", - Operator: LessThanEqual.String(), - }: false, - { - Measurement: MeasurementTimeToBurnBudget.String(), - AlertingWindow: "10m", - Value: "30m", - Operator: LessThan.String(), - }: false, - { - Measurement: MeasurementBurnedBudget.String(), - AlertingWindow: "10m", - Value: 30.0, - Operator: GreaterThanEqual.String(), - }: false, - { - Measurement: MeasurementAverageBurnRate.String(), - AlertingWindow: "10m", - Value: 30.0, - Operator: GreaterThanEqual.String(), - }: true, - } { - t.Run(condition.Measurement, func(t *testing.T) { - err := validate.Check(condition) - if isValid { - assert.NoError(t, err) - } else { - assert.Error(t, err) - } - }) - } -} - func TestAlertConditionAllowedOptionalOperatorForMeasurementType(t *testing.T) { const emptyOperator = "" allOps := []string{"gt", "lt", "lte", "gte", "noop", ""} @@ -1140,486 +467,3 @@ func TestAlertingWindowValidation(t *testing.T) { }) } } - -func TestAzureMonitorSloSpecValidation(t *testing.T) { - t.Parallel() - testCases := []struct { - desc string - sloSpec SLOSpec - isValid bool - }{ - { - desc: "different namespace good/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - GoodMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - MetricNamespace: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - MetricNamespace: "2", - }}, - }, - }}, - }, - isValid: false, - }, { - desc: "different namespace bad/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - MetricNamespace: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - MetricNamespace: "2", - }}, - }, - }}, - }, - isValid: false, - }, { - desc: "different resourceID good/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - GoodMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "2", - }}, - }, - }}, - }, - isValid: false, - }, { - desc: "different resourceID bad/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "2", - }}, - }, - }}, - }, - isValid: false, - }, { - desc: "the same resourceID and namespace good/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - GoodMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - MetricNamespace: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - MetricNamespace: "1", - }}, - }, - }}, - }, - isValid: true, - }, { - desc: "the same resourceID and namespace bad/total", - sloSpec: SLOSpec{ - Objectives: []Objective{{ - CountMetrics: &CountMetricsSpec{ - BadMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - MetricNamespace: "1", - }}, - TotalMetric: &MetricSpec{AzureMonitor: &AzureMonitorMetric{ - ResourceID: "1", - MetricNamespace: "1", - }}, - }, - }}, - }, - isValid: true, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.desc, func(t *testing.T) { - t.Parallel() - isValid := haveAzureMonitorCountMetricSpecTheSameResourceIDAndMetricNamespace(tc.sloSpec) - assert.Equal(t, tc.isValid, isValid) - }) - } -} - -func Test_isValidAWSAccountID(t *testing.T) { - tests := []struct { - name string - accountID string - want bool - }{ - { - name: "allow empty accountID", - accountID: "", - want: true, - }, - { - name: "allow proper accountID", - accountID: "123456789012", - want: true, - }, - { - name: "deny too short numeric accountID", - accountID: "1234", - want: false, - }, - { - name: "deny too long numeric accountID", - accountID: "1234567890121", - want: false, - }, - { - name: "deny too short alfa-numeric accountID", - accountID: "1234avb", - want: false, - }, - { - name: "deny 12 char alfa-numeric accountID", - accountID: "1234avb12345", - want: false, - }, - { - name: "deny 12 char alfa accountID", - accountID: "abcerjasdyja", - want: false, - }, - { - name: "deny short char alfa accountID", - accountID: "abcasdyja", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, isValidAWSAccountID(tt.accountID), "isValidCloudWatchAccountID(%v)", tt.accountID) - }) - } -} - -func Test_cloudWatchMetricStructValidation(t *testing.T) { - validator := v.New() - validator.RegisterStructValidation(cloudWatchMetricStructValidation, CloudWatchMetric{}) - _ = validator.RegisterValidation("uniqueDimensionNames", areDimensionNamesUnique) - - type fieldError struct { - field string - tag string - } - - tests := []struct { - name string - metric CloudWatchMetric - wantErrorTags []fieldError - }{ - { - name: "exact one config type", - metric: CloudWatchMetric{ - SQL: aws.String("test"), - JSON: aws.String("test"), - }, - wantErrorTags: []fieldError{ - {"Region", "required"}, - {"stat", "exactlyOneConfigType"}, - {"sql", "exactlyOneConfigType"}, - {"json", "exactlyOneConfigType"}, - }, - }, - { - name: "invalid region", - metric: CloudWatchMetric{ - SQL: aws.String("test"), - Region: aws.String("test"), - }, - wantErrorTags: []fieldError{ - {"region", "regionNotAvailable"}, - }, - }, - { - name: "invalid accountId", - metric: CloudWatchMetric{ - Namespace: aws.String("namespace"), - Region: aws.String("us-east-2"), - MetricName: aws.String("metric"), - Stat: aws.String("Average"), - Dimensions: []CloudWatchMetricDimension{}, - AccountID: aws.String("1234"), - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdInvalid"}, - }, - }, - { - name: "accountId for json config must be empty", - metric: CloudWatchMetric{ - JSON: aws.String(`[{"id":"1","period":60}]`), - Region: aws.String("us-east-2"), - AccountID: aws.String("1234"), - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdMustBeEmpty"}, - }, - }, - { - name: "empty region will not throw panic", - metric: CloudWatchMetric{ - JSON: aws.String(`[{"id":"1","period":60}]`), - AccountID: aws.String("1234"), - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdMustBeEmpty"}, - {"Region", "required"}, - }, - }, - { - name: "AccountId must be empty for JSON", - metric: CloudWatchMetric{ - JSON: aws.String(`[{"id":"1","period":60}]`), - Region: aws.String("us-east-2"), - }, - wantErrorTags: []fieldError{}, - }, - { - name: "accountId for configuration config is optional", - metric: CloudWatchMetric{ - Namespace: aws.String("namespace"), - Region: aws.String("us-east-2"), - MetricName: aws.String("metric"), - Stat: aws.String("Average"), - Dimensions: []CloudWatchMetricDimension{}, - }, - wantErrorTags: []fieldError{}, - }, - { - name: "accountId for configuration config is validated", - metric: CloudWatchMetric{ - AccountID: aws.String("1234"), - Namespace: aws.String("namespace"), - Region: aws.String("us-east-2"), - MetricName: aws.String("metric"), - Stat: aws.String("Average"), - Dimensions: []CloudWatchMetricDimension{}, - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdInvalid"}, - }, - }, - { - name: "accountId for sql not supported", - metric: CloudWatchMetric{ - AccountID: aws.String("1234"), - SQL: aws.String("test sql"), - Region: aws.String("us-east-2"), - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdForSQLNotSupported"}, - }, - }, - { - name: "accountId for json with sql is not supported", - metric: CloudWatchMetric{ - JSON: aws.String(`[{"Id": "m1","AccountId":"123456789012", "Expression": "SQL TEST","Period": 60}]`), - Region: aws.String("us-east-2"), - }, - wantErrorTags: []fieldError{ - {"json", "accountIdForSQLNotSupported"}, - }, - }, - { - name: "accountId for supported json query", - metric: CloudWatchMetric{ - JSON: aws.String(`[ - { - "Id": "m1", - "AccountId": "123456789012", - "MetricStat": { - "Metric": { - "Namespace": "AWS/ApplicationELB", - "MetricName": "HTTPCode_Target_2XX_Count", - "Dimensions": [ - { - "Name": "LoadBalancer", - "Value": "app/main-default-appingress-350b/904311bedb964754" - } - ] - }, - "Period": 60, - "Stat": "SampleCount" - } - } - ]`), - Region: aws.String("us-east-2"), - }, - wantErrorTags: []fieldError{}, - }, - { - name: "validate accountId in json query", - metric: CloudWatchMetric{ - JSON: aws.String(`[ - { - "Id": "m1", - "AccountId": "12345678", - "MetricStat": { - "Metric": { - "Namespace": "AWS/ApplicationELB", - "MetricName": "HTTPCode_Target_2XX_Count", - "Dimensions": [ - { - "Name": "LoadBalancer", - "Value": "app/main-default-appingress-350b/904311bedb964754" - } - ] - }, - "Period": 60, - "Stat": "SampleCount" - } - } - ]`), - Region: aws.String("us-east-2"), - }, - wantErrorTags: []fieldError{ - {"accountId", "accountIdInvalid"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.Struct(tt.metric) - if len(tt.wantErrorTags) == 0 { - assert.Nil(t, err) - return - } - - validationErrors, ok := err.(v.ValidationErrors) - if !ok { - t.Error("Expected a validation error, but got a different error type") - } - - var tags []fieldError - for _, err := range validationErrors { - tags = append(tags, fieldError{tag: err.Tag(), field: err.Field()}) - } - - assert.ElementsMatch(t, tags, tt.wantErrorTags) - }) - } -} - -func TestHoneycombValidation(t *testing.T) { - validate := NewValidator() - testCases := []struct { - desc string - input HoneycombMetric - isValid bool - }{ - { - desc: "Invalid Honeycomb Condition Count", - input: HoneycombMetric{ - Dataset: "dataset1", - Calculation: "COUNT", - Attribute: "attr", - Filter: HoneycombFilter{ - Operator: "AND", - Conditions: createTooManyHoneycombConditions(), - }, - }, - isValid: false, - }, - { - desc: "Valid HoneycombMetric", - input: HoneycombMetric{ - Dataset: "dataset1", - Calculation: "COUNT", - Attribute: "attr", - Filter: HoneycombFilter{ - Operator: "AND", - Conditions: []HoneycombFilterCondition{ - {Attribute: "attr", Operator: ">"}, - }, - }, - }, - isValid: true, - }, - { - desc: "Invalid Operator in HoneycombFilter with multiple Conditions", - input: HoneycombMetric{ - Dataset: "dataset1", - Calculation: "COUNT", - Attribute: "attr", - Filter: HoneycombFilter{ - Operator: "INVALID", - Conditions: []HoneycombFilterCondition{ - {Attribute: "attr", Operator: ">"}, - {Attribute: "attr2", Operator: "<"}, - }, - }, - }, - isValid: false, - }, - { - desc: "Invalid Calculation Type", - input: HoneycombMetric{ - Dataset: "dataset1", - Calculation: "INVALID", - Attribute: "attr", - Filter: HoneycombFilter{ - Operator: "AND", - Conditions: []HoneycombFilterCondition{ - {Attribute: "attr", Operator: ">"}, - }, - }, - }, - isValid: false, - }, - { - desc: "Invalid Condition Operator", - input: HoneycombMetric{ - Dataset: "dataset1", - Calculation: "COUNT", - Attribute: "attr", - Filter: HoneycombFilter{ - Operator: "AND", - Conditions: []HoneycombFilterCondition{ - {Attribute: "attr", Operator: "INVALID_OPERATOR"}, - }, - }, - }, - isValid: false, - }, - } - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - err := validate.Check(tc.input) - if tc.isValid { - assert.Nil(t, err) - } else { - assert.NotNil(t, err) - } - }) - } -} - -func createTooManyHoneycombConditions() []HoneycombFilterCondition { - tooManyHoneycombConditions := make([]HoneycombFilterCondition, 101) - for i := 0; i < 101; i++ { - tooManyHoneycombConditions[i] = HoneycombFilterCondition{ - Attribute: fmt.Sprintf("attr%d", i), - Operator: ">", - } - } - return tooManyHoneycombConditions -} diff --git a/package.json b/package.json index b8e00b05..cf7ecfc6 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,5 @@ "check-trailing-whitespaces": "node ./scripts/check-trailing-whitespaces.js", "format-cspell-config": "node ./scripts/format-cspell-config.js" }, - "packageManager": "yarn@4.0.2" + "packageManager": "yarn@1.22.21" } diff --git a/sdk/test_data/client/simple_module/go.mod b/sdk/test_data/client/simple_module/go.mod index e660c580..59b6e3ef 100644 --- a/sdk/test_data/client/simple_module/go.mod +++ b/sdk/test_data/client/simple_module/go.mod @@ -34,9 +34,9 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rs/zerolog v1.30.0 // indirect golang.org/x/crypto v0.14.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect ) diff --git a/sdk/test_data/client/simple_module/go.sum b/sdk/test_data/client/simple_module/go.sum index e6c51ad4..683f3ae2 100644 --- a/sdk/test_data/client/simple_module/go.sum +++ b/sdk/test_data/client/simple_module/go.sum @@ -91,6 +91,7 @@ golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -118,6 +119,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/validation/error_codes.go b/validation/error_codes.go index c29ccbff..7aa568ba 100644 --- a/validation/error_codes.go +++ b/validation/error_codes.go @@ -21,8 +21,8 @@ const ( ErrorCodeStringJSON ErrorCode = "string_json" ErrorCodeStringContains ErrorCode = "string_contains" ErrorCodeStringLength ErrorCode = "string_length" - ErrorCodeStringMinLength ErrorCode = "string_max_length" - ErrorCodeStringMaxLength ErrorCode = "string_min_length" + ErrorCodeStringMinLength ErrorCode = "string_min_length" + ErrorCodeStringMaxLength ErrorCode = "string_max_length" ErrorCodeSliceLength ErrorCode = "slice_length" ErrorCodeSliceMinLength ErrorCode = "slice_min_length" ErrorCodeSliceMaxLength ErrorCode = "slice_max_length"