diff --git a/api-tests/alerting/thresholds_test.go b/api-tests/alerting/thresholds_test.go new file mode 100644 index 00000000000..31f5dc5c3c1 --- /dev/null +++ b/api-tests/alerting/thresholds_test.go @@ -0,0 +1,370 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "fmt" + "net/http" + "testing" + + "github.com/AlekSi/pointer" + "github.com/grafana/grafana-openapi-client-go/client/folders" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + + pmmapitests "github.com/percona/pmm/api-tests" + alertingClient "github.com/percona/pmm/api/alerting/v1/json/client" + alerting "github.com/percona/pmm/api/alerting/v1/json/client/alerting_service" +) + +const ( + scopeNode = "THRESHOLD_SCOPE_NODE" + scopeService = "THRESHOLD_SCOPE_SERVICE" + scopeCluster = "THRESHOLD_SCOPE_CLUSTER" +) + +// thresholdFixture is a rule created from an overridable template, plus the node its +// thresholds are set against. +type thresholdFixture struct { + client alerting.ClientService + ruleID string + nodeID string +} + +// setupThresholdFixture registers an overridable template, creates a rule from it and a +// node to target, and cleans all three up afterwards. +func setupThresholdFixture(t *testing.T) *thresholdFixture { + t.Helper() + + client := alertingClient.Default.AlertingService + + floatType := "PARAM_TYPE_FLOAT" + severity := "SEVERITY_WARNING" + forceDelete := true + + templateName := pmmapitests.TestString(t, "test-threshold-template") + yml := fmt.Sprintf(`templates: + - name: %s + version: 1 + summary: Overridable threshold + queries: + - ref_id: A + expr: |- + (1 - avg by(node_name) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100 + expressions: + - ref_id: C + type: math + expression: "$A > [[ .threshold ]]" + condition: C + params: + - name: threshold + summary: A percentage from configured maximum + unit: "%%" + type: float + range: [0, 100] + value: 80 + overridable: true + for: 60s + severity: warning + annotations: + summary: overridable threshold +`, templateName) + + _, err := client.CreateTemplate(&alerting.CreateTemplateParams{ + Body: alerting.CreateTemplateBody{Yaml: yml}, + Context: pmmapitests.Context, + }) + require.NoError(t, err) + t.Cleanup(func() { deleteTemplate(t, client, templateName) }) + + gClient := pmmapitests.GetGrafanaClient(t) + createdFolder, err := gClient.Folders.CreateFolder(&models.CreateFolderCommand{ + Title: pmmapitests.TestString(t, "test-threshold-folder"), + }) + require.NoError(t, err) + folder := createdFolder.Payload + t.Cleanup(func() { + _, _ = gClient.Folders.DeleteFolder( + folders.NewDeleteFolderParams().WithFolderUID(folder.UID).WithForceDeleteRules(&forceDelete), + ) + }) + + created, err := client.CreateRule(&alerting.CreateRuleParams{ + Body: alerting.CreateRuleBody{ + TemplateName: templateName, + Name: pmmapitests.TestString(t, "test-threshold-rule"), + FolderUID: folder.UID, + Group: "test", + Interval: "10s", + For: "60s", + Severity: &severity, + Params: []*alerting.CreateRuleParamsBodyParamsItems0{ + {Name: "threshold", Type: &floatType, Float: 80}, + }, + }, + Context: pmmapitests.Context, + }) + require.NoError(t, err) + + // A rule built from an overridable template must come back with an identity to key + // its overrides on; without one the whole feature is unreachable. + require.NotEmpty(t, created.Payload.RuleID, + "CreateRule must return a rule_id for an overridable template") + + node := pmmapitests.AddGenericNode(t, pmmapitests.TestString(t, "test-threshold-node")) + t.Cleanup(func() { pmmapitests.RemoveNodes(t, node.NodeID) }) + + return &thresholdFixture{ + client: client, + ruleID: created.Payload.RuleID, + nodeID: node.NodeID, + } +} + +func (f *thresholdFixture) list(t *testing.T) []*alerting.ListThresholdsOKBodyThresholdsItems0 { + t.Helper() + + res, err := f.client.ListThresholds(&alerting.ListThresholdsParams{ + Scope: new(scopeNode), + Target: new(f.nodeID), + RuleID: new(f.ruleID), + Context: pmmapitests.Context, + }) + require.NoError(t, err) + + return res.Payload.Thresholds +} + +func (f *thresholdFixture) set(t *testing.T, value float64) (*alerting.SetThresholdOK, error) { + t.Helper() + + return f.client.SetThreshold(&alerting.SetThresholdParams{ + Body: alerting.SetThresholdBody{ + Scope: new(scopeNode), + Target: f.nodeID, + RuleID: f.ruleID, + ParamName: "threshold", + Value: value, + }, + Context: pmmapitests.Context, + }) +} + +func TestThresholdOverrideLifecycle(t *testing.T) { + t.Parallel() + + f := setupThresholdFixture(t) + + // An untouched target still reports the parameter, at the rule's default. + before := f.list(t) + require.Len(t, before, 1) + assert.InDelta(t, 80, before[0].DefaultValue, 0.0001) + assert.InDelta(t, 80, before[0].EffectiveValue, 0.0001) + assert.False(t, before[0].IsOverridden) + + set, err := f.set(t, 95) + require.NoError(t, err) + assert.InDelta(t, 95, set.Payload.Threshold.EffectiveValue, 0.0001) + assert.True(t, set.Payload.Threshold.IsOverridden) + require.NotNil(t, set.Payload.Threshold.Scope) + assert.Equal(t, scopeNode, *set.Payload.Threshold.Scope) + + after := f.list(t) + require.Len(t, after, 1) + assert.InDelta(t, 95, after[0].EffectiveValue, 0.0001) + assert.True(t, after[0].IsOverridden) + + // Clearing returns the target to the default. The override row survives as a + // tombstone so the emitted series keeps existing and merely changes value, but that + // is invisible from here - what the API must report is "not overridden". + _, err = f.client.ClearThreshold(&alerting.ClearThresholdParams{ + Scope: new(scopeNode), + Target: new(f.nodeID), + RuleID: new(f.ruleID), + ParamName: new("threshold"), + Context: pmmapitests.Context, + }) + require.NoError(t, err) + + cleared := f.list(t) + require.Len(t, cleared, 1) + assert.InDelta(t, 80, cleared[0].EffectiveValue, 0.0001) + assert.False(t, cleared[0].IsOverridden, + "a cleared override must not read as overridden, or every target ever tuned reads as tuned forever") +} + +func TestThresholdOverrideValidation(t *testing.T) { + t.Parallel() + + f := setupThresholdFixture(t) + + t.Run("value outside the declared range", func(t *testing.T) { + t.Parallel() + + _, err := f.set(t, 150) + pmmapitests.AssertAPIErrorf(t, err, http.StatusBadRequest, codes.InvalidArgument, "") + }) + + t.Run("unknown parameter", func(t *testing.T) { + t.Parallel() + + _, err := f.client.SetThreshold(&alerting.SetThresholdParams{ + Body: alerting.SetThresholdBody{ + Scope: new(scopeNode), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "not-overridable", Value: 90, + }, + Context: pmmapitests.Context, + }) + pmmapitests.AssertAPIErrorf(t, err, http.StatusNotFound, codes.NotFound, "") + }) + + t.Run("unknown rule", func(t *testing.T) { + t.Parallel() + + _, err := f.client.SetThreshold(&alerting.SetThresholdParams{ + Body: alerting.SetThresholdBody{ + Scope: new(scopeNode), Target: f.nodeID, + RuleID: "no-such-rule", ParamName: "threshold", Value: 90, + }, + Context: pmmapitests.Context, + }) + pmmapitests.AssertAPIErrorf(t, err, http.StatusNotFound, codes.NotFound, "") + }) + + t.Run("target that does not exist", func(t *testing.T) { + t.Parallel() + + _, err := f.client.SetThreshold(&alerting.SetThresholdParams{ + Body: alerting.SetThresholdBody{ + Scope: new(scopeNode), Target: "no-such-node", + RuleID: f.ruleID, ParamName: "threshold", Value: 90, + }, + Context: pmmapitests.Context, + }) + pmmapitests.AssertAPIErrorf(t, err, http.StatusNotFound, codes.NotFound, "") + }) + + // Service and cluster are already carried by the schema, the resolver and the proto, + // so they report as not-yet-implemented rather than as a malformed request. + t.Run("scopes that are not implemented yet", func(t *testing.T) { + t.Parallel() + + for _, scope := range []string{scopeService, scopeCluster} { + _, err := f.client.SetThreshold(&alerting.SetThresholdParams{ + Body: alerting.SetThresholdBody{ + Scope: new(scope), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "threshold", Value: 90, + }, + Context: pmmapitests.Context, + }) + pmmapitests.AssertAPIErrorf(t, err, http.StatusNotImplemented, codes.Unimplemented, "") + } + }) +} + +// TestThresholdBatchUpdate is deliberately not parallel, at either level: its subtests +// drive one override row through set, clear and rollback in that order, and the rollback +// case asserts against the state the clear case left behind. +func TestThresholdBatchUpdate(t *testing.T) { + f := setupThresholdFixture(t) + + t.Run("sets through the batch endpoint", func(t *testing.T) { + res, err := f.client.BatchUpdateThresholds(&alerting.BatchUpdateThresholdsParams{ + Body: alerting.BatchUpdateThresholdsBody{ + Updates: []*alerting.BatchUpdateThresholdsParamsBodyUpdatesItems0{{ + Scope: new(scopeNode), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "threshold", + Value: pointer.ToFloat64(70), + }}, + }, + Context: pmmapitests.Context, + }) + require.NoError(t, err) + require.Len(t, res.Payload.Thresholds, 1) + assert.InDelta(t, 70, res.Payload.Thresholds[0].EffectiveValue, 0.0001) + }) + + t.Run("an update with no value clears instead of setting", func(t *testing.T) { + res, err := f.client.BatchUpdateThresholds(&alerting.BatchUpdateThresholdsParams{ + Body: alerting.BatchUpdateThresholdsBody{ + Updates: []*alerting.BatchUpdateThresholdsParamsBodyUpdatesItems0{{ + Scope: new(scopeNode), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "threshold", + }}, + }, + Context: pmmapitests.Context, + }) + require.NoError(t, err) + assert.Empty(t, res.Payload.Thresholds, "cleared entries are omitted from the response") + + current := f.list(t) + require.Len(t, current, 1) + assert.False(t, current[0].IsOverridden) + }) + + // The reason the batch endpoint exists: a client editing several rows at once must + // never land a partial result it cannot report. + t.Run("one invalid update rolls the whole batch back", func(t *testing.T) { + _, err := f.client.BatchUpdateThresholds(&alerting.BatchUpdateThresholdsParams{ + Body: alerting.BatchUpdateThresholdsBody{ + Updates: []*alerting.BatchUpdateThresholdsParamsBodyUpdatesItems0{ + { + Scope: new(scopeNode), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "threshold", + Value: pointer.ToFloat64(60), + }, + { + Scope: new(scopeNode), Target: f.nodeID, + RuleID: f.ruleID, ParamName: "threshold", + Value: pointer.ToFloat64(500), // outside the declared range + }, + }, + }, + Context: pmmapitests.Context, + }) + pmmapitests.AssertAPIErrorf(t, err, http.StatusBadRequest, codes.InvalidArgument, "") + + current := f.list(t) + require.Len(t, current, 1) + assert.False(t, current[0].IsOverridden, + "the valid update must not survive the invalid one failing") + }) +} + +// TestThresholdOverrideRemovedWithNode covers the cleanup that has no cascade to rely on: +// the override's target column is polymorphic, so it carries no foreign key and the rows +// are removed by the node removal API itself. +func TestThresholdOverrideRemovedWithNode(t *testing.T) { + t.Parallel() + + f := setupThresholdFixture(t) + + _, err := f.set(t, 90) + require.NoError(t, err) + + pmmapitests.RemoveNodes(t, f.nodeID) + + // With the node gone the override is unreachable by target, so ask for every + // override of this rule instead. + res, err := f.client.ListThresholds(&alerting.ListThresholdsParams{ + RuleID: new(f.ruleID), + Context: pmmapitests.Context, + }) + require.NoError(t, err) + assert.Empty(t, res.Payload.Thresholds, "an override must not outlive the node it targets") +} diff --git a/api/alerting/v1/alerting.pb.go b/api/alerting/v1/alerting.pb.go index 2b627924970..c7315e7bd8b 100644 --- a/api/alerting/v1/alerting.pb.go +++ b/api/alerting/v1/alerting.pb.go @@ -138,6 +138,62 @@ func (FilterType) EnumDescriptor() ([]byte, []int) { return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{1} } +// ThresholdScope says what a threshold override's target refers to. +type ThresholdScope int32 + +const ( + ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED ThresholdScope = 0 + // Target is a Node ID. + ThresholdScope_THRESHOLD_SCOPE_NODE ThresholdScope = 1 + // Target is a Service ID. + ThresholdScope_THRESHOLD_SCOPE_SERVICE ThresholdScope = 2 + // Target is a cluster label value. + ThresholdScope_THRESHOLD_SCOPE_CLUSTER ThresholdScope = 3 +) + +// Enum value maps for ThresholdScope. +var ( + ThresholdScope_name = map[int32]string{ + 0: "THRESHOLD_SCOPE_UNSPECIFIED", + 1: "THRESHOLD_SCOPE_NODE", + 2: "THRESHOLD_SCOPE_SERVICE", + 3: "THRESHOLD_SCOPE_CLUSTER", + } + ThresholdScope_value = map[string]int32{ + "THRESHOLD_SCOPE_UNSPECIFIED": 0, + "THRESHOLD_SCOPE_NODE": 1, + "THRESHOLD_SCOPE_SERVICE": 2, + "THRESHOLD_SCOPE_CLUSTER": 3, + } +) + +func (x ThresholdScope) Enum() *ThresholdScope { + p := new(ThresholdScope) + *p = x + return p +} + +func (x ThresholdScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ThresholdScope) Descriptor() protoreflect.EnumDescriptor { + return file_alerting_v1_alerting_proto_enumTypes[2].Descriptor() +} + +func (ThresholdScope) Type() protoreflect.EnumType { + return &file_alerting_v1_alerting_proto_enumTypes[2] +} + +func (x ThresholdScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ThresholdScope.Descriptor instead. +func (ThresholdScope) EnumDescriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{2} +} + // BoolParamDefinition represents boolean parameter's default value. type BoolParamDefinition struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -311,7 +367,11 @@ type ParamDefinition struct { // *ParamDefinition_Bool // *ParamDefinition_Float // *ParamDefinition_String_ - Value isParamDefinition_Value `protobuf_oneof:"value"` + Value isParamDefinition_Value `protobuf_oneof:"value"` + // Whether this parameter's threshold can be overridden per target without editing the + // rule. Only set for templates that support it; the scopes it may be set at are + // reported per rule by ListThresholds. + Overridable bool `protobuf:"varint,8,opt,name=overridable,proto3" json:"overridable,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -408,6 +468,13 @@ func (x *ParamDefinition) GetString_() *StringParamDefinition { return nil } +func (x *ParamDefinition) GetOverridable() bool { + if x != nil { + return x.Overridable + } + return false +} + type isParamDefinition_Value interface { isParamDefinition_Value() } @@ -1402,7 +1469,12 @@ func (x *CreateRuleRequest) GetInterval() *durationpb.Duration { } type CreateRuleResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Identifier PMM assigns to a rule whose thresholds can be overridden per target. + // Empty when the rule has no overridable parameters, since nothing can be keyed on it. + // This is the rule's identity for threshold purposes rather than its Grafana UID: + // copying or renaming the rule in Grafana preserves it. + RuleId string `protobuf:"bytes,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1437,6 +1509,636 @@ func (*CreateRuleResponse) Descriptor() ([]byte, []int) { return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{18} } +func (x *CreateRuleResponse) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +// Threshold is one overridable parameter of one rule, as it applies to one target. +type Threshold struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identifier PMM assigned to the rule. Not unique within a response: rules duplicated + // in Grafana share it, so two entries can carry the same rule_id and param_name and + // differ only in which rule they came from. Do not key a map on it. + RuleId string `protobuf:"bytes,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + // Machine-readable name of the overridable parameter. + ParamName string `protobuf:"bytes,2,opt,name=param_name,json=paramName,proto3" json:"param_name,omitempty"` + // Short human-readable parameter summary, as it was when the rule was created. + Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"` + // Parameter unit. + Unit ParamUnit `protobuf:"varint,4,opt,name=unit,proto3,enum=alerting.v1.ParamUnit" json:"unit,omitempty"` + // Value the rule falls back to when no override applies. + DefaultValue float64 `protobuf:"fixed64,5,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` + // Value the rule currently evaluates this target against. + EffectiveValue float64 `protobuf:"fixed64,6,opt,name=effective_value,json=effectiveValue,proto3" json:"effective_value,omitempty"` + // Whether effective_value comes from an override rather than the default. + IsOverridden bool `protobuf:"varint,7,opt,name=is_overridden,json=isOverridden,proto3" json:"is_overridden,omitempty"` + // Scope the effective override was set at. Unspecified when not overridden. + Scope ThresholdScope `protobuf:"varint,8,opt,name=scope,proto3,enum=alerting.v1.ThresholdScope" json:"scope,omitempty"` + // Target the effective override was set on. Empty when not overridden. + Target string `protobuf:"bytes,9,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Threshold) Reset() { + *x = Threshold{} + mi := &file_alerting_v1_alerting_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Threshold) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Threshold) ProtoMessage() {} + +func (x *Threshold) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Threshold.ProtoReflect.Descriptor instead. +func (*Threshold) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{19} +} + +func (x *Threshold) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +func (x *Threshold) GetParamName() string { + if x != nil { + return x.ParamName + } + return "" +} + +func (x *Threshold) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +func (x *Threshold) GetUnit() ParamUnit { + if x != nil { + return x.Unit + } + return ParamUnit_PARAM_UNIT_UNSPECIFIED +} + +func (x *Threshold) GetDefaultValue() float64 { + if x != nil { + return x.DefaultValue + } + return 0 +} + +func (x *Threshold) GetEffectiveValue() float64 { + if x != nil { + return x.EffectiveValue + } + return 0 +} + +func (x *Threshold) GetIsOverridden() bool { + if x != nil { + return x.IsOverridden + } + return false +} + +func (x *Threshold) GetScope() ThresholdScope { + if x != nil { + return x.Scope + } + return ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +func (x *Threshold) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +type ListThresholdsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Scope of the target to report thresholds for. Must be set together with target. + Scope ThresholdScope `protobuf:"varint,1,opt,name=scope,proto3,enum=alerting.v1.ThresholdScope" json:"scope,omitempty"` + // Target to report thresholds for. When set, every overridable parameter is returned + // for that target, overridden or not. When empty, only existing overrides are + // returned, since there is otherwise no bounded set to enumerate. + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + // Return only thresholds of this rule. + RuleId string `protobuf:"bytes,3,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListThresholdsRequest) Reset() { + *x = ListThresholdsRequest{} + mi := &file_alerting_v1_alerting_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListThresholdsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListThresholdsRequest) ProtoMessage() {} + +func (x *ListThresholdsRequest) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListThresholdsRequest.ProtoReflect.Descriptor instead. +func (*ListThresholdsRequest) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{20} +} + +func (x *ListThresholdsRequest) GetScope() ThresholdScope { + if x != nil { + return x.Scope + } + return ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +func (x *ListThresholdsRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ListThresholdsRequest) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +type ListThresholdsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Thresholds []*Threshold `protobuf:"bytes,1,rep,name=thresholds,proto3" json:"thresholds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListThresholdsResponse) Reset() { + *x = ListThresholdsResponse{} + mi := &file_alerting_v1_alerting_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListThresholdsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListThresholdsResponse) ProtoMessage() {} + +func (x *ListThresholdsResponse) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListThresholdsResponse.ProtoReflect.Descriptor instead. +func (*ListThresholdsResponse) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{21} +} + +func (x *ListThresholdsResponse) GetThresholds() []*Threshold { + if x != nil { + return x.Thresholds + } + return nil +} + +type SetThresholdRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope ThresholdScope `protobuf:"varint,1,opt,name=scope,proto3,enum=alerting.v1.ThresholdScope" json:"scope,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + RuleId string `protobuf:"bytes,3,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + ParamName string `protobuf:"bytes,4,opt,name=param_name,json=paramName,proto3" json:"param_name,omitempty"` + // Must be finite and within the parameter's declared range. + Value float64 `protobuf:"fixed64,5,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetThresholdRequest) Reset() { + *x = SetThresholdRequest{} + mi := &file_alerting_v1_alerting_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetThresholdRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetThresholdRequest) ProtoMessage() {} + +func (x *SetThresholdRequest) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetThresholdRequest.ProtoReflect.Descriptor instead. +func (*SetThresholdRequest) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{22} +} + +func (x *SetThresholdRequest) GetScope() ThresholdScope { + if x != nil { + return x.Scope + } + return ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +func (x *SetThresholdRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *SetThresholdRequest) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +func (x *SetThresholdRequest) GetParamName() string { + if x != nil { + return x.ParamName + } + return "" +} + +func (x *SetThresholdRequest) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +type SetThresholdResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Threshold *Threshold `protobuf:"bytes,1,opt,name=threshold,proto3" json:"threshold,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetThresholdResponse) Reset() { + *x = SetThresholdResponse{} + mi := &file_alerting_v1_alerting_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetThresholdResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetThresholdResponse) ProtoMessage() {} + +func (x *SetThresholdResponse) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetThresholdResponse.ProtoReflect.Descriptor instead. +func (*SetThresholdResponse) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{23} +} + +func (x *SetThresholdResponse) GetThreshold() *Threshold { + if x != nil { + return x.Threshold + } + return nil +} + +type ClearThresholdRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope ThresholdScope `protobuf:"varint,1,opt,name=scope,proto3,enum=alerting.v1.ThresholdScope" json:"scope,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + RuleId string `protobuf:"bytes,3,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + ParamName string `protobuf:"bytes,4,opt,name=param_name,json=paramName,proto3" json:"param_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearThresholdRequest) Reset() { + *x = ClearThresholdRequest{} + mi := &file_alerting_v1_alerting_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearThresholdRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearThresholdRequest) ProtoMessage() {} + +func (x *ClearThresholdRequest) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearThresholdRequest.ProtoReflect.Descriptor instead. +func (*ClearThresholdRequest) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{24} +} + +func (x *ClearThresholdRequest) GetScope() ThresholdScope { + if x != nil { + return x.Scope + } + return ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +func (x *ClearThresholdRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ClearThresholdRequest) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +func (x *ClearThresholdRequest) GetParamName() string { + if x != nil { + return x.ParamName + } + return "" +} + +type ClearThresholdResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearThresholdResponse) Reset() { + *x = ClearThresholdResponse{} + mi := &file_alerting_v1_alerting_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearThresholdResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearThresholdResponse) ProtoMessage() {} + +func (x *ClearThresholdResponse) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClearThresholdResponse.ProtoReflect.Descriptor instead. +func (*ClearThresholdResponse) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{25} +} + +// ThresholdUpdate sets or clears one override. +type ThresholdUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope ThresholdScope `protobuf:"varint,1,opt,name=scope,proto3,enum=alerting.v1.ThresholdScope" json:"scope,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + RuleId string `protobuf:"bytes,3,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + ParamName string `protobuf:"bytes,4,opt,name=param_name,json=paramName,proto3" json:"param_name,omitempty"` + // Omit to clear the override rather than set it. + Value *float64 `protobuf:"fixed64,5,opt,name=value,proto3,oneof" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ThresholdUpdate) Reset() { + *x = ThresholdUpdate{} + mi := &file_alerting_v1_alerting_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ThresholdUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ThresholdUpdate) ProtoMessage() {} + +func (x *ThresholdUpdate) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ThresholdUpdate.ProtoReflect.Descriptor instead. +func (*ThresholdUpdate) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{26} +} + +func (x *ThresholdUpdate) GetScope() ThresholdScope { + if x != nil { + return x.Scope + } + return ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +func (x *ThresholdUpdate) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ThresholdUpdate) GetRuleId() string { + if x != nil { + return x.RuleId + } + return "" +} + +func (x *ThresholdUpdate) GetParamName() string { + if x != nil { + return x.ParamName + } + return "" +} + +func (x *ThresholdUpdate) GetValue() float64 { + if x != nil && x.Value != nil { + return *x.Value + } + return 0 +} + +type BatchUpdateThresholdsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Applied in one transaction: either every update lands or none does. A client + // editing several rows at once cannot otherwise report which ones took effect. + Updates []*ThresholdUpdate `protobuf:"bytes,1,rep,name=updates,proto3" json:"updates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BatchUpdateThresholdsRequest) Reset() { + *x = BatchUpdateThresholdsRequest{} + mi := &file_alerting_v1_alerting_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchUpdateThresholdsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateThresholdsRequest) ProtoMessage() {} + +func (x *BatchUpdateThresholdsRequest) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateThresholdsRequest.ProtoReflect.Descriptor instead. +func (*BatchUpdateThresholdsRequest) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{27} +} + +func (x *BatchUpdateThresholdsRequest) GetUpdates() []*ThresholdUpdate { + if x != nil { + return x.Updates + } + return nil +} + +type BatchUpdateThresholdsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Thresholds that were set, in request order. Cleared ones are omitted. + Thresholds []*Threshold `protobuf:"bytes,1,rep,name=thresholds,proto3" json:"thresholds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BatchUpdateThresholdsResponse) Reset() { + *x = BatchUpdateThresholdsResponse{} + mi := &file_alerting_v1_alerting_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchUpdateThresholdsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateThresholdsResponse) ProtoMessage() {} + +func (x *BatchUpdateThresholdsResponse) ProtoReflect() protoreflect.Message { + mi := &file_alerting_v1_alerting_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateThresholdsResponse.ProtoReflect.Descriptor instead. +func (*BatchUpdateThresholdsResponse) Descriptor() ([]byte, []int) { + return file_alerting_v1_alerting_proto_rawDescGZIP(), []int{28} +} + +func (x *BatchUpdateThresholdsResponse) GetThresholds() []*Threshold { + if x != nil { + return x.Thresholds + } + return nil +} + var File_alerting_v1_alerting_proto protoreflect.FileDescriptor const file_alerting_v1_alerting_proto_rawDesc = "" + @@ -1457,7 +2159,7 @@ const file_alerting_v1_alerting_proto_rawDesc = "" + "\x15StringParamDefinition\x12\x1d\n" + "\adefault\x18\x01 \x01(\tH\x00R\adefault\x88\x01\x01B\n" + "\n" + - "\b_default\"\xe3\x02\n" + + "\b_default\"\x85\x03\n" + "\x0fParamDefinition\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x04name\x12!\n" + "\asummary\x18\x02 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\asummary\x12*\n" + @@ -1465,7 +2167,8 @@ const file_alerting_v1_alerting_proto_rawDesc = "" + "\x04type\x18\x04 \x01(\x0e2\x16.alerting.v1.ParamTypeR\x04type\x126\n" + "\x04bool\x18\x05 \x01(\v2 .alerting.v1.BoolParamDefinitionH\x00R\x04bool\x129\n" + "\x05float\x18\x06 \x01(\v2!.alerting.v1.FloatParamDefinitionH\x00R\x05float\x12<\n" + - "\x06string\x18\a \x01(\v2\".alerting.v1.StringParamDefinitionH\x00R\x06stringB\a\n" + + "\x06string\x18\a \x01(\v2\".alerting.v1.StringParamDefinitionH\x00R\x06string\x12 \n" + + "\voverridable\x18\b \x01(\bR\voverridableB\a\n" + "\x05value\":\n" + "\rTemplateQuery\x12\x15\n" + "\x06ref_id\x18\x01 \x01(\tR\x05refId\x12\x12\n" + @@ -1550,8 +2253,58 @@ const file_alerting_v1_alerting_proto_rawDesc = "" + " \x01(\v2\x19.google.protobuf.DurationR\binterval\x1a?\n" + "\x11CustomLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x14\n" + - "\x12CreateRuleResponse*\xa6\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"-\n" + + "\x12CreateRuleResponse\x12\x17\n" + + "\arule_id\x18\x01 \x01(\tR\x06ruleId\"\xc7\x02\n" + + "\tThreshold\x12\x17\n" + + "\arule_id\x18\x01 \x01(\tR\x06ruleId\x12\x1d\n" + + "\n" + + "param_name\x18\x02 \x01(\tR\tparamName\x12\x18\n" + + "\asummary\x18\x03 \x01(\tR\asummary\x12*\n" + + "\x04unit\x18\x04 \x01(\x0e2\x16.alerting.v1.ParamUnitR\x04unit\x12#\n" + + "\rdefault_value\x18\x05 \x01(\x01R\fdefaultValue\x12'\n" + + "\x0feffective_value\x18\x06 \x01(\x01R\x0eeffectiveValue\x12#\n" + + "\ris_overridden\x18\a \x01(\bR\fisOverridden\x121\n" + + "\x05scope\x18\b \x01(\x0e2\x1b.alerting.v1.ThresholdScopeR\x05scope\x12\x16\n" + + "\x06target\x18\t \x01(\tR\x06target\"{\n" + + "\x15ListThresholdsRequest\x121\n" + + "\x05scope\x18\x01 \x01(\x0e2\x1b.alerting.v1.ThresholdScopeR\x05scope\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\x12\x17\n" + + "\arule_id\x18\x03 \x01(\tR\x06ruleId\"P\n" + + "\x16ListThresholdsResponse\x126\n" + + "\n" + + "thresholds\x18\x01 \x03(\v2\x16.alerting.v1.ThresholdR\n" + + "thresholds\"\xc9\x01\n" + + "\x13SetThresholdRequest\x121\n" + + "\x05scope\x18\x01 \x01(\x0e2\x1b.alerting.v1.ThresholdScopeR\x05scope\x12\x1f\n" + + "\x06target\x18\x02 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06target\x12 \n" + + "\arule_id\x18\x03 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06ruleId\x12&\n" + + "\n" + + "param_name\x18\x04 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\tparamName\x12\x14\n" + + "\x05value\x18\x05 \x01(\x01R\x05value\"L\n" + + "\x14SetThresholdResponse\x124\n" + + "\tthreshold\x18\x01 \x01(\v2\x16.alerting.v1.ThresholdR\tthreshold\"\xb5\x01\n" + + "\x15ClearThresholdRequest\x121\n" + + "\x05scope\x18\x01 \x01(\x0e2\x1b.alerting.v1.ThresholdScopeR\x05scope\x12\x1f\n" + + "\x06target\x18\x02 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06target\x12 \n" + + "\arule_id\x18\x03 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06ruleId\x12&\n" + + "\n" + + "param_name\x18\x04 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\tparamName\"\x18\n" + + "\x16ClearThresholdResponse\"\xd4\x01\n" + + "\x0fThresholdUpdate\x121\n" + + "\x05scope\x18\x01 \x01(\x0e2\x1b.alerting.v1.ThresholdScopeR\x05scope\x12\x1f\n" + + "\x06target\x18\x02 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06target\x12 \n" + + "\arule_id\x18\x03 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\x06ruleId\x12&\n" + + "\n" + + "param_name\x18\x04 \x01(\tB\a\xfaB\x04r\x02\x10\x01R\tparamName\x12\x19\n" + + "\x05value\x18\x05 \x01(\x01H\x00R\x05value\x88\x01\x01B\b\n" + + "\x06_value\"`\n" + + "\x1cBatchUpdateThresholdsRequest\x12@\n" + + "\aupdates\x18\x01 \x03(\v2\x1c.alerting.v1.ThresholdUpdateB\b\xfaB\x05\x92\x01\x02\b\x01R\aupdates\"W\n" + + "\x1dBatchUpdateThresholdsResponse\x126\n" + + "\n" + + "thresholds\x18\x01 \x03(\v2\x16.alerting.v1.ThresholdR\n" + + "thresholds*\xa6\x01\n" + "\x0eTemplateSource\x12\x1f\n" + "\x1bTEMPLATE_SOURCE_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18TEMPLATE_SOURCE_BUILT_IN\x10\x01\x12\x18\n" + @@ -1562,14 +2315,23 @@ const file_alerting_v1_alerting_proto_rawDesc = "" + "FilterType\x12\x1b\n" + "\x17FILTER_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + "\x11FILTER_TYPE_MATCH\x10\x01\x12\x18\n" + - "\x14FILTER_TYPE_MISMATCH\x10\x022\xfe\x04\n" + + "\x14FILTER_TYPE_MISMATCH\x10\x02*\x85\x01\n" + + "\x0eThresholdScope\x12\x1f\n" + + "\x1bTHRESHOLD_SCOPE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14THRESHOLD_SCOPE_NODE\x10\x01\x12\x1b\n" + + "\x17THRESHOLD_SCOPE_SERVICE\x10\x02\x12\x1b\n" + + "\x17THRESHOLD_SCOPE_CLUSTER\x10\x032\x90\t\n" + "\x0fAlertingService\x12v\n" + "\rListTemplates\x12!.alerting.v1.ListTemplatesRequest\x1a\".alerting.v1.ListTemplatesResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/v1/alerting/templates\x12|\n" + "\x0eCreateTemplate\x12\".alerting.v1.CreateTemplateRequest\x1a#.alerting.v1.CreateTemplateResponse\"!\x82\xd3\xe4\x93\x02\x1b:\x01*\"\x16/v1/alerting/templates\x12\x83\x01\n" + "\x0eUpdateTemplate\x12\".alerting.v1.UpdateTemplateRequest\x1a#.alerting.v1.UpdateTemplateResponse\"(\x82\xd3\xe4\x93\x02\":\x01*\x1a\x1d/v1/alerting/templates/{name}\x12\x80\x01\n" + "\x0eDeleteTemplate\x12\".alerting.v1.DeleteTemplateRequest\x1a#.alerting.v1.DeleteTemplateResponse\"%\x82\xd3\xe4\x93\x02\x1f*\x1d/v1/alerting/templates/{name}\x12l\n" + "\n" + - "CreateRule\x12\x1e.alerting.v1.CreateRuleRequest\x1a\x1f.alerting.v1.CreateRuleResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/v1/alerting/rulesB\xa0\x01\n" + + "CreateRule\x12\x1e.alerting.v1.CreateRuleRequest\x1a\x1f.alerting.v1.CreateRuleResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/v1/alerting/rules\x12z\n" + + "\x0eListThresholds\x12\".alerting.v1.ListThresholdsRequest\x1a#.alerting.v1.ListThresholdsResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/v1/alerting/thresholds\x12w\n" + + "\fSetThreshold\x12 .alerting.v1.SetThresholdRequest\x1a!.alerting.v1.SetThresholdResponse\"\"\x82\xd3\xe4\x93\x02\x1c:\x01*\"\x17/v1/alerting/thresholds\x12z\n" + + "\x0eClearThreshold\x12\".alerting.v1.ClearThresholdRequest\x1a#.alerting.v1.ClearThresholdResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/v1/alerting/thresholds\x12\x9e\x01\n" + + "\x15BatchUpdateThresholds\x12).alerting.v1.BatchUpdateThresholdsRequest\x1a*.alerting.v1.BatchUpdateThresholdsResponse\".\x82\xd3\xe4\x93\x02(:\x01*\"#/v1/alerting/thresholds:batchUpdateB\xa0\x01\n" + "\x0fcom.alerting.v1B\rAlertingProtoP\x01Z1github.com/percona/pmm/api/alerting/v1;alertingv1\xa2\x02\x03AXX\xaa\x02\vAlerting.V1\xca\x02\vAlerting\\V1\xe2\x02\x17Alerting\\V1\\GPBMetadata\xea\x02\fAlerting::V1b\x06proto3" var ( @@ -1585,80 +2347,109 @@ func file_alerting_v1_alerting_proto_rawDescGZIP() []byte { } var ( - file_alerting_v1_alerting_proto_enumTypes = make([]protoimpl.EnumInfo, 2) - file_alerting_v1_alerting_proto_msgTypes = make([]protoimpl.MessageInfo, 22) + file_alerting_v1_alerting_proto_enumTypes = make([]protoimpl.EnumInfo, 3) + file_alerting_v1_alerting_proto_msgTypes = make([]protoimpl.MessageInfo, 32) file_alerting_v1_alerting_proto_goTypes = []any{ - TemplateSource(0), // 0: alerting.v1.TemplateSource - FilterType(0), // 1: alerting.v1.FilterType - (*BoolParamDefinition)(nil), // 2: alerting.v1.BoolParamDefinition - (*FloatParamDefinition)(nil), // 3: alerting.v1.FloatParamDefinition - (*StringParamDefinition)(nil), // 4: alerting.v1.StringParamDefinition - (*ParamDefinition)(nil), // 5: alerting.v1.ParamDefinition - (*TemplateQuery)(nil), // 6: alerting.v1.TemplateQuery - (*TemplateExpression)(nil), // 7: alerting.v1.TemplateExpression - (*Template)(nil), // 8: alerting.v1.Template - (*ListTemplatesRequest)(nil), // 9: alerting.v1.ListTemplatesRequest - (*ListTemplatesResponse)(nil), // 10: alerting.v1.ListTemplatesResponse - (*CreateTemplateRequest)(nil), // 11: alerting.v1.CreateTemplateRequest - (*CreateTemplateResponse)(nil), // 12: alerting.v1.CreateTemplateResponse - (*UpdateTemplateRequest)(nil), // 13: alerting.v1.UpdateTemplateRequest - (*UpdateTemplateResponse)(nil), // 14: alerting.v1.UpdateTemplateResponse - (*DeleteTemplateRequest)(nil), // 15: alerting.v1.DeleteTemplateRequest - (*DeleteTemplateResponse)(nil), // 16: alerting.v1.DeleteTemplateResponse - (*Filter)(nil), // 17: alerting.v1.Filter - (*ParamValue)(nil), // 18: alerting.v1.ParamValue - (*CreateRuleRequest)(nil), // 19: alerting.v1.CreateRuleRequest - (*CreateRuleResponse)(nil), // 20: alerting.v1.CreateRuleResponse - nil, // 21: alerting.v1.Template.LabelsEntry - nil, // 22: alerting.v1.Template.AnnotationsEntry - nil, // 23: alerting.v1.CreateRuleRequest.CustomLabelsEntry - ParamUnit(0), // 24: alerting.v1.ParamUnit - ParamType(0), // 25: alerting.v1.ParamType - (*durationpb.Duration)(nil), // 26: google.protobuf.Duration - v1.Severity(0), // 27: management.v1.Severity - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp + TemplateSource(0), // 0: alerting.v1.TemplateSource + FilterType(0), // 1: alerting.v1.FilterType + ThresholdScope(0), // 2: alerting.v1.ThresholdScope + (*BoolParamDefinition)(nil), // 3: alerting.v1.BoolParamDefinition + (*FloatParamDefinition)(nil), // 4: alerting.v1.FloatParamDefinition + (*StringParamDefinition)(nil), // 5: alerting.v1.StringParamDefinition + (*ParamDefinition)(nil), // 6: alerting.v1.ParamDefinition + (*TemplateQuery)(nil), // 7: alerting.v1.TemplateQuery + (*TemplateExpression)(nil), // 8: alerting.v1.TemplateExpression + (*Template)(nil), // 9: alerting.v1.Template + (*ListTemplatesRequest)(nil), // 10: alerting.v1.ListTemplatesRequest + (*ListTemplatesResponse)(nil), // 11: alerting.v1.ListTemplatesResponse + (*CreateTemplateRequest)(nil), // 12: alerting.v1.CreateTemplateRequest + (*CreateTemplateResponse)(nil), // 13: alerting.v1.CreateTemplateResponse + (*UpdateTemplateRequest)(nil), // 14: alerting.v1.UpdateTemplateRequest + (*UpdateTemplateResponse)(nil), // 15: alerting.v1.UpdateTemplateResponse + (*DeleteTemplateRequest)(nil), // 16: alerting.v1.DeleteTemplateRequest + (*DeleteTemplateResponse)(nil), // 17: alerting.v1.DeleteTemplateResponse + (*Filter)(nil), // 18: alerting.v1.Filter + (*ParamValue)(nil), // 19: alerting.v1.ParamValue + (*CreateRuleRequest)(nil), // 20: alerting.v1.CreateRuleRequest + (*CreateRuleResponse)(nil), // 21: alerting.v1.CreateRuleResponse + (*Threshold)(nil), // 22: alerting.v1.Threshold + (*ListThresholdsRequest)(nil), // 23: alerting.v1.ListThresholdsRequest + (*ListThresholdsResponse)(nil), // 24: alerting.v1.ListThresholdsResponse + (*SetThresholdRequest)(nil), // 25: alerting.v1.SetThresholdRequest + (*SetThresholdResponse)(nil), // 26: alerting.v1.SetThresholdResponse + (*ClearThresholdRequest)(nil), // 27: alerting.v1.ClearThresholdRequest + (*ClearThresholdResponse)(nil), // 28: alerting.v1.ClearThresholdResponse + (*ThresholdUpdate)(nil), // 29: alerting.v1.ThresholdUpdate + (*BatchUpdateThresholdsRequest)(nil), // 30: alerting.v1.BatchUpdateThresholdsRequest + (*BatchUpdateThresholdsResponse)(nil), // 31: alerting.v1.BatchUpdateThresholdsResponse + nil, // 32: alerting.v1.Template.LabelsEntry + nil, // 33: alerting.v1.Template.AnnotationsEntry + nil, // 34: alerting.v1.CreateRuleRequest.CustomLabelsEntry + ParamUnit(0), // 35: alerting.v1.ParamUnit + ParamType(0), // 36: alerting.v1.ParamType + (*durationpb.Duration)(nil), // 37: google.protobuf.Duration + v1.Severity(0), // 38: management.v1.Severity + (*timestamppb.Timestamp)(nil), // 39: google.protobuf.Timestamp } ) var file_alerting_v1_alerting_proto_depIdxs = []int32{ - 24, // 0: alerting.v1.ParamDefinition.unit:type_name -> alerting.v1.ParamUnit - 25, // 1: alerting.v1.ParamDefinition.type:type_name -> alerting.v1.ParamType - 2, // 2: alerting.v1.ParamDefinition.bool:type_name -> alerting.v1.BoolParamDefinition - 3, // 3: alerting.v1.ParamDefinition.float:type_name -> alerting.v1.FloatParamDefinition - 4, // 4: alerting.v1.ParamDefinition.string:type_name -> alerting.v1.StringParamDefinition - 5, // 5: alerting.v1.Template.params:type_name -> alerting.v1.ParamDefinition - 26, // 6: alerting.v1.Template.for:type_name -> google.protobuf.Duration - 27, // 7: alerting.v1.Template.severity:type_name -> management.v1.Severity - 21, // 8: alerting.v1.Template.labels:type_name -> alerting.v1.Template.LabelsEntry - 22, // 9: alerting.v1.Template.annotations:type_name -> alerting.v1.Template.AnnotationsEntry + 35, // 0: alerting.v1.ParamDefinition.unit:type_name -> alerting.v1.ParamUnit + 36, // 1: alerting.v1.ParamDefinition.type:type_name -> alerting.v1.ParamType + 3, // 2: alerting.v1.ParamDefinition.bool:type_name -> alerting.v1.BoolParamDefinition + 4, // 3: alerting.v1.ParamDefinition.float:type_name -> alerting.v1.FloatParamDefinition + 5, // 4: alerting.v1.ParamDefinition.string:type_name -> alerting.v1.StringParamDefinition + 6, // 5: alerting.v1.Template.params:type_name -> alerting.v1.ParamDefinition + 37, // 6: alerting.v1.Template.for:type_name -> google.protobuf.Duration + 38, // 7: alerting.v1.Template.severity:type_name -> management.v1.Severity + 32, // 8: alerting.v1.Template.labels:type_name -> alerting.v1.Template.LabelsEntry + 33, // 9: alerting.v1.Template.annotations:type_name -> alerting.v1.Template.AnnotationsEntry 0, // 10: alerting.v1.Template.source:type_name -> alerting.v1.TemplateSource - 28, // 11: alerting.v1.Template.created_at:type_name -> google.protobuf.Timestamp - 6, // 12: alerting.v1.Template.queries:type_name -> alerting.v1.TemplateQuery - 7, // 13: alerting.v1.Template.expressions:type_name -> alerting.v1.TemplateExpression - 8, // 14: alerting.v1.ListTemplatesResponse.templates:type_name -> alerting.v1.Template + 39, // 11: alerting.v1.Template.created_at:type_name -> google.protobuf.Timestamp + 7, // 12: alerting.v1.Template.queries:type_name -> alerting.v1.TemplateQuery + 8, // 13: alerting.v1.Template.expressions:type_name -> alerting.v1.TemplateExpression + 9, // 14: alerting.v1.ListTemplatesResponse.templates:type_name -> alerting.v1.Template 1, // 15: alerting.v1.Filter.type:type_name -> alerting.v1.FilterType - 25, // 16: alerting.v1.ParamValue.type:type_name -> alerting.v1.ParamType - 18, // 17: alerting.v1.CreateRuleRequest.params:type_name -> alerting.v1.ParamValue - 26, // 18: alerting.v1.CreateRuleRequest.for:type_name -> google.protobuf.Duration - 27, // 19: alerting.v1.CreateRuleRequest.severity:type_name -> management.v1.Severity - 23, // 20: alerting.v1.CreateRuleRequest.custom_labels:type_name -> alerting.v1.CreateRuleRequest.CustomLabelsEntry - 17, // 21: alerting.v1.CreateRuleRequest.filters:type_name -> alerting.v1.Filter - 26, // 22: alerting.v1.CreateRuleRequest.interval:type_name -> google.protobuf.Duration - 9, // 23: alerting.v1.AlertingService.ListTemplates:input_type -> alerting.v1.ListTemplatesRequest - 11, // 24: alerting.v1.AlertingService.CreateTemplate:input_type -> alerting.v1.CreateTemplateRequest - 13, // 25: alerting.v1.AlertingService.UpdateTemplate:input_type -> alerting.v1.UpdateTemplateRequest - 15, // 26: alerting.v1.AlertingService.DeleteTemplate:input_type -> alerting.v1.DeleteTemplateRequest - 19, // 27: alerting.v1.AlertingService.CreateRule:input_type -> alerting.v1.CreateRuleRequest - 10, // 28: alerting.v1.AlertingService.ListTemplates:output_type -> alerting.v1.ListTemplatesResponse - 12, // 29: alerting.v1.AlertingService.CreateTemplate:output_type -> alerting.v1.CreateTemplateResponse - 14, // 30: alerting.v1.AlertingService.UpdateTemplate:output_type -> alerting.v1.UpdateTemplateResponse - 16, // 31: alerting.v1.AlertingService.DeleteTemplate:output_type -> alerting.v1.DeleteTemplateResponse - 20, // 32: alerting.v1.AlertingService.CreateRule:output_type -> alerting.v1.CreateRuleResponse - 28, // [28:33] is the sub-list for method output_type - 23, // [23:28] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 36, // 16: alerting.v1.ParamValue.type:type_name -> alerting.v1.ParamType + 19, // 17: alerting.v1.CreateRuleRequest.params:type_name -> alerting.v1.ParamValue + 37, // 18: alerting.v1.CreateRuleRequest.for:type_name -> google.protobuf.Duration + 38, // 19: alerting.v1.CreateRuleRequest.severity:type_name -> management.v1.Severity + 34, // 20: alerting.v1.CreateRuleRequest.custom_labels:type_name -> alerting.v1.CreateRuleRequest.CustomLabelsEntry + 18, // 21: alerting.v1.CreateRuleRequest.filters:type_name -> alerting.v1.Filter + 37, // 22: alerting.v1.CreateRuleRequest.interval:type_name -> google.protobuf.Duration + 35, // 23: alerting.v1.Threshold.unit:type_name -> alerting.v1.ParamUnit + 2, // 24: alerting.v1.Threshold.scope:type_name -> alerting.v1.ThresholdScope + 2, // 25: alerting.v1.ListThresholdsRequest.scope:type_name -> alerting.v1.ThresholdScope + 22, // 26: alerting.v1.ListThresholdsResponse.thresholds:type_name -> alerting.v1.Threshold + 2, // 27: alerting.v1.SetThresholdRequest.scope:type_name -> alerting.v1.ThresholdScope + 22, // 28: alerting.v1.SetThresholdResponse.threshold:type_name -> alerting.v1.Threshold + 2, // 29: alerting.v1.ClearThresholdRequest.scope:type_name -> alerting.v1.ThresholdScope + 2, // 30: alerting.v1.ThresholdUpdate.scope:type_name -> alerting.v1.ThresholdScope + 29, // 31: alerting.v1.BatchUpdateThresholdsRequest.updates:type_name -> alerting.v1.ThresholdUpdate + 22, // 32: alerting.v1.BatchUpdateThresholdsResponse.thresholds:type_name -> alerting.v1.Threshold + 10, // 33: alerting.v1.AlertingService.ListTemplates:input_type -> alerting.v1.ListTemplatesRequest + 12, // 34: alerting.v1.AlertingService.CreateTemplate:input_type -> alerting.v1.CreateTemplateRequest + 14, // 35: alerting.v1.AlertingService.UpdateTemplate:input_type -> alerting.v1.UpdateTemplateRequest + 16, // 36: alerting.v1.AlertingService.DeleteTemplate:input_type -> alerting.v1.DeleteTemplateRequest + 20, // 37: alerting.v1.AlertingService.CreateRule:input_type -> alerting.v1.CreateRuleRequest + 23, // 38: alerting.v1.AlertingService.ListThresholds:input_type -> alerting.v1.ListThresholdsRequest + 25, // 39: alerting.v1.AlertingService.SetThreshold:input_type -> alerting.v1.SetThresholdRequest + 27, // 40: alerting.v1.AlertingService.ClearThreshold:input_type -> alerting.v1.ClearThresholdRequest + 30, // 41: alerting.v1.AlertingService.BatchUpdateThresholds:input_type -> alerting.v1.BatchUpdateThresholdsRequest + 11, // 42: alerting.v1.AlertingService.ListTemplates:output_type -> alerting.v1.ListTemplatesResponse + 13, // 43: alerting.v1.AlertingService.CreateTemplate:output_type -> alerting.v1.CreateTemplateResponse + 15, // 44: alerting.v1.AlertingService.UpdateTemplate:output_type -> alerting.v1.UpdateTemplateResponse + 17, // 45: alerting.v1.AlertingService.DeleteTemplate:output_type -> alerting.v1.DeleteTemplateResponse + 21, // 46: alerting.v1.AlertingService.CreateRule:output_type -> alerting.v1.CreateRuleResponse + 24, // 47: alerting.v1.AlertingService.ListThresholds:output_type -> alerting.v1.ListThresholdsResponse + 26, // 48: alerting.v1.AlertingService.SetThreshold:output_type -> alerting.v1.SetThresholdResponse + 28, // 49: alerting.v1.AlertingService.ClearThreshold:output_type -> alerting.v1.ClearThresholdResponse + 31, // 50: alerting.v1.AlertingService.BatchUpdateThresholds:output_type -> alerting.v1.BatchUpdateThresholdsResponse + 42, // [42:51] is the sub-list for method output_type + 33, // [33:42] is the sub-list for method input_type + 33, // [33:33] is the sub-list for extension type_name + 33, // [33:33] is the sub-list for extension extendee + 0, // [0:33] is the sub-list for field type_name } func init() { file_alerting_v1_alerting_proto_init() } @@ -1681,13 +2472,14 @@ func file_alerting_v1_alerting_proto_init() { (*ParamValue_Float)(nil), (*ParamValue_String_)(nil), } + file_alerting_v1_alerting_proto_msgTypes[26].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_alerting_v1_alerting_proto_rawDesc), len(file_alerting_v1_alerting_proto_rawDesc)), - NumEnums: 2, - NumMessages: 22, + NumEnums: 3, + NumMessages: 32, NumExtensions: 0, NumServices: 1, }, diff --git a/api/alerting/v1/alerting.pb.gw.go b/api/alerting/v1/alerting.pb.gw.go index 20f0459d216..0ce754a5025 100644 --- a/api/alerting/v1/alerting.pb.gw.go +++ b/api/alerting/v1/alerting.pb.gw.go @@ -208,6 +208,130 @@ func local_request_AlertingService_CreateRule_0(ctx context.Context, marshaler r return msg, metadata, err } +var filter_AlertingService_ListThresholds_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_AlertingService_ListThresholds_0(ctx context.Context, marshaler runtime.Marshaler, client AlertingServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListThresholdsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AlertingService_ListThresholds_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ListThresholds(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AlertingService_ListThresholds_0(ctx context.Context, marshaler runtime.Marshaler, server AlertingServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListThresholdsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AlertingService_ListThresholds_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListThresholds(ctx, &protoReq) + return msg, metadata, err +} + +func request_AlertingService_SetThreshold_0(ctx context.Context, marshaler runtime.Marshaler, client AlertingServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetThresholdRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.SetThreshold(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AlertingService_SetThreshold_0(ctx context.Context, marshaler runtime.Marshaler, server AlertingServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetThresholdRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetThreshold(ctx, &protoReq) + return msg, metadata, err +} + +var filter_AlertingService_ClearThreshold_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_AlertingService_ClearThreshold_0(ctx context.Context, marshaler runtime.Marshaler, client AlertingServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClearThresholdRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AlertingService_ClearThreshold_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ClearThreshold(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AlertingService_ClearThreshold_0(ctx context.Context, marshaler runtime.Marshaler, server AlertingServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClearThresholdRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AlertingService_ClearThreshold_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ClearThreshold(ctx, &protoReq) + return msg, metadata, err +} + +func request_AlertingService_BatchUpdateThresholds_0(ctx context.Context, marshaler runtime.Marshaler, client AlertingServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BatchUpdateThresholdsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.BatchUpdateThresholds(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AlertingService_BatchUpdateThresholds_0(ctx context.Context, marshaler runtime.Marshaler, server AlertingServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq BatchUpdateThresholdsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.BatchUpdateThresholds(ctx, &protoReq) + return msg, metadata, err +} + // RegisterAlertingServiceHandlerServer registers the http handlers for service AlertingService to "mux". // UnaryRPC :call AlertingServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -314,6 +438,86 @@ func RegisterAlertingServiceHandlerServer(ctx context.Context, mux *runtime.Serv } forward_AlertingService_CreateRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_AlertingService_ListThresholds_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/alerting.v1.AlertingService/ListThresholds", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AlertingService_ListThresholds_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_ListThresholds_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AlertingService_SetThreshold_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/alerting.v1.AlertingService/SetThreshold", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AlertingService_SetThreshold_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_SetThreshold_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AlertingService_ClearThreshold_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/alerting.v1.AlertingService/ClearThreshold", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AlertingService_ClearThreshold_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_ClearThreshold_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AlertingService_BatchUpdateThresholds_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/alerting.v1.AlertingService/BatchUpdateThresholds", runtime.WithHTTPPathPattern("/v1/alerting/thresholds:batchUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AlertingService_BatchUpdateThresholds_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_BatchUpdateThresholds_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -439,21 +643,97 @@ func RegisterAlertingServiceHandlerClient(ctx context.Context, mux *runtime.Serv } forward_AlertingService_CreateRule_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_AlertingService_ListThresholds_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/alerting.v1.AlertingService/ListThresholds", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AlertingService_ListThresholds_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_ListThresholds_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AlertingService_SetThreshold_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/alerting.v1.AlertingService/SetThreshold", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AlertingService_SetThreshold_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_SetThreshold_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AlertingService_ClearThreshold_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/alerting.v1.AlertingService/ClearThreshold", runtime.WithHTTPPathPattern("/v1/alerting/thresholds")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AlertingService_ClearThreshold_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_ClearThreshold_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AlertingService_BatchUpdateThresholds_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/alerting.v1.AlertingService/BatchUpdateThresholds", runtime.WithHTTPPathPattern("/v1/alerting/thresholds:batchUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AlertingService_BatchUpdateThresholds_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AlertingService_BatchUpdateThresholds_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } var ( - pattern_AlertingService_ListTemplates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "templates"}, "")) - pattern_AlertingService_CreateTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "templates"}, "")) - pattern_AlertingService_UpdateTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"v1", "alerting", "templates", "name"}, "")) - pattern_AlertingService_DeleteTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"v1", "alerting", "templates", "name"}, "")) - pattern_AlertingService_CreateRule_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "rules"}, "")) + pattern_AlertingService_ListTemplates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "templates"}, "")) + pattern_AlertingService_CreateTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "templates"}, "")) + pattern_AlertingService_UpdateTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"v1", "alerting", "templates", "name"}, "")) + pattern_AlertingService_DeleteTemplate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"v1", "alerting", "templates", "name"}, "")) + pattern_AlertingService_CreateRule_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "rules"}, "")) + pattern_AlertingService_ListThresholds_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "thresholds"}, "")) + pattern_AlertingService_SetThreshold_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "thresholds"}, "")) + pattern_AlertingService_ClearThreshold_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "thresholds"}, "")) + pattern_AlertingService_BatchUpdateThresholds_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "alerting", "thresholds"}, "batchUpdate")) ) var ( - forward_AlertingService_ListTemplates_0 = runtime.ForwardResponseMessage - forward_AlertingService_CreateTemplate_0 = runtime.ForwardResponseMessage - forward_AlertingService_UpdateTemplate_0 = runtime.ForwardResponseMessage - forward_AlertingService_DeleteTemplate_0 = runtime.ForwardResponseMessage - forward_AlertingService_CreateRule_0 = runtime.ForwardResponseMessage + forward_AlertingService_ListTemplates_0 = runtime.ForwardResponseMessage + forward_AlertingService_CreateTemplate_0 = runtime.ForwardResponseMessage + forward_AlertingService_UpdateTemplate_0 = runtime.ForwardResponseMessage + forward_AlertingService_DeleteTemplate_0 = runtime.ForwardResponseMessage + forward_AlertingService_CreateRule_0 = runtime.ForwardResponseMessage + forward_AlertingService_ListThresholds_0 = runtime.ForwardResponseMessage + forward_AlertingService_SetThreshold_0 = runtime.ForwardResponseMessage + forward_AlertingService_ClearThreshold_0 = runtime.ForwardResponseMessage + forward_AlertingService_BatchUpdateThresholds_0 = runtime.ForwardResponseMessage ) diff --git a/api/alerting/v1/alerting.pb.validate.go b/api/alerting/v1/alerting.pb.validate.go index 978008ce59b..9d9df147091 100644 --- a/api/alerting/v1/alerting.pb.validate.go +++ b/api/alerting/v1/alerting.pb.validate.go @@ -416,6 +416,8 @@ func (m *ParamDefinition) validate(all bool) error { // no validation rules for Type + // no validation rules for Overridable + switch v := m.Value.(type) { case *ParamDefinition_Bool: if v == nil { @@ -2571,6 +2573,8 @@ func (m *CreateRuleResponse) validate(all bool) error { var errors []error + // no validation rules for RuleId + if len(errors) > 0 { return CreateRuleResponseMultiError(errors) } @@ -2651,3 +2655,1307 @@ var _ interface { Cause() error ErrorName() string } = CreateRuleResponseValidationError{} + +// Validate checks the field values on Threshold with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *Threshold) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on Threshold with the rules defined in +// the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in ThresholdMultiError, or nil +// if none found. +func (m *Threshold) ValidateAll() error { + return m.validate(true) +} + +func (m *Threshold) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for RuleId + + // no validation rules for ParamName + + // no validation rules for Summary + + // no validation rules for Unit + + // no validation rules for DefaultValue + + // no validation rules for EffectiveValue + + // no validation rules for IsOverridden + + // no validation rules for Scope + + // no validation rules for Target + + if len(errors) > 0 { + return ThresholdMultiError(errors) + } + + return nil +} + +// ThresholdMultiError is an error wrapping multiple validation errors returned +// by Threshold.ValidateAll() if the designated constraints aren't met. +type ThresholdMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ThresholdMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ThresholdMultiError) AllErrors() []error { return m } + +// ThresholdValidationError is the validation error returned by +// Threshold.Validate if the designated constraints aren't met. +type ThresholdValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ThresholdValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ThresholdValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ThresholdValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ThresholdValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ThresholdValidationError) ErrorName() string { return "ThresholdValidationError" } + +// Error satisfies the builtin error interface +func (e ThresholdValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sThreshold.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ThresholdValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ThresholdValidationError{} + +// Validate checks the field values on ListThresholdsRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ListThresholdsRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListThresholdsRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListThresholdsRequestMultiError, or nil if none found. +func (m *ListThresholdsRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ListThresholdsRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Scope + + // no validation rules for Target + + // no validation rules for RuleId + + if len(errors) > 0 { + return ListThresholdsRequestMultiError(errors) + } + + return nil +} + +// ListThresholdsRequestMultiError is an error wrapping multiple validation +// errors returned by ListThresholdsRequest.ValidateAll() if the designated +// constraints aren't met. +type ListThresholdsRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListThresholdsRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListThresholdsRequestMultiError) AllErrors() []error { return m } + +// ListThresholdsRequestValidationError is the validation error returned by +// ListThresholdsRequest.Validate if the designated constraints aren't met. +type ListThresholdsRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListThresholdsRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListThresholdsRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListThresholdsRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListThresholdsRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListThresholdsRequestValidationError) ErrorName() string { + return "ListThresholdsRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e ListThresholdsRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListThresholdsRequest.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ListThresholdsRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListThresholdsRequestValidationError{} + +// Validate checks the field values on ListThresholdsResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ListThresholdsResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListThresholdsResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListThresholdsResponseMultiError, or nil if none found. +func (m *ListThresholdsResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ListThresholdsResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetThresholds() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ListThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ListThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ListThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return ListThresholdsResponseMultiError(errors) + } + + return nil +} + +// ListThresholdsResponseMultiError is an error wrapping multiple validation +// errors returned by ListThresholdsResponse.ValidateAll() if the designated +// constraints aren't met. +type ListThresholdsResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListThresholdsResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListThresholdsResponseMultiError) AllErrors() []error { return m } + +// ListThresholdsResponseValidationError is the validation error returned by +// ListThresholdsResponse.Validate if the designated constraints aren't met. +type ListThresholdsResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListThresholdsResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListThresholdsResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListThresholdsResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListThresholdsResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListThresholdsResponseValidationError) ErrorName() string { + return "ListThresholdsResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ListThresholdsResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListThresholdsResponse.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ListThresholdsResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListThresholdsResponseValidationError{} + +// Validate checks the field values on SetThresholdRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *SetThresholdRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on SetThresholdRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// SetThresholdRequestMultiError, or nil if none found. +func (m *SetThresholdRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *SetThresholdRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Scope + + if utf8.RuneCountInString(m.GetTarget()) < 1 { + err := SetThresholdRequestValidationError{ + field: "Target", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetRuleId()) < 1 { + err := SetThresholdRequestValidationError{ + field: "RuleId", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetParamName()) < 1 { + err := SetThresholdRequestValidationError{ + field: "ParamName", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + // no validation rules for Value + + if len(errors) > 0 { + return SetThresholdRequestMultiError(errors) + } + + return nil +} + +// SetThresholdRequestMultiError is an error wrapping multiple validation +// errors returned by SetThresholdRequest.ValidateAll() if the designated +// constraints aren't met. +type SetThresholdRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m SetThresholdRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m SetThresholdRequestMultiError) AllErrors() []error { return m } + +// SetThresholdRequestValidationError is the validation error returned by +// SetThresholdRequest.Validate if the designated constraints aren't met. +type SetThresholdRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e SetThresholdRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e SetThresholdRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e SetThresholdRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e SetThresholdRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e SetThresholdRequestValidationError) ErrorName() string { + return "SetThresholdRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e SetThresholdRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sSetThresholdRequest.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = SetThresholdRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = SetThresholdRequestValidationError{} + +// Validate checks the field values on SetThresholdResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *SetThresholdResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on SetThresholdResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// SetThresholdResponseMultiError, or nil if none found. +func (m *SetThresholdResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *SetThresholdResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetThreshold()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, SetThresholdResponseValidationError{ + field: "Threshold", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, SetThresholdResponseValidationError{ + field: "Threshold", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetThreshold()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return SetThresholdResponseValidationError{ + field: "Threshold", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return SetThresholdResponseMultiError(errors) + } + + return nil +} + +// SetThresholdResponseMultiError is an error wrapping multiple validation +// errors returned by SetThresholdResponse.ValidateAll() if the designated +// constraints aren't met. +type SetThresholdResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m SetThresholdResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m SetThresholdResponseMultiError) AllErrors() []error { return m } + +// SetThresholdResponseValidationError is the validation error returned by +// SetThresholdResponse.Validate if the designated constraints aren't met. +type SetThresholdResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e SetThresholdResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e SetThresholdResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e SetThresholdResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e SetThresholdResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e SetThresholdResponseValidationError) ErrorName() string { + return "SetThresholdResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e SetThresholdResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sSetThresholdResponse.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = SetThresholdResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = SetThresholdResponseValidationError{} + +// Validate checks the field values on ClearThresholdRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ClearThresholdRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ClearThresholdRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ClearThresholdRequestMultiError, or nil if none found. +func (m *ClearThresholdRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ClearThresholdRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Scope + + if utf8.RuneCountInString(m.GetTarget()) < 1 { + err := ClearThresholdRequestValidationError{ + field: "Target", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetRuleId()) < 1 { + err := ClearThresholdRequestValidationError{ + field: "RuleId", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetParamName()) < 1 { + err := ClearThresholdRequestValidationError{ + field: "ParamName", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if len(errors) > 0 { + return ClearThresholdRequestMultiError(errors) + } + + return nil +} + +// ClearThresholdRequestMultiError is an error wrapping multiple validation +// errors returned by ClearThresholdRequest.ValidateAll() if the designated +// constraints aren't met. +type ClearThresholdRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ClearThresholdRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ClearThresholdRequestMultiError) AllErrors() []error { return m } + +// ClearThresholdRequestValidationError is the validation error returned by +// ClearThresholdRequest.Validate if the designated constraints aren't met. +type ClearThresholdRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ClearThresholdRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ClearThresholdRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ClearThresholdRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ClearThresholdRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ClearThresholdRequestValidationError) ErrorName() string { + return "ClearThresholdRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e ClearThresholdRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sClearThresholdRequest.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ClearThresholdRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ClearThresholdRequestValidationError{} + +// Validate checks the field values on ClearThresholdResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ClearThresholdResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ClearThresholdResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ClearThresholdResponseMultiError, or nil if none found. +func (m *ClearThresholdResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ClearThresholdResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return ClearThresholdResponseMultiError(errors) + } + + return nil +} + +// ClearThresholdResponseMultiError is an error wrapping multiple validation +// errors returned by ClearThresholdResponse.ValidateAll() if the designated +// constraints aren't met. +type ClearThresholdResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ClearThresholdResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ClearThresholdResponseMultiError) AllErrors() []error { return m } + +// ClearThresholdResponseValidationError is the validation error returned by +// ClearThresholdResponse.Validate if the designated constraints aren't met. +type ClearThresholdResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ClearThresholdResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ClearThresholdResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ClearThresholdResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ClearThresholdResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ClearThresholdResponseValidationError) ErrorName() string { + return "ClearThresholdResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ClearThresholdResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sClearThresholdResponse.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ClearThresholdResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ClearThresholdResponseValidationError{} + +// Validate checks the field values on ThresholdUpdate with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ThresholdUpdate) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ThresholdUpdate with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ThresholdUpdateMultiError, or nil if none found. +func (m *ThresholdUpdate) ValidateAll() error { + return m.validate(true) +} + +func (m *ThresholdUpdate) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Scope + + if utf8.RuneCountInString(m.GetTarget()) < 1 { + err := ThresholdUpdateValidationError{ + field: "Target", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetRuleId()) < 1 { + err := ThresholdUpdateValidationError{ + field: "RuleId", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetParamName()) < 1 { + err := ThresholdUpdateValidationError{ + field: "ParamName", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if m.Value != nil { + // no validation rules for Value + } + + if len(errors) > 0 { + return ThresholdUpdateMultiError(errors) + } + + return nil +} + +// ThresholdUpdateMultiError is an error wrapping multiple validation errors +// returned by ThresholdUpdate.ValidateAll() if the designated constraints +// aren't met. +type ThresholdUpdateMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ThresholdUpdateMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ThresholdUpdateMultiError) AllErrors() []error { return m } + +// ThresholdUpdateValidationError is the validation error returned by +// ThresholdUpdate.Validate if the designated constraints aren't met. +type ThresholdUpdateValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ThresholdUpdateValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ThresholdUpdateValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ThresholdUpdateValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ThresholdUpdateValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ThresholdUpdateValidationError) ErrorName() string { return "ThresholdUpdateValidationError" } + +// Error satisfies the builtin error interface +func (e ThresholdUpdateValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sThresholdUpdate.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = ThresholdUpdateValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ThresholdUpdateValidationError{} + +// Validate checks the field values on BatchUpdateThresholdsRequest with the +// rules defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *BatchUpdateThresholdsRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on BatchUpdateThresholdsRequest with the +// rules defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// BatchUpdateThresholdsRequestMultiError, or nil if none found. +func (m *BatchUpdateThresholdsRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *BatchUpdateThresholdsRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(m.GetUpdates()) < 1 { + err := BatchUpdateThresholdsRequestValidationError{ + field: "Updates", + reason: "value must contain at least 1 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + for idx, item := range m.GetUpdates() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, BatchUpdateThresholdsRequestValidationError{ + field: fmt.Sprintf("Updates[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, BatchUpdateThresholdsRequestValidationError{ + field: fmt.Sprintf("Updates[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return BatchUpdateThresholdsRequestValidationError{ + field: fmt.Sprintf("Updates[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return BatchUpdateThresholdsRequestMultiError(errors) + } + + return nil +} + +// BatchUpdateThresholdsRequestMultiError is an error wrapping multiple +// validation errors returned by BatchUpdateThresholdsRequest.ValidateAll() if +// the designated constraints aren't met. +type BatchUpdateThresholdsRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m BatchUpdateThresholdsRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m BatchUpdateThresholdsRequestMultiError) AllErrors() []error { return m } + +// BatchUpdateThresholdsRequestValidationError is the validation error returned +// by BatchUpdateThresholdsRequest.Validate if the designated constraints +// aren't met. +type BatchUpdateThresholdsRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e BatchUpdateThresholdsRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e BatchUpdateThresholdsRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e BatchUpdateThresholdsRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e BatchUpdateThresholdsRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e BatchUpdateThresholdsRequestValidationError) ErrorName() string { + return "BatchUpdateThresholdsRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e BatchUpdateThresholdsRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sBatchUpdateThresholdsRequest.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = BatchUpdateThresholdsRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = BatchUpdateThresholdsRequestValidationError{} + +// Validate checks the field values on BatchUpdateThresholdsResponse with the +// rules defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *BatchUpdateThresholdsResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on BatchUpdateThresholdsResponse with +// the rules defined in the proto definition for this message. If any rules +// are violated, the result is a list of violation errors wrapped in +// BatchUpdateThresholdsResponseMultiError, or nil if none found. +func (m *BatchUpdateThresholdsResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *BatchUpdateThresholdsResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetThresholds() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, BatchUpdateThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, BatchUpdateThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return BatchUpdateThresholdsResponseValidationError{ + field: fmt.Sprintf("Thresholds[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return BatchUpdateThresholdsResponseMultiError(errors) + } + + return nil +} + +// BatchUpdateThresholdsResponseMultiError is an error wrapping multiple +// validation errors returned by BatchUpdateThresholdsResponse.ValidateAll() +// if the designated constraints aren't met. +type BatchUpdateThresholdsResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m BatchUpdateThresholdsResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m BatchUpdateThresholdsResponseMultiError) AllErrors() []error { return m } + +// BatchUpdateThresholdsResponseValidationError is the validation error +// returned by BatchUpdateThresholdsResponse.Validate if the designated +// constraints aren't met. +type BatchUpdateThresholdsResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e BatchUpdateThresholdsResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e BatchUpdateThresholdsResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e BatchUpdateThresholdsResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e BatchUpdateThresholdsResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e BatchUpdateThresholdsResponseValidationError) ErrorName() string { + return "BatchUpdateThresholdsResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e BatchUpdateThresholdsResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sBatchUpdateThresholdsResponse.%s: %s%s", + key, + e.field, + e.reason, + cause, + ) +} + +var _ error = BatchUpdateThresholdsResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = BatchUpdateThresholdsResponseValidationError{} diff --git a/api/alerting/v1/alerting.proto b/api/alerting/v1/alerting.proto index 1b7dd727acb..e4f11bc64ac 100644 --- a/api/alerting/v1/alerting.proto +++ b/api/alerting/v1/alerting.proto @@ -49,6 +49,10 @@ message ParamDefinition { // String value. StringParamDefinition string = 7; } + // Whether this parameter's threshold can be overridden per target without editing the + // rule. Only set for templates that support it; the scopes it may be set at are + // reported per rule by ListThresholds. + bool overridable = 8; } // TemplateSource defines template source. @@ -208,7 +212,106 @@ message CreateRuleRequest { google.protobuf.Duration interval = 10; } -message CreateRuleResponse {} +message CreateRuleResponse { + // Identifier PMM assigns to a rule whose thresholds can be overridden per target. + // Empty when the rule has no overridable parameters, since nothing can be keyed on it. + // This is the rule's identity for threshold purposes rather than its Grafana UID: + // copying or renaming the rule in Grafana preserves it. + string rule_id = 1; +} + +// ThresholdScope says what a threshold override's target refers to. +enum ThresholdScope { + THRESHOLD_SCOPE_UNSPECIFIED = 0; + // Target is a Node ID. + THRESHOLD_SCOPE_NODE = 1; + // Target is a Service ID. + THRESHOLD_SCOPE_SERVICE = 2; + // Target is a cluster label value. + THRESHOLD_SCOPE_CLUSTER = 3; +} + +// Threshold is one overridable parameter of one rule, as it applies to one target. +message Threshold { + // Identifier PMM assigned to the rule. Not unique within a response: rules duplicated + // in Grafana share it, so two entries can carry the same rule_id and param_name and + // differ only in which rule they came from. Do not key a map on it. + string rule_id = 1; + // Machine-readable name of the overridable parameter. + string param_name = 2; + // Short human-readable parameter summary, as it was when the rule was created. + string summary = 3; + // Parameter unit. + ParamUnit unit = 4; + // Value the rule falls back to when no override applies. + double default_value = 5; + // Value the rule currently evaluates this target against. + double effective_value = 6; + // Whether effective_value comes from an override rather than the default. + bool is_overridden = 7; + // Scope the effective override was set at. Unspecified when not overridden. + ThresholdScope scope = 8; + // Target the effective override was set on. Empty when not overridden. + string target = 9; +} + +message ListThresholdsRequest { + // Scope of the target to report thresholds for. Must be set together with target. + ThresholdScope scope = 1; + // Target to report thresholds for. When set, every overridable parameter is returned + // for that target, overridden or not. When empty, only existing overrides are + // returned, since there is otherwise no bounded set to enumerate. + string target = 2; + // Return only thresholds of this rule. + string rule_id = 3; +} + +message ListThresholdsResponse { + repeated Threshold thresholds = 1; +} + +message SetThresholdRequest { + ThresholdScope scope = 1; + string target = 2 [(validate.rules).string.min_len = 1]; + string rule_id = 3 [(validate.rules).string.min_len = 1]; + string param_name = 4 [(validate.rules).string.min_len = 1]; + // Must be finite and within the parameter's declared range. + double value = 5; +} + +message SetThresholdResponse { + Threshold threshold = 1; +} + +message ClearThresholdRequest { + ThresholdScope scope = 1; + string target = 2 [(validate.rules).string.min_len = 1]; + string rule_id = 3 [(validate.rules).string.min_len = 1]; + string param_name = 4 [(validate.rules).string.min_len = 1]; +} + +message ClearThresholdResponse {} + +// ThresholdUpdate sets or clears one override. +message ThresholdUpdate { + ThresholdScope scope = 1; + string target = 2 [(validate.rules).string.min_len = 1]; + string rule_id = 3 [(validate.rules).string.min_len = 1]; + string param_name = 4 [(validate.rules).string.min_len = 1]; + // Omit to clear the override rather than set it. + optional double value = 5; +} + +message BatchUpdateThresholdsRequest { + // Applied in one transaction: either every update lands or none does. A client + // editing several rows at once cannot otherwise report which ones took effect. + repeated ThresholdUpdate updates = 1 [(validate.rules).repeated.min_items = 1]; +} + +message BatchUpdateThresholdsResponse { + // Thresholds that were set, in request order. Cleared ones are omitted. + repeated Threshold thresholds = 1; +} // Alerting service lets to manage alerting templates and create alerting rules from them. service AlertingService { @@ -241,4 +344,27 @@ service AlertingService { body: "*" }; } + // ListThresholds returns per-target threshold overrides. + rpc ListThresholds(ListThresholdsRequest) returns (ListThresholdsResponse) { + option (google.api.http) = {get: "/v1/alerting/thresholds"}; + } + // SetThreshold overrides one rule parameter for one target. + rpc SetThreshold(SetThresholdRequest) returns (SetThresholdResponse) { + option (google.api.http) = { + post: "/v1/alerting/thresholds" + body: "*" + }; + } + // ClearThreshold removes an override so the target falls back to the rule's default, + // or to a broader override still covering it. + rpc ClearThreshold(ClearThresholdRequest) returns (ClearThresholdResponse) { + option (google.api.http) = {delete: "/v1/alerting/thresholds"}; + } + // BatchUpdateThresholds applies several set and clear operations in one transaction. + rpc BatchUpdateThresholds(BatchUpdateThresholdsRequest) returns (BatchUpdateThresholdsResponse) { + option (google.api.http) = { + post: "/v1/alerting/thresholds:batchUpdate" + body: "*" + }; + } } diff --git a/api/alerting/v1/alerting_grpc.pb.go b/api/alerting/v1/alerting_grpc.pb.go index d559dc19bfe..18c1f79db2f 100644 --- a/api/alerting/v1/alerting_grpc.pb.go +++ b/api/alerting/v1/alerting_grpc.pb.go @@ -20,11 +20,15 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AlertingService_ListTemplates_FullMethodName = "/alerting.v1.AlertingService/ListTemplates" - AlertingService_CreateTemplate_FullMethodName = "/alerting.v1.AlertingService/CreateTemplate" - AlertingService_UpdateTemplate_FullMethodName = "/alerting.v1.AlertingService/UpdateTemplate" - AlertingService_DeleteTemplate_FullMethodName = "/alerting.v1.AlertingService/DeleteTemplate" - AlertingService_CreateRule_FullMethodName = "/alerting.v1.AlertingService/CreateRule" + AlertingService_ListTemplates_FullMethodName = "/alerting.v1.AlertingService/ListTemplates" + AlertingService_CreateTemplate_FullMethodName = "/alerting.v1.AlertingService/CreateTemplate" + AlertingService_UpdateTemplate_FullMethodName = "/alerting.v1.AlertingService/UpdateTemplate" + AlertingService_DeleteTemplate_FullMethodName = "/alerting.v1.AlertingService/DeleteTemplate" + AlertingService_CreateRule_FullMethodName = "/alerting.v1.AlertingService/CreateRule" + AlertingService_ListThresholds_FullMethodName = "/alerting.v1.AlertingService/ListThresholds" + AlertingService_SetThreshold_FullMethodName = "/alerting.v1.AlertingService/SetThreshold" + AlertingService_ClearThreshold_FullMethodName = "/alerting.v1.AlertingService/ClearThreshold" + AlertingService_BatchUpdateThresholds_FullMethodName = "/alerting.v1.AlertingService/BatchUpdateThresholds" ) // AlertingServiceClient is the client API for AlertingService service. @@ -43,6 +47,15 @@ type AlertingServiceClient interface { DeleteTemplate(ctx context.Context, in *DeleteTemplateRequest, opts ...grpc.CallOption) (*DeleteTemplateResponse, error) // CreateRule creates alerting rule from the given template. CreateRule(ctx context.Context, in *CreateRuleRequest, opts ...grpc.CallOption) (*CreateRuleResponse, error) + // ListThresholds returns per-target threshold overrides. + ListThresholds(ctx context.Context, in *ListThresholdsRequest, opts ...grpc.CallOption) (*ListThresholdsResponse, error) + // SetThreshold overrides one rule parameter for one target. + SetThreshold(ctx context.Context, in *SetThresholdRequest, opts ...grpc.CallOption) (*SetThresholdResponse, error) + // ClearThreshold removes an override so the target falls back to the rule's default, + // or to a broader override still covering it. + ClearThreshold(ctx context.Context, in *ClearThresholdRequest, opts ...grpc.CallOption) (*ClearThresholdResponse, error) + // BatchUpdateThresholds applies several set and clear operations in one transaction. + BatchUpdateThresholds(ctx context.Context, in *BatchUpdateThresholdsRequest, opts ...grpc.CallOption) (*BatchUpdateThresholdsResponse, error) } type alertingServiceClient struct { @@ -103,6 +116,46 @@ func (c *alertingServiceClient) CreateRule(ctx context.Context, in *CreateRuleRe return out, nil } +func (c *alertingServiceClient) ListThresholds(ctx context.Context, in *ListThresholdsRequest, opts ...grpc.CallOption) (*ListThresholdsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListThresholdsResponse) + err := c.cc.Invoke(ctx, AlertingService_ListThresholds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *alertingServiceClient) SetThreshold(ctx context.Context, in *SetThresholdRequest, opts ...grpc.CallOption) (*SetThresholdResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetThresholdResponse) + err := c.cc.Invoke(ctx, AlertingService_SetThreshold_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *alertingServiceClient) ClearThreshold(ctx context.Context, in *ClearThresholdRequest, opts ...grpc.CallOption) (*ClearThresholdResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClearThresholdResponse) + err := c.cc.Invoke(ctx, AlertingService_ClearThreshold_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *alertingServiceClient) BatchUpdateThresholds(ctx context.Context, in *BatchUpdateThresholdsRequest, opts ...grpc.CallOption) (*BatchUpdateThresholdsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BatchUpdateThresholdsResponse) + err := c.cc.Invoke(ctx, AlertingService_BatchUpdateThresholds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AlertingServiceServer is the server API for AlertingService service. // All implementations must embed UnimplementedAlertingServiceServer // for forward compatibility. @@ -119,6 +172,15 @@ type AlertingServiceServer interface { DeleteTemplate(context.Context, *DeleteTemplateRequest) (*DeleteTemplateResponse, error) // CreateRule creates alerting rule from the given template. CreateRule(context.Context, *CreateRuleRequest) (*CreateRuleResponse, error) + // ListThresholds returns per-target threshold overrides. + ListThresholds(context.Context, *ListThresholdsRequest) (*ListThresholdsResponse, error) + // SetThreshold overrides one rule parameter for one target. + SetThreshold(context.Context, *SetThresholdRequest) (*SetThresholdResponse, error) + // ClearThreshold removes an override so the target falls back to the rule's default, + // or to a broader override still covering it. + ClearThreshold(context.Context, *ClearThresholdRequest) (*ClearThresholdResponse, error) + // BatchUpdateThresholds applies several set and clear operations in one transaction. + BatchUpdateThresholds(context.Context, *BatchUpdateThresholdsRequest) (*BatchUpdateThresholdsResponse, error) mustEmbedUnimplementedAlertingServiceServer() } @@ -148,6 +210,22 @@ func (UnimplementedAlertingServiceServer) DeleteTemplate(context.Context, *Delet func (UnimplementedAlertingServiceServer) CreateRule(context.Context, *CreateRuleRequest) (*CreateRuleResponse, error) { return nil, status.Error(codes.Unimplemented, "method CreateRule not implemented") } + +func (UnimplementedAlertingServiceServer) ListThresholds(context.Context, *ListThresholdsRequest) (*ListThresholdsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListThresholds not implemented") +} + +func (UnimplementedAlertingServiceServer) SetThreshold(context.Context, *SetThresholdRequest) (*SetThresholdResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetThreshold not implemented") +} + +func (UnimplementedAlertingServiceServer) ClearThreshold(context.Context, *ClearThresholdRequest) (*ClearThresholdResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ClearThreshold not implemented") +} + +func (UnimplementedAlertingServiceServer) BatchUpdateThresholds(context.Context, *BatchUpdateThresholdsRequest) (*BatchUpdateThresholdsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method BatchUpdateThresholds not implemented") +} func (UnimplementedAlertingServiceServer) mustEmbedUnimplementedAlertingServiceServer() {} func (UnimplementedAlertingServiceServer) testEmbeddedByValue() {} @@ -259,6 +337,78 @@ func _AlertingService_CreateRule_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _AlertingService_ListThresholds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListThresholdsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AlertingServiceServer).ListThresholds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AlertingService_ListThresholds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AlertingServiceServer).ListThresholds(ctx, req.(*ListThresholdsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AlertingService_SetThreshold_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetThresholdRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AlertingServiceServer).SetThreshold(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AlertingService_SetThreshold_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AlertingServiceServer).SetThreshold(ctx, req.(*SetThresholdRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AlertingService_ClearThreshold_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClearThresholdRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AlertingServiceServer).ClearThreshold(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AlertingService_ClearThreshold_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AlertingServiceServer).ClearThreshold(ctx, req.(*ClearThresholdRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AlertingService_BatchUpdateThresholds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BatchUpdateThresholdsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AlertingServiceServer).BatchUpdateThresholds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AlertingService_BatchUpdateThresholds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AlertingServiceServer).BatchUpdateThresholds(ctx, req.(*BatchUpdateThresholdsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AlertingService_ServiceDesc is the grpc.ServiceDesc for AlertingService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -286,6 +436,22 @@ var AlertingService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CreateRule", Handler: _AlertingService_CreateRule_Handler, }, + { + MethodName: "ListThresholds", + Handler: _AlertingService_ListThresholds_Handler, + }, + { + MethodName: "SetThreshold", + Handler: _AlertingService_SetThreshold_Handler, + }, + { + MethodName: "ClearThreshold", + Handler: _AlertingService_ClearThreshold_Handler, + }, + { + MethodName: "BatchUpdateThresholds", + Handler: _AlertingService_BatchUpdateThresholds_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "alerting/v1/alerting.proto", diff --git a/api/alerting/v1/json/client/alerting_service/alerting_service_client.go b/api/alerting/v1/json/client/alerting_service/alerting_service_client.go index 40690a8da6e..9fca2484889 100644 --- a/api/alerting/v1/json/client/alerting_service/alerting_service_client.go +++ b/api/alerting/v1/json/client/alerting_service/alerting_service_client.go @@ -51,6 +51,10 @@ type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { + BatchUpdateThresholds(params *BatchUpdateThresholdsParams, opts ...ClientOption) (*BatchUpdateThresholdsOK, error) + + ClearThreshold(params *ClearThresholdParams, opts ...ClientOption) (*ClearThresholdOK, error) + CreateRule(params *CreateRuleParams, opts ...ClientOption) (*CreateRuleOK, error) CreateTemplate(params *CreateTemplateParams, opts ...ClientOption) (*CreateTemplateOK, error) @@ -59,11 +63,99 @@ type ClientService interface { ListTemplates(params *ListTemplatesParams, opts ...ClientOption) (*ListTemplatesOK, error) + ListThresholds(params *ListThresholdsParams, opts ...ClientOption) (*ListThresholdsOK, error) + + SetThreshold(params *SetThresholdParams, opts ...ClientOption) (*SetThresholdOK, error) + UpdateTemplate(params *UpdateTemplateParams, opts ...ClientOption) (*UpdateTemplateOK, error) SetTransport(transport runtime.ClientTransport) } +/* +BatchUpdateThresholds batches update thresholds applies several set and clear operations in one transaction +*/ +func (a *Client) BatchUpdateThresholds(params *BatchUpdateThresholdsParams, opts ...ClientOption) (*BatchUpdateThresholdsOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewBatchUpdateThresholdsParams() + } + op := &runtime.ClientOperation{ + ID: "BatchUpdateThresholds", + Method: "POST", + PathPattern: "/v1/alerting/thresholds:batchUpdate", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &BatchUpdateThresholdsReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*BatchUpdateThresholdsOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*BatchUpdateThresholdsDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +ClearThreshold clears threshold removes an override so the target falls back to the rule s default or to a broader override still covering it +*/ +func (a *Client) ClearThreshold(params *ClearThresholdParams, opts ...ClientOption) (*ClearThresholdOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewClearThresholdParams() + } + op := &runtime.ClientOperation{ + ID: "ClearThreshold", + Method: "DELETE", + PathPattern: "/v1/alerting/thresholds", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &ClearThresholdReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*ClearThresholdOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*ClearThresholdDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + /* CreateRule creates rule creates alerting rule from the given template */ @@ -232,6 +324,90 @@ func (a *Client) ListTemplates(params *ListTemplatesParams, opts ...ClientOption return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) } +/* +ListThresholds lists thresholds returns per target threshold overrides +*/ +func (a *Client) ListThresholds(params *ListThresholdsParams, opts ...ClientOption) (*ListThresholdsOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewListThresholdsParams() + } + op := &runtime.ClientOperation{ + ID: "ListThresholds", + Method: "GET", + PathPattern: "/v1/alerting/thresholds", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &ListThresholdsReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*ListThresholdsOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*ListThresholdsDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + +/* +SetThreshold sets threshold overrides one rule parameter for one target +*/ +func (a *Client) SetThreshold(params *SetThresholdParams, opts ...ClientOption) (*SetThresholdOK, error) { + // NOTE: parameters are not validated before sending + if params == nil { + params = NewSetThresholdParams() + } + op := &runtime.ClientOperation{ + ID: "SetThreshold", + Method: "POST", + PathPattern: "/v1/alerting/thresholds", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &SetThresholdReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + + // only one success response has to be checked + success, ok := result.(*SetThresholdOK) + if ok { + return success, nil + } + + // unexpected success response. + // + // a default response is provided: fill this and return an error + unexpectedSuccess := result.(*SetThresholdDefault) + + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + /* UpdateTemplate updates template updates existing template previously created via API */ diff --git a/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_parameters.go b/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_parameters.go new file mode 100644 index 00000000000..805fd11da4d --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_parameters.go @@ -0,0 +1,141 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewBatchUpdateThresholdsParams creates a new BatchUpdateThresholdsParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewBatchUpdateThresholdsParams() *BatchUpdateThresholdsParams { + return &BatchUpdateThresholdsParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewBatchUpdateThresholdsParamsWithTimeout creates a new BatchUpdateThresholdsParams object +// with the ability to set a timeout on a request. +func NewBatchUpdateThresholdsParamsWithTimeout(timeout time.Duration) *BatchUpdateThresholdsParams { + return &BatchUpdateThresholdsParams{ + timeout: timeout, + } +} + +// NewBatchUpdateThresholdsParamsWithContext creates a new BatchUpdateThresholdsParams object +// with the ability to set a context for a request. +func NewBatchUpdateThresholdsParamsWithContext(ctx context.Context) *BatchUpdateThresholdsParams { + return &BatchUpdateThresholdsParams{ + Context: ctx, + } +} + +// NewBatchUpdateThresholdsParamsWithHTTPClient creates a new BatchUpdateThresholdsParams object +// with the ability to set a custom HTTPClient for a request. +func NewBatchUpdateThresholdsParamsWithHTTPClient(client *http.Client) *BatchUpdateThresholdsParams { + return &BatchUpdateThresholdsParams{ + HTTPClient: client, + } +} + +/* +BatchUpdateThresholdsParams contains all the parameters to send to the API endpoint + + for the batch update thresholds operation. + + Typically these are written to a http.Request. +*/ +type BatchUpdateThresholdsParams struct { + // Body. + Body BatchUpdateThresholdsBody + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the batch update thresholds params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *BatchUpdateThresholdsParams) WithDefaults() *BatchUpdateThresholdsParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the batch update thresholds params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *BatchUpdateThresholdsParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) WithTimeout(timeout time.Duration) *BatchUpdateThresholdsParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) WithContext(ctx context.Context) *BatchUpdateThresholdsParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) WithHTTPClient(client *http.Client) *BatchUpdateThresholdsParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) WithBody(body BatchUpdateThresholdsBody) *BatchUpdateThresholdsParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the batch update thresholds params +func (o *BatchUpdateThresholdsParams) SetBody(body BatchUpdateThresholdsBody) { + o.Body = body +} + +// WriteToRequest writes these params to a swagger request +func (o *BatchUpdateThresholdsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_responses.go b/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_responses.go new file mode 100644 index 00000000000..3305e647fda --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/batch_update_thresholds_responses.go @@ -0,0 +1,927 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// BatchUpdateThresholdsReader is a Reader for the BatchUpdateThresholds structure. +type BatchUpdateThresholdsReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *BatchUpdateThresholdsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewBatchUpdateThresholdsOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewBatchUpdateThresholdsDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewBatchUpdateThresholdsOK creates a BatchUpdateThresholdsOK with default headers values +func NewBatchUpdateThresholdsOK() *BatchUpdateThresholdsOK { + return &BatchUpdateThresholdsOK{} +} + +/* +BatchUpdateThresholdsOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type BatchUpdateThresholdsOK struct { + Payload *BatchUpdateThresholdsOKBody +} + +// IsSuccess returns true when this batch update thresholds Ok response has a 2xx status code +func (o *BatchUpdateThresholdsOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this batch update thresholds Ok response has a 3xx status code +func (o *BatchUpdateThresholdsOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this batch update thresholds Ok response has a 4xx status code +func (o *BatchUpdateThresholdsOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this batch update thresholds Ok response has a 5xx status code +func (o *BatchUpdateThresholdsOK) IsServerError() bool { + return false +} + +// IsCode returns true when this batch update thresholds Ok response a status code equal to that given +func (o *BatchUpdateThresholdsOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the batch update thresholds Ok response +func (o *BatchUpdateThresholdsOK) Code() int { + return 200 +} + +func (o *BatchUpdateThresholdsOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds:batchUpdate][%d] batchUpdateThresholdsOk %s", 200, payload) +} + +func (o *BatchUpdateThresholdsOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds:batchUpdate][%d] batchUpdateThresholdsOk %s", 200, payload) +} + +func (o *BatchUpdateThresholdsOK) GetPayload() *BatchUpdateThresholdsOKBody { + return o.Payload +} + +func (o *BatchUpdateThresholdsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(BatchUpdateThresholdsOKBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewBatchUpdateThresholdsDefault creates a BatchUpdateThresholdsDefault with default headers values +func NewBatchUpdateThresholdsDefault(code int) *BatchUpdateThresholdsDefault { + return &BatchUpdateThresholdsDefault{ + _statusCode: code, + } +} + +/* +BatchUpdateThresholdsDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type BatchUpdateThresholdsDefault struct { + _statusCode int + + Payload *BatchUpdateThresholdsDefaultBody +} + +// IsSuccess returns true when this batch update thresholds default response has a 2xx status code +func (o *BatchUpdateThresholdsDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this batch update thresholds default response has a 3xx status code +func (o *BatchUpdateThresholdsDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this batch update thresholds default response has a 4xx status code +func (o *BatchUpdateThresholdsDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this batch update thresholds default response has a 5xx status code +func (o *BatchUpdateThresholdsDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this batch update thresholds default response a status code equal to that given +func (o *BatchUpdateThresholdsDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the batch update thresholds default response +func (o *BatchUpdateThresholdsDefault) Code() int { + return o._statusCode +} + +func (o *BatchUpdateThresholdsDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds:batchUpdate][%d] BatchUpdateThresholds default %s", o._statusCode, payload) +} + +func (o *BatchUpdateThresholdsDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds:batchUpdate][%d] BatchUpdateThresholds default %s", o._statusCode, payload) +} + +func (o *BatchUpdateThresholdsDefault) GetPayload() *BatchUpdateThresholdsDefaultBody { + return o.Payload +} + +func (o *BatchUpdateThresholdsDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(BatchUpdateThresholdsDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +BatchUpdateThresholdsBody batch update thresholds body +swagger:model BatchUpdateThresholdsBody +*/ +type BatchUpdateThresholdsBody struct { + // Applied in one transaction: either every update lands or none does. A client + // editing several rows at once cannot otherwise report which ones took effect. + Updates []*BatchUpdateThresholdsParamsBodyUpdatesItems0 `json:"updates"` +} + +// Validate validates this batch update thresholds body +func (o *BatchUpdateThresholdsBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateUpdates(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsBody) validateUpdates(formats strfmt.Registry) error { + if swag.IsZero(o.Updates) { // not required + return nil + } + + for i := 0; i < len(o.Updates); i++ { + if swag.IsZero(o.Updates[i]) { // not required + continue + } + + if o.Updates[i] != nil { + if err := o.Updates[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("body" + "." + "updates" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("body" + "." + "updates" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this batch update thresholds body based on the context it is used +func (o *BatchUpdateThresholdsBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateUpdates(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsBody) contextValidateUpdates(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Updates); i++ { + if o.Updates[i] != nil { + + if swag.IsZero(o.Updates[i]) { // not required + return nil + } + + if err := o.Updates[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("body" + "." + "updates" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("body" + "." + "updates" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsBody) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +BatchUpdateThresholdsDefaultBody batch update thresholds default body +swagger:model BatchUpdateThresholdsDefaultBody +*/ +type BatchUpdateThresholdsDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*BatchUpdateThresholdsDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this batch update thresholds default body +func (o *BatchUpdateThresholdsDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("BatchUpdateThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("BatchUpdateThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this batch update thresholds default body based on the context it is used +func (o *BatchUpdateThresholdsDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("BatchUpdateThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("BatchUpdateThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsDefaultBody) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +BatchUpdateThresholdsDefaultBodyDetailsItems0 batch update thresholds default body details items0 +swagger:model BatchUpdateThresholdsDefaultBodyDetailsItems0 +*/ +type BatchUpdateThresholdsDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // batch update thresholds default body details items0 + BatchUpdateThresholdsDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *BatchUpdateThresholdsDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv BatchUpdateThresholdsDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.BatchUpdateThresholdsDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o BatchUpdateThresholdsDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.BatchUpdateThresholdsDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.BatchUpdateThresholdsDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this batch update thresholds default body details items0 +func (o *BatchUpdateThresholdsDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this batch update thresholds default body details items0 based on context it is used +func (o *BatchUpdateThresholdsDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +BatchUpdateThresholdsOKBody batch update thresholds OK body +swagger:model BatchUpdateThresholdsOKBody +*/ +type BatchUpdateThresholdsOKBody struct { + // Thresholds that were set, in request order. Cleared ones are omitted. + Thresholds []*BatchUpdateThresholdsOKBodyThresholdsItems0 `json:"thresholds"` +} + +// Validate validates this batch update thresholds OK body +func (o *BatchUpdateThresholdsOKBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateThresholds(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsOKBody) validateThresholds(formats strfmt.Registry) error { + if swag.IsZero(o.Thresholds) { // not required + return nil + } + + for i := 0; i < len(o.Thresholds); i++ { + if swag.IsZero(o.Thresholds[i]) { // not required + continue + } + + if o.Thresholds[i] != nil { + if err := o.Thresholds[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("batchUpdateThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("batchUpdateThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this batch update thresholds OK body based on the context it is used +func (o *BatchUpdateThresholdsOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateThresholds(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *BatchUpdateThresholdsOKBody) contextValidateThresholds(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Thresholds); i++ { + if o.Thresholds[i] != nil { + + if swag.IsZero(o.Thresholds[i]) { // not required + return nil + } + + if err := o.Thresholds[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("batchUpdateThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("batchUpdateThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsOKBody) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +BatchUpdateThresholdsOKBodyThresholdsItems0 Threshold is one overridable parameter of one rule, as it applies to one target. +swagger:model BatchUpdateThresholdsOKBodyThresholdsItems0 +*/ +type BatchUpdateThresholdsOKBodyThresholdsItems0 struct { + // Identifier PMM assigned to the rule. Not unique within a response: rules duplicated + // in Grafana share it, so two entries can carry the same rule_id and param_name and + // differ only in which rule they came from. Do not key a map on it. + RuleID string `json:"rule_id,omitempty"` + + // Machine-readable name of the overridable parameter. + ParamName string `json:"param_name,omitempty"` + + // Short human-readable parameter summary, as it was when the rule was created. + Summary string `json:"summary,omitempty"` + + // ParamUnit represents template parameter unit. + // + // - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent. + // - PARAM_UNIT_PERCENTAGE: % + // - PARAM_UNIT_SECONDS: s + // Enum: ["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"] + Unit *string `json:"unit,omitempty"` + + // Value the rule falls back to when no override applies. + DefaultValue float64 `json:"default_value,omitempty"` + + // Value the rule currently evaluates this target against. + EffectiveValue float64 `json:"effective_value,omitempty"` + + // Whether effective_value comes from an override rather than the default. + IsOverridden bool `json:"is_overridden,omitempty"` + + // ThresholdScope says what a threshold override's target refers to. + // + // - THRESHOLD_SCOPE_NODE: Target is a Node ID. + // - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + // - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + // Enum: ["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"] + Scope *string `json:"scope,omitempty"` + + // Target the effective override was set on. Empty when not overridden. + Target string `json:"target,omitempty"` +} + +// Validate validates this batch update thresholds OK body thresholds items0 +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateUnit(formats); err != nil { + res = append(res, err) + } + + if err := o.validateScope(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var batchUpdateThresholdsOkBodyThresholdsItems0TypeUnitPropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + batchUpdateThresholdsOkBodyThresholdsItems0TypeUnitPropEnum = append(batchUpdateThresholdsOkBodyThresholdsItems0TypeUnitPropEnum, v) + } +} + +const ( + + // BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITUNSPECIFIED captures enum value "PARAM_UNIT_UNSPECIFIED" + BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITUNSPECIFIED string = "PARAM_UNIT_UNSPECIFIED" + + // BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITPERCENTAGE captures enum value "PARAM_UNIT_PERCENTAGE" + BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITPERCENTAGE string = "PARAM_UNIT_PERCENTAGE" + + // BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITSECONDS captures enum value "PARAM_UNIT_SECONDS" + BatchUpdateThresholdsOKBodyThresholdsItems0UnitPARAMUNITSECONDS string = "PARAM_UNIT_SECONDS" +) + +// prop value enum +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) validateUnitEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, batchUpdateThresholdsOkBodyThresholdsItems0TypeUnitPropEnum, true); err != nil { + return err + } + return nil +} + +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) validateUnit(formats strfmt.Registry) error { + if swag.IsZero(o.Unit) { // not required + return nil + } + + // value enum + if err := o.validateUnitEnum("unit", "body", *o.Unit); err != nil { + return err + } + + return nil +} + +var batchUpdateThresholdsOkBodyThresholdsItems0TypeScopePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + batchUpdateThresholdsOkBodyThresholdsItems0TypeScopePropEnum = append(batchUpdateThresholdsOkBodyThresholdsItems0TypeScopePropEnum, v) + } +} + +const ( + + // BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPEUNSPECIFIED captures enum value "THRESHOLD_SCOPE_UNSPECIFIED" + BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPEUNSPECIFIED string = "THRESHOLD_SCOPE_UNSPECIFIED" + + // BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPENODE captures enum value "THRESHOLD_SCOPE_NODE" + BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPENODE string = "THRESHOLD_SCOPE_NODE" + + // BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPESERVICE captures enum value "THRESHOLD_SCOPE_SERVICE" + BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPESERVICE string = "THRESHOLD_SCOPE_SERVICE" + + // BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPECLUSTER captures enum value "THRESHOLD_SCOPE_CLUSTER" + BatchUpdateThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPECLUSTER string = "THRESHOLD_SCOPE_CLUSTER" +) + +// prop value enum +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) validateScopeEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, batchUpdateThresholdsOkBodyThresholdsItems0TypeScopePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) validateScope(formats strfmt.Registry) error { + if swag.IsZero(o.Scope) { // not required + return nil + } + + // value enum + if err := o.validateScopeEnum("scope", "body", *o.Scope); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this batch update thresholds OK body thresholds items0 based on context it is used +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsOKBodyThresholdsItems0) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsOKBodyThresholdsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +BatchUpdateThresholdsParamsBodyUpdatesItems0 ThresholdUpdate sets or clears one override. +swagger:model BatchUpdateThresholdsParamsBodyUpdatesItems0 +*/ +type BatchUpdateThresholdsParamsBodyUpdatesItems0 struct { + // ThresholdScope says what a threshold override's target refers to. + // + // - THRESHOLD_SCOPE_NODE: Target is a Node ID. + // - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + // - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + // Enum: ["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"] + Scope *string `json:"scope,omitempty"` + + // target + Target string `json:"target,omitempty"` + + // rule id + RuleID string `json:"rule_id,omitempty"` + + // param name + ParamName string `json:"param_name,omitempty"` + + // Omit to clear the override rather than set it. + Value *float64 `json:"value,omitempty"` +} + +// Validate validates this batch update thresholds params body updates items0 +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateScope(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var batchUpdateThresholdsParamsBodyUpdatesItems0TypeScopePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + batchUpdateThresholdsParamsBodyUpdatesItems0TypeScopePropEnum = append(batchUpdateThresholdsParamsBodyUpdatesItems0TypeScopePropEnum, v) + } +} + +const ( + + // BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPEUNSPECIFIED captures enum value "THRESHOLD_SCOPE_UNSPECIFIED" + BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPEUNSPECIFIED string = "THRESHOLD_SCOPE_UNSPECIFIED" + + // BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPENODE captures enum value "THRESHOLD_SCOPE_NODE" + BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPENODE string = "THRESHOLD_SCOPE_NODE" + + // BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPESERVICE captures enum value "THRESHOLD_SCOPE_SERVICE" + BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPESERVICE string = "THRESHOLD_SCOPE_SERVICE" + + // BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPECLUSTER captures enum value "THRESHOLD_SCOPE_CLUSTER" + BatchUpdateThresholdsParamsBodyUpdatesItems0ScopeTHRESHOLDSCOPECLUSTER string = "THRESHOLD_SCOPE_CLUSTER" +) + +// prop value enum +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) validateScopeEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, batchUpdateThresholdsParamsBodyUpdatesItems0TypeScopePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) validateScope(formats strfmt.Registry) error { + if swag.IsZero(o.Scope) { // not required + return nil + } + + // value enum + if err := o.validateScopeEnum("scope", "body", *o.Scope); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this batch update thresholds params body updates items0 based on context it is used +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *BatchUpdateThresholdsParamsBodyUpdatesItems0) UnmarshalBinary(b []byte) error { + var res BatchUpdateThresholdsParamsBodyUpdatesItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/clear_threshold_parameters.go b/api/alerting/v1/json/client/alerting_service/clear_threshold_parameters.go new file mode 100644 index 00000000000..387bcbc9ad0 --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/clear_threshold_parameters.go @@ -0,0 +1,260 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewClearThresholdParams creates a new ClearThresholdParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewClearThresholdParams() *ClearThresholdParams { + return &ClearThresholdParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewClearThresholdParamsWithTimeout creates a new ClearThresholdParams object +// with the ability to set a timeout on a request. +func NewClearThresholdParamsWithTimeout(timeout time.Duration) *ClearThresholdParams { + return &ClearThresholdParams{ + timeout: timeout, + } +} + +// NewClearThresholdParamsWithContext creates a new ClearThresholdParams object +// with the ability to set a context for a request. +func NewClearThresholdParamsWithContext(ctx context.Context) *ClearThresholdParams { + return &ClearThresholdParams{ + Context: ctx, + } +} + +// NewClearThresholdParamsWithHTTPClient creates a new ClearThresholdParams object +// with the ability to set a custom HTTPClient for a request. +func NewClearThresholdParamsWithHTTPClient(client *http.Client) *ClearThresholdParams { + return &ClearThresholdParams{ + HTTPClient: client, + } +} + +/* +ClearThresholdParams contains all the parameters to send to the API endpoint + + for the clear threshold operation. + + Typically these are written to a http.Request. +*/ +type ClearThresholdParams struct { + // ParamName. + ParamName *string + + // RuleID. + RuleID *string + + /* Scope. + + - THRESHOLD_SCOPE_NODE: Target is a Node ID. + - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + + Default: "THRESHOLD_SCOPE_UNSPECIFIED" + */ + Scope *string + + // Target. + Target *string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the clear threshold params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ClearThresholdParams) WithDefaults() *ClearThresholdParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the clear threshold params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ClearThresholdParams) SetDefaults() { + scopeDefault := string("THRESHOLD_SCOPE_UNSPECIFIED") + + val := ClearThresholdParams{ + Scope: &scopeDefault, + } + + val.timeout = o.timeout + val.Context = o.Context + val.HTTPClient = o.HTTPClient + *o = val +} + +// WithTimeout adds the timeout to the clear threshold params +func (o *ClearThresholdParams) WithTimeout(timeout time.Duration) *ClearThresholdParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the clear threshold params +func (o *ClearThresholdParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the clear threshold params +func (o *ClearThresholdParams) WithContext(ctx context.Context) *ClearThresholdParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the clear threshold params +func (o *ClearThresholdParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the clear threshold params +func (o *ClearThresholdParams) WithHTTPClient(client *http.Client) *ClearThresholdParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the clear threshold params +func (o *ClearThresholdParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithParamName adds the paramName to the clear threshold params +func (o *ClearThresholdParams) WithParamName(paramName *string) *ClearThresholdParams { + o.SetParamName(paramName) + return o +} + +// SetParamName adds the paramName to the clear threshold params +func (o *ClearThresholdParams) SetParamName(paramName *string) { + o.ParamName = paramName +} + +// WithRuleID adds the ruleID to the clear threshold params +func (o *ClearThresholdParams) WithRuleID(ruleID *string) *ClearThresholdParams { + o.SetRuleID(ruleID) + return o +} + +// SetRuleID adds the ruleId to the clear threshold params +func (o *ClearThresholdParams) SetRuleID(ruleID *string) { + o.RuleID = ruleID +} + +// WithScope adds the scope to the clear threshold params +func (o *ClearThresholdParams) WithScope(scope *string) *ClearThresholdParams { + o.SetScope(scope) + return o +} + +// SetScope adds the scope to the clear threshold params +func (o *ClearThresholdParams) SetScope(scope *string) { + o.Scope = scope +} + +// WithTarget adds the target to the clear threshold params +func (o *ClearThresholdParams) WithTarget(target *string) *ClearThresholdParams { + o.SetTarget(target) + return o +} + +// SetTarget adds the target to the clear threshold params +func (o *ClearThresholdParams) SetTarget(target *string) { + o.Target = target +} + +// WriteToRequest writes these params to a swagger request +func (o *ClearThresholdParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if o.ParamName != nil { + + // query param param_name + var qrParamName string + + if o.ParamName != nil { + qrParamName = *o.ParamName + } + qParamName := qrParamName + if qParamName != "" { + if err := r.SetQueryParam("param_name", qParamName); err != nil { + return err + } + } + } + + if o.RuleID != nil { + + // query param rule_id + var qrRuleID string + + if o.RuleID != nil { + qrRuleID = *o.RuleID + } + qRuleID := qrRuleID + if qRuleID != "" { + if err := r.SetQueryParam("rule_id", qRuleID); err != nil { + return err + } + } + } + + if o.Scope != nil { + + // query param scope + var qrScope string + + if o.Scope != nil { + qrScope = *o.Scope + } + qScope := qrScope + if qScope != "" { + if err := r.SetQueryParam("scope", qScope); err != nil { + return err + } + } + } + + if o.Target != nil { + + // query param target + var qrTarget string + + if o.Target != nil { + qrTarget = *o.Target + } + qTarget := qrTarget + if qTarget != "" { + if err := r.SetQueryParam("target", qTarget); err != nil { + return err + } + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/clear_threshold_responses.go b/api/alerting/v1/json/client/alerting_service/clear_threshold_responses.go new file mode 100644 index 00000000000..af56989ef5d --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/clear_threshold_responses.go @@ -0,0 +1,411 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// ClearThresholdReader is a Reader for the ClearThreshold structure. +type ClearThresholdReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *ClearThresholdReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewClearThresholdOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewClearThresholdDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewClearThresholdOK creates a ClearThresholdOK with default headers values +func NewClearThresholdOK() *ClearThresholdOK { + return &ClearThresholdOK{} +} + +/* +ClearThresholdOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type ClearThresholdOK struct { + Payload any +} + +// IsSuccess returns true when this clear threshold Ok response has a 2xx status code +func (o *ClearThresholdOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this clear threshold Ok response has a 3xx status code +func (o *ClearThresholdOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this clear threshold Ok response has a 4xx status code +func (o *ClearThresholdOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this clear threshold Ok response has a 5xx status code +func (o *ClearThresholdOK) IsServerError() bool { + return false +} + +// IsCode returns true when this clear threshold Ok response a status code equal to that given +func (o *ClearThresholdOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the clear threshold Ok response +func (o *ClearThresholdOK) Code() int { + return 200 +} + +func (o *ClearThresholdOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /v1/alerting/thresholds][%d] clearThresholdOk %s", 200, payload) +} + +func (o *ClearThresholdOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /v1/alerting/thresholds][%d] clearThresholdOk %s", 200, payload) +} + +func (o *ClearThresholdOK) GetPayload() any { + return o.Payload +} + +func (o *ClearThresholdOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewClearThresholdDefault creates a ClearThresholdDefault with default headers values +func NewClearThresholdDefault(code int) *ClearThresholdDefault { + return &ClearThresholdDefault{ + _statusCode: code, + } +} + +/* +ClearThresholdDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type ClearThresholdDefault struct { + _statusCode int + + Payload *ClearThresholdDefaultBody +} + +// IsSuccess returns true when this clear threshold default response has a 2xx status code +func (o *ClearThresholdDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this clear threshold default response has a 3xx status code +func (o *ClearThresholdDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this clear threshold default response has a 4xx status code +func (o *ClearThresholdDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this clear threshold default response has a 5xx status code +func (o *ClearThresholdDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this clear threshold default response a status code equal to that given +func (o *ClearThresholdDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the clear threshold default response +func (o *ClearThresholdDefault) Code() int { + return o._statusCode +} + +func (o *ClearThresholdDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /v1/alerting/thresholds][%d] ClearThreshold default %s", o._statusCode, payload) +} + +func (o *ClearThresholdDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[DELETE /v1/alerting/thresholds][%d] ClearThreshold default %s", o._statusCode, payload) +} + +func (o *ClearThresholdDefault) GetPayload() *ClearThresholdDefaultBody { + return o.Payload +} + +func (o *ClearThresholdDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(ClearThresholdDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +ClearThresholdDefaultBody clear threshold default body +swagger:model ClearThresholdDefaultBody +*/ +type ClearThresholdDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*ClearThresholdDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this clear threshold default body +func (o *ClearThresholdDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ClearThresholdDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ClearThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ClearThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this clear threshold default body based on the context it is used +func (o *ClearThresholdDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ClearThresholdDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ClearThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ClearThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *ClearThresholdDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ClearThresholdDefaultBody) UnmarshalBinary(b []byte) error { + var res ClearThresholdDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ClearThresholdDefaultBodyDetailsItems0 clear threshold default body details items0 +swagger:model ClearThresholdDefaultBodyDetailsItems0 +*/ +type ClearThresholdDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // clear threshold default body details items0 + ClearThresholdDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *ClearThresholdDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv ClearThresholdDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.ClearThresholdDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o ClearThresholdDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.ClearThresholdDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.ClearThresholdDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this clear threshold default body details items0 +func (o *ClearThresholdDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this clear threshold default body details items0 based on context it is used +func (o *ClearThresholdDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *ClearThresholdDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ClearThresholdDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res ClearThresholdDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/create_rule_responses.go b/api/alerting/v1/json/client/alerting_service/create_rule_responses.go index 908bc88e1d5..e86e37804a1 100644 --- a/api/alerting/v1/json/client/alerting_service/create_rule_responses.go +++ b/api/alerting/v1/json/client/alerting_service/create_rule_responses.go @@ -54,7 +54,7 @@ CreateRuleOK describes a response with status code 200, with default header valu A successful response. */ type CreateRuleOK struct { - Payload any + Payload *CreateRuleOKBody } // IsSuccess returns true when this create rule Ok response has a 2xx status code @@ -97,13 +97,15 @@ func (o *CreateRuleOK) String() string { return fmt.Sprintf("[POST /v1/alerting/rules][%d] createRuleOk %s", 200, payload) } -func (o *CreateRuleOK) GetPayload() any { +func (o *CreateRuleOK) GetPayload() *CreateRuleOKBody { return o.Payload } func (o *CreateRuleOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(CreateRuleOKBody) + // response payload - if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } @@ -681,6 +683,46 @@ func (o *CreateRuleDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { return nil } +/* +CreateRuleOKBody create rule OK body +swagger:model CreateRuleOKBody +*/ +type CreateRuleOKBody struct { + // Identifier PMM assigns to a rule whose thresholds can be overridden per target. + // Empty when the rule has no overridable parameters, since nothing can be keyed on it. + // This is the rule's identity for threshold purposes rather than its Grafana UID: + // copying or renaming the rule in Grafana preserves it. + RuleID string `json:"rule_id,omitempty"` +} + +// Validate validates this create rule OK body +func (o *CreateRuleOKBody) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this create rule OK body based on context it is used +func (o *CreateRuleOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *CreateRuleOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *CreateRuleOKBody) UnmarshalBinary(b []byte) error { + var res CreateRuleOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + /* CreateRuleParamsBodyFiltersItems0 Filter represents a single filter condition. swagger:model CreateRuleParamsBodyFiltersItems0 diff --git a/api/alerting/v1/json/client/alerting_service/list_templates_responses.go b/api/alerting/v1/json/client/alerting_service/list_templates_responses.go index b263d1e67e0..b7d9682b6f5 100644 --- a/api/alerting/v1/json/client/alerting_service/list_templates_responses.go +++ b/api/alerting/v1/json/client/alerting_service/list_templates_responses.go @@ -1020,6 +1020,11 @@ type ListTemplatesOKBodyTemplatesItems0ParamsItems0 struct { // Enum: ["PARAM_TYPE_UNSPECIFIED","PARAM_TYPE_BOOL","PARAM_TYPE_FLOAT","PARAM_TYPE_STRING"] Type *string `json:"type,omitempty"` + // Whether this parameter's threshold can be overridden per target without editing the + // rule. Only set for templates that support it; the scopes it may be set at are + // reported per rule by ListThresholds. + Overridable bool `json:"overridable,omitempty"` + // bool Bool *ListTemplatesOKBodyTemplatesItems0ParamsItems0Bool `json:"bool,omitempty"` diff --git a/api/alerting/v1/json/client/alerting_service/list_thresholds_parameters.go b/api/alerting/v1/json/client/alerting_service/list_thresholds_parameters.go new file mode 100644 index 00000000000..e9c03b979b5 --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/list_thresholds_parameters.go @@ -0,0 +1,240 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewListThresholdsParams creates a new ListThresholdsParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewListThresholdsParams() *ListThresholdsParams { + return &ListThresholdsParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewListThresholdsParamsWithTimeout creates a new ListThresholdsParams object +// with the ability to set a timeout on a request. +func NewListThresholdsParamsWithTimeout(timeout time.Duration) *ListThresholdsParams { + return &ListThresholdsParams{ + timeout: timeout, + } +} + +// NewListThresholdsParamsWithContext creates a new ListThresholdsParams object +// with the ability to set a context for a request. +func NewListThresholdsParamsWithContext(ctx context.Context) *ListThresholdsParams { + return &ListThresholdsParams{ + Context: ctx, + } +} + +// NewListThresholdsParamsWithHTTPClient creates a new ListThresholdsParams object +// with the ability to set a custom HTTPClient for a request. +func NewListThresholdsParamsWithHTTPClient(client *http.Client) *ListThresholdsParams { + return &ListThresholdsParams{ + HTTPClient: client, + } +} + +/* +ListThresholdsParams contains all the parameters to send to the API endpoint + + for the list thresholds operation. + + Typically these are written to a http.Request. +*/ +type ListThresholdsParams struct { + /* RuleID. + + Return only thresholds of this rule. + */ + RuleID *string + + /* Scope. + + Scope of the target to report thresholds for. Must be set together with target. + + - THRESHOLD_SCOPE_NODE: Target is a Node ID. + - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + + Default: "THRESHOLD_SCOPE_UNSPECIFIED" + */ + Scope *string + + /* Target. + + Target to report thresholds for. When set, every overridable parameter is returned + for that target, overridden or not. When empty, only existing overrides are + returned, since there is otherwise no bounded set to enumerate. + */ + Target *string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the list thresholds params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListThresholdsParams) WithDefaults() *ListThresholdsParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the list thresholds params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *ListThresholdsParams) SetDefaults() { + scopeDefault := string("THRESHOLD_SCOPE_UNSPECIFIED") + + val := ListThresholdsParams{ + Scope: &scopeDefault, + } + + val.timeout = o.timeout + val.Context = o.Context + val.HTTPClient = o.HTTPClient + *o = val +} + +// WithTimeout adds the timeout to the list thresholds params +func (o *ListThresholdsParams) WithTimeout(timeout time.Duration) *ListThresholdsParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the list thresholds params +func (o *ListThresholdsParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the list thresholds params +func (o *ListThresholdsParams) WithContext(ctx context.Context) *ListThresholdsParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the list thresholds params +func (o *ListThresholdsParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the list thresholds params +func (o *ListThresholdsParams) WithHTTPClient(client *http.Client) *ListThresholdsParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the list thresholds params +func (o *ListThresholdsParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithRuleID adds the ruleID to the list thresholds params +func (o *ListThresholdsParams) WithRuleID(ruleID *string) *ListThresholdsParams { + o.SetRuleID(ruleID) + return o +} + +// SetRuleID adds the ruleId to the list thresholds params +func (o *ListThresholdsParams) SetRuleID(ruleID *string) { + o.RuleID = ruleID +} + +// WithScope adds the scope to the list thresholds params +func (o *ListThresholdsParams) WithScope(scope *string) *ListThresholdsParams { + o.SetScope(scope) + return o +} + +// SetScope adds the scope to the list thresholds params +func (o *ListThresholdsParams) SetScope(scope *string) { + o.Scope = scope +} + +// WithTarget adds the target to the list thresholds params +func (o *ListThresholdsParams) WithTarget(target *string) *ListThresholdsParams { + o.SetTarget(target) + return o +} + +// SetTarget adds the target to the list thresholds params +func (o *ListThresholdsParams) SetTarget(target *string) { + o.Target = target +} + +// WriteToRequest writes these params to a swagger request +func (o *ListThresholdsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if o.RuleID != nil { + + // query param rule_id + var qrRuleID string + + if o.RuleID != nil { + qrRuleID = *o.RuleID + } + qRuleID := qrRuleID + if qRuleID != "" { + if err := r.SetQueryParam("rule_id", qRuleID); err != nil { + return err + } + } + } + + if o.Scope != nil { + + // query param scope + var qrScope string + + if o.Scope != nil { + qrScope = *o.Scope + } + qScope := qrScope + if qScope != "" { + if err := r.SetQueryParam("scope", qScope); err != nil { + return err + } + } + } + + if o.Target != nil { + + // query param target + var qrTarget string + + if o.Target != nil { + qrTarget = *o.Target + } + qTarget := qrTarget + if qTarget != "" { + if err := r.SetQueryParam("target", qTarget); err != nil { + return err + } + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/list_thresholds_responses.go b/api/alerting/v1/json/client/alerting_service/list_thresholds_responses.go new file mode 100644 index 00000000000..60783b05125 --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/list_thresholds_responses.go @@ -0,0 +1,704 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// ListThresholdsReader is a Reader for the ListThresholds structure. +type ListThresholdsReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *ListThresholdsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewListThresholdsOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewListThresholdsDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewListThresholdsOK creates a ListThresholdsOK with default headers values +func NewListThresholdsOK() *ListThresholdsOK { + return &ListThresholdsOK{} +} + +/* +ListThresholdsOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type ListThresholdsOK struct { + Payload *ListThresholdsOKBody +} + +// IsSuccess returns true when this list thresholds Ok response has a 2xx status code +func (o *ListThresholdsOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this list thresholds Ok response has a 3xx status code +func (o *ListThresholdsOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this list thresholds Ok response has a 4xx status code +func (o *ListThresholdsOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this list thresholds Ok response has a 5xx status code +func (o *ListThresholdsOK) IsServerError() bool { + return false +} + +// IsCode returns true when this list thresholds Ok response a status code equal to that given +func (o *ListThresholdsOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the list thresholds Ok response +func (o *ListThresholdsOK) Code() int { + return 200 +} + +func (o *ListThresholdsOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/alerting/thresholds][%d] listThresholdsOk %s", 200, payload) +} + +func (o *ListThresholdsOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/alerting/thresholds][%d] listThresholdsOk %s", 200, payload) +} + +func (o *ListThresholdsOK) GetPayload() *ListThresholdsOKBody { + return o.Payload +} + +func (o *ListThresholdsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(ListThresholdsOKBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewListThresholdsDefault creates a ListThresholdsDefault with default headers values +func NewListThresholdsDefault(code int) *ListThresholdsDefault { + return &ListThresholdsDefault{ + _statusCode: code, + } +} + +/* +ListThresholdsDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type ListThresholdsDefault struct { + _statusCode int + + Payload *ListThresholdsDefaultBody +} + +// IsSuccess returns true when this list thresholds default response has a 2xx status code +func (o *ListThresholdsDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this list thresholds default response has a 3xx status code +func (o *ListThresholdsDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this list thresholds default response has a 4xx status code +func (o *ListThresholdsDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this list thresholds default response has a 5xx status code +func (o *ListThresholdsDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this list thresholds default response a status code equal to that given +func (o *ListThresholdsDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the list thresholds default response +func (o *ListThresholdsDefault) Code() int { + return o._statusCode +} + +func (o *ListThresholdsDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/alerting/thresholds][%d] ListThresholds default %s", o._statusCode, payload) +} + +func (o *ListThresholdsDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[GET /v1/alerting/thresholds][%d] ListThresholds default %s", o._statusCode, payload) +} + +func (o *ListThresholdsDefault) GetPayload() *ListThresholdsDefaultBody { + return o.Payload +} + +func (o *ListThresholdsDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(ListThresholdsDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +ListThresholdsDefaultBody list thresholds default body +swagger:model ListThresholdsDefaultBody +*/ +type ListThresholdsDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*ListThresholdsDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this list thresholds default body +func (o *ListThresholdsDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListThresholdsDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ListThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ListThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this list thresholds default body based on the context it is used +func (o *ListThresholdsDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListThresholdsDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("ListThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("ListThresholds default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *ListThresholdsDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListThresholdsDefaultBody) UnmarshalBinary(b []byte) error { + var res ListThresholdsDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListThresholdsDefaultBodyDetailsItems0 list thresholds default body details items0 +swagger:model ListThresholdsDefaultBodyDetailsItems0 +*/ +type ListThresholdsDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // list thresholds default body details items0 + ListThresholdsDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *ListThresholdsDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv ListThresholdsDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.ListThresholdsDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o ListThresholdsDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.ListThresholdsDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.ListThresholdsDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this list thresholds default body details items0 +func (o *ListThresholdsDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this list thresholds default body details items0 based on context it is used +func (o *ListThresholdsDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *ListThresholdsDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListThresholdsDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res ListThresholdsDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListThresholdsOKBody list thresholds OK body +swagger:model ListThresholdsOKBody +*/ +type ListThresholdsOKBody struct { + // thresholds + Thresholds []*ListThresholdsOKBodyThresholdsItems0 `json:"thresholds"` +} + +// Validate validates this list thresholds OK body +func (o *ListThresholdsOKBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateThresholds(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListThresholdsOKBody) validateThresholds(formats strfmt.Registry) error { + if swag.IsZero(o.Thresholds) { // not required + return nil + } + + for i := 0; i < len(o.Thresholds); i++ { + if swag.IsZero(o.Thresholds[i]) { // not required + continue + } + + if o.Thresholds[i] != nil { + if err := o.Thresholds[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("listThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("listThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this list thresholds OK body based on the context it is used +func (o *ListThresholdsOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateThresholds(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *ListThresholdsOKBody) contextValidateThresholds(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Thresholds); i++ { + if o.Thresholds[i] != nil { + + if swag.IsZero(o.Thresholds[i]) { // not required + return nil + } + + if err := o.Thresholds[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("listThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("listThresholdsOk" + "." + "thresholds" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *ListThresholdsOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListThresholdsOKBody) UnmarshalBinary(b []byte) error { + var res ListThresholdsOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +ListThresholdsOKBodyThresholdsItems0 Threshold is one overridable parameter of one rule, as it applies to one target. +swagger:model ListThresholdsOKBodyThresholdsItems0 +*/ +type ListThresholdsOKBodyThresholdsItems0 struct { + // Identifier PMM assigned to the rule. Not unique within a response: rules duplicated + // in Grafana share it, so two entries can carry the same rule_id and param_name and + // differ only in which rule they came from. Do not key a map on it. + RuleID string `json:"rule_id,omitempty"` + + // Machine-readable name of the overridable parameter. + ParamName string `json:"param_name,omitempty"` + + // Short human-readable parameter summary, as it was when the rule was created. + Summary string `json:"summary,omitempty"` + + // ParamUnit represents template parameter unit. + // + // - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent. + // - PARAM_UNIT_PERCENTAGE: % + // - PARAM_UNIT_SECONDS: s + // Enum: ["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"] + Unit *string `json:"unit,omitempty"` + + // Value the rule falls back to when no override applies. + DefaultValue float64 `json:"default_value,omitempty"` + + // Value the rule currently evaluates this target against. + EffectiveValue float64 `json:"effective_value,omitempty"` + + // Whether effective_value comes from an override rather than the default. + IsOverridden bool `json:"is_overridden,omitempty"` + + // ThresholdScope says what a threshold override's target refers to. + // + // - THRESHOLD_SCOPE_NODE: Target is a Node ID. + // - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + // - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + // Enum: ["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"] + Scope *string `json:"scope,omitempty"` + + // Target the effective override was set on. Empty when not overridden. + Target string `json:"target,omitempty"` +} + +// Validate validates this list thresholds OK body thresholds items0 +func (o *ListThresholdsOKBodyThresholdsItems0) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateUnit(formats); err != nil { + res = append(res, err) + } + + if err := o.validateScope(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var listThresholdsOkBodyThresholdsItems0TypeUnitPropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + listThresholdsOkBodyThresholdsItems0TypeUnitPropEnum = append(listThresholdsOkBodyThresholdsItems0TypeUnitPropEnum, v) + } +} + +const ( + + // ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITUNSPECIFIED captures enum value "PARAM_UNIT_UNSPECIFIED" + ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITUNSPECIFIED string = "PARAM_UNIT_UNSPECIFIED" + + // ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITPERCENTAGE captures enum value "PARAM_UNIT_PERCENTAGE" + ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITPERCENTAGE string = "PARAM_UNIT_PERCENTAGE" + + // ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITSECONDS captures enum value "PARAM_UNIT_SECONDS" + ListThresholdsOKBodyThresholdsItems0UnitPARAMUNITSECONDS string = "PARAM_UNIT_SECONDS" +) + +// prop value enum +func (o *ListThresholdsOKBodyThresholdsItems0) validateUnitEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, listThresholdsOkBodyThresholdsItems0TypeUnitPropEnum, true); err != nil { + return err + } + return nil +} + +func (o *ListThresholdsOKBodyThresholdsItems0) validateUnit(formats strfmt.Registry) error { + if swag.IsZero(o.Unit) { // not required + return nil + } + + // value enum + if err := o.validateUnitEnum("unit", "body", *o.Unit); err != nil { + return err + } + + return nil +} + +var listThresholdsOkBodyThresholdsItems0TypeScopePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + listThresholdsOkBodyThresholdsItems0TypeScopePropEnum = append(listThresholdsOkBodyThresholdsItems0TypeScopePropEnum, v) + } +} + +const ( + + // ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPEUNSPECIFIED captures enum value "THRESHOLD_SCOPE_UNSPECIFIED" + ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPEUNSPECIFIED string = "THRESHOLD_SCOPE_UNSPECIFIED" + + // ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPENODE captures enum value "THRESHOLD_SCOPE_NODE" + ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPENODE string = "THRESHOLD_SCOPE_NODE" + + // ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPESERVICE captures enum value "THRESHOLD_SCOPE_SERVICE" + ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPESERVICE string = "THRESHOLD_SCOPE_SERVICE" + + // ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPECLUSTER captures enum value "THRESHOLD_SCOPE_CLUSTER" + ListThresholdsOKBodyThresholdsItems0ScopeTHRESHOLDSCOPECLUSTER string = "THRESHOLD_SCOPE_CLUSTER" +) + +// prop value enum +func (o *ListThresholdsOKBodyThresholdsItems0) validateScopeEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, listThresholdsOkBodyThresholdsItems0TypeScopePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *ListThresholdsOKBodyThresholdsItems0) validateScope(formats strfmt.Registry) error { + if swag.IsZero(o.Scope) { // not required + return nil + } + + // value enum + if err := o.validateScopeEnum("scope", "body", *o.Scope); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this list thresholds OK body thresholds items0 based on context it is used +func (o *ListThresholdsOKBodyThresholdsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *ListThresholdsOKBodyThresholdsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *ListThresholdsOKBodyThresholdsItems0) UnmarshalBinary(b []byte) error { + var res ListThresholdsOKBodyThresholdsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/set_threshold_parameters.go b/api/alerting/v1/json/client/alerting_service/set_threshold_parameters.go new file mode 100644 index 00000000000..79faf72993a --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/set_threshold_parameters.go @@ -0,0 +1,141 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewSetThresholdParams creates a new SetThresholdParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewSetThresholdParams() *SetThresholdParams { + return &SetThresholdParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewSetThresholdParamsWithTimeout creates a new SetThresholdParams object +// with the ability to set a timeout on a request. +func NewSetThresholdParamsWithTimeout(timeout time.Duration) *SetThresholdParams { + return &SetThresholdParams{ + timeout: timeout, + } +} + +// NewSetThresholdParamsWithContext creates a new SetThresholdParams object +// with the ability to set a context for a request. +func NewSetThresholdParamsWithContext(ctx context.Context) *SetThresholdParams { + return &SetThresholdParams{ + Context: ctx, + } +} + +// NewSetThresholdParamsWithHTTPClient creates a new SetThresholdParams object +// with the ability to set a custom HTTPClient for a request. +func NewSetThresholdParamsWithHTTPClient(client *http.Client) *SetThresholdParams { + return &SetThresholdParams{ + HTTPClient: client, + } +} + +/* +SetThresholdParams contains all the parameters to send to the API endpoint + + for the set threshold operation. + + Typically these are written to a http.Request. +*/ +type SetThresholdParams struct { + // Body. + Body SetThresholdBody + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the set threshold params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *SetThresholdParams) WithDefaults() *SetThresholdParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the set threshold params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *SetThresholdParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the set threshold params +func (o *SetThresholdParams) WithTimeout(timeout time.Duration) *SetThresholdParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the set threshold params +func (o *SetThresholdParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the set threshold params +func (o *SetThresholdParams) WithContext(ctx context.Context) *SetThresholdParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the set threshold params +func (o *SetThresholdParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the set threshold params +func (o *SetThresholdParams) WithHTTPClient(client *http.Client) *SetThresholdParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the set threshold params +func (o *SetThresholdParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the set threshold params +func (o *SetThresholdParams) WithBody(body SetThresholdBody) *SetThresholdParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the set threshold params +func (o *SetThresholdParams) SetBody(body SetThresholdBody) { + o.Body = body +} + +// WriteToRequest writes these params to a swagger request +func (o *SetThresholdParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/api/alerting/v1/json/client/alerting_service/set_threshold_responses.go b/api/alerting/v1/json/client/alerting_service/set_threshold_responses.go new file mode 100644 index 00000000000..0512ee4b891 --- /dev/null +++ b/api/alerting/v1/json/client/alerting_service/set_threshold_responses.go @@ -0,0 +1,806 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package alerting_service + +import ( + "context" + "encoding/json" + stderrors "errors" + "fmt" + "io" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// SetThresholdReader is a Reader for the SetThreshold structure. +type SetThresholdReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *SetThresholdReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { + switch response.Code() { + case 200: + result := NewSetThresholdOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewSetThresholdDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewSetThresholdOK creates a SetThresholdOK with default headers values +func NewSetThresholdOK() *SetThresholdOK { + return &SetThresholdOK{} +} + +/* +SetThresholdOK describes a response with status code 200, with default header values. + +A successful response. +*/ +type SetThresholdOK struct { + Payload *SetThresholdOKBody +} + +// IsSuccess returns true when this set threshold Ok response has a 2xx status code +func (o *SetThresholdOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this set threshold Ok response has a 3xx status code +func (o *SetThresholdOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this set threshold Ok response has a 4xx status code +func (o *SetThresholdOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this set threshold Ok response has a 5xx status code +func (o *SetThresholdOK) IsServerError() bool { + return false +} + +// IsCode returns true when this set threshold Ok response a status code equal to that given +func (o *SetThresholdOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the set threshold Ok response +func (o *SetThresholdOK) Code() int { + return 200 +} + +func (o *SetThresholdOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds][%d] setThresholdOk %s", 200, payload) +} + +func (o *SetThresholdOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds][%d] setThresholdOk %s", 200, payload) +} + +func (o *SetThresholdOK) GetPayload() *SetThresholdOKBody { + return o.Payload +} + +func (o *SetThresholdOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(SetThresholdOKBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +// NewSetThresholdDefault creates a SetThresholdDefault with default headers values +func NewSetThresholdDefault(code int) *SetThresholdDefault { + return &SetThresholdDefault{ + _statusCode: code, + } +} + +/* +SetThresholdDefault describes a response with status code -1, with default header values. + +An unexpected error response. +*/ +type SetThresholdDefault struct { + _statusCode int + + Payload *SetThresholdDefaultBody +} + +// IsSuccess returns true when this set threshold default response has a 2xx status code +func (o *SetThresholdDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this set threshold default response has a 3xx status code +func (o *SetThresholdDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this set threshold default response has a 4xx status code +func (o *SetThresholdDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this set threshold default response has a 5xx status code +func (o *SetThresholdDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this set threshold default response a status code equal to that given +func (o *SetThresholdDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the set threshold default response +func (o *SetThresholdDefault) Code() int { + return o._statusCode +} + +func (o *SetThresholdDefault) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds][%d] SetThreshold default %s", o._statusCode, payload) +} + +func (o *SetThresholdDefault) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /v1/alerting/thresholds][%d] SetThreshold default %s", o._statusCode, payload) +} + +func (o *SetThresholdDefault) GetPayload() *SetThresholdDefaultBody { + return o.Payload +} + +func (o *SetThresholdDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + o.Payload = new(SetThresholdDefaultBody) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { + return err + } + + return nil +} + +/* +SetThresholdBody set threshold body +swagger:model SetThresholdBody +*/ +type SetThresholdBody struct { + // ThresholdScope says what a threshold override's target refers to. + // + // - THRESHOLD_SCOPE_NODE: Target is a Node ID. + // - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + // - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + // Enum: ["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"] + Scope *string `json:"scope,omitempty"` + + // target + Target string `json:"target,omitempty"` + + // rule id + RuleID string `json:"rule_id,omitempty"` + + // param name + ParamName string `json:"param_name,omitempty"` + + // Must be finite and within the parameter's declared range. + Value float64 `json:"value,omitempty"` +} + +// Validate validates this set threshold body +func (o *SetThresholdBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateScope(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var setThresholdBodyTypeScopePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + setThresholdBodyTypeScopePropEnum = append(setThresholdBodyTypeScopePropEnum, v) + } +} + +const ( + + // SetThresholdBodyScopeTHRESHOLDSCOPEUNSPECIFIED captures enum value "THRESHOLD_SCOPE_UNSPECIFIED" + SetThresholdBodyScopeTHRESHOLDSCOPEUNSPECIFIED string = "THRESHOLD_SCOPE_UNSPECIFIED" + + // SetThresholdBodyScopeTHRESHOLDSCOPENODE captures enum value "THRESHOLD_SCOPE_NODE" + SetThresholdBodyScopeTHRESHOLDSCOPENODE string = "THRESHOLD_SCOPE_NODE" + + // SetThresholdBodyScopeTHRESHOLDSCOPESERVICE captures enum value "THRESHOLD_SCOPE_SERVICE" + SetThresholdBodyScopeTHRESHOLDSCOPESERVICE string = "THRESHOLD_SCOPE_SERVICE" + + // SetThresholdBodyScopeTHRESHOLDSCOPECLUSTER captures enum value "THRESHOLD_SCOPE_CLUSTER" + SetThresholdBodyScopeTHRESHOLDSCOPECLUSTER string = "THRESHOLD_SCOPE_CLUSTER" +) + +// prop value enum +func (o *SetThresholdBody) validateScopeEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, setThresholdBodyTypeScopePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *SetThresholdBody) validateScope(formats strfmt.Registry) error { + if swag.IsZero(o.Scope) { // not required + return nil + } + + // value enum + if err := o.validateScopeEnum("body"+"."+"scope", "body", *o.Scope); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this set threshold body based on context it is used +func (o *SetThresholdBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *SetThresholdBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SetThresholdBody) UnmarshalBinary(b []byte) error { + var res SetThresholdBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +SetThresholdDefaultBody set threshold default body +swagger:model SetThresholdDefaultBody +*/ +type SetThresholdDefaultBody struct { + // code + Code int32 `json:"code,omitempty"` + + // message + Message string `json:"message,omitempty"` + + // details + Details []*SetThresholdDefaultBodyDetailsItems0 `json:"details"` +} + +// Validate validates this set threshold default body +func (o *SetThresholdDefaultBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateDetails(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *SetThresholdDefaultBody) validateDetails(formats strfmt.Registry) error { + if swag.IsZero(o.Details) { // not required + return nil + } + + for i := 0; i < len(o.Details); i++ { + if swag.IsZero(o.Details[i]) { // not required + continue + } + + if o.Details[i] != nil { + if err := o.Details[i].Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("SetThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("SetThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + + } + + return nil +} + +// ContextValidate validate this set threshold default body based on the context it is used +func (o *SetThresholdDefaultBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateDetails(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *SetThresholdDefaultBody) contextValidateDetails(ctx context.Context, formats strfmt.Registry) error { + for i := 0; i < len(o.Details); i++ { + if o.Details[i] != nil { + + if swag.IsZero(o.Details[i]) { // not required + return nil + } + + if err := o.Details[i].ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("SetThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("SetThreshold default" + "." + "details" + "." + strconv.Itoa(i)) + } + + return err + } + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *SetThresholdDefaultBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SetThresholdDefaultBody) UnmarshalBinary(b []byte) error { + var res SetThresholdDefaultBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +SetThresholdDefaultBodyDetailsItems0 set threshold default body details items0 +swagger:model SetThresholdDefaultBodyDetailsItems0 +*/ +type SetThresholdDefaultBodyDetailsItems0 struct { + // at type + AtType string `json:"@type,omitempty"` + + // set threshold default body details items0 + SetThresholdDefaultBodyDetailsItems0 map[string]any `json:"-"` +} + +// UnmarshalJSON unmarshals this object with additional properties from JSON +func (o *SetThresholdDefaultBodyDetailsItems0) UnmarshalJSON(data []byte) error { + // stage 1, bind the properties + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + if err := json.Unmarshal(data, &stage1); err != nil { + return err + } + var rcv SetThresholdDefaultBodyDetailsItems0 + + rcv.AtType = stage1.AtType + *o = rcv + + // stage 2, remove properties and add to map + stage2 := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &stage2); err != nil { + return err + } + + delete(stage2, "@type") + // stage 3, add additional properties values + if len(stage2) > 0 { + result := make(map[string]any) + for k, v := range stage2 { + var toadd any + if err := json.Unmarshal(v, &toadd); err != nil { + return err + } + result[k] = toadd + } + o.SetThresholdDefaultBodyDetailsItems0 = result + } + + return nil +} + +// MarshalJSON marshals this object with additional properties into a JSON object +func (o SetThresholdDefaultBodyDetailsItems0) MarshalJSON() ([]byte, error) { + var stage1 struct { + // at type + AtType string `json:"@type,omitempty"` + } + + stage1.AtType = o.AtType + + // make JSON object for known properties + props, err := json.Marshal(stage1) + if err != nil { + return nil, err + } + + if len(o.SetThresholdDefaultBodyDetailsItems0) == 0 { // no additional properties + return props, nil + } + + // make JSON object for the additional properties + additional, err := json.Marshal(o.SetThresholdDefaultBodyDetailsItems0) + if err != nil { + return nil, err + } + + if len(props) < 3 { // "{}": only additional properties + return additional, nil + } + + // concatenate the 2 objects + return swag.ConcatJSON(props, additional), nil +} + +// Validate validates this set threshold default body details items0 +func (o *SetThresholdDefaultBodyDetailsItems0) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this set threshold default body details items0 based on context it is used +func (o *SetThresholdDefaultBodyDetailsItems0) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *SetThresholdDefaultBodyDetailsItems0) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SetThresholdDefaultBodyDetailsItems0) UnmarshalBinary(b []byte) error { + var res SetThresholdDefaultBodyDetailsItems0 + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +SetThresholdOKBody set threshold OK body +swagger:model SetThresholdOKBody +*/ +type SetThresholdOKBody struct { + // threshold + Threshold *SetThresholdOKBodyThreshold `json:"threshold,omitempty"` +} + +// Validate validates this set threshold OK body +func (o *SetThresholdOKBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateThreshold(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *SetThresholdOKBody) validateThreshold(formats strfmt.Registry) error { + if swag.IsZero(o.Threshold) { // not required + return nil + } + + if o.Threshold != nil { + if err := o.Threshold.Validate(formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("setThresholdOk" + "." + "threshold") + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("setThresholdOk" + "." + "threshold") + } + + return err + } + } + + return nil +} + +// ContextValidate validate this set threshold OK body based on the context it is used +func (o *SetThresholdOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := o.contextValidateThreshold(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *SetThresholdOKBody) contextValidateThreshold(ctx context.Context, formats strfmt.Registry) error { + if o.Threshold != nil { + + if swag.IsZero(o.Threshold) { // not required + return nil + } + + if err := o.Threshold.ContextValidate(ctx, formats); err != nil { + ve := new(errors.Validation) + if stderrors.As(err, &ve) { + return ve.ValidateName("setThresholdOk" + "." + "threshold") + } + ce := new(errors.CompositeError) + if stderrors.As(err, &ce) { + return ce.ValidateName("setThresholdOk" + "." + "threshold") + } + + return err + } + } + + return nil +} + +// MarshalBinary interface implementation +func (o *SetThresholdOKBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SetThresholdOKBody) UnmarshalBinary(b []byte) error { + var res SetThresholdOKBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} + +/* +SetThresholdOKBodyThreshold Threshold is one overridable parameter of one rule, as it applies to one target. +swagger:model SetThresholdOKBodyThreshold +*/ +type SetThresholdOKBodyThreshold struct { + // Identifier PMM assigned to the rule. Not unique within a response: rules duplicated + // in Grafana share it, so two entries can carry the same rule_id and param_name and + // differ only in which rule they came from. Do not key a map on it. + RuleID string `json:"rule_id,omitempty"` + + // Machine-readable name of the overridable parameter. + ParamName string `json:"param_name,omitempty"` + + // Short human-readable parameter summary, as it was when the rule was created. + Summary string `json:"summary,omitempty"` + + // ParamUnit represents template parameter unit. + // + // - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent. + // - PARAM_UNIT_PERCENTAGE: % + // - PARAM_UNIT_SECONDS: s + // Enum: ["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"] + Unit *string `json:"unit,omitempty"` + + // Value the rule falls back to when no override applies. + DefaultValue float64 `json:"default_value,omitempty"` + + // Value the rule currently evaluates this target against. + EffectiveValue float64 `json:"effective_value,omitempty"` + + // Whether effective_value comes from an override rather than the default. + IsOverridden bool `json:"is_overridden,omitempty"` + + // ThresholdScope says what a threshold override's target refers to. + // + // - THRESHOLD_SCOPE_NODE: Target is a Node ID. + // - THRESHOLD_SCOPE_SERVICE: Target is a Service ID. + // - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value. + // Enum: ["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"] + Scope *string `json:"scope,omitempty"` + + // Target the effective override was set on. Empty when not overridden. + Target string `json:"target,omitempty"` +} + +// Validate validates this set threshold OK body threshold +func (o *SetThresholdOKBodyThreshold) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateUnit(formats); err != nil { + res = append(res, err) + } + + if err := o.validateScope(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +var setThresholdOkBodyThresholdTypeUnitPropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["PARAM_UNIT_UNSPECIFIED","PARAM_UNIT_PERCENTAGE","PARAM_UNIT_SECONDS"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + setThresholdOkBodyThresholdTypeUnitPropEnum = append(setThresholdOkBodyThresholdTypeUnitPropEnum, v) + } +} + +const ( + + // SetThresholdOKBodyThresholdUnitPARAMUNITUNSPECIFIED captures enum value "PARAM_UNIT_UNSPECIFIED" + SetThresholdOKBodyThresholdUnitPARAMUNITUNSPECIFIED string = "PARAM_UNIT_UNSPECIFIED" + + // SetThresholdOKBodyThresholdUnitPARAMUNITPERCENTAGE captures enum value "PARAM_UNIT_PERCENTAGE" + SetThresholdOKBodyThresholdUnitPARAMUNITPERCENTAGE string = "PARAM_UNIT_PERCENTAGE" + + // SetThresholdOKBodyThresholdUnitPARAMUNITSECONDS captures enum value "PARAM_UNIT_SECONDS" + SetThresholdOKBodyThresholdUnitPARAMUNITSECONDS string = "PARAM_UNIT_SECONDS" +) + +// prop value enum +func (o *SetThresholdOKBodyThreshold) validateUnitEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, setThresholdOkBodyThresholdTypeUnitPropEnum, true); err != nil { + return err + } + return nil +} + +func (o *SetThresholdOKBodyThreshold) validateUnit(formats strfmt.Registry) error { + if swag.IsZero(o.Unit) { // not required + return nil + } + + // value enum + if err := o.validateUnitEnum("setThresholdOk"+"."+"threshold"+"."+"unit", "body", *o.Unit); err != nil { + return err + } + + return nil +} + +var setThresholdOkBodyThresholdTypeScopePropEnum []any + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["THRESHOLD_SCOPE_UNSPECIFIED","THRESHOLD_SCOPE_NODE","THRESHOLD_SCOPE_SERVICE","THRESHOLD_SCOPE_CLUSTER"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + setThresholdOkBodyThresholdTypeScopePropEnum = append(setThresholdOkBodyThresholdTypeScopePropEnum, v) + } +} + +const ( + + // SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPEUNSPECIFIED captures enum value "THRESHOLD_SCOPE_UNSPECIFIED" + SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPEUNSPECIFIED string = "THRESHOLD_SCOPE_UNSPECIFIED" + + // SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPENODE captures enum value "THRESHOLD_SCOPE_NODE" + SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPENODE string = "THRESHOLD_SCOPE_NODE" + + // SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPESERVICE captures enum value "THRESHOLD_SCOPE_SERVICE" + SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPESERVICE string = "THRESHOLD_SCOPE_SERVICE" + + // SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPECLUSTER captures enum value "THRESHOLD_SCOPE_CLUSTER" + SetThresholdOKBodyThresholdScopeTHRESHOLDSCOPECLUSTER string = "THRESHOLD_SCOPE_CLUSTER" +) + +// prop value enum +func (o *SetThresholdOKBodyThreshold) validateScopeEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, setThresholdOkBodyThresholdTypeScopePropEnum, true); err != nil { + return err + } + return nil +} + +func (o *SetThresholdOKBodyThreshold) validateScope(formats strfmt.Registry) error { + if swag.IsZero(o.Scope) { // not required + return nil + } + + // value enum + if err := o.validateScopeEnum("setThresholdOk"+"."+"threshold"+"."+"scope", "body", *o.Scope); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this set threshold OK body threshold based on context it is used +func (o *SetThresholdOKBodyThreshold) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *SetThresholdOKBodyThreshold) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SetThresholdOKBodyThreshold) UnmarshalBinary(b []byte) error { + var res SetThresholdOKBodyThreshold + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/api/alerting/v1/json/v1.json b/api/alerting/v1/json/v1.json index 2223c2443dd..93cdfa64d75 100644 --- a/api/alerting/v1/json/v1.json +++ b/api/alerting/v1/json/v1.json @@ -167,7 +167,14 @@ "200": { "description": "A successful response.", "schema": { - "type": "object" + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigns to a rule whose thresholds can be overridden per target.\nEmpty when the rule has no overridable parameters, since nothing can be keyed on it.\nThis is the rule's identity for threshold purposes rather than its Grafana UID:\ncopying or renaming the rule in Grafana preserves it.", + "type": "string", + "x-order": 0 + } + } } }, "default": { @@ -365,6 +372,11 @@ } }, "x-order": 6 + }, + "overridable": { + "description": "Whether this parameter's threshold can be overridden per target without editing the\nrule. Only set for templates that support it; the scopes it may be set at are\nreported per rule by ListThresholds.", + "type": "boolean", + "x-order": 7 } } }, @@ -713,6 +725,561 @@ } } } + }, + "/v1/alerting/thresholds": { + "get": { + "tags": [ + "AlertingService" + ], + "summary": "ListThresholds returns per-target threshold overrides.", + "operationId": "ListThresholds", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": "Scope of the target to report thresholds for. Must be set together with target.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Target to report thresholds for. When set, every overridable parameter is returned\nfor that target, overridden or not. When empty, only existing overrides are\nreturned, since there is otherwise no bounded set to enumerate.", + "name": "target", + "in": "query" + }, + { + "type": "string", + "description": "Return only thresholds of this rule.", + "name": "rule_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "post": { + "tags": [ + "AlertingService" + ], + "summary": "SetThreshold overrides one rule parameter for one target.", + "operationId": "SetThreshold", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Must be finite and within the parameter's declared range.", + "type": "number", + "format": "double", + "x-order": 4 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "threshold": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "delete": { + "tags": [ + "AlertingService" + ], + "summary": "ClearThreshold removes an override so the target falls back to the rule's default,\nor to a broader override still covering it.", + "operationId": "ClearThreshold", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": " - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "name": "target", + "in": "query" + }, + { + "type": "string", + "name": "rule_id", + "in": "query" + }, + { + "type": "string", + "name": "param_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/alerting/thresholds:batchUpdate": { + "post": { + "tags": [ + "AlertingService" + ], + "summary": "BatchUpdateThresholds applies several set and clear operations in one transaction.", + "operationId": "BatchUpdateThresholds", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "updates": { + "description": "Applied in one transaction: either every update lands or none does. A client\nediting several rows at once cannot otherwise report which ones took effect.", + "type": "array", + "items": { + "description": "ThresholdUpdate sets or clears one override.", + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Omit to clear the override rather than set it.", + "type": "number", + "format": "double", + "x-nullable": true, + "x-order": 4 + } + } + }, + "x-order": 0 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "description": "Thresholds that were set, in request order. Cleared ones are omitted.", + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } } }, "tags": [ diff --git a/api/swagger/swagger-dev.json b/api/swagger/swagger-dev.json index 2e478847389..90828195776 100644 --- a/api/swagger/swagger-dev.json +++ b/api/swagger/swagger-dev.json @@ -2155,7 +2155,14 @@ "200": { "description": "A successful response.", "schema": { - "type": "object" + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigns to a rule whose thresholds can be overridden per target.\nEmpty when the rule has no overridable parameters, since nothing can be keyed on it.\nThis is the rule's identity for threshold purposes rather than its Grafana UID:\ncopying or renaming the rule in Grafana preserves it.", + "type": "string", + "x-order": 0 + } + } } }, "default": { @@ -2353,6 +2360,11 @@ } }, "x-order": 6 + }, + "overridable": { + "description": "Whether this parameter's threshold can be overridden per target without editing the\nrule. Only set for templates that support it; the scopes it may be set at are\nreported per rule by ListThresholds.", + "type": "boolean", + "x-order": 7 } } }, @@ -2702,6 +2714,561 @@ } } }, + "/v1/alerting/thresholds": { + "get": { + "tags": [ + "AlertingService" + ], + "summary": "ListThresholds returns per-target threshold overrides.", + "operationId": "ListThresholds", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": "Scope of the target to report thresholds for. Must be set together with target.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Target to report thresholds for. When set, every overridable parameter is returned\nfor that target, overridden or not. When empty, only existing overrides are\nreturned, since there is otherwise no bounded set to enumerate.", + "name": "target", + "in": "query" + }, + { + "type": "string", + "description": "Return only thresholds of this rule.", + "name": "rule_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "post": { + "tags": [ + "AlertingService" + ], + "summary": "SetThreshold overrides one rule parameter for one target.", + "operationId": "SetThreshold", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Must be finite and within the parameter's declared range.", + "type": "number", + "format": "double", + "x-order": 4 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "threshold": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "delete": { + "tags": [ + "AlertingService" + ], + "summary": "ClearThreshold removes an override so the target falls back to the rule's default,\nor to a broader override still covering it.", + "operationId": "ClearThreshold", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": " - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "name": "target", + "in": "query" + }, + { + "type": "string", + "name": "rule_id", + "in": "query" + }, + { + "type": "string", + "name": "param_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/alerting/thresholds:batchUpdate": { + "post": { + "tags": [ + "AlertingService" + ], + "summary": "BatchUpdateThresholds applies several set and clear operations in one transaction.", + "operationId": "BatchUpdateThresholds", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "updates": { + "description": "Applied in one transaction: either every update lands or none does. A client\nediting several rows at once cannot otherwise report which ones took effect.", + "type": "array", + "items": { + "description": "ThresholdUpdate sets or clears one override.", + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Omit to clear the override rather than set it.", + "type": "number", + "format": "double", + "x-nullable": true, + "x-order": 4 + } + } + }, + "x-order": 0 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "description": "Thresholds that were set, in request order. Cleared ones are omitted.", + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, "/v1/backups/artifacts": { "get": { "description": "Return a list of backup artifacts.", diff --git a/api/swagger/swagger.json b/api/swagger/swagger.json index cb4c916b74d..00225c6fe96 100644 --- a/api/swagger/swagger.json +++ b/api/swagger/swagger.json @@ -1638,7 +1638,14 @@ "200": { "description": "A successful response.", "schema": { - "type": "object" + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigns to a rule whose thresholds can be overridden per target.\nEmpty when the rule has no overridable parameters, since nothing can be keyed on it.\nThis is the rule's identity for threshold purposes rather than its Grafana UID:\ncopying or renaming the rule in Grafana preserves it.", + "type": "string", + "x-order": 0 + } + } } }, "default": { @@ -1836,6 +1843,11 @@ } }, "x-order": 6 + }, + "overridable": { + "description": "Whether this parameter's threshold can be overridden per target without editing the\nrule. Only set for templates that support it; the scopes it may be set at are\nreported per rule by ListThresholds.", + "type": "boolean", + "x-order": 7 } } }, @@ -2185,6 +2197,561 @@ } } }, + "/v1/alerting/thresholds": { + "get": { + "tags": [ + "AlertingService" + ], + "summary": "ListThresholds returns per-target threshold overrides.", + "operationId": "ListThresholds", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": "Scope of the target to report thresholds for. Must be set together with target.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Target to report thresholds for. When set, every overridable parameter is returned\nfor that target, overridden or not. When empty, only existing overrides are\nreturned, since there is otherwise no bounded set to enumerate.", + "name": "target", + "in": "query" + }, + { + "type": "string", + "description": "Return only thresholds of this rule.", + "name": "rule_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "post": { + "tags": [ + "AlertingService" + ], + "summary": "SetThreshold overrides one rule parameter for one target.", + "operationId": "SetThreshold", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Must be finite and within the parameter's declared range.", + "type": "number", + "format": "double", + "x-order": 4 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "threshold": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + }, + "delete": { + "tags": [ + "AlertingService" + ], + "summary": "ClearThreshold removes an override so the target falls back to the rule's default,\nor to a broader override still covering it.", + "operationId": "ClearThreshold", + "parameters": [ + { + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "description": " - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "name": "target", + "in": "query" + }, + { + "type": "string", + "name": "rule_id", + "in": "query" + }, + { + "type": "string", + "name": "param_name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, + "/v1/alerting/thresholds:batchUpdate": { + "post": { + "tags": [ + "AlertingService" + ], + "summary": "BatchUpdateThresholds applies several set and clear operations in one transaction.", + "operationId": "BatchUpdateThresholds", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "updates": { + "description": "Applied in one transaction: either every update lands or none does. A client\nediting several rows at once cannot otherwise report which ones took effect.", + "type": "array", + "items": { + "description": "ThresholdUpdate sets or clears one override.", + "type": "object", + "properties": { + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 0 + }, + "target": { + "type": "string", + "x-order": 1 + }, + "rule_id": { + "type": "string", + "x-order": 2 + }, + "param_name": { + "type": "string", + "x-order": 3 + }, + "value": { + "description": "Omit to clear the override rather than set it.", + "type": "number", + "format": "double", + "x-nullable": true, + "x-order": 4 + } + } + }, + "x-order": 0 + } + } + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object", + "properties": { + "thresholds": { + "description": "Thresholds that were set, in request order. Cleared ones are omitted.", + "type": "array", + "items": { + "description": "Threshold is one overridable parameter of one rule, as it applies to one target.", + "type": "object", + "properties": { + "rule_id": { + "description": "Identifier PMM assigned to the rule. Not unique within a response: rules duplicated\nin Grafana share it, so two entries can carry the same rule_id and param_name and\ndiffer only in which rule they came from. Do not key a map on it.", + "type": "string", + "x-order": 0 + }, + "param_name": { + "description": "Machine-readable name of the overridable parameter.", + "type": "string", + "x-order": 1 + }, + "summary": { + "description": "Short human-readable parameter summary, as it was when the rule was created.", + "type": "string", + "x-order": 2 + }, + "unit": { + "description": "ParamUnit represents template parameter unit.\n\n - PARAM_UNIT_UNSPECIFIED: Invalid, unknown or absent.\n - PARAM_UNIT_PERCENTAGE: %\n - PARAM_UNIT_SECONDS: s", + "type": "string", + "default": "PARAM_UNIT_UNSPECIFIED", + "enum": [ + "PARAM_UNIT_UNSPECIFIED", + "PARAM_UNIT_PERCENTAGE", + "PARAM_UNIT_SECONDS" + ], + "x-order": 3 + }, + "default_value": { + "description": "Value the rule falls back to when no override applies.", + "type": "number", + "format": "double", + "x-order": 4 + }, + "effective_value": { + "description": "Value the rule currently evaluates this target against.", + "type": "number", + "format": "double", + "x-order": 5 + }, + "is_overridden": { + "description": "Whether effective_value comes from an override rather than the default.", + "type": "boolean", + "x-order": 6 + }, + "scope": { + "description": "ThresholdScope says what a threshold override's target refers to.\n\n - THRESHOLD_SCOPE_NODE: Target is a Node ID.\n - THRESHOLD_SCOPE_SERVICE: Target is a Service ID.\n - THRESHOLD_SCOPE_CLUSTER: Target is a cluster label value.", + "type": "string", + "default": "THRESHOLD_SCOPE_UNSPECIFIED", + "enum": [ + "THRESHOLD_SCOPE_UNSPECIFIED", + "THRESHOLD_SCOPE_NODE", + "THRESHOLD_SCOPE_SERVICE", + "THRESHOLD_SCOPE_CLUSTER" + ], + "x-order": 7 + }, + "target": { + "description": "Target the effective override was set on. Empty when not overridden.", + "type": "string", + "x-order": 8 + } + } + }, + "x-order": 0 + } + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "x-order": 0 + }, + "message": { + "type": "string", + "x-order": 1 + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string", + "x-order": 0 + } + }, + "additionalProperties": {} + }, + "x-order": 2 + } + } + } + } + } + } + }, "/v1/backups/artifacts": { "get": { "description": "Return a list of backup artifacts.", diff --git a/managed/cmd/pmm-managed/main.go b/managed/cmd/pmm-managed/main.go index 227f86921c3..dd9bb9f6fef 100644 --- a/managed/cmd/pmm-managed/main.go +++ b/managed/cmd/pmm-managed/main.go @@ -1029,6 +1029,9 @@ func main() { //nolint:gocognit,maintidx,cyclop } alertingService.CollectTemplates(ctx) + alertThresholdMetricsCollector := alerting.NewAlertThresholdMetricsCollector(db) + prom.MustRegister(alertThresholdMetricsCollector) + agentService := agents.NewAgentService(agentsRegistry) versioner := agents.NewVersionerService(agentsRegistry) @@ -1154,6 +1157,13 @@ func main() { //nolint:gocognit,maintidx,cyclop return nil })) + // Leader-only: every replica shares one database, so several sweeps would duplicate + // the same deletions and race each other. + haService.AddLeaderService(ha.NewContextService("alert-rule-reconciler", func(ctx context.Context) error { + alertingService.RunReconciler(ctx) + return nil + })) + wg.Go(func() { updater.Run(ctx) }) diff --git a/managed/data/alerting-templates/node_high_cpu_load.yml b/managed/data/alerting-templates/node_high_cpu_load.yml index 598b3d3a740..fb1d0d5dc7d 100644 --- a/managed/data/alerting-templates/node_high_cpu_load.yml +++ b/managed/data/alerting-templates/node_high_cpu_load.yml @@ -19,6 +19,7 @@ templates: type: float range: [0, 100] value: 80 + overridable: true for: 5m severity: warning annotations: diff --git a/managed/models/alert_rule_helpers.go b/managed/models/alert_rule_helpers.go new file mode 100644 index 00000000000..c62d4829c42 --- /dev/null +++ b/managed/models/alert_rule_helpers.go @@ -0,0 +1,327 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/reform.v1" +) + +func checkThresholdOverrideKey(ruleID, paramName string, scope ThresholdScope, target string) error { + if ruleID == "" { + return status.Error(codes.InvalidArgument, "Empty rule ID.") + } + + if paramName == "" { + return status.Error(codes.InvalidArgument, "Empty parameter name.") + } + + err := scope.Validate() + if err != nil { + return err + } + + if target == "" { + return status.Error(codes.InvalidArgument, "Empty target.") + } + + return nil +} + +// FindAlertRules returns all alert rules registered by PMM. +func FindAlertRules(q *reform.Querier) ([]*AlertRule, error) { + structs, err := q.SelectAllFrom(AlertRuleTable, "") + if err != nil { + return nil, fmt.Errorf("failed to select alert rules: %w", err) + } + + rules := make([]*AlertRule, len(structs)) + for i, s := range structs { + rules[i] = s.(*AlertRule) //nolint:forcetypeassert + } + + return rules, nil +} + +// FindAlertRuleByID returns an alert rule by its PMM-minted ID. +func FindAlertRuleByID(q *reform.Querier, ruleID string) (*AlertRule, error) { + if ruleID == "" { + return nil, status.Error(codes.InvalidArgument, "Empty rule ID.") + } + + rule := &AlertRule{RuleID: ruleID} + err := q.Reload(rule) + if err != nil { + if errors.Is(err, reform.ErrNoRows) { + return nil, status.Errorf(codes.NotFound, "Alert rule with ID %q not found.", ruleID) + } + + return nil, err + } + + return rule, nil +} + +// FindAllThresholdOverrides returns every threshold override row, tombstones included. +// The collector needs the tombstones: they are what keeps a cleared target's series +// alive at the rule's default. +func FindAllThresholdOverrides(q *reform.Querier) ([]*AlertRuleThresholdOverride, error) { + return selectThresholdOverrides(q, "") +} + +// FindThresholdOverridesByRule returns every override row for one rule, tombstones included. +func FindThresholdOverridesByRule(q *reform.Querier, ruleID string) ([]*AlertRuleThresholdOverride, error) { + if ruleID == "" { + return nil, status.Error(codes.InvalidArgument, "Empty rule ID.") + } + + return selectThresholdOverrides(q, whereAllEqual(q, "rule_id"), ruleID) +} + +// FindThresholdOverridesByTarget returns every override row for one target, tombstones included. +func FindThresholdOverridesByTarget(q *reform.Querier, scope ThresholdScope, target string) ([]*AlertRuleThresholdOverride, error) { + err := scope.Validate() + if err != nil { + return nil, err + } + + if target == "" { + return nil, status.Error(codes.InvalidArgument, "Empty target.") + } + + tail := whereAllEqual(q, "scope", "target") + + return selectThresholdOverrides(q, tail, string(scope), target) +} + +// whereAllEqual builds a WHERE clause matching every named column, numbering the +// placeholders in column order. Callers pass their arguments in that same order, so +// the numbering cannot drift out of step with them the way hand-written placeholders can. +func whereAllEqual(q *reform.Querier, columns ...string) string { + conditions := make([]string, len(columns)) + for i, column := range columns { + conditions[i] = column + " = " + q.Placeholder(i+1) + } + + return "WHERE " + strings.Join(conditions, " AND ") +} + +func selectThresholdOverrides(q *reform.Querier, tail string, args ...any) ([]*AlertRuleThresholdOverride, error) { + structs, err := q.SelectAllFrom(AlertRuleThresholdOverrideTable, tail, args...) + if err != nil { + return nil, fmt.Errorf("failed to select threshold overrides: %w", err) + } + + overrides := make([]*AlertRuleThresholdOverride, len(structs)) + for i, s := range structs { + overrides[i] = s.(*AlertRuleThresholdOverride) //nolint:forcetypeassert + } + + return overrides, nil +} + +func findThresholdOverride(q *reform.Querier, ruleID, paramName string, scope ThresholdScope, target string) (*AlertRuleThresholdOverride, error) { + tail := whereAllEqual(q, "rule_id", "param_name", "scope", "target") + + override := &AlertRuleThresholdOverride{} + err := q.SelectOneTo(override, tail, ruleID, paramName, string(scope), target) + if err != nil { + return nil, err + } + + return override, nil +} + +// CreateAlertRuleParams are params for creating a new alert rule registry row. +type CreateAlertRuleParams struct { + RuleID string + Params AlertRuleParams +} + +// CreateAlertRule registers an alert rule created by PMM. +func CreateAlertRule(q *reform.Querier, params *CreateAlertRuleParams) (*AlertRule, error) { + if params.RuleID == "" { + return nil, status.Error(codes.InvalidArgument, "Empty rule ID.") + } + + rule := &AlertRule{ + RuleID: params.RuleID, + Params: params.Params, + } + if rule.Params == nil { + rule.Params = AlertRuleParams{} + } + + err := q.Insert(rule) + if err != nil { + return nil, fmt.Errorf("failed to create alert rule: %w", err) + } + + return rule, nil +} + +// ChangeAlertRuleGrafanaUID stores the Grafana rule UID for an already-registered rule. +// The UID is a cached handle, not the identity, so it is set after Grafana has accepted +// the rule rather than being required up front. +func ChangeAlertRuleGrafanaUID(q *reform.Querier, ruleID, grafanaRuleUID string) (*AlertRule, error) { + rule, err := FindAlertRuleByID(q, ruleID) + if err != nil { + return nil, err + } + + if grafanaRuleUID == "" { + return nil, status.Error(codes.InvalidArgument, "Empty Grafana rule UID.") + } + + rule.GrafanaRuleUID = &grafanaRuleUID + err = q.Update(rule) + if err != nil { + return nil, fmt.Errorf("failed to update alert rule: %w", err) + } + + return rule, nil +} + +// UpsertThresholdOverride sets the override for one parameter of one rule at one target, +// creating the row if it does not exist. Writing to a tombstoned row revives it. +func UpsertThresholdOverride( + q *reform.Querier, + ruleID, paramName string, + scope ThresholdScope, + target string, + value float64, +) (*AlertRuleThresholdOverride, error) { + err := checkThresholdOverrideKey(ruleID, paramName, scope, target) + if err != nil { + return nil, err + } + + override, err := findThresholdOverride(q, ruleID, paramName, scope, target) + switch { + case err == nil: + override.Value = value + override.ClearedAt = nil + err = q.Update(override) + if err != nil { + return nil, fmt.Errorf("failed to update threshold override: %w", err) + } + + return override, nil + + case errors.Is(err, reform.ErrNoRows): + override = &AlertRuleThresholdOverride{ + ID: uuid.New().String(), + RuleID: ruleID, + ParamName: paramName, + Scope: scope, + Target: target, + Value: value, + } + err = q.Insert(override) + if err != nil { + return nil, fmt.Errorf("failed to create threshold override: %w", err) + } + + return override, nil + + default: + return nil, fmt.Errorf("failed to look up threshold override: %w", err) + } +} + +// ClearThresholdOverride tombstones an override instead of deleting it, so the emitted +// series keeps existing and merely changes value. Deleting the row would signal the +// clear by absence, which takes a full VictoriaMetrics lookbehind to become visible. +func ClearThresholdOverride(q *reform.Querier, ruleID, paramName string, scope ThresholdScope, target string) error { + err := checkThresholdOverrideKey(ruleID, paramName, scope, target) + if err != nil { + return err + } + + override, err := findThresholdOverride(q, ruleID, paramName, scope, target) + if err != nil { + if errors.Is(err, reform.ErrNoRows) { + return status.Errorf(codes.NotFound, "Threshold override for rule %q parameter %q not found.", ruleID, paramName) + } + + return fmt.Errorf("failed to look up threshold override: %w", err) + } + + if override.IsCleared() { + return nil + } + + override.ClearedAt = new(Now()) + err = q.Update(override) + if err != nil { + return fmt.Errorf("failed to clear threshold override: %w", err) + } + + return nil +} + +// DeleteThresholdOverridesForTarget hard-deletes every override for a target, and is for +// entity removal only. A user clearing an override tombstones it (the target still +// exists and its series must keep resolving); a removed node or service has no target +// left to emit for, so a tombstone there would be pure residue. +// +// Cluster scope is rejected: there is no "delete a cluster" operation to hook, and a +// cluster override with no matching services is dormant rather than stale - services may +// be added to that cluster later, and the override should apply again when they are. +func DeleteThresholdOverridesForTarget(q *reform.Querier, scope ThresholdScope, target string) error { + err := scope.Validate() + if err != nil { + return err + } + + if scope == ThresholdScopeCluster { + return status.Error(codes.InvalidArgument, "Cluster-scoped threshold overrides are not deleted by target removal.") + } + + if target == "" { + return status.Error(codes.InvalidArgument, "Empty target.") + } + + tail := whereAllEqual(q, "scope", "target") + _, err = q.DeleteFrom(AlertRuleThresholdOverrideTable, tail, string(scope), target) + if err != nil { + return fmt.Errorf("failed to delete threshold overrides: %w", err) + } + + return nil +} + +// DeleteAlertRule removes a rule registry row. Its overrides go with it through the +// foreign key's ON DELETE CASCADE. +func DeleteAlertRule(q *reform.Querier, ruleID string) error { + _, err := FindAlertRuleByID(q, ruleID) + if err != nil { + return err + } + + err = q.Delete(&AlertRule{RuleID: ruleID}) + if err != nil { + return fmt.Errorf("failed to delete alert rule: %w", err) + } + + return nil +} diff --git a/managed/models/alert_rule_helpers_test.go b/managed/models/alert_rule_helpers_test.go new file mode 100644 index 00000000000..cf5a0d79301 --- /dev/null +++ b/managed/models/alert_rule_helpers_test.go @@ -0,0 +1,496 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models_test + +import ( + "math" + "testing" + + "github.com/AlekSi/pointer" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/dialects/postgresql" + + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/utils/testdb" +) + +func createTestAlertRule(t *testing.T, q *reform.Querier) *models.AlertRule { + t.Helper() + + rule, err := models.CreateAlertRule(q, &models.CreateAlertRuleParams{ + RuleID: uuid.New().String(), + Params: models.AlertRuleParams{ + "threshold": { + Default: 80, + JoinLabel: "node_name", + Scopes: []string{string(models.ThresholdScopeNode)}, + }, + }, + }) + require.NoError(t, err) + + return rule +} + +func TestAlertRuleRegistry(t *testing.T) { + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + t.Run("create and find round-trips the params snapshot", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + assert.Nil(t, rule.GrafanaRuleUID) + + found, err := models.FindAlertRuleByID(q, rule.RuleID) + require.NoError(t, err) + assert.InDelta(t, 80.0, found.Params["threshold"].Default, 0.0001) + assert.Equal(t, "node_name", found.Params["threshold"].JoinLabel) + assert.Equal(t, []string{"node"}, found.Params["threshold"].Scopes) + }) + + t.Run("missing rule is NotFound", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + + _, err = models.FindAlertRuleByID(tx.Querier, uuid.New().String()) + require.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("grafana rule uid is set after the fact", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + updated, err := models.ChangeAlertRuleGrafanaUID(q, rule.RuleID, "grafana-uid-1") + require.NoError(t, err) + require.NotNil(t, updated.GrafanaRuleUID) + assert.Equal(t, "grafana-uid-1", *updated.GrafanaRuleUID) + }) +} + +func TestThresholdOverrides(t *testing.T) { + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + t.Run("upsert creates then updates in place", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + + created, err := models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + assert.InDelta(t, 90.0, created.Value, 0.0001) + assert.False(t, created.IsCleared()) + + updated, err := models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 95) + require.NoError(t, err) + assert.Equal(t, created.ID, updated.ID, "upsert must reuse the row, not insert a second one") + assert.InDelta(t, 95.0, updated.Value, 0.0001) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + require.Len(t, all, 1) + }) + + t.Run("clear tombstones the row rather than deleting it", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + + require.NoError(t, models.ClearThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1")) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + require.Len(t, all, 1, "the row must survive so the emitted series keeps existing") + assert.True(t, all[0].IsCleared()) + assert.InDelta(t, 90.0, all[0].Value, 0.0001, "the stale value is kept for audit") + }) + + t.Run("clear is idempotent", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + + require.NoError(t, models.ClearThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1")) + require.NoError(t, models.ClearThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1")) + }) + + t.Run("clearing an override that was never set is NotFound", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + err = models.ClearThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1") + require.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("upsert revives a tombstone", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + created, err := models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + require.NoError(t, models.ClearThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1")) + + revived, err := models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 75) + require.NoError(t, err) + assert.Equal(t, created.ID, revived.ID) + assert.False(t, revived.IsCleared(), "writing a value must clear the tombstone") + assert.InDelta(t, 75.0, revived.Value, 0.0001) + }) + + t.Run("the unique key is rule, param, scope and target", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + + // Same target at three scopes, plus a second param, are four distinct rows. + for _, scope := range []models.ThresholdScope{ + models.ThresholdScopeNode, + models.ThresholdScopeService, + models.ThresholdScopeCluster, + } { + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", scope, "same-target", 90) + require.NoError(t, err) + } + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "other", models.ThresholdScopeNode, "same-target", 90) + require.NoError(t, err) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + assert.Len(t, all, 4) + }) + + t.Run("find by target is scoped", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "shared", 90) + require.NoError(t, err) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeService, "shared", 70) + require.NoError(t, err) + + found, err := models.FindThresholdOverridesByTarget(q, models.ThresholdScopeNode, "shared") + require.NoError(t, err) + require.Len(t, found, 1) + assert.InDelta(t, 90.0, found[0].Value, 0.0001) + }) + + t.Run("delete for target hard-deletes, unlike clear", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-2", 91) + require.NoError(t, err) + + require.NoError(t, models.DeleteThresholdOverridesForTarget(q, models.ThresholdScopeNode, "node-id-1")) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, "node-id-2", all[0].Target) + }) + + t.Run("delete for target refuses cluster scope", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + + // A cluster override with no matching services is dormant, not stale. + err = models.DeleteThresholdOverridesForTarget(tx.Querier, models.ThresholdScopeCluster, "prod") + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("deleting the rule cascades to its overrides", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + + require.NoError(t, models.DeleteAlertRule(q, rule.RuleID)) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + assert.Empty(t, all) + }) + + t.Run("an unknown scope is rejected before touching the database", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScope("rack"), "r1", 90) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) +} + +// TestThresholdOverrideRejectsNonFiniteValues exercises migration 119's CHECK. This +// would be main's first float column, so there is no existing precedent to inherit and +// the guard has to be verified rather than assumed. +func TestThresholdOverrideRejectsNonFiniteValues(t *testing.T) { + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + for name, value := range map[string]float64{ + "NaN": math.NaN(), + "positive infinity": math.Inf(1), + "negative infinity": math.Inf(-1), + } { + t.Run(name, func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, "node-id-1", value) + require.Error(t, err, "the database must reject %s", name) + }) + } +} + +// TestThresholdOverridesFollowTargetRemoval covers the cleanup that has no cascade to +// rely on: the target column is polymorphic, so it carries no foreign key and rows must +// be removed by the removal API itself. +func TestThresholdOverridesFollowTargetRemoval(t *testing.T) { + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + t.Run("removing a node removes its overrides", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + node, err := models.CreateNode(q, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: "doomed-node", + Address: "doomed.example.com", + }) + require.NoError(t, err) + + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, node.NodeID, 90) + require.NoError(t, err) + + require.NoError(t, models.RemoveNode(q, node.NodeID, models.RemoveRestrict)) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + assert.Empty(t, all, "an override must not outlive the node it targets") + }) + + t.Run("removing a service removes its overrides", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + node, err := models.CreateNode(q, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: "svc-host", + Address: "svc-host.example.com", + }) + require.NoError(t, err) + + service, err := models.AddNewService(q, models.MySQLServiceType, &models.AddDBMSServiceParams{ + ServiceName: "doomed-service", + NodeID: node.NodeID, + Address: new("127.0.0.1"), + Port: pointer.ToUint16(3306), + }) + require.NoError(t, err) + + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeService, service.ServiceID, 70) + require.NoError(t, err) + + require.NoError(t, models.RemoveService(q, service.ServiceID, models.RemoveRestrict)) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + assert.Empty(t, all) + }) + + // Removing a node cascades into its services, which is where the service-scoped + // rows are reached from - there is no second cleanup path for them. + t.Run("removing a node cascades to its services' overrides", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + node, err := models.CreateNode(q, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: "cascade-host", + Address: "cascade-host.example.com", + }) + require.NoError(t, err) + + service, err := models.AddNewService(q, models.MySQLServiceType, &models.AddDBMSServiceParams{ + ServiceName: "cascade-service", + NodeID: node.NodeID, + Address: new("127.0.0.1"), + Port: pointer.ToUint16(3306), + }) + require.NoError(t, err) + + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeNode, node.NodeID, 90) + require.NoError(t, err) + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeService, service.ServiceID, 70) + require.NoError(t, err) + + require.NoError(t, models.RemoveNode(q, node.NodeID, models.RemoveCascade)) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + assert.Empty(t, all, "the service's override must go with the node that hosted it") + }) + + // A cluster override with no matching services is dormant, not stale: services may + // join that cluster later, and the override should apply again when they do. + t.Run("a cluster override survives removal of its services", func(t *testing.T) { + tx, err := db.Begin() + require.NoError(t, err) + defer func() { + require.NoError(t, tx.Rollback()) + }() + q := tx.Querier + + rule := createTestAlertRule(t, q) + node, err := models.CreateNode(q, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: "cluster-host", + Address: "cluster-host.example.com", + }) + require.NoError(t, err) + + service, err := models.AddNewService(q, models.MySQLServiceType, &models.AddDBMSServiceParams{ + ServiceName: "clustered-service", + NodeID: node.NodeID, + Cluster: "prod", + Address: new("127.0.0.1"), + Port: pointer.ToUint16(3306), + }) + require.NoError(t, err) + + _, err = models.UpsertThresholdOverride(q, rule.RuleID, "threshold", models.ThresholdScopeCluster, "prod", 60) + require.NoError(t, err) + + require.NoError(t, models.RemoveService(q, service.ServiceID, models.RemoveRestrict)) + + all, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, models.ThresholdScopeCluster, all[0].Scope) + }) +} diff --git a/managed/models/alert_rule_model.go b/managed/models/alert_rule_model.go new file mode 100644 index 00000000000..a18b70f04a9 --- /dev/null +++ b/managed/models/alert_rule_model.go @@ -0,0 +1,94 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "database/sql/driver" + "time" + + "gopkg.in/reform.v1" +) + +//go:generate go tool reform + +// AlertRuleParam is the snapshot of one overridable parameter, taken when the rule was +// created. The template it came from can be edited or deleted afterwards, so this is the +// only durable record of what the rule actually evaluates against, and the only place the +// effective default can be read back from. +type AlertRuleParam struct { + Default float64 `json:"default"` + JoinLabel string `json:"join_label"` + Scopes []string `json:"scopes"` + Unit string `json:"unit,omitempty"` + Summary string `json:"summary,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` +} + +// AlertRuleParams maps a parameter name to its snapshot. +type AlertRuleParams map[string]AlertRuleParam + +// Value implements database/sql/driver.Valuer interface. Should be defined on the value. +func (p AlertRuleParams) Value() (driver.Value, error) { return jsonValue(p) } + +// Scan implements database/sql.Scanner interface. Should be defined on the pointer. +func (p *AlertRuleParams) Scan(src any) error { return jsonScan(p, src) } + +// AlertRule represents a PMM-created Grafana alert rule that carries overridable +// thresholds. The row is a registry entry, not the rule itself: Grafana remains the +// authority for the rule definition, and PMM keeps only what Grafana cannot supply. +// +//reform:alert_rules +type AlertRule struct { + RuleID string `reform:"rule_id,pk"` + // GrafanaRuleUID is a cached handle for the rule in Grafana, never the identity. + // It is nil until the rule has been created there. + GrafanaRuleUID *string `reform:"grafana_rule_uid"` + Params AlertRuleParams `reform:"params"` + CreatedAt time.Time `reform:"created_at"` + UpdatedAt time.Time `reform:"updated_at"` +} + +// BeforeInsert implements reform.BeforeInserter interface. +func (r *AlertRule) BeforeInsert() error { + now := Now() + r.CreatedAt = now + r.UpdatedAt = now + + return nil +} + +// BeforeUpdate implements reform.BeforeUpdater interface. +func (r *AlertRule) BeforeUpdate() error { + r.UpdatedAt = Now() + + return nil +} + +// AfterFind implements reform.AfterFinder interface. +func (r *AlertRule) AfterFind() error { + r.CreatedAt = r.CreatedAt.UTC() + r.UpdatedAt = r.UpdatedAt.UTC() + + return nil +} + +// check interfaces. +var ( + _ reform.BeforeInserter = (*AlertRule)(nil) + _ reform.BeforeUpdater = (*AlertRule)(nil) + _ reform.AfterFinder = (*AlertRule)(nil) +) diff --git a/managed/models/alert_rule_model_reform.go b/managed/models/alert_rule_model_reform.go new file mode 100644 index 00000000000..107c21593ba --- /dev/null +++ b/managed/models/alert_rule_model_reform.go @@ -0,0 +1,151 @@ +// Code generated by gopkg.in/reform.v1. DO NOT EDIT. + +package models + +import ( + "fmt" + "strings" + + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/parse" +) + +type alertRuleTableType struct { + s parse.StructInfo + z []interface{} +} + +// Schema returns a schema name in SQL database (""). +func (v *alertRuleTableType) Schema() string { + return v.s.SQLSchema +} + +// Name returns a view or table name in SQL database ("alert_rules"). +func (v *alertRuleTableType) Name() string { + return v.s.SQLName +} + +// Columns returns a new slice of column names for that view or table in SQL database. +func (v *alertRuleTableType) Columns() []string { + return []string{ + "rule_id", + "grafana_rule_uid", + "params", + "created_at", + "updated_at", + } +} + +// NewStruct makes a new struct for that view or table. +func (v *alertRuleTableType) NewStruct() reform.Struct { + return new(AlertRule) +} + +// NewRecord makes a new record for that table. +func (v *alertRuleTableType) NewRecord() reform.Record { + return new(AlertRule) +} + +// PKColumnIndex returns an index of primary key column for that table in SQL database. +func (v *alertRuleTableType) PKColumnIndex() uint { + return uint(v.s.PKFieldIndex) +} + +// AlertRuleTable represents alert_rules view or table in SQL database. +var AlertRuleTable = &alertRuleTableType{ + s: parse.StructInfo{ + Type: "AlertRule", + SQLName: "alert_rules", + Fields: []parse.FieldInfo{ + {Name: "RuleID", Type: "string", Column: "rule_id"}, + {Name: "GrafanaRuleUID", Type: "*string", Column: "grafana_rule_uid"}, + {Name: "Params", Type: "AlertRuleParams", Column: "params"}, + {Name: "CreatedAt", Type: "time.Time", Column: "created_at"}, + {Name: "UpdatedAt", Type: "time.Time", Column: "updated_at"}, + }, + PKFieldIndex: 0, + }, + z: new(AlertRule).Values(), +} + +// String returns a string representation of this struct or record. +func (s AlertRule) String() string { + res := make([]string, 5) + res[0] = "RuleID: " + reform.Inspect(s.RuleID, true) + res[1] = "GrafanaRuleUID: " + reform.Inspect(s.GrafanaRuleUID, true) + res[2] = "Params: " + reform.Inspect(s.Params, true) + res[3] = "CreatedAt: " + reform.Inspect(s.CreatedAt, true) + res[4] = "UpdatedAt: " + reform.Inspect(s.UpdatedAt, true) + return strings.Join(res, ", ") +} + +// Values returns a slice of struct or record field values. +// Returned interface{} values are never untyped nils. +func (s *AlertRule) Values() []interface{} { + return []interface{}{ + s.RuleID, + s.GrafanaRuleUID, + s.Params, + s.CreatedAt, + s.UpdatedAt, + } +} + +// Pointers returns a slice of pointers to struct or record fields. +// Returned interface{} values are never untyped nils. +func (s *AlertRule) Pointers() []interface{} { + return []interface{}{ + &s.RuleID, + &s.GrafanaRuleUID, + &s.Params, + &s.CreatedAt, + &s.UpdatedAt, + } +} + +// View returns View object for that struct. +func (s *AlertRule) View() reform.View { + return AlertRuleTable +} + +// Table returns Table object for that record. +func (s *AlertRule) Table() reform.Table { + return AlertRuleTable +} + +// PKValue returns a value of primary key for that record. +// Returned interface{} value is never untyped nil. +func (s *AlertRule) PKValue() interface{} { + return s.RuleID +} + +// PKPointer returns a pointer to primary key field for that record. +// Returned interface{} value is never untyped nil. +func (s *AlertRule) PKPointer() interface{} { + return &s.RuleID +} + +// HasPK returns true if record has non-zero primary key set, false otherwise. +func (s *AlertRule) HasPK() bool { + return s.RuleID != AlertRuleTable.z[AlertRuleTable.s.PKFieldIndex] +} + +// SetPK sets record primary key, if possible. +// +// Deprecated: prefer direct field assignment where possible: s.RuleID = pk. +func (s *AlertRule) SetPK(pk interface{}) { + reform.SetPK(s, pk) +} + +// check interfaces +var ( + _ reform.View = AlertRuleTable + _ reform.Struct = (*AlertRule)(nil) + _ reform.Table = AlertRuleTable + _ reform.Record = (*AlertRule)(nil) + _ fmt.Stringer = (*AlertRule)(nil) +) + +func init() { + parse.AssertUpToDate(&AlertRuleTable.s, new(AlertRule)) +} diff --git a/managed/models/alert_rule_threshold_override_model.go b/managed/models/alert_rule_threshold_override_model.go new file mode 100644 index 00000000000..ba8604c3de9 --- /dev/null +++ b/managed/models/alert_rule_threshold_override_model.go @@ -0,0 +1,118 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/reform.v1" +) + +//go:generate go tool reform + +// ThresholdScope says what an override's target refers to. +type ThresholdScope string + +// Threshold override scopes. Declaration order carries no meaning: precedence lives in +// thresholdScopeSpecificity, which ranks service above node above cluster. +const ( + ThresholdScopeNode = ThresholdScope("node") + ThresholdScopeService = ThresholdScope("service") + ThresholdScopeCluster = ThresholdScope("cluster") +) + +// Validate validates the threshold override scope. +// +// This returns a gRPC status error rather than an InvalidArgumentError, matching +// template_helpers.go in the same feature area, so that every validation failure the +// threshold helpers can produce surfaces as the same code without the service layer +// having to convert two different error shapes. +func (s ThresholdScope) Validate() error { + switch s { + case ThresholdScopeNode: + case ThresholdScopeService: + case ThresholdScopeCluster: + default: + return status.Errorf(codes.InvalidArgument, "Invalid threshold scope %q.", string(s)) + } + + return nil +} + +// AlertRuleThresholdOverride is a per-target threshold for one parameter of one alert +// rule. Clearing an override tombstones the row rather than deleting it: a deleted row +// stops being emitted, and a series that stops being emitted keeps resolving for the +// whole of VictoriaMetrics' lookbehind, so the clear would take minutes to take effect +// instead of one scrape. +// +//reform:alert_rule_threshold_overrides +type AlertRuleThresholdOverride struct { + ID string `reform:"id,pk"` + RuleID string `reform:"rule_id"` + ParamName string `reform:"param_name"` + Scope ThresholdScope `reform:"scope"` + // Target is a node_id, a service_id, or a cluster label value, depending on Scope. + Target string `reform:"target"` + Value float64 `reform:"value"` + // ClearedAt marks the row as a tombstone. The stale Value is kept for audit and + // must never be emitted: a cleared override resolves through the remaining scopes, + // falling back to the rule's default only when none apply. + ClearedAt *time.Time `reform:"cleared_at"` + CreatedAt time.Time `reform:"created_at"` + UpdatedAt time.Time `reform:"updated_at"` +} + +// IsCleared reports whether the override has been cleared and is therefore a tombstone. +func (o *AlertRuleThresholdOverride) IsCleared() bool { + return o.ClearedAt != nil +} + +// BeforeInsert implements reform.BeforeInserter interface. +func (o *AlertRuleThresholdOverride) BeforeInsert() error { + now := Now() + o.CreatedAt = now + o.UpdatedAt = now + + return nil +} + +// BeforeUpdate implements reform.BeforeUpdater interface. +func (o *AlertRuleThresholdOverride) BeforeUpdate() error { + o.UpdatedAt = Now() + + return nil +} + +// AfterFind implements reform.AfterFinder interface. +func (o *AlertRuleThresholdOverride) AfterFind() error { + o.CreatedAt = o.CreatedAt.UTC() + o.UpdatedAt = o.UpdatedAt.UTC() + if o.ClearedAt != nil { + cleared := o.ClearedAt.UTC() + o.ClearedAt = &cleared + } + + return nil +} + +// check interfaces. +var ( + _ reform.BeforeInserter = (*AlertRuleThresholdOverride)(nil) + _ reform.BeforeUpdater = (*AlertRuleThresholdOverride)(nil) + _ reform.AfterFinder = (*AlertRuleThresholdOverride)(nil) +) diff --git a/managed/models/alert_rule_threshold_override_model_reform.go b/managed/models/alert_rule_threshold_override_model_reform.go new file mode 100644 index 00000000000..63bba32c284 --- /dev/null +++ b/managed/models/alert_rule_threshold_override_model_reform.go @@ -0,0 +1,171 @@ +// Code generated by gopkg.in/reform.v1. DO NOT EDIT. + +package models + +import ( + "fmt" + "strings" + + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/parse" +) + +type alertRuleThresholdOverrideTableType struct { + s parse.StructInfo + z []interface{} +} + +// Schema returns a schema name in SQL database (""). +func (v *alertRuleThresholdOverrideTableType) Schema() string { + return v.s.SQLSchema +} + +// Name returns a view or table name in SQL database ("alert_rule_threshold_overrides"). +func (v *alertRuleThresholdOverrideTableType) Name() string { + return v.s.SQLName +} + +// Columns returns a new slice of column names for that view or table in SQL database. +func (v *alertRuleThresholdOverrideTableType) Columns() []string { + return []string{ + "id", + "rule_id", + "param_name", + "scope", + "target", + "value", + "cleared_at", + "created_at", + "updated_at", + } +} + +// NewStruct makes a new struct for that view or table. +func (v *alertRuleThresholdOverrideTableType) NewStruct() reform.Struct { + return new(AlertRuleThresholdOverride) +} + +// NewRecord makes a new record for that table. +func (v *alertRuleThresholdOverrideTableType) NewRecord() reform.Record { + return new(AlertRuleThresholdOverride) +} + +// PKColumnIndex returns an index of primary key column for that table in SQL database. +func (v *alertRuleThresholdOverrideTableType) PKColumnIndex() uint { + return uint(v.s.PKFieldIndex) +} + +// AlertRuleThresholdOverrideTable represents alert_rule_threshold_overrides view or table in SQL database. +var AlertRuleThresholdOverrideTable = &alertRuleThresholdOverrideTableType{ + s: parse.StructInfo{ + Type: "AlertRuleThresholdOverride", + SQLName: "alert_rule_threshold_overrides", + Fields: []parse.FieldInfo{ + {Name: "ID", Type: "string", Column: "id"}, + {Name: "RuleID", Type: "string", Column: "rule_id"}, + {Name: "ParamName", Type: "string", Column: "param_name"}, + {Name: "Scope", Type: "ThresholdScope", Column: "scope"}, + {Name: "Target", Type: "string", Column: "target"}, + {Name: "Value", Type: "float64", Column: "value"}, + {Name: "ClearedAt", Type: "*time.Time", Column: "cleared_at"}, + {Name: "CreatedAt", Type: "time.Time", Column: "created_at"}, + {Name: "UpdatedAt", Type: "time.Time", Column: "updated_at"}, + }, + PKFieldIndex: 0, + }, + z: new(AlertRuleThresholdOverride).Values(), +} + +// String returns a string representation of this struct or record. +func (s AlertRuleThresholdOverride) String() string { + res := make([]string, 9) + res[0] = "ID: " + reform.Inspect(s.ID, true) + res[1] = "RuleID: " + reform.Inspect(s.RuleID, true) + res[2] = "ParamName: " + reform.Inspect(s.ParamName, true) + res[3] = "Scope: " + reform.Inspect(s.Scope, true) + res[4] = "Target: " + reform.Inspect(s.Target, true) + res[5] = "Value: " + reform.Inspect(s.Value, true) + res[6] = "ClearedAt: " + reform.Inspect(s.ClearedAt, true) + res[7] = "CreatedAt: " + reform.Inspect(s.CreatedAt, true) + res[8] = "UpdatedAt: " + reform.Inspect(s.UpdatedAt, true) + return strings.Join(res, ", ") +} + +// Values returns a slice of struct or record field values. +// Returned interface{} values are never untyped nils. +func (s *AlertRuleThresholdOverride) Values() []interface{} { + return []interface{}{ + s.ID, + s.RuleID, + s.ParamName, + s.Scope, + s.Target, + s.Value, + s.ClearedAt, + s.CreatedAt, + s.UpdatedAt, + } +} + +// Pointers returns a slice of pointers to struct or record fields. +// Returned interface{} values are never untyped nils. +func (s *AlertRuleThresholdOverride) Pointers() []interface{} { + return []interface{}{ + &s.ID, + &s.RuleID, + &s.ParamName, + &s.Scope, + &s.Target, + &s.Value, + &s.ClearedAt, + &s.CreatedAt, + &s.UpdatedAt, + } +} + +// View returns View object for that struct. +func (s *AlertRuleThresholdOverride) View() reform.View { + return AlertRuleThresholdOverrideTable +} + +// Table returns Table object for that record. +func (s *AlertRuleThresholdOverride) Table() reform.Table { + return AlertRuleThresholdOverrideTable +} + +// PKValue returns a value of primary key for that record. +// Returned interface{} value is never untyped nil. +func (s *AlertRuleThresholdOverride) PKValue() interface{} { + return s.ID +} + +// PKPointer returns a pointer to primary key field for that record. +// Returned interface{} value is never untyped nil. +func (s *AlertRuleThresholdOverride) PKPointer() interface{} { + return &s.ID +} + +// HasPK returns true if record has non-zero primary key set, false otherwise. +func (s *AlertRuleThresholdOverride) HasPK() bool { + return s.ID != AlertRuleThresholdOverrideTable.z[AlertRuleThresholdOverrideTable.s.PKFieldIndex] +} + +// SetPK sets record primary key, if possible. +// +// Deprecated: prefer direct field assignment where possible: s.ID = pk. +func (s *AlertRuleThresholdOverride) SetPK(pk interface{}) { + reform.SetPK(s, pk) +} + +// check interfaces +var ( + _ reform.View = AlertRuleThresholdOverrideTable + _ reform.Struct = (*AlertRuleThresholdOverride)(nil) + _ reform.Table = AlertRuleThresholdOverrideTable + _ reform.Record = (*AlertRuleThresholdOverride)(nil) + _ fmt.Stringer = (*AlertRuleThresholdOverride)(nil) +) + +func init() { + parse.AssertUpToDate(&AlertRuleThresholdOverrideTable.s, new(AlertRuleThresholdOverride)) +} diff --git a/managed/models/database.go b/managed/models/database.go index 431e480fe30..7e708e1ac01 100644 --- a/managed/models/database.go +++ b/managed/models/database.go @@ -1185,6 +1185,46 @@ var databaseSchema = [][]string{ `ALTER TABLE dumps ADD COLUMN encrypted boolean NOT NULL DEFAULT false`, `UPDATE dumps SET encrypted = false`, }, + 119: { + `CREATE TABLE alert_rules ( + rule_id VARCHAR NOT NULL, + grafana_rule_uid VARCHAR CHECK (grafana_rule_uid <> ''), + params JSONB NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + PRIMARY KEY (rule_id), + UNIQUE (grafana_rule_uid) + )`, + + // target is polymorphic - a node_id, a service_id, or a cluster label value - + // so it carries no foreign key: cluster is a label value with no referent table. + // Rows for a deleted node or service are removed by the removal API instead. + `CREATE TABLE alert_rule_threshold_overrides ( + id VARCHAR NOT NULL, + rule_id VARCHAR NOT NULL, + param_name VARCHAR NOT NULL CHECK (param_name <> ''), + scope VARCHAR NOT NULL CHECK (scope <> ''), + target VARCHAR NOT NULL CHECK (target <> ''), + value DOUBLE PRECISION NOT NULL + CHECK (value = value AND value > '-Infinity'::float8 AND value < 'Infinity'::float8), + cleared_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + PRIMARY KEY (id), + UNIQUE (rule_id, param_name, scope, target), + FOREIGN KEY (rule_id) REFERENCES alert_rules (rule_id) ON DELETE CASCADE + )`, + + `CREATE INDEX alert_rule_threshold_overrides_target_idx + ON alert_rule_threshold_overrides (scope, target)`, + + // The foreign key above does not imply an index in PostgreSQL, and the + // collector reads by rule_id on every scrape. + `CREATE INDEX alert_rule_threshold_overrides_rule_idx + ON alert_rule_threshold_overrides (rule_id)`, + }, } // ^^^ Avoid default values in schema definition. ^^^ diff --git a/managed/models/node_helpers.go b/managed/models/node_helpers.go index ccd63e85d7c..e39545326b0 100644 --- a/managed/models/node_helpers.go +++ b/managed/models/node_helpers.go @@ -349,6 +349,14 @@ func removeNode(q *reform.Querier, id string, mode RemoveMode, allowPMMServerNod } } + // Threshold overrides carry no foreign key - their target column is polymorphic, and + // a cluster target has no referent table to point at - so they are removed here, in + // the same transaction, rather than by a cascade. + err = DeleteThresholdOverridesForTarget(q, ThresholdScopeNode, id) + if err != nil { + return err + } + err = q.Delete(n) if err != nil { return fmt.Errorf("failed to delete Node: %w", err) diff --git a/managed/models/service_helpers.go b/managed/models/service_helpers.go index 659e1a25b9e..f8890d84ea8 100644 --- a/managed/models/service_helpers.go +++ b/managed/models/service_helpers.go @@ -425,6 +425,12 @@ func RemoveService(q *reform.Querier, id string, mode RemoveMode) error { //noli panic(fmt.Errorf("unhandled RemoveMode %v", mode)) } + // See RemoveNode: override rows are not reachable by cascade, so they go here. + err = DeleteThresholdOverridesForTarget(q, ThresholdScopeService, id) + if err != nil { + return err + } + err = q.Delete(s) if err != nil { return fmt.Errorf("failed to delete Service: %w", err) diff --git a/managed/models/template_helpers.go b/managed/models/template_helpers.go index 471a805e1e4..2085574f0d6 100644 --- a/managed/models/template_helpers.go +++ b/managed/models/template_helpers.go @@ -225,10 +225,15 @@ func ConvertParamsDefinitions(params []alert.Parameter) (AlertExprParamsDefiniti res := make(AlertExprParamsDefinitions, 0, len(params)) for _, param := range params { p := AlertExprParamDefinition{ - Name: param.Name, - Summary: param.Summary, - Unit: ParamUnit(param.Unit), - Type: ParamType(param.Type), + Name: param.Name, + Summary: param.Summary, + Unit: ParamUnit(param.Unit), + Type: ParamType(param.Type), + Overridable: param.Overridable, + } + + if param.Overridable { + p.OverrideScopes = param.GetOverrideScopes() } switch param.Type { diff --git a/managed/models/template_model.go b/managed/models/template_model.go index 60cc77a2f84..3d715ad4b15 100644 --- a/managed/models/template_model.go +++ b/managed/models/template_model.go @@ -110,6 +110,12 @@ type AlertExprParamDefinition struct { FloatParam *FloatParam `json:"float_param"` // BoolParam *BoolParam `json:"bool_param"` // StringParam *StringParam `json:"string_param"` + + // Overridable reports whether a per-target threshold override may be set for this + // parameter without rewriting the alert rule. + Overridable bool `json:"overridable,omitempty"` + // OverrideScopes lists the scopes an override may be set at. Empty means node. + OverrideScopes []string `json:"override_scopes,omitempty"` } // ParamType represents parameter type. diff --git a/managed/models/threshold_resolver.go b/managed/models/threshold_resolver.go new file mode 100644 index 00000000000..8148eb572ab --- /dev/null +++ b/managed/models/threshold_resolver.go @@ -0,0 +1,169 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +// thresholdScopeSpecificity ranks scopes most-specific-first, so a narrower override +// wins over a broader one covering the same target. +// +// Service ranks highest because it is the only relation that actually holds: a service +// runs on exactly one node and belongs to at most one cluster, so a service override is +// strictly narrower than either. Node over cluster is a convention rather than a +// containment - the two cross-cut, since a cluster spans several nodes while a node +// hosts services from several clusters - but one machine is the narrower intent. +// +// Precedence cannot be expressed in PromQL: reducing both sides of an `or` to a common +// label set is what makes `or` prefer the left operand, but that reduction is exactly +// what destroys the scope information needed to rank by. So precedence is resolved here, +// in Go, and there is no backstop in the query if this function is wrong. +// +// The iota order below is the precedence chain itself, narrowest last. +const ( + thresholdSpecificityCluster = iota + 1 + thresholdSpecificityNode + thresholdSpecificityService +) + +var thresholdScopeSpecificity = map[ThresholdScope]int{ + ThresholdScopeService: thresholdSpecificityService, + ThresholdScopeNode: thresholdSpecificityNode, + ThresholdScopeCluster: thresholdSpecificityCluster, +} + +// ThresholdInventory maps override targets onto the join-label values an alert rule +// matches on. Targets missing from it no longer exist and are skipped. +type ThresholdInventory struct { + // NodeNames maps node_id to node_name. + NodeNames map[string]string + // ServiceNames maps service_id to service_name. + ServiceNames map[string]string + // ServicesByCluster maps a cluster label value to the names of its services. + ServicesByCluster map[string][]string +} + +// targetNames returns the join-label values an override applies to. A node or service +// override yields at most one; a cluster override fans out onto every service in that +// cluster. An unresolvable target yields none, so a row left behind by a deleted entity +// is inert rather than wrong. +func (inv ThresholdInventory) targetNames(override *AlertRuleThresholdOverride) []string { + switch override.Scope { + case ThresholdScopeNode: + name, ok := inv.NodeNames[override.Target] + if !ok { + return nil + } + + return []string{name} + + case ThresholdScopeService: + name, ok := inv.ServiceNames[override.Target] + if !ok { + return nil + } + + return []string{name} + + case ThresholdScopeCluster: + return inv.ServicesByCluster[override.Target] + } + + // do not add `default:` to make exhaustive linter do its job + + return nil +} + +// ResolveThresholds returns the effective threshold for every target covered by an +// override or a tombstone, keyed by the join-label value the rule matches on. +// +// This is the single implementation of precedence. Both the metrics collector and the +// API must call it: if they resolved separately and drifted, the value the API reports +// and the value the rule evaluates against would silently disagree. +// +// Tombstoned rows contribute no candidate for their own scope, so clearing a service +// override correctly falls through to a covering cluster override rather than jumping +// straight to the default. A tombstoned target with no surviving override at any scope +// resolves to defaultValue - which is what makes clearing an override a value change on +// an existing series rather than the series disappearing. +func ResolveThresholds(overrides []*AlertRuleThresholdOverride, defaultValue float64, inv ThresholdInventory) map[string]float64 { + detailed := ResolveThresholdsDetailed(overrides, defaultValue, inv) + + resolved := make(map[string]float64, len(detailed)) + for name, threshold := range detailed { + resolved[name] = threshold.Value + } + + return resolved +} + +// ResolvedThreshold is the effective threshold for one target, with the override it came +// from. Source is nil when the value is the rule's default, which happens when every +// override covering the target has been cleared. +type ResolvedThreshold struct { + Value float64 + Source *AlertRuleThresholdOverride +} + +// IsOverridden reports whether the value comes from an override rather than the default. +func (r ResolvedThreshold) IsOverridden() bool { + return r.Source != nil +} + +// ResolveThresholdsDetailed applies precedence and reports which override won for each +// target. It is the one implementation of precedence; ResolveThresholds is a thin view +// over it, so the value the API reports and the value the collector emits cannot drift. +func ResolveThresholdsDetailed( + overrides []*AlertRuleThresholdOverride, + defaultValue float64, + inv ThresholdInventory, +) map[string]ResolvedThreshold { + resolved := make(map[string]ResolvedThreshold, len(overrides)) + specificity := make(map[string]int, len(overrides)) + + var cleared []string + + for _, override := range overrides { + names := inv.targetNames(override) + if len(names) == 0 { + continue + } + + if override.IsCleared() { + cleared = append(cleared, names...) + continue + } + + rank := thresholdScopeSpecificity[override.Scope] + for _, name := range names { + existing, ok := specificity[name] + if ok && existing >= rank { + continue + } + + resolved[name] = ResolvedThreshold{Value: override.Value, Source: override} + specificity[name] = rank + } + } + + // A cleared target keeps its series alive at the rule's default, unless a coarser + // override still applies to it. + for _, name := range cleared { + _, ok := resolved[name] + if !ok { + resolved[name] = ResolvedThreshold{Value: defaultValue} + } + } + + return resolved +} diff --git a/managed/models/threshold_resolver_test.go b/managed/models/threshold_resolver_test.go new file mode 100644 index 00000000000..043fee0d8bb --- /dev/null +++ b/managed/models/threshold_resolver_test.go @@ -0,0 +1,262 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testDefault = 80.0 + +func testInventory() ThresholdInventory { + return ThresholdInventory{ + NodeNames: map[string]string{ + "node-id-1": "node-1", + "node-id-2": "node-2", + }, + ServiceNames: map[string]string{ + "svc-id-1": "svc-1", + "svc-id-2": "svc-2", + }, + ServicesByCluster: map[string][]string{ + "prod": {"svc-1", "svc-2"}, + }, + } +} + +func override(scope ThresholdScope, target string, value float64) *AlertRuleThresholdOverride { + return &AlertRuleThresholdOverride{ + ID: fmt.Sprintf("%s-%s", scope, target), + RuleID: "rule-1", + ParamName: "threshold", + Scope: scope, + Target: target, + Value: value, + } +} + +func tombstone(scope ThresholdScope, target string, value float64) *AlertRuleThresholdOverride { + o := override(scope, target, value) + cleared := time.Now() + o.ClearedAt = &cleared + + return o +} + +func TestResolveThresholds(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + overrides []*AlertRuleThresholdOverride + expected map[string]float64 + }{ + { + name: "no overrides emits nothing", + overrides: nil, + expected: map[string]float64{}, + }, + { + name: "node override resolves to the node name", + overrides: []*AlertRuleThresholdOverride{override(ThresholdScopeNode, "node-id-1", 90)}, + expected: map[string]float64{"node-1": 90}, + }, + { + name: "service override resolves to the service name", + overrides: []*AlertRuleThresholdOverride{override(ThresholdScopeService, "svc-id-1", 91)}, + expected: map[string]float64{"svc-1": 91}, + }, + { + name: "cluster override fans out onto every service in the cluster", + overrides: []*AlertRuleThresholdOverride{override(ThresholdScopeCluster, "prod", 70)}, + expected: map[string]float64{"svc-1": 70, "svc-2": 70}, + }, + { + name: "service beats cluster regardless of value", + overrides: []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 99), + override(ThresholdScopeService, "svc-id-1", 50), + }, + // svc-1 takes the more specific 50 even though the cluster value is larger: + // precedence is by scope, not by magnitude. + expected: map[string]float64{"svc-1": 50, "svc-2": 99}, + }, + { + name: "unresolvable target is skipped, never defaulted", + overrides: []*AlertRuleThresholdOverride{ + override(ThresholdScopeNode, "deleted-node-id", 90), + }, + expected: map[string]float64{}, + }, + { + name: "unknown cluster expands to nothing", + overrides: []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "staging", 90), + }, + expected: map[string]float64{}, + }, + { + name: "tombstone with no surviving override resolves to the default", + overrides: []*AlertRuleThresholdOverride{ + tombstone(ThresholdScopeNode, "node-id-1", 90), + }, + expected: map[string]float64{"node-1": testDefault}, + }, + { + name: "tombstone falls through to a covering cluster override, not the default", + overrides: []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 70), + tombstone(ThresholdScopeService, "svc-id-1", 50), + }, + expected: map[string]float64{"svc-1": 70, "svc-2": 70}, + }, + { + name: "tombstoned cluster override clears every service it covered", + overrides: []*AlertRuleThresholdOverride{ + tombstone(ThresholdScopeCluster, "prod", 70), + }, + expected: map[string]float64{"svc-1": testDefault, "svc-2": testDefault}, + }, + { + name: "a tombstone never contributes its stale value", + overrides: []*AlertRuleThresholdOverride{ + tombstone(ThresholdScopeNode, "node-id-1", 12345), + }, + expected: map[string]float64{"node-1": testDefault}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + actual := ResolveThresholds(tc.overrides, testDefault, testInventory()) + assert.Equal(t, tc.expected, actual) + }) + } +} + +// TestResolveThresholdsPrecedenceAcrossAllScopes pins the order that is derivable rather +// than conventional: a service runs on exactly one node and belongs to at most one +// cluster, so a service override is strictly narrower than either and must win. +func TestResolveThresholdsPrecedenceAcrossAllScopes(t *testing.T) { + t.Parallel() + + inv := testInventory() + // A node whose name collides with a service name is the only way node and service + // scope can reach the same target, since they otherwise resolve into separate label + // namespaces. Nothing in the schema prevents it: node_name and service_name are + // unique within their own tables, not across them. + inv.NodeNames["node-id-3"] = "svc-1" + + overrides := []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 10), + override(ThresholdScopeService, "svc-id-1", 20), + override(ThresholdScopeNode, "node-id-3", 30), + } + + resolved := ResolveThresholds(overrides, testDefault, inv) + assert.InDelta(t, 20.0, resolved["svc-1"], 0.0001, "service scope must win over node and cluster") +} + +// TestResolveThresholdsNodeBeatsCluster pins the conventional half of the order. Node and +// cluster cross-cut rather than nest - a cluster spans several nodes, a node hosts +// services from several clusters - so this is a chosen tie-break, not a containment. +func TestResolveThresholdsNodeBeatsCluster(t *testing.T) { + t.Parallel() + + inv := testInventory() + inv.NodeNames["node-id-3"] = "svc-1" + + overrides := []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 10), + override(ThresholdScopeNode, "node-id-3", 30), + } + + resolved := ResolveThresholds(overrides, testDefault, inv) + assert.InDelta(t, 30.0, resolved["svc-1"], 0.0001) +} + +func TestResolveThresholdsIsOrderIndependent(t *testing.T) { + t.Parallel() + + forward := []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 99), + override(ThresholdScopeService, "svc-id-1", 50), + } + reversed := []*AlertRuleThresholdOverride{forward[1], forward[0]} + + inv := testInventory() + assert.Equal(t, + ResolveThresholds(forward, testDefault, inv), + ResolveThresholds(reversed, testDefault, inv), + "precedence must not depend on row order returned by the database") +} + +// TestResolveThresholdsEmitsOneValuePerTarget guards the invariant that matters most +// operationally: two series with identical labels make the Prometheus gatherer fail the +// entire /metrics response, taking every other collector down with it. +func TestResolveThresholdsEmitsOneValuePerTarget(t *testing.T) { + t.Parallel() + + inv := testInventory() + inv.ServicesByCluster["prod"] = []string{"svc-1", "svc-1", "svc-2"} + + overrides := []*AlertRuleThresholdOverride{ + override(ThresholdScopeCluster, "prod", 70), + override(ThresholdScopeNode, "node-id-1", 90), + tombstone(ThresholdScopeNode, "node-id-2", 60), + } + + resolved := ResolveThresholds(overrides, testDefault, inv) + require.Len(t, resolved, 4) + assert.InDelta(t, 70.0, resolved["svc-1"], 0.0001) + assert.InDelta(t, 70.0, resolved["svc-2"], 0.0001) + assert.InDelta(t, 90.0, resolved["node-1"], 0.0001) + assert.InDelta(t, testDefault, resolved["node-2"], 0.0001) +} + +func BenchmarkResolveThresholds(b *testing.B) { + inv := ThresholdInventory{ + NodeNames: make(map[string]string, 1000), + ServiceNames: map[string]string{}, + ServicesByCluster: make(map[string][]string, 50), + } + + overrides := make([]*AlertRuleThresholdOverride, 0, 1000) + for i := range 1000 { + id := fmt.Sprintf("node-id-%d", i) + inv.NodeNames[id] = fmt.Sprintf("node-%d", i) + overrides = append(overrides, override(ThresholdScopeNode, id, float64(i%100))) + } + + for i := range 50 { + cluster := fmt.Sprintf("cluster-%d", i) + services := make([]string, 0, 200) + for j := range 200 { + services = append(services, fmt.Sprintf("svc-%d-%d", i, j)) + } + inv.ServicesByCluster[cluster] = services + overrides = append(overrides, override(ThresholdScopeCluster, cluster, 55)) + } + + for b.Loop() { + ResolveThresholds(overrides, testDefault, inv) + } +} diff --git a/managed/pi/alert/overridable.go b/managed/pi/alert/overridable.go new file mode 100644 index 00000000000..bcab1c93d74 --- /dev/null +++ b/managed/pi/alert/overridable.go @@ -0,0 +1,157 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alert + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/prometheus/prometheus/promql/parser" +) + +// ParamTokenRegexp returns a regexp matching a parameter's placeholder token, tolerating +// the optional whitespace the template syntax allows, e.g. both `[[ .threshold ]]` and +// `[[.threshold]]`. The name is quoted, so any parameter name is safe to pass. +func ParamTokenRegexp(name string) *regexp.Regexp { + return regexp.MustCompile(`\[\[\s*\.` + regexp.QuoteMeta(name) + `\s*\]\]`) +} + +// ParamReferencedInExpressions reports whether any expression step references the parameter. +func (r *Template) ParamReferencedInExpressions(name string) bool { + re := ParamTokenRegexp(name) + for _, expression := range r.Expressions { + if re.MatchString(expression.Expression) { + return true + } + } + + return false +} + +// OverridableParams returns the template's overridable parameters, in declaration order. +func (r *Template) OverridableParams() []Parameter { + var params []Parameter + for _, param := range r.Params { + if param.Overridable { + params = append(params, param) + } + } + + return params +} + +// SingleExprSplit is a single-expression template taken apart so the builder can emit the +// same three steps a multi-expression template produces: the observed query, an injected +// threshold, and a math comparison between them. +type SingleExprSplit struct { + // LHS is everything left of the comparison, sliced from the original text so the + // author's formatting survives. + LHS string + // Operator is the comparison the template used, e.g. "<" or ">=". + Operator string +} + +// SplitSingleExpr takes a single-expression template apart at its final comparison. +// +// The parameter token is not valid PromQL, so it is replaced by a numeric sentinel padded +// to the token's exact byte length. AST positions then map 1:1 onto the original text, +// which is what lets the left-hand side be sliced out of the original string rather than +// printed back from the AST - preserving line breaks and spacing exactly as written. +// +// Splitting on the AST rather than with a regexp is deliberate. A regexp has to special-case +// parentheses, the `bool` modifier, vector matching and nested comparisons, and it fails +// silently by producing a plausible but wrong left-hand side. +func SplitSingleExpr(expr, paramName string) (SingleExprSplit, error) { + var zero SingleExprSplit + + token := ParamTokenRegexp(paramName).FindString(expr) + if token == "" { + return zero, fmt.Errorf("parameter %q is not referenced in the expression", paramName) + } + + // "0" padded with spaces to the token's exact byte length: parseable, and leaves every + // following position unchanged. + sentinel := "0" + strings.Repeat(" ", len(token)-1) + probe := strings.Replace(expr, token, sentinel, 1) + + // Default options: templates are ordinary PromQL, so nothing experimental is enabled. + parsed, err := parser.NewParser(parser.Options{}).ParseExpr(probe) + if err != nil { + return zero, fmt.Errorf("failed to parse expression: %w", err) + } + + binary, ok := parsed.(*parser.BinaryExpr) + if !ok || !binary.Op.IsComparisonOperator() { + return zero, errors.New("an overridable parameter must be the right-hand side of the expression's top-level comparison") + } + + // Anything other than the bare sentinel means the token was used inside a larger + // expression, e.g. `foo > [[ .threshold ]] * 100`, which cannot become a threshold step. + number, ok := binary.RHS.(*parser.NumberLiteral) + if !ok || number.Val != 0 { + return zero, errors.New("an overridable parameter must be compared directly, not used inside a larger expression") + } + + // Vector matching on the comparison itself needs no guard: PromQL only allows it + // between two instant vectors, and the threshold is a scalar, so such an expression + // fails to parse above with "vector matching only allowed between instant vectors". + // Matching on an operator *inside* the left-hand side is untouched and stays valid. + + // The `bool` modifier is read and then dropped: Grafana math comparisons already yield + // 0/1, so carrying it across would be redundant. + position := binary.LHS.PositionRange() + + return SingleExprSplit{ + LHS: strings.TrimSpace(expr[position.Start:position.End]), + Operator: binary.Op.String(), + }, nil +} + +// validateOverridableParams checks the constraints that depend on the template's shape, +// rather than on the parameter alone. +func (r *Template) validateOverridableParams() error { + overridable := r.OverridableParams() + + for _, param := range overridable { + if r.UsesMultipleExpressions() { + // The threshold is injected as a separate query step and referenced from the + // expression, so a parameter no expression mentions has nothing to override. + if !r.ParamReferencedInExpressions(param.Name) { + return fmt.Errorf("overridable parameter %q must be referenced by an expression step", param.Name) + } + + continue + } + + // A single-expression template is split apart at build time. Checking that here + // means an expression that cannot be split fails when the template is parsed, with + // the reason, rather than producing a rule whose threshold silently never applies. + if len(overridable) > 1 { + return fmt.Errorf( + "a single-expression template supports at most one overridable parameter, got %d", len(overridable), + ) + } + + _, err := SplitSingleExpr(r.Expr, param.Name) + if err != nil { + return fmt.Errorf("overridable parameter %q: %w", param.Name, err) + } + } + + return nil +} diff --git a/managed/pi/alert/overridable_test.go b/managed/pi/alert/overridable_test.go new file mode 100644 index 00000000000..1cb1c705dc4 --- /dev/null +++ b/managed/pi/alert/overridable_test.go @@ -0,0 +1,247 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/percona/pmm/managed/pi/common" +) + +// overridableTemplate returns a valid multi-expression template whose single param is +// overridable, so each test can vary exactly the one field it cares about. +func overridableTemplate() Template { + return Template{ + Name: "test_template", + Version: 1, + Summary: "summary", + For: 300, + Severity: common.Warning, + Queries: []TemplateQuery{{ + RefID: "A", + Expr: "up", + }}, + Expressions: []TemplateExpression{{ + RefID: "C", + Type: "math", + Expression: "$A > [[ .threshold ]]", + }}, + Condition: "C", + Params: []Parameter{{ + Name: "threshold", + Summary: "threshold", + Type: Float, + Value: 80, + Overridable: true, + }}, + } +} + +func TestParamTokenRegexp(t *testing.T) { + t.Parallel() + + re := ParamTokenRegexp("threshold") + + assert.True(t, re.MatchString("$A > [[ .threshold ]]")) + assert.True(t, re.MatchString("$A > [[.threshold]]")) + assert.True(t, re.MatchString("$A > [[ .threshold ]]")) + + assert.False(t, re.MatchString("$A > [[ .other ]]")) + assert.False(t, re.MatchString("$A > 80")) + + // A prefix must not match a longer parameter name. + assert.False(t, ParamTokenRegexp("thresh").MatchString("$A > [[ .threshold ]]")) +} + +func TestParamTokenRegexpQuotesName(t *testing.T) { + t.Parallel() + + // A name containing regexp metacharacters must be matched literally, not as a pattern. + re := ParamTokenRegexp("a.b") + assert.True(t, re.MatchString("[[ .a.b ]]")) + assert.False(t, re.MatchString("[[ .axb ]]")) +} + +func TestParamReferencedInExpressions(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + + assert.True(t, template.ParamReferencedInExpressions("threshold")) + assert.False(t, template.ParamReferencedInExpressions("missing")) +} + +func TestGetOverrideScopesDefaultsToNode(t *testing.T) { + t.Parallel() + + param := Parameter{Name: "threshold", Type: Float, Overridable: true} + assert.Equal(t, []string{OverrideScopeNode}, param.GetOverrideScopes()) + + param.OverrideScopes = []string{OverrideScopeService, OverrideScopeCluster} + assert.Equal(t, []string{OverrideScopeService, OverrideScopeCluster}, param.GetOverrideScopes()) +} + +func TestOverridableParams(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params = append(template.Params, Parameter{ + Name: "other", + Summary: "other", + Type: Float, + Value: 1, + }) + + params := template.OverridableParams() + require.Len(t, params, 1) + assert.Equal(t, "threshold", params[0].Name) +} + +func TestValidateOverridableTemplate(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + require.NoError(t, template.Validate()) +} + +// singleExprTemplate returns a valid single-expression template whose one param is +// overridable, so the desugaring constraints can be varied one at a time. +func singleExprTemplate() Template { + template := overridableTemplate() + template.Queries = nil + template.Expressions = nil + template.Condition = "" + template.Expr = "up > bool [[ .threshold ]]" + + return template +} + +func TestValidateOverridableAcceptsSplittableSingleExpression(t *testing.T) { + t.Parallel() + + template := singleExprTemplate() + require.NoError(t, template.Validate()) +} + +// An expression that cannot be split must fail when the template is parsed. Accepting it +// would produce a rule whose threshold silently never applies. +func TestValidateOverridableRejectsUnsplittableSingleExpression(t *testing.T) { + t.Parallel() + + template := singleExprTemplate() + template.Expr = "up > bool [[ .threshold ]] * 100" + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be compared directly") +} + +func TestValidateOverridableRejectsTwoParamsOnSingleExpression(t *testing.T) { + t.Parallel() + + template := singleExprTemplate() + template.Expr = "up > bool [[ .threshold ]]" + template.Params = append(template.Params, Parameter{ + Name: "second", + Summary: "second", + Type: Float, + Value: 1, + Overridable: true, + }) + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "at most one overridable parameter") +} + +func TestValidateOverridableRejectsUnreferencedParam(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Expressions[0].Expression = "$A > 80" + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be referenced by an expression step") +} + +func TestValidateOverridableRejectsNonFloat(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].Type = String + template.Params[0].Value = "80" + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be of type float") +} + +func TestValidateOverridableRejectsUnknownScope(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].OverrideScopes = []string{"rack"} + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown override scope") +} + +func TestValidateRejectsScopesWithoutOverridable(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].Overridable = false + template.Params[0].OverrideScopes = []string{OverrideScopeNode} + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "not overridable") +} + +func TestValidateAcceptsNonOverridableTemplates(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].Overridable = false + template.Expressions[0].Expression = "$A > [[ .threshold ]]" + + require.NoError(t, template.Validate()) +} + +func TestValidateOverridableRejectsMixedScopeFamilies(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].OverrideScopes = []string{OverrideScopeNode, OverrideScopeCluster} + + err := template.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "join on different labels") +} + +func TestValidateOverridableAcceptsServiceAndCluster(t *testing.T) { + t.Parallel() + + template := overridableTemplate() + template.Params[0].OverrideScopes = []string{OverrideScopeService, OverrideScopeCluster} + + require.NoError(t, template.Validate()) +} diff --git a/managed/pi/alert/parameter.go b/managed/pi/alert/parameter.go index bb2a6a54863..9ef83283866 100644 --- a/managed/pi/alert/parameter.go +++ b/managed/pi/alert/parameter.go @@ -21,14 +21,33 @@ import ( "strconv" ) +// Override scopes an overridable parameter may be tuned at. +const ( + OverrideScopeNode = "node" + OverrideScopeService = "service" + OverrideScopeCluster = "cluster" +) + // Parameter represents alerting template or rule parameter. type Parameter struct { - Name string `yaml:"name"` // required - Summary string `yaml:"summary"` // required - Unit Unit `yaml:"unit,omitempty"` // optional - Type Type `yaml:"type"` // required - Range []any `yaml:"range,flow,omitempty"` - Value any `yaml:"value,omitempty"` + Name string `yaml:"name"` // required + Summary string `yaml:"summary"` // required + Unit Unit `yaml:"unit,omitempty"` // optional + Type Type `yaml:"type"` // required + Range []any `yaml:"range,flow,omitempty"` // optional + Value any `yaml:"value,omitempty"` // optional + Overridable bool `yaml:"overridable,omitempty"` // optional + OverrideScopes []string `yaml:"override_scopes,flow,omitempty"` // optional +} + +// GetOverrideScopes returns the scopes an override may be set at, defaulting to node +// when the template does not declare any. +func (p *Parameter) GetOverrideScopes() []string { + if len(p.OverrideScopes) == 0 { + return []string{OverrideScopeNode} + } + + return p.OverrideScopes } // GetValueForBool casts parameter value to the bool. @@ -132,7 +151,52 @@ func (p *Parameter) Validate() error { return err } - return p.validateRange() + err = p.validateRange() + if err != nil { + return err + } + + return p.validateOverride() +} + +// validateOverride checks the constraints an overridable parameter carries on its own. +// Constraints that depend on the template's shape are checked in Template.Validate. +func (p *Parameter) validateOverride() error { + if !p.Overridable { + if len(p.OverrideScopes) != 0 { + return errors.New("override_scopes is set but the parameter is not overridable") + } + + return nil + } + + // The threshold travels as a float64 gauge sample, so no other type can carry it. + if p.Type != Float { + return fmt.Errorf("an overridable parameter must be of type float, got %s", p.Type) + } + + var node, service bool + + for _, scope := range p.OverrideScopes { + switch scope { + case OverrideScopeNode: + node = true + case OverrideScopeService, OverrideScopeCluster: + service = true + default: + return fmt.Errorf("unknown override scope %q", scope) + } + } + + // A node override identifies its target by node name, while service and cluster + // overrides both identify theirs by service name. A rule joins its threshold on one + // label, so a parameter offering both would silently ignore overrides set at the + // scope that does not match. + if node && service { + return errors.New("override scopes cannot mix node with service or cluster, which join on different labels") + } + + return nil } func (p *Parameter) validateValue() error { diff --git a/managed/pi/alert/singleexpr_test.go b/managed/pi/alert/singleexpr_test.go new file mode 100644 index 00000000000..4b749243df1 --- /dev/null +++ b/managed/pi/alert/singleexpr_test.go @@ -0,0 +1,203 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alert + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitSingleExpr(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + expr string + param string + wantLHS string + wantOp string + wantErr string + }{ + { + name: "greater than with bool", + expr: "node_load1 > bool [[ .threshold ]]", + param: "threshold", + wantLHS: "node_load1", + wantOp: ">", + }, + { + // 8 of the 16 shipped candidates compare with `<`, so assuming `>` would + // invert half the corpus. + name: "less than preserves the operator", + expr: "node_memory_MemAvailable_bytes < bool [[ .threshold ]]", + param: "threshold", + wantLHS: "node_memory_MemAvailable_bytes", + wantOp: "<", + }, + { + name: "greater or equal", + expr: "proxysql_runtime_servers_status >= bool [[ .status ]]", + param: "status", + wantLHS: "proxysql_runtime_servers_status", + wantOp: ">=", + }, + { + name: "bool is optional", + expr: "(max by (cluster) (some_metric)) > [[ .threshold ]]", + param: "threshold", + wantLHS: "(max by (cluster) (some_metric))", + wantOp: ">", + }, + { + // The left-hand side is sliced from the original text, so line breaks and + // spacing survive rather than being reprinted from the AST. + name: "multi-line left-hand side keeps its formatting", + expr: "node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes\n* 100\n< bool [[ .threshold ]]", + param: "threshold", + wantLHS: "node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes\n* 100", + wantOp: "<", + }, + { + // Vector matching on an operator *inside* the left-hand side is fine; only + // matching on the comparison itself is not. A string search for "ignoring(" + // would wrongly reject this, which is why the split is done on the AST. + name: "vector matching inside the left-hand side is allowed", + expr: "max_over_time(a[5m]) / ignoring (job) b * 100 > bool [[ .threshold ]]", + param: "threshold", + wantLHS: "max_over_time(a[5m]) / ignoring (job) b * 100", + wantOp: ">", + }, + { + name: "tolerates whitespace-free tokens", + expr: "node_load1 > bool [[.threshold]]", + param: "threshold", + wantLHS: "node_load1", + wantOp: ">", + }, + { + name: "parameter not referenced", + expr: "node_load1 > bool 80", + param: "threshold", + wantErr: "is not referenced in the expression", + }, + { + name: "token used inside a larger expression", + expr: "node_load1 > bool [[ .threshold ]] * 100", + param: "threshold", + wantErr: "must be compared directly", + }, + { + name: "no top-level comparison", + expr: "node_load1 + [[ .threshold ]]", + param: "threshold", + wantErr: "must be the right-hand side of the expression's top-level comparison", + }, + { + // PromQL allows vector matching only between two instant vectors, and a + // threshold is a scalar, so this is rejected by the parser rather than needing + // a guard of our own - such a template could never be valid in the first place. + name: "vector matching on the comparison itself", + expr: "a > bool on (node_name) [[ .threshold ]]", + param: "threshold", + wantErr: "vector matching only allowed between instant vectors", + }, + { + name: "expression that is not valid PromQL", + expr: "max(some_metric[1m]) > [[ .threshold ]]", + param: "threshold", + wantErr: "failed to parse expression", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + split, err := SplitSingleExpr(tc.expr, tc.param) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantLHS, split.LHS) + assert.Equal(t, tc.wantOp, split.Operator) + }) + } +} + +// TestSplitShippedSingleExprTemplates runs the splitter over every shipped single-expression +// template that carries a parameter. These are the expressions desugaring has to handle, so +// the corpus itself is the test: a template reworded into a shape the splitter cannot take +// apart should fail here rather than when someone marks it overridable. +func TestSplitShippedSingleExprTemplates(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob(filepath.Join("..", "..", "data", "alerting-templates", "*.yml")) + require.NoError(t, err) + require.NotEmpty(t, files) + + splittable := 0 + + for _, file := range files { + b, err := os.ReadFile(file) + require.NoError(t, err) + + templates, err := Parse(strings.NewReader(string(b)), &ParseParams{ + DisallowUnknownFields: true, + DisallowInvalidTemplates: true, + }) + require.NoErrorf(t, err, "%s must parse", filepath.Base(file)) + + for _, template := range templates { + if template.UsesMultipleExpressions() || len(template.Params) == 0 { + continue + } + + for _, param := range template.Params { + if !ParamTokenRegexp(param.Name).MatchString(template.Expr) { + continue + } + + split, err := SplitSingleExpr(template.Expr, param.Name) + if err != nil { + // mongodb_replication_lag uses `max([1m])`, which is not + // valid PromQL - a pre-existing bug the parser surfaces here. Report it + // rather than failing, so this test tracks the corpus instead of + // blocking on a defect it did not introduce. + t.Logf("NOT SPLITTABLE %s (%s): %v", template.Name, param.Name, err) + + continue + } + + splittable++ + assert.NotEmptyf(t, split.LHS, "%s: left-hand side must not be empty", template.Name) + assert.NotEmptyf(t, split.Operator, "%s: operator must not be empty", template.Name) + assert.NotContainsf(t, split.LHS, "[[", + "%s: the parameter token must not survive into the observed query", template.Name) + } + } + } + + t.Logf("splittable single-expression templates: %d", splittable) + assert.GreaterOrEqual(t, splittable, 15, + "the shipped corpus should be almost entirely splittable") +} diff --git a/managed/pi/alert/template.go b/managed/pi/alert/template.go index 696fb9159f5..c5a111709cb 100644 --- a/managed/pi/alert/template.go +++ b/managed/pi/alert/template.go @@ -146,6 +146,11 @@ func (r *Template) Validate() error { return err } + err = r.validateOverridableParams() + if err != nil { + return err + } + return r.Severity.Validate() } diff --git a/managed/services/alert_rule.go b/managed/services/alert_rule.go index e9c08230017..80addd77258 100644 --- a/managed/services/alert_rule.go +++ b/managed/services/alert_rule.go @@ -19,6 +19,11 @@ import "encoding/json" // This file contains grafana alerting API DTOs. +// PMMRuleIDLabel is the label carrying PMM's own identity for a rule whose thresholds can +// be overridden. It lives on the rule rather than being its Grafana UID, so the rule can +// be matched back to its overrides after being copied or renamed in Grafana. +const PMMRuleIDLabel = "pmm_rule_id" + // Rule represents grafana alerting rule. type Rule struct { GrafanaAlert GrafanaAlert `json:"grafana_alert"` diff --git a/managed/services/alerting/deps.go b/managed/services/alerting/deps.go index 57e9eee88e3..2bae06f0422 100644 --- a/managed/services/alerting/deps.go +++ b/managed/services/alerting/deps.go @@ -25,6 +25,7 @@ import ( type grafanaClient interface { CreateAlertRule(ctx context.Context, folderUID, groupName, interval string, rule *services.Rule) error + ListPMMRuleIDs(ctx context.Context) (map[string]struct{}, error) GetDatasourceUIDByName(ctx context.Context, name string) (string, error) GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) } diff --git a/managed/services/alerting/mock_grafana_client_test.go b/managed/services/alerting/mock_grafana_client_test.go index 72e1fde3e7a..4c5d456354b 100644 --- a/managed/services/alerting/mock_grafana_client_test.go +++ b/managed/services/alerting/mock_grafana_client_test.go @@ -92,6 +92,36 @@ func (_m *mockGrafanaClient) GetFolderByUID(ctx context.Context, uid string) (*m return r0, r1 } +// ListPMMRuleIDs provides a mock function with given fields: ctx +func (_m *mockGrafanaClient) ListPMMRuleIDs(ctx context.Context) (map[string]struct{}, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListPMMRuleIDs") + } + + var r0 map[string]struct{} + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (map[string]struct{}, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) map[string]struct{}); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]struct{}) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // newMockGrafanaClient creates a new instance of mockGrafanaClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func newMockGrafanaClient(t interface { diff --git a/managed/services/alerting/reconciler.go b/managed/services/alerting/reconciler.go new file mode 100644 index 00000000000..7e7ffd20b16 --- /dev/null +++ b/managed/services/alerting/reconciler.go @@ -0,0 +1,110 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "context" + "time" + + "gopkg.in/reform.v1" + + "github.com/percona/pmm/managed/models" +) + +const ( + // How often orphaned registry rows are reaped. Orphans are inert rather than + // harmful - the collector emits nothing for a rule that is gone - so this trades + // promptness for staying out of the way. + reconcileInterval = 15 * time.Minute + + // Keeps a freshly created row safe from the sweep. CreateRule writes the registry + // row before the rule exists in Grafana, so without this a sweep landing in that + // window would delete the row of a rule being created successfully. + reconcileGracePeriod = 10 * time.Minute +) + +// RunReconciler reaps registry rows whose Grafana rule no longer exists, until the +// context is cancelled. +// +// It must run leader-only: every replica shares one database, so several sweeps would +// duplicate the same deletions and race each other. +func (s *Service) RunReconciler(ctx context.Context) { + ticker := time.NewTicker(reconcileInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := s.ReconcileAlertRules(ctx) + if err != nil { + s.l.WithError(err).Warn("Failed to reconcile alert rule registry") + } + } + } +} + +// ReconcileAlertRules deletes registry rows for rules that are no longer in Grafana, +// taking their threshold overrides with them through the foreign key. +// +// Rules are matched by the identity label PMM stamps on them rather than by Grafana UID, +// so a rule that was copied or renamed still counts as present. +func (s *Service) ReconcileAlertRules(ctx context.Context) error { + live, err := s.grafanaClient.ListPMMRuleIDs(ctx) + if err != nil { + return err + } + + cutoff := models.Now().Add(-reconcileGracePeriod) + + var reaped []string + + errTx := s.db.InTransaction(func(tx *reform.TX) error { + rules, err := models.FindAlertRules(tx.Querier) + if err != nil { + return err + } + + for _, rule := range rules { + _, exists := live[rule.RuleID] + if exists || rule.CreatedAt.After(cutoff) { + continue + } + + err = models.DeleteAlertRule(tx.Querier, rule.RuleID) + if err != nil { + return err + } + + reaped = append(reaped, rule.RuleID) + } + + return nil + }) + if errTx != nil { + return errTx + } + + if len(reaped) != 0 { + // Worth a log line: this deletes override configuration a user set by hand, so + // it should be explainable after the fact. + s.l.WithField("rule_ids", reaped). + Infof("Reaped %d alert rule registry rows whose rules no longer exist", len(reaped)) + } + + return nil +} diff --git a/managed/services/alerting/reconciler_test.go b/managed/services/alerting/reconciler_test.go new file mode 100644 index 00000000000..e667c62a566 --- /dev/null +++ b/managed/services/alerting/reconciler_test.go @@ -0,0 +1,145 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/dialects/postgresql" + + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/utils/testdb" +) + +func setupReconciler(t *testing.T) (*Service, *mockGrafanaClient, *reform.DB) { + t.Helper() + + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + m := newMockGrafanaClient(t) + svc, err := NewService(db, m) + require.NoError(t, err) + + return svc, m, db +} + +// createRegistryRow inserts a rule row and ages it past the grace period, so the +// reconciler is willing to consider it. +func createRegistryRow(t *testing.T, db *reform.DB, ruleID string, age time.Duration) { + t.Helper() + + _, err := models.CreateAlertRule(db.Querier, &models.CreateAlertRuleParams{ + RuleID: ruleID, + Params: models.AlertRuleParams{ + "threshold": {Default: 80, JoinLabel: "node_name", Scopes: []string{string(models.ThresholdScopeNode)}}, + }, + }) + require.NoError(t, err) + + _, err = db.Exec(`UPDATE alert_rules SET created_at = $1 WHERE rule_id = $2`, + models.Now().Add(-age), ruleID) + require.NoError(t, err) +} + +func TestReconcileAlertRules(t *testing.T) { + ctx := t.Context() + + t.Run("reaps a row whose rule is gone from Grafana", func(t *testing.T) { + svc, m, db := setupReconciler(t) + createRegistryRow(t, db, "gone-rule", time.Hour) + + m.On("ListPMMRuleIDs", mock.Anything).Return(map[string]struct{}{}, nil) + + require.NoError(t, svc.ReconcileAlertRules(ctx)) + + rules, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + assert.Empty(t, rules) + }) + + t.Run("keeps a row whose rule still exists", func(t *testing.T) { + svc, m, db := setupReconciler(t) + createRegistryRow(t, db, "live-rule", time.Hour) + + m.On("ListPMMRuleIDs", mock.Anything). + Return(map[string]struct{}{"live-rule": {}}, nil) + + require.NoError(t, svc.ReconcileAlertRules(ctx)) + + rules, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + require.Len(t, rules, 1) + assert.Equal(t, "live-rule", rules[0].RuleID) + }) + + // CreateRule writes the registry row before the rule exists in Grafana. Without the + // grace period a sweep landing in that window would delete the row of a rule that is + // being created perfectly successfully. + t.Run("spares a row still inside the creation grace period", func(t *testing.T) { + svc, m, db := setupReconciler(t) + createRegistryRow(t, db, "just-created", time.Minute) + + m.On("ListPMMRuleIDs", mock.Anything).Return(map[string]struct{}{}, nil) + + require.NoError(t, svc.ReconcileAlertRules(ctx)) + + rules, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + require.Len(t, rules, 1, "a row younger than the grace period must survive") + }) + + t.Run("reaping takes the rule's overrides with it", func(t *testing.T) { + svc, m, db := setupReconciler(t) + createRegistryRow(t, db, "gone-rule", time.Hour) + + _, err := models.UpsertThresholdOverride(db.Querier, "gone-rule", "threshold", + models.ThresholdScopeNode, "node-id-1", 90) + require.NoError(t, err) + + m.On("ListPMMRuleIDs", mock.Anything).Return(map[string]struct{}{}, nil) + + require.NoError(t, svc.ReconcileAlertRules(ctx)) + + overrides, err := models.FindAllThresholdOverrides(db.Querier) + require.NoError(t, err) + assert.Empty(t, overrides, "the foreign key cascade should have removed them") + }) + + // A failed lookup must not be read as "Grafana has no rules", which would reap the + // whole registry and destroy override configuration a user set by hand. + t.Run("a Grafana failure deletes nothing", func(t *testing.T) { + svc, m, db := setupReconciler(t) + createRegistryRow(t, db, "some-rule", time.Hour) + + m.On("ListPMMRuleIDs", mock.Anything). + Return(map[string]struct{}(nil), errors.New("grafana unreachable")) + + err := svc.ReconcileAlertRules(ctx) + require.Error(t, err) + + rules, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + require.Len(t, rules, 1, "an unreachable Grafana must never look like an empty one") + }) +} diff --git a/managed/services/alerting/rule_builder.go b/managed/services/alerting/rule_builder.go index dec0db32a21..f58997817bd 100644 --- a/managed/services/alerting/rule_builder.go +++ b/managed/services/alerting/rule_builder.go @@ -18,6 +18,7 @@ package alerting import ( "encoding/json" "fmt" + "regexp" "strings" alertingv1 "github.com/percona/pmm/api/alerting/v1" @@ -34,8 +35,26 @@ const ( expressionTypeMath = "math" queryIntervalMs = 1000 maxDataPoints = 43200 + + // Prefixes the ref ID of each injected threshold query. + thresholdRefIDPrefix = "T_" + + // Ref IDs used when a single-expression template is desugared. A multi-expression + // template names its own steps; a desugared one has none to inherit. + desugaredQueryRefID = "A" + desugaredConditionRefID = "C" + + // The label the injected threshold query joins the observed query on. It follows + // from the scope: an override targets a node by node_name, and a service - whether + // named directly or reached through its cluster - by service_name. + nodeJoinLabel = "node_name" + serviceJoinLabel = "service_name" ) +// thresholdRefIDSanitizer strips anything a Grafana ref ID cannot carry, so a parameter +// name with punctuation still yields a usable ref ID. +var thresholdRefIDSanitizer = regexp.MustCompile(`[^A-Za-z0-9_]`) + type promQueryModel struct { Expr string `json:"expr"` RefID string `json:"refId"` @@ -58,11 +77,17 @@ type mathExpressionModel struct { func buildGrafanaRuleData( template *alert.Template, metricsDatasourceUID string, + ruleID string, params map[string]string, filters []*alertingv1.Filter, ) ([]services.Data, string, error) { if template.UsesMultipleExpressions() { - return buildMultiExpressionRuleData(template, metricsDatasourceUID, params, filters) + return buildMultiExpressionRuleData(template, metricsDatasourceUID, ruleID, params, filters) + } + + overridable := template.OverridableParams() + if ruleID != "" && len(overridable) != 0 { + return buildDesugaredRuleData(template, metricsDatasourceUID, ruleID, overridable[0], params, filters) } expr, err := fillAndFilterExpr(template.Expr, params, filters) @@ -78,13 +103,87 @@ func buildGrafanaRuleData( return []services.Data{data}, "A", nil } +// buildDesugaredRuleData turns a single-expression template into the same three steps a +// multi-expression template produces, so its threshold can be overridden per target: +// the observed query, an injected threshold, and a math comparison between them. +func buildDesugaredRuleData( + template *alert.Template, + metricsDatasourceUID string, + ruleID string, + param alert.Parameter, + params map[string]string, + filters []*alertingv1.Filter, +) ([]services.Data, string, error) { + split, err := alert.SplitSingleExpr(template.Expr, param.Name) + if err != nil { + return nil, "", fmt.Errorf("failed to split expression for parameter %q: %w", param.Name, err) + } + + joinLabel, err := joinLabelForScopes(param.GetOverrideScopes()) + if err != nil { + return nil, "", fmt.Errorf("parameter %q: %w", param.Name, err) + } + + defaultValue, ok := params[param.Name] + if !ok { + return nil, "", fmt.Errorf("no value supplied for overridable parameter %q", param.Name) + } + + // The observed query carries the alert's filters; the threshold deliberately does not, + // since a filtered threshold would leave the targets the filter excludes with none. + observed, err := fillAndFilterExpr(split.LHS, params, filters) + if err != nil { + return nil, "", err + } + + fanOut, err := fillExprWithParams(split.LHS, params) + if err != nil { + return nil, "", err + } + + // A and C are fixed here, unlike the multi-expression path where the template chooses + // its own ref IDs, so only those two can be collided with. + taken := map[string]struct{}{ + desugaredQueryRefID: {}, + desugaredConditionRefID: {}, + } + thresholdRefID := allocateThresholdRefID(param.Name, taken) + + query, err := newPromQueryData(metricsDatasourceUID, desugaredQueryRefID, observed) + if err != nil { + return nil, "", err + } + + threshold, err := newPromQueryData(metricsDatasourceUID, thresholdRefID, + thresholdQueryExpr(ruleID, param.Name, joinLabel, fanOut, defaultValue)) + if err != nil { + return nil, "", err + } + + // The template's `bool` modifier, if any, is dropped: Grafana math comparisons already + // yield 0/1, so carrying it across would be redundant. + condition, err := newMathExpressionData(desugaredConditionRefID, + fmt.Sprintf("$%s %s $%s", desugaredQueryRefID, split.Operator, thresholdRefID)) + if err != nil { + return nil, "", err + } + + return []services.Data{query, threshold, condition}, desugaredConditionRefID, nil +} + func buildMultiExpressionRuleData( template *alert.Template, metricsDatasourceUID string, + ruleID string, params map[string]string, filters []*alertingv1.Filter, ) ([]services.Data, string, error) { - data := make([]services.Data, 0, len(template.Queries)+len(template.Expressions)) + injections, err := planThresholdInjections(template, ruleID, params) + if err != nil { + return nil, "", err + } + + data := make([]services.Data, 0, len(template.Queries)+len(template.Expressions)+len(injections)) for _, query := range template.Queries { expr, err := fillAndFilterExpr(query.Expr, params, filters) @@ -100,8 +199,21 @@ func buildMultiExpressionRuleData( data = append(data, item) } + for _, injection := range injections { + item, err := newPromQueryData(metricsDatasourceUID, injection.refID, injection.expr) + if err != nil { + return nil, "", err + } + + data = append(data, item) + } + for _, expression := range template.Expressions { - expr, err := fillExprWithParams(expression.Expression, params) + // Swap the parameter tokens for their threshold ref IDs before filling, so the + // default is never baked into the rule. + body := swapOverridableTokens(expression.Expression, injections) + + expr, err := fillExprWithParams(body, params) if err != nil { return nil, "", fmt.Errorf("failed to fill expression %s: %w", expression.RefID, err) } @@ -207,3 +319,231 @@ func parseAlertTemplate(yamlContent string) (*alert.Template, error) { return &templates[0], nil } + +// thresholdInjection is one generated threshold query step: the ref ID the expression +// will reference, and the PromQL that resolves the effective threshold per target. +type thresholdInjection struct { + paramName string + refID string + expr string +} + +// planThresholdInjections builds one threshold query per overridable parameter. It +// returns nothing when the rule has no PMM-minted ID, which is how rules created before +// this feature - and rules with no overridable parameters - keep their previous shape. +func planThresholdInjections(template *alert.Template, ruleID string, params map[string]string) ([]thresholdInjection, error) { + overridable := template.OverridableParams() + if ruleID == "" || len(overridable) == 0 { + return nil, nil + } + + taken := make(map[string]struct{}, len(template.Queries)+len(template.Expressions)) + for _, query := range template.Queries { + taken[query.RefID] = struct{}{} + } + for _, expression := range template.Expressions { + taken[expression.RefID] = struct{}{} + } + + injections := make([]thresholdInjection, 0, len(overridable)) + for _, param := range overridable { + joinLabel, err := joinLabelForScopes(param.GetOverrideScopes()) + if err != nil { + return nil, fmt.Errorf("parameter %q: %w", param.Name, err) + } + + observed, err := observedQueryForParam(template, param.Name) + if err != nil { + return nil, err + } + + // The fan-out reuses the observed query with its parameters filled but its + // filters left off: a filtered threshold would leave the targets the filter + // excludes with no threshold at all. + observedExpr, err := fillExprWithParams(observed.Expr, params) + if err != nil { + return nil, fmt.Errorf("failed to fill query %s for parameter %q: %w", observed.RefID, param.Name, err) + } + + defaultValue, ok := params[param.Name] + if !ok { + return nil, fmt.Errorf("no value supplied for overridable parameter %q", param.Name) + } + + refID := allocateThresholdRefID(param.Name, taken) + injections = append(injections, thresholdInjection{ + paramName: param.Name, + refID: refID, + expr: thresholdQueryExpr(ruleID, param.Name, joinLabel, observedExpr, defaultValue), + }) + } + + return injections, nil +} + +// thresholdQueryExpr renders the injected threshold step. +// +// The first clause carries the overrides, with label_replace mapping the collector's +// generic target label onto whichever label this rule joins on - without it the two +// operands of the `or` have different label sets and `or` returns both instead of +// preferring the left. +// +// The second clause manufactures the default for every target the observed query +// reports, by reusing that query and discarding its value with `* 0`. Fanning out over +// the observed query rather than over an inventory metric is what makes the threshold +// share the observed data's fate: it cannot go missing while the data it guards is still +// arriving, so a rule cannot silently stop evaluating. +// +// `max by` is load-bearing in both clauses. It strips instance and job - the threshold is +// scraped from pmm-managed, which can never match an observed series - reduces both +// operands to identical label sets so `or` prefers the left, and collapses the duplicate +// series an HA cluster emits. +func thresholdQueryExpr(ruleID, paramName, joinLabel, observedExpr, defaultValue string) string { + return fmt.Sprintf( + `max by (%s) (label_replace(%s{%s=%q, %s=%q}, %q, "$1", %q, "(.*)")) or (max by (%s) (%s) * 0 + %s)`, + joinLabel, + thresholdMetricName, thresholdRuleIDLabel, ruleID, thresholdParamLabel, paramName, + joinLabel, thresholdTargetLabel, + joinLabel, observedExpr, defaultValue, + ) +} + +// joinLabelForScopes derives the join label from the scopes a parameter may be overridden +// at. Node overrides resolve to a node_name while service and cluster overrides both +// resolve to a service_name, so a parameter cannot mix node with the other two: a rule +// joins on one label, and overrides landing in the other namespace would never match. +func joinLabelForScopes(scopes []string) (string, error) { + var node, service bool + for _, scope := range scopes { + switch scope { + case alert.OverrideScopeNode: + node = true + case alert.OverrideScopeService, alert.OverrideScopeCluster: + service = true + default: + return "", fmt.Errorf("unknown override scope %q", scope) + } + } + + if node && service { + return "", fmt.Errorf("override scopes %v mix node with service or cluster, which join on different labels", scopes) + } + + if node { + return nodeJoinLabel, nil + } + + return serviceJoinLabel, nil +} + +// observedQueryForParam returns the query a parameter is compared against, which is the +// one the default clause fans out over. It is the nearest query reference to the left of +// the parameter's token, so a template comparing several queries in one expression - +// `$A > [[ .a ]] && $B > [[ .b ]]` - pairs each parameter with its own query. +func observedQueryForParam(template *alert.Template, paramName string) (alert.TemplateQuery, error) { + token := alert.ParamTokenRegexp(paramName) + + for _, expression := range template.Expressions { + loc := token.FindStringIndex(expression.Expression) + if loc == nil { + continue + } + + preceding := expression.Expression[:loc[0]] + + var ( + found alert.TemplateQuery + at = -1 + ) + + for _, query := range template.Queries { + ref := regexp.MustCompile(`\$` + regexp.QuoteMeta(query.RefID) + `\b`) + + matches := ref.FindAllStringIndex(preceding, -1) + if len(matches) == 0 { + continue + } + + last := matches[len(matches)-1][0] + if last > at { + at, found = last, query + } + } + + if at < 0 { + return alert.TemplateQuery{}, fmt.Errorf( + "overridable parameter %q is not compared against any query in expression %s", paramName, expression.RefID, + ) + } + + return found, nil + } + + return alert.TemplateQuery{}, fmt.Errorf("overridable parameter %q is not referenced by any expression", paramName) +} + +// allocateThresholdRefID derives a ref ID for a parameter's threshold query, suffixing it +// if the template already uses that ref ID. +func allocateThresholdRefID(paramName string, taken map[string]struct{}) string { + base := thresholdRefIDPrefix + thresholdRefIDSanitizer.ReplaceAllString(paramName, "_") + + refID := base + for i := 1; ; i++ { + _, clash := taken[refID] + if !clash { + break + } + + refID = fmt.Sprintf("%s_%d", base, i) + } + + taken[refID] = struct{}{} + + return refID +} + +// swapOverridableTokens rewrites each overridable parameter's token to its threshold ref +// ID. Replacement is literal so that a `$` in the ref ID is never treated as an expansion. +func swapOverridableTokens(expression string, injections []thresholdInjection) string { + for _, injection := range injections { + expression = alert.ParamTokenRegexp(injection.paramName). + ReplaceAllLiteralString(expression, "$"+injection.refID) + } + + return expression +} + +// desugaredValueRegexp matches Grafana's `$value` variable, but not `$values`, whose name +// starts with it. A plain string replacement would turn `$values.A` into nonsense. +var desugaredValueRegexp = regexp.MustCompile(`\$value\b`) + +// desugaredBareValueRegexp matches a whole action that is nothing but `$value`, which is the +// case worth formatting rather than only renaming. +var desugaredBareValueRegexp = regexp.MustCompile(`\{\{\s*\$value\s*\}\}`) + +// isDesugaredRule reports whether this rule is built by splitting a single expression apart. +func isDesugaredRule(template *alert.Template, ruleID string) bool { + return ruleID != "" && !template.UsesMultipleExpressions() && len(template.OverridableParams()) != 0 +} + +// rewriteDesugaredAnnotations repoints Grafana's `$value` at the observed query. +// +// `$value` is only a single scalar when a rule has one step. A desugared rule has three, so +// the variable stops resolving and the alert text ships broken - which is why this runs for +// every desugared rule rather than only where it looks necessary. +// +// A bare `{{ $value }}` also gains formatting, since an unformatted float renders every +// digit it has. An action that pipes the value, such as `{{ $value | humanizeDuration }}`, +// keeps its pipeline and only has the variable renamed. +func rewriteDesugaredAnnotations(annotations map[string]string) { + for key, text := range annotations { + // Literal replacement throughout: `$values` would otherwise be read as a capture + // group reference and silently dropped. + rewritten := desugaredBareValueRegexp.ReplaceAllLiteralString(text, + `{{ printf "%.2f" $values.`+desugaredQueryRefID+`.Value }}`) + rewritten = desugaredValueRegexp.ReplaceAllLiteralString(rewritten, + `$values.`+desugaredQueryRefID+`.Value`) + + annotations[key] = rewritten + } +} diff --git a/managed/services/alerting/rule_builder_dynamic_test.go b/managed/services/alerting/rule_builder_dynamic_test.go new file mode 100644 index 00000000000..0967873269d --- /dev/null +++ b/managed/services/alerting/rule_builder_dynamic_test.go @@ -0,0 +1,535 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + alertingv1 "github.com/percona/pmm/api/alerting/v1" + "github.com/percona/pmm/managed/pi/alert" + "github.com/percona/pmm/managed/services" +) + +const testObservedExpr = `avg by(node_name) (rate(node_cpu_seconds_total[5m]))` + +func overridableRuleTemplate() *alert.Template { + return &alert.Template{ + Name: "test_template", + Version: 1, + Summary: "summary", + Queries: []alert.TemplateQuery{{ + RefID: "A", + Expr: testObservedExpr, + }}, + Expressions: []alert.TemplateExpression{{ + RefID: "C", + Type: "math", + Expression: "$A > [[ .threshold ]]", + }}, + Condition: "C", + Params: []alert.Parameter{{ + Name: "threshold", + Summary: "threshold", + Type: alert.Float, + Value: 80, + Overridable: true, + }}, + } +} + +// dataByRefID indexes generated steps so assertions can address one by name. +func dataByRefID(t *testing.T, data []services.Data) map[string]services.Data { + t.Helper() + + byRef := make(map[string]services.Data, len(data)) + for _, item := range data { + byRef[item.RefID] = item + } + + return byRef +} + +// exprOf pulls the PromQL back out of a generated prom query step. +func exprOf(t *testing.T, item services.Data) string { + t.Helper() + + var model promQueryModel + require.NoError(t, json.Unmarshal(item.Model, &model)) + + return model.Expr +} + +// expressionOf pulls the body back out of a generated math expression step. +func expressionOf(t *testing.T, item services.Data) string { + t.Helper() + + var model mathExpressionModel + require.NoError(t, json.Unmarshal(item.Model, &model)) + + return model.Expression +} + +func TestBuildRuleDataInjectsThresholdQuery(t *testing.T) { + t.Parallel() + + data, condition, err := buildGrafanaRuleData( + overridableRuleTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + assert.Equal(t, "C", condition) + + byRef := dataByRefID(t, data) + require.Len(t, data, 3, "observed query, injected threshold, math expression") + require.Contains(t, byRef, "T_threshold") + + expr := exprOf(t, byRef["T_threshold"]) + + // The override clause, mapping the collector's generic target label onto this + // rule's join label. + assert.Contains(t, expr, `pmm_alert_threshold_override{rule_id="rule-1", param="threshold"}`) + assert.Contains(t, expr, `label_replace(`) + assert.Contains(t, expr, `"node_name", "$1", "target", "(.*)"`) + + // The default clause, fanned out over the rule's own observed query. + assert.Contains(t, expr, `or (max by (node_name) (`+testObservedExpr+`) * 0 + 80)`) + + // The threshold query is a metrics query, not an expression. + assert.Equal(t, "metrics-uid", byRef["T_threshold"].DatasourceUID) +} + +func TestBuildRuleDataSwapsTokenForThresholdRef(t *testing.T) { + t.Parallel() + + data, _, err := buildGrafanaRuleData( + overridableRuleTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + + body := expressionOf(t, dataByRefID(t, data)["C"]) + + assert.Equal(t, "$A > $T_threshold", body) + assert.NotContains(t, body, "80", "the default must never be baked into the expression") +} + +// TestBuildRuleDataWithoutRuleIDIsUnchanged pins the compatibility path: a rule with no +// PMM-minted ID generates exactly what it did before this feature existed. +func TestBuildRuleDataWithoutRuleIDIsUnchanged(t *testing.T) { + t.Parallel() + + data, _, err := buildGrafanaRuleData( + overridableRuleTemplate(), "metrics-uid", "", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + + require.Len(t, data, 2) + assert.NotContains(t, dataByRefID(t, data), "T_threshold") + assert.Equal(t, "$A > 80", expressionOf(t, dataByRefID(t, data)["C"])) +} + +func TestBuildRuleDataWithoutOverridableParams(t *testing.T) { + t.Parallel() + + template := overridableRuleTemplate() + template.Params[0].Overridable = false + + data, _, err := buildGrafanaRuleData( + template, "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + + require.Len(t, data, 2) + assert.Equal(t, "$A > 80", expressionOf(t, dataByRefID(t, data)["C"])) +} + +// TestThresholdQueryMatchesCollectorDescriptor is the contract test between the generated +// PromQL and the emitted series. The proof-of-concept this replaces shipped a builder +// querying pmm_alert_threshold while the collector emitted pmm_alert_threshold_override, +// so every rule matched nothing and silently never fired. Nothing about that failure is +// visible at runtime, which is why it is asserted here. +func TestThresholdQueryMatchesCollectorDescriptor(t *testing.T) { + t.Parallel() + + desc := NewAlertThresholdMetricsCollector(nil).desc.String() + + fqName := regexp.MustCompile(`fqName: "([^"]+)"`).FindStringSubmatch(desc) + require.Len(t, fqName, 2, "could not read fqName from %s", desc) + + labels := regexp.MustCompile(`variableLabels: \{([^}]*)\}`).FindStringSubmatch(desc) + require.Len(t, labels, 2, "could not read variableLabels from %s", desc) + + data, _, err := buildGrafanaRuleData( + overridableRuleTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + + expr := exprOf(t, dataByRefID(t, data)["T_threshold"]) + + assert.Contains(t, expr, fqName[1]+"{", "the query must select the metric the collector registers") + + for label := range strings.SplitSeq(labels[1], ",") { + label = strings.TrimSpace(label) + require.NotEmpty(t, label) + assert.Contains(t, expr, label, "the query must reference every label the collector emits") + } +} + +func TestThresholdRefIDAvoidsTemplateCollision(t *testing.T) { + t.Parallel() + + template := overridableRuleTemplate() + // The template already uses the ref ID the parameter would otherwise claim. + template.Queries = append(template.Queries, alert.TemplateQuery{ + RefID: "T_threshold", + Expr: "up", + }) + + data, _, err := buildGrafanaRuleData( + template, "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, nil, + ) + require.NoError(t, err) + + byRef := dataByRefID(t, data) + assert.Contains(t, byRef, "T_threshold_1") + assert.Equal(t, "$A > $T_threshold_1", expressionOf(t, byRef["C"])) +} + +// TestThresholdPairsEachParamWithItsOwnQuery covers a template comparing two queries in +// one expression: each parameter's default must fan out over the query it is actually +// compared against, not over whichever query happens to come first. +func TestThresholdPairsEachParamWithItsOwnQuery(t *testing.T) { + t.Parallel() + + template := &alert.Template{ + Name: "dual", + Version: 1, + Summary: "summary", + Queries: []alert.TemplateQuery{ + {RefID: "A", Expr: "query_a"}, + {RefID: "B", Expr: "query_b"}, + }, + Expressions: []alert.TemplateExpression{{ + RefID: "C", + Type: "math", + Expression: "$A > [[ .first ]] && $B > [[ .second ]]", + }}, + Condition: "C", + Params: []alert.Parameter{ + {Name: "first", Summary: "first", Type: alert.Float, Value: 1, Overridable: true}, + {Name: "second", Summary: "second", Type: alert.Float, Value: 2, Overridable: true}, + }, + } + + data, _, err := buildGrafanaRuleData( + template, "metrics-uid", "rule-1", + map[string]string{"first": "1", "second": "2"}, nil, + ) + require.NoError(t, err) + + byRef := dataByRefID(t, data) + + assert.Contains(t, exprOf(t, byRef["T_first"]), `(query_a) * 0 + 1)`) + assert.Contains(t, exprOf(t, byRef["T_second"]), `(query_b) * 0 + 2)`) + assert.Equal(t, "$A > $T_first && $B > $T_second", expressionOf(t, byRef["C"])) +} + +// TestThresholdQueryIsNotFiltered pins that alert filters narrow the observed query but +// not the threshold. A filtered threshold would leave the targets the filter excludes +// with no threshold at all. +func TestThresholdQueryIsNotFiltered(t *testing.T) { + t.Parallel() + + data, _, err := buildGrafanaRuleData( + overridableRuleTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "80"}, + []*alertingv1.Filter{{ + Type: alertingv1.FilterType_FILTER_TYPE_MATCH, + Label: "node_name", + Regexp: "prod-.*", + }}, + ) + require.NoError(t, err) + + byRef := dataByRefID(t, data) + + assert.Contains(t, exprOf(t, byRef["A"]), "label_match(", "the observed query is filtered") + assert.NotContains(t, exprOf(t, byRef["T_threshold"]), "label_match(", "the threshold query is not") +} + +func TestJoinLabelForScopes(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + scopes []string + want string + wantErr string + }{ + {name: "node", scopes: []string{alert.OverrideScopeNode}, want: nodeJoinLabel}, + {name: "service", scopes: []string{alert.OverrideScopeService}, want: serviceJoinLabel}, + {name: "cluster", scopes: []string{alert.OverrideScopeCluster}, want: serviceJoinLabel}, + { + name: "service and cluster share a join label", + scopes: []string{alert.OverrideScopeService, alert.OverrideScopeCluster}, + want: serviceJoinLabel, + }, + { + name: "node cannot be mixed with cluster", + scopes: []string{alert.OverrideScopeNode, alert.OverrideScopeCluster}, + wantErr: "join on different labels", + }, + { + name: "unknown scope", + scopes: []string{"rack"}, + wantErr: "unknown override scope", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := joinLabelForScopes(tc.scopes) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestObservedQueryForParamErrors(t *testing.T) { + t.Parallel() + + t.Run("parameter compared against no query", func(t *testing.T) { + t.Parallel() + + template := overridableRuleTemplate() + template.Expressions[0].Expression = "[[ .threshold ]] > 1" + + _, err := observedQueryForParam(template, "threshold") + require.Error(t, err) + assert.Contains(t, err.Error(), "not compared against any query") + }) + + t.Run("parameter referenced by no expression", func(t *testing.T) { + t.Parallel() + + _, err := observedQueryForParam(overridableRuleTemplate(), "missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "not referenced by any expression") + }) +} + +// desugarTemplate returns a single-expression template whose one param is overridable - +// the shape desugaring exists to handle. +func desugarTemplate() *alert.Template { + return &alert.Template{ + Name: "test_single_expr", + Version: 1, + Summary: "summary", + Expr: "node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes\n* 100\n< bool [[ .threshold ]]", + Params: []alert.Parameter{{ + Name: "threshold", + Summary: "threshold", + Type: alert.Float, + Value: 20, + Overridable: true, + }}, + } +} + +func TestBuildDesugaredRuleData(t *testing.T) { + t.Parallel() + + data, condition, err := buildGrafanaRuleData( + desugarTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "20"}, nil, + ) + require.NoError(t, err) + assert.Equal(t, "C", condition) + require.Len(t, data, 3, "observed query, injected threshold, math condition") + + byRef := dataByRefID(t, data) + require.Contains(t, byRef, "T_threshold") + + // The observed query is the left-hand side, with the author's line breaks intact and + // the parameter token gone. + observed := exprOf(t, byRef["A"]) + assert.Equal(t, "node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes\n* 100", observed) + assert.NotContains(t, observed, "[[") + + // The operator is carried across and `bool` is dropped, since Grafana math already + // yields 0/1. + body := expressionOf(t, byRef["C"]) + assert.Equal(t, "$A < $T_threshold", body) + assert.NotContains(t, body, "bool") + assert.NotContains(t, body, "20", "the default must never be baked into the expression") + + // The threshold fans out over the same observed query, exactly as it does for a + // multi-expression template. + threshold := exprOf(t, byRef["T_threshold"]) + assert.Contains(t, threshold, `pmm_alert_threshold_override{rule_id="rule-1", param="threshold"}`) + assert.Contains(t, threshold, "* 0 + 20)") +} + +// A single-expression template with no PMM-minted rule ID must generate exactly what it did +// before desugaring existed: one query, condition A, default baked in. +func TestBuildDesugaredRuleDataWithoutRuleIDIsUnchanged(t *testing.T) { + t.Parallel() + + data, condition, err := buildGrafanaRuleData( + desugarTemplate(), "metrics-uid", "", + map[string]string{"threshold": "20"}, nil, + ) + require.NoError(t, err) + assert.Equal(t, "A", condition) + require.Len(t, data, 1) + assert.Contains(t, exprOf(t, data[0]), "< bool 20") +} + +func TestBuildDesugaredRuleDataWithoutOverridableParam(t *testing.T) { + t.Parallel() + + template := desugarTemplate() + template.Params[0].Overridable = false + + data, condition, err := buildGrafanaRuleData( + template, "metrics-uid", "rule-1", + map[string]string{"threshold": "20"}, nil, + ) + require.NoError(t, err) + assert.Equal(t, "A", condition) + require.Len(t, data, 1) +} + +// Filters narrow the observed query but not the threshold: a filtered threshold would leave +// the targets the filter excludes with no threshold at all. +func TestBuildDesugaredRuleDataDoesNotFilterTheThreshold(t *testing.T) { + t.Parallel() + + data, _, err := buildGrafanaRuleData( + desugarTemplate(), "metrics-uid", "rule-1", + map[string]string{"threshold": "20"}, + []*alertingv1.Filter{{ + Type: alertingv1.FilterType_FILTER_TYPE_MATCH, + Label: "node_name", + Regexp: "prod-.*", + }}, + ) + require.NoError(t, err) + + byRef := dataByRefID(t, data) + assert.Contains(t, exprOf(t, byRef["A"]), "label_match(") + assert.NotContains(t, exprOf(t, byRef["T_threshold"]), "label_match(") +} + +// The template chooses no ref IDs of its own, so only the fixed A and C can be collided with. +func TestBuildDesugaredRuleDataAvoidsFixedRefIDCollision(t *testing.T) { + t.Parallel() + + template := desugarTemplate() + template.Expr = "up < bool [[ .A ]]" + template.Params[0].Name = "A" + + data, _, err := buildGrafanaRuleData( + template, "metrics-uid", "rule-1", + map[string]string{"A": "20"}, nil, + ) + require.NoError(t, err) + + byRef := dataByRefID(t, data) + require.Contains(t, byRef, "T_A") + assert.Equal(t, "$A < $T_A", expressionOf(t, byRef["C"])) +} + +func TestRewriteDesugaredAnnotations(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + in string + want string + }{ + { + // A bare value gains formatting: an unformatted float renders every digit. + name: "bare value is repointed and formatted", + in: "Memory is {{ $value }}% free.", + want: `Memory is {{ printf "%.2f" $values.A.Value }}% free.`, + }, + { + // mongodb_pbm_backup_stale pipes the value; the pipeline must survive. + name: "piped value keeps its pipeline", + in: "Backup is {{ $value | humanizeDuration }} old.", + want: "Backup is {{ $values.A.Value | humanizeDuration }} old.", + }, + { + // $values starts with $value, so a plain string replacement would corrupt it. + // mongodb_replication_lag already uses this form. + name: "an existing $values reference is left alone", + in: "Lag is {{ $values.A }}s.", + want: "Lag is {{ $values.A }}s.", + }, + { + name: "text with no value reference is untouched", + in: "{{ $labels.node_name }} is unhealthy.", + want: "{{ $labels.node_name }} is unhealthy.", + }, + { + name: "several references in one annotation", + in: "{{ $value }} and {{ $value | humanize }}", + want: `{{ printf "%.2f" $values.A.Value }} and {{ $values.A.Value | humanize }}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + annotations := map[string]string{"description": tc.in} + rewriteDesugaredAnnotations(annotations) + assert.Equal(t, tc.want, annotations["description"]) + }) + } +} + +func TestIsDesugaredRule(t *testing.T) { + t.Parallel() + + assert.True(t, isDesugaredRule(desugarTemplate(), "rule-1")) + + assert.False(t, isDesugaredRule(desugarTemplate(), ""), + "a rule with no PMM identity is built as it always was") + + plain := desugarTemplate() + plain.Params[0].Overridable = false + assert.False(t, isDesugaredRule(plain, "rule-1")) + + assert.False(t, isDesugaredRule(overridableRuleTemplate(), "rule-1"), + "a multi-expression template is not desugared") +} diff --git a/managed/services/alerting/rule_builder_test.go b/managed/services/alerting/rule_builder_test.go index f4d1ebe5751..f5ae779ccf6 100644 --- a/managed/services/alerting/rule_builder_test.go +++ b/managed/services/alerting/rule_builder_test.go @@ -31,7 +31,7 @@ func TestBuildGrafanaRuleDataSingleExpression(t *testing.T) { data, condition, err := buildGrafanaRuleData(&alert.Template{ Expr: "up == 1", - }, "metrics-uid", nil, nil) + }, "metrics-uid", "", nil, nil) require.NoError(t, err) assert.Equal(t, "A", condition) require.Len(t, data, 1) @@ -53,7 +53,7 @@ func TestBuildGrafanaRuleDataMultiExpression(t *testing.T) { Expression: "$A > $B", }}, Condition: "C", - }, "metrics-uid", map[string]string{}, nil) + }, "metrics-uid", "", map[string]string{}, nil) require.NoError(t, err) assert.Equal(t, "C", condition) require.Len(t, data, 3) @@ -85,7 +85,7 @@ func TestBuildGrafanaRuleDataMultiExpressionWithParamsAndFilters(t *testing.T) { Expression: "$A < $B", }}, Condition: "C", - }, "metrics-uid", map[string]string{ + }, "metrics-uid", "", map[string]string{ "window": "[5m]", "threshold": "80", }, []*alertingv1.Filter{{ @@ -120,7 +120,7 @@ func TestBuildGrafanaRuleDataModelContract(t *testing.T) { }, Expressions: []alert.TemplateExpression{{RefID: "C", Type: "math", Expression: "$A > $B"}}, Condition: "C", - }, "metrics-uid", map[string]string{}, nil) + }, "metrics-uid", "", map[string]string{}, nil) require.NoError(t, err) require.Len(t, data, 3) @@ -152,7 +152,7 @@ func TestBuildGrafanaRuleDataMismatchFilter(t *testing.T) { Queries: []alert.TemplateQuery{{RefID: "A", Expr: "up"}}, Expressions: []alert.TemplateExpression{{RefID: "C", Type: "math", Expression: "$A > 0"}}, Condition: "C", - }, "metrics-uid", map[string]string{}, []*alertingv1.Filter{{ + }, "metrics-uid", "", map[string]string{}, []*alertingv1.Filter{{ Type: alertingv1.FilterType_FILTER_TYPE_MISMATCH, Label: "node_name", Regexp: "staging.*", @@ -205,7 +205,7 @@ func TestBuildGrafanaRuleDataMultiExpressionErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - _, _, err := buildGrafanaRuleData(tc.tmpl, "metrics-uid", map[string]string{}, tc.filters) + _, _, err := buildGrafanaRuleData(tc.tmpl, "metrics-uid", "", map[string]string{}, tc.filters) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) }) diff --git a/managed/services/alerting/service.go b/managed/services/alerting/service.go index 0ff33df1b2b..b84e6c321a0 100644 --- a/managed/services/alerting/service.go +++ b/managed/services/alerting/service.go @@ -32,6 +32,7 @@ import ( "time" "github.com/AlekSi/pointer" + "github.com/google/uuid" "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -639,10 +640,11 @@ func convertParamDefinitions(l *logrus.Entry, params models.AlertExprParamsDefin res := make([]*alerting.ParamDefinition, 0, len(params)) for _, p := range params { pd := &alerting.ParamDefinition{ - Name: p.Name, - Summary: p.Summary, - Unit: convertParamUnit(p.Unit), - Type: convertParamType(p.Type), + Name: p.Name, + Summary: p.Summary, + Unit: convertParamUnit(p.Unit), + Type: convertParamType(p.Type), + Overridable: p.Overridable, } switch p.Type { @@ -729,7 +731,25 @@ func (s *Service) CreateRule(ctx context.Context, req *alerting.CreateRuleReques return nil, status.Errorf(codes.Internal, "Invalid template %s: %v.", req.TemplateName, err) } - ruleData, condition, err := buildGrafanaRuleData(alertTemplate, metricsDatasourceUID, paramsValues.AsStringMap(), req.Filters) + // A rule only carries threshold steps once it has a PMM-minted ID to key its + // overrides on. The ID is minted first because it is the idempotency key for every + // step that follows; a rule with no overridable parameters never gets one, and is + // generated exactly as it was before this feature. + var ( + ruleID string + ruleParams models.AlertRuleParams + ) + + if len(alertTemplate.OverridableParams()) != 0 { + ruleID = uuid.New().String() + + ruleParams, err = collectOverridableParams(alertTemplate, paramsValues) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "Invalid overridable parameters: %v.", err) + } + } + + ruleData, condition, err := buildGrafanaRuleData(alertTemplate, metricsDatasourceUID, ruleID, paramsValues.AsStringMap(), req.Filters) if err != nil { return nil, fmt.Errorf("failed to build alert rule data: %w", err) } @@ -746,6 +766,12 @@ func (s *Service) CreateRule(ctx context.Context, req *alerting.CreateRuleReques return nil, fmt.Errorf("failed to fill template annotations placeholders: %w", err) } + // A desugared rule has three steps, so Grafana's `$value` no longer resolves and the + // alert text would ship broken. + if isDesugaredRule(alertTemplate, ruleID) { + rewriteDesugaredAnnotations(annotations) + } + labels := make(map[string]string) // Copy labels form template err = transformMaps(req.CustomLabels, labels, paramsValues.AsStringMap()) @@ -768,6 +794,14 @@ func (s *Service) CreateRule(ctx context.Context, req *alerting.CreateRuleReques labels["percona_alerting"] = "1" // TODO: do we actually need it? labels["severity"] = common.Severity(req.Severity).String() labels["template_name"] = req.TemplateName + + // The rule's identity for threshold purposes travels on the rule itself, so a rule + // can still be matched back to its registry row after being copied or renamed in + // Grafana. The stored Grafana UID is only a cache of where it currently lives. + if ruleID != "" { + labels[services.PMMRuleIDLabel] = ruleID + } + labelSourceRefID := queryRefForRuleLabels(alertTemplate) ensureRuleLabel(labels, "node_name", buildRuleLabelTemplate("node_name", labelSourceRefID)) ensureRuleLabel(labels, "service_name", buildRuleLabelTemplate("service_name", labelSourceRefID)) @@ -791,12 +825,100 @@ func (s *Service) CreateRule(ctx context.Context, req *alerting.CreateRuleReques interval = req.Interval.AsDuration().String() } + // The registry row is written before the rule exists in Grafana. The other order + // would leave a rule whose thresholds cannot be overridden and whose row may never + // arrive; this order can only leave an orphaned row, which the reconciler reaps. + if ruleID != "" { + err = s.db.InTransaction(func(tx *reform.TX) error { + _, err := models.CreateAlertRule(tx.Querier, &models.CreateAlertRuleParams{ + RuleID: ruleID, + Params: ruleParams, + }) + + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to register alert rule: %w", err) + } + } + err = s.grafanaClient.CreateAlertRule(ctx, req.FolderUid, req.Group, interval, &rule) if err != nil { + s.deleteRuleRegistration(ruleID) + return nil, err } - return &alerting.CreateRuleResponse{}, nil + return &alerting.CreateRuleResponse{RuleId: ruleID}, nil +} + +// deleteRuleRegistration removes a registry row whose Grafana rule was never created. +// Failure is logged rather than returned: the caller is already reporting the original +// error, and a row left behind is reaped by the reconciler. +func (s *Service) deleteRuleRegistration(ruleID string) { + if ruleID == "" { + return + } + + err := s.db.InTransaction(func(tx *reform.TX) error { + return models.DeleteAlertRule(tx.Querier, ruleID) + }) + if err != nil { + s.l.WithError(err).WithField("rule_id", ruleID).Warn("Failed to roll back alert rule registration") + } +} + +// collectOverridableParams snapshots what an overridable parameter needs in order to be +// resolved later. The template it came from can be edited or deleted afterwards, so this +// is the only durable record of the default the rule actually evaluates against, and of +// the range an override is validated within. +func collectOverridableParams(template *alert.Template, values AlertExprParamsValues) (models.AlertRuleParams, error) { + overridable := template.OverridableParams() + if len(overridable) == 0 { + // A nil AlertRuleParams map is the valid "nothing to snapshot" result: the rule + // is registered without params rather than being an error. + return nil, nil //nolint:nilnil + } + + byName := make(map[string]AlertExprParamValue, len(values)) + for _, value := range values { + byName[value.Name] = value + } + + params := make(models.AlertRuleParams, len(overridable)) + + for _, param := range overridable { + joinLabel, err := joinLabelForScopes(param.GetOverrideScopes()) + if err != nil { + return nil, fmt.Errorf("parameter %q: %w", param.Name, err) + } + + supplied, ok := byName[param.Name] + if !ok { + return nil, fmt.Errorf("no value supplied for overridable parameter %q", param.Name) + } + + snapshot := models.AlertRuleParam{ + Default: supplied.FloatValue, + JoinLabel: joinLabel, + Scopes: param.GetOverrideScopes(), + Unit: string(param.Unit), + Summary: param.Summary, + } + + if len(param.Range) != 0 { + pMin, pMax, err := param.GetRangeForFloat() + if err != nil { + return nil, fmt.Errorf("parameter %q: failed to parse range: %w", param.Name, err) + } + + snapshot.Min, snapshot.Max = new(pMin), new(pMax) + } + + params[param.Name] = snapshot + } + + return params, nil } func convertParamsValuesToModel(params []*alerting.ParamValue) (AlertExprParamsValues, error) { diff --git a/managed/services/alerting/service_threshold_test.go b/managed/services/alerting/service_threshold_test.go new file mode 100644 index 00000000000..9aded8d4668 --- /dev/null +++ b/managed/services/alerting/service_threshold_test.go @@ -0,0 +1,341 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/dialects/postgresql" + + alerting "github.com/percona/pmm/api/alerting/v1" + managementv1 "github.com/percona/pmm/api/management/v1" + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/pi/alert" + "github.com/percona/pmm/managed/services" + "github.com/percona/pmm/managed/utils/testdb" +) + +const overridableWiringYAML = `templates: + - name: test_overridable_wiring + version: 1 + summary: Overridable threshold wiring + queries: + - ref_id: A + expr: |- + (1 - avg by(node_name) (rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100 + expressions: + - ref_id: C + type: math + expression: "$A > [[ .threshold ]]" + condition: C + params: + - name: threshold + summary: A percentage from configured maximum + unit: "%" + type: float + range: [0, 100] + value: 80 + overridable: true + for: 5m + severity: warning + annotations: + summary: Node high CPU load ({{ $labels.node_name }}) +` + +func templateFromYAML(t *testing.T, yaml string) *models.Template { + t.Helper() + + parsed, err := alert.Parse(strings.NewReader(yaml), &alert.ParseParams{ + DisallowUnknownFields: true, + DisallowInvalidTemplates: true, + }) + require.NoError(t, err) + require.Len(t, parsed, 1) + + tm, err := models.ConvertTemplate(&parsed[0], models.UserAPISource) + require.NoError(t, err) + + return tm +} + +// TestCreateRuleRegistersOverridableRule covers the registry lifecycle: a rule with an +// overridable parameter gets a PMM-minted ID, that ID reaches Grafana as a label and the +// database as a row, and the snapshot it stores is what later resolves the threshold. +func TestCreateRuleRegistersOverridableRule(t *testing.T) { + ctx := t.Context() + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + tm := templateFromYAML(t, overridableWiringYAML) + plain := templateFromYAML(t, multiExpressionWiringYAML) + + setup := func(t *testing.T) (*Service, *mockGrafanaClient) { + t.Helper() + + m := newMockGrafanaClient(t) + svc, err := NewService(db, m) + require.NoError(t, err) + svc.templates = map[string]models.Template{ + tm.Name: *tm, + plain.Name: *plain, + } + + return svc, m + } + + thresholdParam := []*alerting.ParamValue{{ + Name: "threshold", + Type: alerting.ParamType_PARAM_TYPE_FLOAT, + Value: &alerting.ParamValue_Float{Float: 80}, + }} + + createRule := func(t *testing.T, svc *Service, templateName string) error { + t.Helper() + + _, err := svc.CreateRule(ctx, &alerting.CreateRuleRequest{ + TemplateName: templateName, + Name: "test-rule", + FolderUid: "folder-uid", + Group: "test-group", + Severity: managementv1.Severity_SEVERITY_WARNING, + Params: thresholdParam, + }) + + return err + } + + t.Run("stamps the identity label and injects the threshold step", func(t *testing.T) { + svc, m := setup(t) + m.On("GetDatasourceUIDByName", mock.Anything, "Metrics").Return("metrics-uid", nil) + + var captured *services.Rule + m.On("CreateAlertRule", mock.Anything, "folder-uid", "test-group", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { captured = args.Get(4).(*services.Rule) }). + Return(nil) + + res, err := svc.CreateRule(ctx, &alerting.CreateRuleRequest{ + TemplateName: tm.Name, + Name: "test-rule", + FolderUid: "folder-uid", + Group: "test-group", + Severity: managementv1.Severity_SEVERITY_WARNING, + Params: thresholdParam, + }) + require.NoError(t, err) + require.NotNil(t, captured) + + ruleID := captured.Labels["pmm_rule_id"] + require.NotEmpty(t, ruleID, "an overridable rule must carry its PMM identity") + assert.Equal(t, ruleID, res.RuleId, "the response must return the same identity the rule carries") + + byRef := dataByRefID(t, captured.GrafanaAlert.Data) + require.Contains(t, byRef, "T_threshold") + assert.Contains(t, exprOf(t, byRef["T_threshold"]), `rule_id="`+ruleID+`"`, + "the injected query must select the same rule ID the label carries") + + // The registry row exists and holds the snapshot the resolver will need. + rule, err := models.FindAlertRuleByID(db.Querier, ruleID) + require.NoError(t, err) + + param, ok := rule.Params["threshold"] + require.True(t, ok) + assert.InDelta(t, 80.0, param.Default, 0.0001) + assert.Equal(t, "node_name", param.JoinLabel) + assert.Equal(t, []string{"node"}, param.Scopes) + assert.Equal(t, "%", param.Unit) + require.NotNil(t, param.Min) + require.NotNil(t, param.Max) + assert.InDelta(t, 0.0, *param.Min, 0.0001) + assert.InDelta(t, 100.0, *param.Max, 0.0001) + }) + + t.Run("the stored default is the value supplied, not the template's", func(t *testing.T) { + svc, m := setup(t) + m.On("GetDatasourceUIDByName", mock.Anything, "Metrics").Return("metrics-uid", nil) + + var captured *services.Rule + m.On("CreateAlertRule", mock.Anything, "folder-uid", "test-group", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { captured = args.Get(4).(*services.Rule) }). + Return(nil) + + _, err := svc.CreateRule(ctx, &alerting.CreateRuleRequest{ + TemplateName: tm.Name, + Name: "test-rule", + FolderUid: "folder-uid", + Group: "test-group", + Severity: managementv1.Severity_SEVERITY_WARNING, + Params: []*alerting.ParamValue{{ + Name: "threshold", + Type: alerting.ParamType_PARAM_TYPE_FLOAT, + Value: &alerting.ParamValue_Float{Float: 42}, + }}, + }) + require.NoError(t, err) + + rule, err := models.FindAlertRuleByID(db.Querier, captured.Labels["pmm_rule_id"]) + require.NoError(t, err) + assert.InDelta(t, 42.0, rule.Params["threshold"].Default, 0.0001) + }) + + t.Run("a rule with no overridable parameters is not registered", func(t *testing.T) { + svc, m := setup(t) + m.On("GetDatasourceUIDByName", mock.Anything, "Metrics").Return("metrics-uid", nil) + + var captured *services.Rule + m.On("CreateAlertRule", mock.Anything, "folder-uid", "test-group", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { captured = args.Get(4).(*services.Rule) }). + Return(nil) + + before, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + + require.NoError(t, createRule(t, svc, plain.Name)) + require.NotNil(t, captured) + + assert.Empty(t, captured.Labels["pmm_rule_id"]) + assert.NotContains(t, dataByRefID(t, captured.GrafanaAlert.Data), "T_threshold") + + after, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + assert.Len(t, after, len(before), "no registry row should have been written") + }) + + // TestCreateRule writes the registry row before creating the rule in Grafana, so a + // Grafana failure must not leave the row behind. The other ordering would be worse - + // a rule whose thresholds can never be overridden - but this one still has to clean up. + t.Run("a Grafana failure rolls the registry row back", func(t *testing.T) { + svc, m := setup(t) + m.On("GetDatasourceUIDByName", mock.Anything, "Metrics").Return("metrics-uid", nil) + m.On("CreateAlertRule", mock.Anything, "folder-uid", "test-group", mock.Anything, mock.Anything). + Return(errors.New("grafana rejected the rule")) + + before, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + + err = createRule(t, svc, tm.Name) + require.Error(t, err) + + after, err := models.FindAlertRules(db.Querier) + require.NoError(t, err) + assert.Len(t, after, len(before), "the registry row must not outlive the failed creation") + }) +} + +func TestCollectOverridableParams(t *testing.T) { + t.Parallel() + + template := overridableRuleTemplate() + + t.Run("snapshots the supplied value and derived join label", func(t *testing.T) { + t.Parallel() + + params, err := collectOverridableParams(template, AlertExprParamsValues{{ + Name: "threshold", + Type: models.Float, + FloatValue: 55, + }}) + require.NoError(t, err) + + require.Contains(t, params, "threshold") + assert.InDelta(t, 55.0, params["threshold"].Default, 0.0001) + assert.Equal(t, "node_name", params["threshold"].JoinLabel) + assert.Equal(t, []string{alert.OverrideScopeNode}, params["threshold"].Scopes) + }) + + t.Run("service and cluster scopes join on service_name", func(t *testing.T) { + t.Parallel() + + scoped := overridableRuleTemplate() + scoped.Params[0].OverrideScopes = []string{alert.OverrideScopeService, alert.OverrideScopeCluster} + + params, err := collectOverridableParams(scoped, AlertExprParamsValues{{ + Name: "threshold", + Type: models.Float, + FloatValue: 55, + }}) + require.NoError(t, err) + assert.Equal(t, "service_name", params["threshold"].JoinLabel) + }) + + t.Run("a missing value is rejected", func(t *testing.T) { + t.Parallel() + + _, err := collectOverridableParams(template, AlertExprParamsValues{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no value supplied") + }) + + t.Run("a template with no overridable parameters snapshots nothing", func(t *testing.T) { + t.Parallel() + + plain := overridableRuleTemplate() + plain.Params[0].Overridable = false + + params, err := collectOverridableParams(plain, AlertExprParamsValues{}) + require.NoError(t, err) + assert.Nil(t, params) + }) +} + +// TestBuiltInOverridableTemplates pins exactly which shipped templates expose an +// overridable threshold. Marking one is not a free change: the injected threshold query +// joins on a single label, so a template whose observed query does not carry that label +// would generate a rule that silently never matches. +func TestBuiltInOverridableTemplates(t *testing.T) { + t.Parallel() + + // Only node scope resolves so far, so a template qualifies when it is + // multi-expression and aggregates by node_name. + want := []string{"pmm_node_high_cpu_load"} + + files, err := filepath.Glob(filepath.Join("..", "..", "data", "alerting-templates", "*.yml")) + require.NoError(t, err) + require.NotEmpty(t, files) + + var got []string + + for _, file := range files { + b, err := os.ReadFile(file) + require.NoError(t, err) + + templates, err := alert.Parse(strings.NewReader(string(b)), &alert.ParseParams{ + DisallowUnknownFields: true, + DisallowInvalidTemplates: true, + }) + require.NoError(t, err, "built-in template %s must parse", filepath.Base(file)) + + for _, template := range templates { + if len(template.OverridableParams()) != 0 { + got = append(got, template.Name) + } + } + } + + sort.Strings(got) + assert.Equal(t, want, got, + "adding a template here needs a join label the threshold query can match on; "+ + "templates that aggregate by (cluster) and drop node_name must not be marked overridable") +} diff --git a/managed/services/alerting/threshold_metrics.go b/managed/services/alerting/threshold_metrics.go new file mode 100644 index 00000000000..29a41dfba7b --- /dev/null +++ b/managed/services/alerting/threshold_metrics.go @@ -0,0 +1,251 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "context" + "time" + + prom "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + "gopkg.in/reform.v1" + + "github.com/percona/pmm/managed/models" +) + +const ( + // Bounds one scrape. The threshold collector shares /debug/metrics, and its + // 0.9 * MR budget, with the inventory and HA collectors, so overrunning here + // would take those down too. + thresholdCollectTimeout = 3 * time.Second + + // How often the emission loop re-checks the deadline. The queries are bounded by + // the context, but the loop that follows them is not, so without this a large + // enough result set could run past the scrape budget with nothing stopping it. + thresholdCtxCheckInterval = 1000 + + // The gauge the injected threshold query reads. Shared with rule_builder.go on + // purpose: the metric name and its label set are a contract between the collector + // and the generated PromQL, and a rule pointing at a metric nobody emits fails + // silently - it simply never fires. + thresholdMetricName = "pmm_alert_threshold_override" + + thresholdRuleIDLabel = "rule_id" + thresholdParamLabel = "param" + // Generic rather than node_name/service_name because one fixed descriptor has to + // serve every scope; the rule query maps it onto whichever label it joins on + // with label_replace. + thresholdTargetLabel = "target" +) + +// AlertThresholdMetricsCollector exposes the effective threshold for every target that +// carries an override. +// +// Only overridden targets are emitted. Emitting a series for every target instead would +// scale with inventory rather than with what was actually tuned: measured at 1,000 nodes +// and 140 rule/parameter groups it consumed 77-82% of the 9s scrape budget, against 1-7% +// for this shape. Targets with no override get their threshold from the default clause +// of the rule query instead. +type AlertThresholdMetricsCollector struct { + db *reform.DB + l *logrus.Entry + + desc *prom.Desc +} + +// NewAlertThresholdMetricsCollector creates a new instance of AlertThresholdMetricsCollector. +func NewAlertThresholdMetricsCollector(db *reform.DB) *AlertThresholdMetricsCollector { + return &AlertThresholdMetricsCollector{ + db: db, + l: logrus.WithField("component", "alerting/threshold-metrics"), + desc: prom.NewDesc( + thresholdMetricName, + "Effective alert threshold for a rule parameter and target. Emitted only where an "+ + "override or a tombstone exists; targets without either fall back to the rule's "+ + "default, which the rule query materialises from its own observed expression.", + []string{thresholdRuleIDLabel, thresholdParamLabel, thresholdTargetLabel}, + nil, + ), + } +} + +// Describe sends the metric description to the provided channel. +// +// This deliberately does not use prom.DescribeByCollect, which would run a full Collect, +// and therefore a database query, merely to describe the collector. +func (c *AlertThresholdMetricsCollector) Describe(ch chan<- *prom.Desc) { + ch <- c.desc +} + +// thresholdGroup is the set of override rows sharing one rule and parameter, which is +// the granularity precedence is resolved at. +type thresholdGroup struct { + ruleID string + paramName string + overrides []*models.AlertRuleThresholdOverride +} + +// Collect sends the collected metrics to the provided channel. A failure is logged and +// yields no threshold metrics for that scrape rather than failing the whole response. +func (c *AlertThresholdMetricsCollector) Collect(ch chan<- prom.Metric) { + ctx, cancelCtx := context.WithTimeout(context.Background(), thresholdCollectTimeout) + defer cancelCtx() + + var ( + groups []thresholdGroup + rules map[string]*models.AlertRule + inv models.ThresholdInventory + ) + + errTx := c.db.InTransactionContext(ctx, nil, func(tx *reform.TX) error { + overrides, err := models.FindAllThresholdOverrides(tx.Querier) + if err != nil { + return err + } + + // The common case is no overrides at all, and it costs nothing. + if len(overrides) == 0 { + return nil + } + + groups = groupThresholdOverrides(overrides) + + allRules, err := models.FindAlertRules(tx.Querier) + if err != nil { + return err + } + + rules = make(map[string]*models.AlertRule, len(allRules)) + for _, rule := range allRules { + rules[rule.RuleID] = rule + } + + inv, err = loadThresholdInventory(tx.Querier, overrides) + + return err + }) + if errTx != nil { + c.l.Warnf("Failed to collect alert thresholds: %v", errTx) + + return + } + + emitted := 0 + for _, group := range groups { + rule, ok := rules[group.ruleID] + if !ok { + continue + } + + param, ok := rule.Params[group.paramName] + if !ok { + continue + } + + for target, value := range models.ResolveThresholds(group.overrides, param.Default, inv) { + if emitted%thresholdCtxCheckInterval == 0 && ctx.Err() != nil { + c.l.Warnf("Alert threshold collection timed out after %d series", emitted) + + return + } + emitted++ + + ch <- prom.MustNewConstMetric(c.desc, prom.GaugeValue, value, group.ruleID, group.paramName, target) + } + } +} + +// groupThresholdOverrides partitions rows by rule and parameter, which is the unit +// precedence applies within: an override on one parameter says nothing about another. +func groupThresholdOverrides(overrides []*models.AlertRuleThresholdOverride) []thresholdGroup { + type key struct { + ruleID string + paramName string + } + + index := make(map[key]int) + + var groups []thresholdGroup + + for _, override := range overrides { + k := key{ruleID: override.RuleID, paramName: override.ParamName} + + i, ok := index[k] + if !ok { + groups = append(groups, thresholdGroup{ruleID: k.ruleID, paramName: k.paramName}) + i = len(groups) - 1 + index[k] = i + } + + groups[i].overrides = append(groups[i].overrides, override) + } + + return groups +} + +// loadThresholdInventory resolves the IDs the overrides actually reference, rather than +// reading the whole inventory. That is what keeps this collector's cost a function of +// how many targets were tuned instead of how large the fleet is. +func loadThresholdInventory(q *reform.Querier, overrides []*models.AlertRuleThresholdOverride) (models.ThresholdInventory, error) { + var nodeIDs, serviceIDs []string + + for _, override := range overrides { + switch override.Scope { + case models.ThresholdScopeNode: + nodeIDs = append(nodeIDs, override.Target) + case models.ThresholdScopeService: + serviceIDs = append(serviceIDs, override.Target) + case models.ThresholdScopeCluster: + // Cluster scope needs services looked up by cluster label, which arrives + // with the service/cluster increment. Until then such a row cannot be + // created through the API, and one inserted directly resolves to nothing + // and is simply inert. + } + + // do not add `default:` to make exhaustive linter do its job + } + + inv := models.ThresholdInventory{} + + if len(nodeIDs) != 0 { + nodes, err := models.FindNodesByIDs(q, nodeIDs) + if err != nil { + return inv, err + } + + inv.NodeNames = make(map[string]string, len(nodes)) + for _, node := range nodes { + inv.NodeNames[node.NodeID] = node.NodeName + } + } + + if len(serviceIDs) != 0 { + services, err := models.FindServicesByIDs(q, serviceIDs) + if err != nil { + return inv, err + } + + inv.ServiceNames = make(map[string]string, len(services)) + for id, service := range services { + inv.ServiceNames[id] = service.ServiceName + } + } + + return inv, nil +} + +// check interfaces. +var _ prom.Collector = (*AlertThresholdMetricsCollector)(nil) diff --git a/managed/services/alerting/threshold_metrics_test.go b/managed/services/alerting/threshold_metrics_test.go new file mode 100644 index 00000000000..233db6056cf --- /dev/null +++ b/managed/services/alerting/threshold_metrics_test.go @@ -0,0 +1,222 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "strings" + "testing" + + prom "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/dialects/postgresql" + + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/utils/testdb" +) + +const testRuleID = "rule-fixed-for-tests" + +// thresholdExpositionHeader is the HELP/TYPE preamble CollectAndCompare requires. It is +// derived from the live descriptor rather than restated, so a help-text edit does not +// break these tests. +func thresholdExposition(t *testing.T, c *AlertThresholdMetricsCollector, samples ...string) *strings.Reader { + t.Helper() + + desc := c.desc.String() + start := strings.Index(desc, `help: "`) + require.GreaterOrEqual(t, start, 0) + help := desc[start+len(`help: "`):] + end := strings.Index(help, `"`) + require.GreaterOrEqual(t, end, 0) + help = help[:end] + + body := "\n# HELP " + thresholdMetricName + " " + help + + "\n# TYPE " + thresholdMetricName + " gauge\n" + + strings.Join(samples, "\n") + "\n" + + return strings.NewReader(body) +} + +// TestThresholdCollectorDescribeDoesNotQuery passes a nil database on purpose: if +// Describe ever reverts to prom.DescribeByCollect it would run a full Collect, and +// therefore a query, and this test would panic instead of passing. +func TestThresholdCollectorDescribeDoesNotQuery(t *testing.T) { + t.Parallel() + + c := NewAlertThresholdMetricsCollector(nil) + + ch := make(chan *prom.Desc, 1) + c.Describe(ch) + close(ch) + + require.Len(t, ch, 1) + assert.Contains(t, (<-ch).String(), thresholdMetricName) +} + +func TestGroupThresholdOverrides(t *testing.T) { + t.Parallel() + + overrides := []*models.AlertRuleThresholdOverride{ + {RuleID: "r1", ParamName: "a", Target: "t1"}, + {RuleID: "r1", ParamName: "b", Target: "t1"}, + {RuleID: "r1", ParamName: "a", Target: "t2"}, + {RuleID: "r2", ParamName: "a", Target: "t1"}, + } + + groups := groupThresholdOverrides(overrides) + require.Len(t, groups, 3, "one group per (rule, param), not per row") + + // Order follows first appearance, so grouping is deterministic. + assert.Equal(t, "r1", groups[0].ruleID) + assert.Equal(t, "a", groups[0].paramName) + assert.Len(t, groups[0].overrides, 2) + + assert.Equal(t, "b", groups[1].paramName) + assert.Len(t, groups[1].overrides, 1) + + assert.Equal(t, "r2", groups[2].ruleID) +} + +func setupThresholdCollector(t *testing.T) (*AlertThresholdMetricsCollector, *reform.DB) { + t.Helper() + + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + return NewAlertThresholdMetricsCollector(db), db +} + +func createThresholdRule(t *testing.T, db *reform.DB) { + t.Helper() + + _, err := models.CreateAlertRule(db.Querier, &models.CreateAlertRuleParams{ + RuleID: testRuleID, + Params: models.AlertRuleParams{ + "threshold": { + Default: 80, + JoinLabel: "node_name", + Scopes: []string{string(models.ThresholdScopeNode)}, + }, + }, + }) + require.NoError(t, err) +} + +func createThresholdNode(t *testing.T, db *reform.DB) *models.Node { + t.Helper() + + const name = "node-1" + + node, err := models.CreateNode(db.Querier, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: name, + Address: name + ".example.com", + }) + require.NoError(t, err) + + return node +} + +func TestThresholdCollectorEmitsNothingWithoutOverrides(t *testing.T) { + c, db := setupThresholdCollector(t) + createThresholdRule(t, db) + + assert.Equal(t, 0, testutil.CollectAndCount(c, thresholdMetricName), + "a rule with no overrides must emit no series at all") +} + +func TestThresholdCollectorEmitsOverride(t *testing.T) { + c, db := setupThresholdCollector(t) + createThresholdRule(t, db) + node := createThresholdNode(t, db) + + _, err := models.UpsertThresholdOverride(db.Querier, testRuleID, "threshold", models.ThresholdScopeNode, node.NodeID, 90) + require.NoError(t, err) + + expected := thresholdExposition(t, c, + `pmm_alert_threshold_override{param="threshold",rule_id="rule-fixed-for-tests",target="node-1"} 90`) + require.NoError(t, testutil.CollectAndCompare(c, expected, thresholdMetricName)) +} + +// TestThresholdCollectorEmitsDefaultForTombstone is the behaviour that makes clearing an +// override fast: the series keeps being emitted and merely changes value. If a cleared +// override stopped being emitted instead, the clear would take a full VictoriaMetrics +// lookbehind to become visible - measured at 309s, against 14-21s for a value change. +func TestThresholdCollectorEmitsDefaultForTombstone(t *testing.T) { + c, db := setupThresholdCollector(t) + createThresholdRule(t, db) + node := createThresholdNode(t, db) + + _, err := models.UpsertThresholdOverride(db.Querier, testRuleID, "threshold", models.ThresholdScopeNode, node.NodeID, 90) + require.NoError(t, err) + require.NoError(t, models.ClearThresholdOverride(db.Querier, testRuleID, "threshold", models.ThresholdScopeNode, node.NodeID)) + + expected := thresholdExposition(t, c, + `pmm_alert_threshold_override{param="threshold",rule_id="rule-fixed-for-tests",target="node-1"} 80`) + require.NoError(t, testutil.CollectAndCompare(c, expected, thresholdMetricName)) +} + +// TestThresholdCollectorSkipsDeletedTarget covers the backstop that keeps a row left +// behind by a deleted node inert rather than wrong. +func TestThresholdCollectorSkipsDeletedTarget(t *testing.T) { + c, db := setupThresholdCollector(t) + createThresholdRule(t, db) + + _, err := models.UpsertThresholdOverride(db.Querier, testRuleID, "threshold", models.ThresholdScopeNode, "no-such-node", 90) + require.NoError(t, err) + + assert.Equal(t, 0, testutil.CollectAndCount(c, thresholdMetricName)) +} + +// TestThresholdCollectorSkipsUnknownParam guards against emitting a series for a +// parameter the rule no longer declares, which would have no default to fall back to. +func TestThresholdCollectorSkipsUnknownParam(t *testing.T) { + c, db := setupThresholdCollector(t) + createThresholdRule(t, db) + node := createThresholdNode(t, db) + + _, err := models.UpsertThresholdOverride(db.Querier, testRuleID, "gone", models.ThresholdScopeNode, node.NodeID, 90) + require.NoError(t, err) + + assert.Equal(t, 0, testutil.CollectAndCount(c, thresholdMetricName)) +} + +func TestThresholdCollectorEmitsOnePerTargetAcrossParams(t *testing.T) { + c, db := setupThresholdCollector(t) + + _, err := models.CreateAlertRule(db.Querier, &models.CreateAlertRuleParams{ + RuleID: testRuleID, + Params: models.AlertRuleParams{ + "threshold": {Default: 80, JoinLabel: "node_name"}, + "second": {Default: 10, JoinLabel: "node_name"}, + }, + }) + require.NoError(t, err) + + node := createThresholdNode(t, db) + for _, param := range []string{"threshold", "second"} { + _, err = models.UpsertThresholdOverride(db.Querier, testRuleID, param, models.ThresholdScopeNode, node.NodeID, 42) + require.NoError(t, err) + } + + // Two params on one target are two distinct series, not a duplicate-label collision. + assert.Equal(t, 2, testutil.CollectAndCount(c, thresholdMetricName)) +} diff --git a/managed/services/alerting/threshold_overrides.go b/managed/services/alerting/threshold_overrides.go new file mode 100644 index 00000000000..d4b058aa83c --- /dev/null +++ b/managed/services/alerting/threshold_overrides.go @@ -0,0 +1,531 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "context" + "math" + "slices" + "sort" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/reform.v1" + + alerting "github.com/percona/pmm/api/alerting/v1" + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/services" +) + +// thresholdScopeFromAPI converts a scope from the wire, defaulting to node. +// +// Only node scope resolves in this increment. Service and cluster are already carried by +// the schema, the resolver and the proto, so enabling them later is a validation change +// rather than an API change - which is why they are rejected as unimplemented rather than +// as invalid. +func thresholdScopeFromAPI(scope alerting.ThresholdScope) (models.ThresholdScope, error) { + switch scope { + case alerting.ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED, alerting.ThresholdScope_THRESHOLD_SCOPE_NODE: + return models.ThresholdScopeNode, nil + case alerting.ThresholdScope_THRESHOLD_SCOPE_SERVICE: + return "", status.Error(codes.Unimplemented, "Service-scoped threshold overrides are not supported yet.") + case alerting.ThresholdScope_THRESHOLD_SCOPE_CLUSTER: + return "", status.Error(codes.Unimplemented, "Cluster-scoped threshold overrides are not supported yet.") + } + + // do not add `default:` to make exhaustive linter do its job + + return "", status.Errorf(codes.InvalidArgument, "Unknown threshold scope %q.", scope.String()) +} + +func thresholdScopeToAPI(scope models.ThresholdScope) alerting.ThresholdScope { + switch scope { + case models.ThresholdScopeNode: + return alerting.ThresholdScope_THRESHOLD_SCOPE_NODE + case models.ThresholdScopeService: + return alerting.ThresholdScope_THRESHOLD_SCOPE_SERVICE + case models.ThresholdScopeCluster: + return alerting.ThresholdScope_THRESHOLD_SCOPE_CLUSTER + } + + // do not add `default:` to make exhaustive linter do its job + + return alerting.ThresholdScope_THRESHOLD_SCOPE_UNSPECIFIED +} + +// checkThresholdTargetExists rejects an override aimed at something that is not there. +// A cluster is a label value rather than an inventory entity, so its existence cannot be +// checked - and should not be, since a cluster override may legitimately precede the +// services that will join it. +func checkThresholdTargetExists(q *reform.Querier, scope models.ThresholdScope, target string) error { + switch scope { + case models.ThresholdScopeNode: + _, err := models.FindNodeByID(q, target) + + return err + case models.ThresholdScopeService: + _, err := models.FindServiceByID(q, target) + + return err + case models.ThresholdScopeCluster: + return nil + } + + // do not add `default:` to make exhaustive linter do its job + + return nil +} + +// resolveThresholdRequest validates one set or clear against the rule registry, and +// returns the rule parameter it addresses. Checks run cheapest-first, so a malformed +// request never reaches the database. +func resolveThresholdRequest( + q *reform.Querier, + scope models.ThresholdScope, + target, ruleID, paramName string, + value *float64, +) (models.AlertRuleParam, error) { + var zero models.AlertRuleParam + + rule, err := models.FindAlertRuleByID(q, ruleID) + if err != nil { + return zero, err + } + + // The registry only holds parameters that were overridable when the rule was + // created, so a parameter missing here is either unknown or not overridable. + param, ok := rule.Params[paramName] + if !ok { + return zero, status.Errorf(codes.NotFound, + "Rule %q has no overridable parameter %q.", ruleID, paramName) + } + + if !slices.Contains(param.Scopes, string(scope)) { + return zero, status.Errorf(codes.InvalidArgument, + "Parameter %q cannot be overridden at %q scope.", paramName, scope) + } + + if value != nil { + err = checkThresholdValue(paramName, param, *value) + if err != nil { + return zero, err + } + } + + err = checkThresholdTargetExists(q, scope, target) + if err != nil { + return zero, err + } + + return param, nil +} + +// checkThresholdValue guards the value at the API as well as the database. The column's +// CHECK rejects non-finite values too, but reaching it would surface as an opaque +// internal error rather than a bad request. +func checkThresholdValue(paramName string, param models.AlertRuleParam, value float64) error { + if math.IsNaN(value) || math.IsInf(value, 0) { + return status.Errorf(codes.InvalidArgument, "Threshold for %q must be a finite number.", paramName) + } + + if param.Min != nil && value < *param.Min { + return status.Errorf(codes.InvalidArgument, + "Threshold for %q must be at least %v.", paramName, *param.Min) + } + + if param.Max != nil && value > *param.Max { + return status.Errorf(codes.InvalidArgument, + "Threshold for %q must be at most %v.", paramName, *param.Max) + } + + return nil +} + +// thresholdFromResolved builds the API view of one parameter as it applies to one target. +func thresholdFromResolved(ruleID, paramName string, param models.AlertRuleParam, resolved models.ResolvedThreshold) *alerting.Threshold { + threshold := &alerting.Threshold{ + RuleId: ruleID, + ParamName: paramName, + Summary: param.Summary, + Unit: convertParamUnit(models.ParamUnit(param.Unit)), + DefaultValue: param.Default, + EffectiveValue: resolved.Value, + IsOverridden: resolved.IsOverridden(), + } + + if resolved.Source != nil { + threshold.Scope = thresholdScopeToAPI(resolved.Source.Scope) + threshold.Target = resolved.Source.Target + } + + return threshold +} + +// ListThresholds returns per-target threshold overrides. +func (s *Service) ListThresholds(_ context.Context, req *alerting.ListThresholdsRequest) (*alerting.ListThresholdsResponse, error) { + settings, err := models.GetSettings(s.db) + if err != nil { + return nil, err + } + + if !settings.IsAlertingEnabled() { + return nil, services.ErrAlertingDisabled + } + + // Converted before the transaction opens: it only reads the request, and a bad scope + // should be rejected without taking a connection. + var scope models.ThresholdScope + if req.Target != "" { + scope, err = thresholdScopeFromAPI(req.Scope) + if err != nil { + return nil, err + } + } + + var thresholds []*alerting.Threshold + + errTx := s.db.InTransaction(func(tx *reform.TX) error { + rules, err := s.thresholdRules(tx.Querier, req.RuleId) + if err != nil { + return err + } + + for _, rule := range rules { + ruleThresholds, err := s.thresholdsForRule(tx.Querier, rule, scope, req.Target) + if err != nil { + return err + } + + thresholds = append(thresholds, ruleThresholds...) + } + + return nil + }) + if errTx != nil { + return nil, errTx + } + + sortThresholds(thresholds) + + return &alerting.ListThresholdsResponse{Thresholds: thresholds}, nil +} + +// thresholdsForRule reports one registry row's thresholds. With no target it reports only +// what has actually been overridden; with a target it reports every parameter of the rule, +// falling back to that rule's own default where nothing overrides it. +func (s *Service) thresholdsForRule( + q *reform.Querier, rule *models.AlertRule, scope models.ThresholdScope, target string, +) ([]*alerting.Threshold, error) { + overrides, err := models.FindThresholdOverridesByRule(q, rule.RuleID) + if err != nil { + return nil, err + } + + inv, err := loadThresholdInventory(q, overrides) + if err != nil { + return nil, err + } + + targetName, err := s.thresholdTargetName(q, scope, target, &inv) + if err != nil { + return nil, err + } + + var thresholds []*alerting.Threshold + + for paramName, param := range rule.Params { + resolved := models.ResolveThresholdsDetailed( + filterOverridesByParam(overrides, paramName), param.Default, inv, + ) + + if target == "" { + // With no target there is no bounded set of targets to enumerate, so + // only what has actually been overridden is reported. + for _, entry := range resolved { + if entry.IsOverridden() { + thresholds = append(thresholds, thresholdFromResolved(rule.RuleID, paramName, param, entry)) + } + } + + continue + } + + entry, ok := resolved[targetName] + if !ok { + entry = models.ResolvedThreshold{Value: param.Default} + } + + thresholds = append(thresholds, thresholdFromResolved(rule.RuleID, paramName, param, entry)) + } + + return thresholds, nil +} + +// thresholdRules returns the registry rows to report on, honouring an optional filter. +func (s *Service) thresholdRules(q *reform.Querier, ruleID string) ([]*models.AlertRule, error) { + if ruleID != "" { + rule, err := models.FindAlertRuleByID(q, ruleID) + if err != nil { + return nil, err + } + + return []*models.AlertRule{rule}, nil + } + + return models.FindAlertRules(q) +} + +// thresholdTargetName resolves the requested target to its join-label value and makes +// sure the inventory carries it, so a target with no override of its own still resolves. +func (s *Service) thresholdTargetName( + q *reform.Querier, + scope models.ThresholdScope, + target string, + inv *models.ThresholdInventory, +) (string, error) { + if target == "" { + return "", nil + } + + switch scope { + case models.ThresholdScopeNode: + node, err := models.FindNodeByID(q, target) + if err != nil { + return "", err + } + + if inv.NodeNames == nil { + inv.NodeNames = make(map[string]string, 1) + } + inv.NodeNames[target] = node.NodeName + + return node.NodeName, nil + + case models.ThresholdScopeService: + service, err := models.FindServiceByID(q, target) + if err != nil { + return "", err + } + + if inv.ServiceNames == nil { + inv.ServiceNames = make(map[string]string, 1) + } + inv.ServiceNames[target] = service.ServiceName + + return service.ServiceName, nil + + case models.ThresholdScopeCluster: + return "", status.Error(codes.InvalidArgument, "Cluster is not a listable target.") + } + + // do not add `default:` to make exhaustive linter do its job + + return "", nil +} + +func filterOverridesByParam(overrides []*models.AlertRuleThresholdOverride, paramName string) []*models.AlertRuleThresholdOverride { + filtered := make([]*models.AlertRuleThresholdOverride, 0, len(overrides)) + for _, override := range overrides { + if override.ParamName == paramName { + filtered = append(filtered, override) + } + } + + return filtered +} + +// sortThresholds gives the response a stable order, since it is assembled from map +// iteration over a rule's parameters. +func sortThresholds(thresholds []*alerting.Threshold) { + sort.Slice(thresholds, func(i, j int) bool { + if thresholds[i].RuleId != thresholds[j].RuleId { + return thresholds[i].RuleId < thresholds[j].RuleId + } + + if thresholds[i].ParamName != thresholds[j].ParamName { + return thresholds[i].ParamName < thresholds[j].ParamName + } + + return thresholds[i].Target < thresholds[j].Target + }) +} + +// SetThreshold overrides one rule parameter for one target. +func (s *Service) SetThreshold(_ context.Context, req *alerting.SetThresholdRequest) (*alerting.SetThresholdResponse, error) { + settings, err := models.GetSettings(s.db) + if err != nil { + return nil, err + } + + if !settings.IsAlertingEnabled() { + return nil, services.ErrAlertingDisabled + } + + var threshold *alerting.Threshold + + errTx := s.db.InTransaction(func(tx *reform.TX) error { + scope, err := thresholdScopeFromAPI(req.Scope) + if err != nil { + return err + } + + param, err := resolveThresholdRequest(tx.Querier, scope, req.Target, req.RuleId, req.ParamName, &req.Value) + if err != nil { + return err + } + + _, err = models.UpsertThresholdOverride(tx.Querier, req.RuleId, req.ParamName, scope, req.Target, req.Value) + if err != nil { + return err + } + + threshold, err = s.readThreshold(tx.Querier, req.RuleId, req.ParamName, param, scope, req.Target) + + return err + }) + if errTx != nil { + return nil, errTx + } + + return &alerting.SetThresholdResponse{Threshold: threshold}, nil +} + +// ClearThreshold removes an override so the target falls back to the rule's default, or +// to a broader override still covering it. +func (s *Service) ClearThreshold(_ context.Context, req *alerting.ClearThresholdRequest) (*alerting.ClearThresholdResponse, error) { + settings, err := models.GetSettings(s.db) + if err != nil { + return nil, err + } + + if !settings.IsAlertingEnabled() { + return nil, services.ErrAlertingDisabled + } + + errTx := s.db.InTransaction(func(tx *reform.TX) error { + scope, err := thresholdScopeFromAPI(req.Scope) + if err != nil { + return err + } + + _, err = resolveThresholdRequest(tx.Querier, scope, req.Target, req.RuleId, req.ParamName, nil) + if err != nil { + return err + } + + return models.ClearThresholdOverride(tx.Querier, req.RuleId, req.ParamName, scope, req.Target) + }) + if errTx != nil { + return nil, errTx + } + + return &alerting.ClearThresholdResponse{}, nil +} + +// BatchUpdateThresholds applies several set and clear operations in one transaction, so +// a client editing many rows at once never lands a partial result it cannot report. +func (s *Service) BatchUpdateThresholds(_ context.Context, req *alerting.BatchUpdateThresholdsRequest) (*alerting.BatchUpdateThresholdsResponse, error) { + settings, err := models.GetSettings(s.db) + if err != nil { + return nil, err + } + + if !settings.IsAlertingEnabled() { + return nil, services.ErrAlertingDisabled + } + + thresholds := make([]*alerting.Threshold, 0, len(req.Updates)) + + errTx := s.db.InTransaction(func(tx *reform.TX) error { + thresholds = thresholds[:0] + + for _, update := range req.Updates { + scope, err := thresholdScopeFromAPI(update.Scope) + if err != nil { + return err + } + + param, err := resolveThresholdRequest(tx.Querier, scope, update.Target, update.RuleId, update.ParamName, update.Value) + if err != nil { + return err + } + + if update.Value == nil { + err = models.ClearThresholdOverride(tx.Querier, update.RuleId, update.ParamName, scope, update.Target) + if err != nil { + return err + } + + continue + } + + _, err = models.UpsertThresholdOverride(tx.Querier, update.RuleId, update.ParamName, scope, update.Target, *update.Value) + if err != nil { + return err + } + + threshold, err := s.readThreshold(tx.Querier, update.RuleId, update.ParamName, param, scope, update.Target) + if err != nil { + return err + } + + thresholds = append(thresholds, threshold) + } + + return nil + }) + if errTx != nil { + return nil, errTx + } + + return &alerting.BatchUpdateThresholdsResponse{Thresholds: thresholds}, nil +} + +// readThreshold reports a parameter as it stands for one target after a write, resolved +// through the same precedence the collector applies. +func (s *Service) readThreshold( + q *reform.Querier, + ruleID, paramName string, + param models.AlertRuleParam, + scope models.ThresholdScope, + target string, +) (*alerting.Threshold, error) { + overrides, err := models.FindThresholdOverridesByRule(q, ruleID) + if err != nil { + return nil, err + } + + overrides = filterOverridesByParam(overrides, paramName) + + inv, err := loadThresholdInventory(q, overrides) + if err != nil { + return nil, err + } + + targetName, err := s.thresholdTargetName(q, scope, target, &inv) + if err != nil { + return nil, err + } + + resolved := models.ResolveThresholdsDetailed(overrides, param.Default, inv) + + entry, ok := resolved[targetName] + if !ok { + entry = models.ResolvedThreshold{Value: param.Default} + } + + return thresholdFromResolved(ruleID, paramName, param, entry), nil +} diff --git a/managed/services/alerting/threshold_overrides_test.go b/managed/services/alerting/threshold_overrides_test.go new file mode 100644 index 00000000000..9025ecc96bd --- /dev/null +++ b/managed/services/alerting/threshold_overrides_test.go @@ -0,0 +1,317 @@ +// Copyright (C) 2023 Percona LLC +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alerting + +import ( + "math" + "testing" + + "github.com/AlekSi/pointer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/reform.v1" + "gopkg.in/reform.v1/dialects/postgresql" + + alerting "github.com/percona/pmm/api/alerting/v1" + "github.com/percona/pmm/managed/models" + "github.com/percona/pmm/managed/utils/testdb" +) + +const thresholdTestRuleID = "threshold-api-rule" + +func setupThresholdAPI(t *testing.T) (*Service, *reform.DB, *models.Node) { + t.Helper() + + sqlDB := testdb.Open(t, models.SkipFixtures, nil) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + db := reform.NewDB(sqlDB, postgresql.Dialect, reform.NewPrintfLogger(t.Logf)) + + svc, err := NewService(db, newMockGrafanaClient(t)) + require.NoError(t, err) + + // Alerting must be on, or every RPC short-circuits. + _, err = models.UpdateSettings(db, &models.ChangeSettingsParams{EnableAlerting: new(true)}) + require.NoError(t, err) + + _, err = models.CreateAlertRule(db.Querier, &models.CreateAlertRuleParams{ + RuleID: thresholdTestRuleID, + Params: models.AlertRuleParams{ + "threshold": { + Default: 80, + JoinLabel: "node_name", + Scopes: []string{string(models.ThresholdScopeNode)}, + Unit: "%", + Summary: "A percentage from configured maximum", + Min: pointer.ToFloat64(0), + Max: pointer.ToFloat64(100), + }, + }, + }) + require.NoError(t, err) + + node, err := models.CreateNode(db.Querier, models.GenericNodeType, &models.CreateNodeParams{ + NodeName: "api-node-1", + Address: "api-node-1.example.com", + }) + require.NoError(t, err) + + return svc, db, node +} + +func TestSetThreshold(t *testing.T) { + ctx := t.Context() + + t.Run("sets an override and reports the effective value", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + res, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, + Target: node.NodeID, + RuleId: thresholdTestRuleID, + ParamName: "threshold", + Value: 90, + }) + require.NoError(t, err) + + assert.InDelta(t, 90.0, res.Threshold.EffectiveValue, 0.0001) + assert.InDelta(t, 80.0, res.Threshold.DefaultValue, 0.0001) + assert.True(t, res.Threshold.IsOverridden) + assert.Equal(t, alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, res.Threshold.Scope) + assert.Equal(t, node.NodeID, res.Threshold.Target) + assert.Equal(t, alerting.ParamUnit_PARAM_UNIT_PERCENTAGE, res.Threshold.Unit) + assert.Equal(t, "A percentage from configured maximum", res.Threshold.Summary) + }) + + t.Run("rejects a value outside the declared range", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 150, + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("rejects non-finite values before they reach the database", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: value, + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err), + "the database CHECK would surface as an opaque internal error instead") + } + }) + + t.Run("rejects an unknown parameter", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "not-overridable", Value: 90, + }) + require.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("rejects an unknown rule", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: "no-such-rule", ParamName: "threshold", Value: 90, + }) + require.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + t.Run("rejects a target that does not exist", func(t *testing.T) { + svc, _, _ := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: "no-such-node", + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 90, + }) + require.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + + // Service and cluster scope are carried by the schema, resolver and proto already, so + // they report as not-yet-implemented rather than as a malformed request. + t.Run("reports unimplemented scopes distinctly from invalid ones", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + for _, scope := range []alerting.ThresholdScope{ + alerting.ThresholdScope_THRESHOLD_SCOPE_SERVICE, + alerting.ThresholdScope_THRESHOLD_SCOPE_CLUSTER, + } { + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: scope, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 90, + }) + require.Error(t, err) + assert.Equal(t, codes.Unimplemented, status.Code(err)) + } + }) +} + +func TestClearThreshold(t *testing.T) { + ctx := t.Context() + + t.Run("clearing returns the target to the default", func(t *testing.T) { + svc, db, node := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 90, + }) + require.NoError(t, err) + + _, err = svc.ClearThreshold(ctx, &alerting.ClearThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", + }) + require.NoError(t, err) + + // The row survives as a tombstone: that is what keeps the emitted series alive + // so the clear lands in one scrape rather than a lookbehind. + overrides, err := models.FindThresholdOverridesByRule(db.Querier, thresholdTestRuleID) + require.NoError(t, err) + require.Len(t, overrides, 1) + assert.True(t, overrides[0].IsCleared()) + + list, err := svc.ListThresholds(ctx, &alerting.ListThresholdsRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + }) + require.NoError(t, err) + require.Len(t, list.Thresholds, 1) + assert.InDelta(t, 80.0, list.Thresholds[0].EffectiveValue, 0.0001) + assert.False(t, list.Thresholds[0].IsOverridden, + "a tombstone must not read as an override, or every target ever tuned reads as tuned forever") + }) +} + +func TestListThresholds(t *testing.T) { + ctx := t.Context() + + t.Run("with a target, every overridable parameter is reported", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + res, err := svc.ListThresholds(ctx, &alerting.ListThresholdsRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + }) + require.NoError(t, err) + + require.Len(t, res.Thresholds, 1, "an untouched target still reports its default") + assert.InDelta(t, 80.0, res.Thresholds[0].EffectiveValue, 0.0001) + assert.False(t, res.Thresholds[0].IsOverridden) + }) + + t.Run("without a target, only actual overrides are reported", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + res, err := svc.ListThresholds(ctx, &alerting.ListThresholdsRequest{}) + require.NoError(t, err) + assert.Empty(t, res.Thresholds, "there is no bounded target set to enumerate") + + _, err = svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 90, + }) + require.NoError(t, err) + + res, err = svc.ListThresholds(ctx, &alerting.ListThresholdsRequest{}) + require.NoError(t, err) + require.Len(t, res.Thresholds, 1) + assert.True(t, res.Thresholds[0].IsOverridden) + }) +} + +func TestBatchUpdateThresholds(t *testing.T) { + ctx := t.Context() + + t.Run("applies several updates", func(t *testing.T) { + svc, _, node := setupThresholdAPI(t) + + res, err := svc.BatchUpdateThresholds(ctx, &alerting.BatchUpdateThresholdsRequest{ + Updates: []*alerting.ThresholdUpdate{{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", + Value: pointer.ToFloat64(95), + }}, + }) + require.NoError(t, err) + require.Len(t, res.Thresholds, 1) + assert.InDelta(t, 95.0, res.Thresholds[0].EffectiveValue, 0.0001) + }) + + t.Run("an update with no value clears instead of setting", func(t *testing.T) { + svc, db, node := setupThresholdAPI(t) + + _, err := svc.SetThreshold(ctx, &alerting.SetThresholdRequest{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", Value: 90, + }) + require.NoError(t, err) + + res, err := svc.BatchUpdateThresholds(ctx, &alerting.BatchUpdateThresholdsRequest{ + Updates: []*alerting.ThresholdUpdate{{ + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", + }}, + }) + require.NoError(t, err) + assert.Empty(t, res.Thresholds, "cleared entries are omitted from the response") + + overrides, err := models.FindThresholdOverridesByRule(db.Querier, thresholdTestRuleID) + require.NoError(t, err) + require.Len(t, overrides, 1) + assert.True(t, overrides[0].IsCleared()) + }) + + // The whole reason the batch endpoint exists: a client editing many rows at once + // must never land a partial result it cannot report. + t.Run("one bad update rolls the whole batch back", func(t *testing.T) { + svc, db, node := setupThresholdAPI(t) + + _, err := svc.BatchUpdateThresholds(ctx, &alerting.BatchUpdateThresholdsRequest{ + Updates: []*alerting.ThresholdUpdate{ + { + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", + Value: pointer.ToFloat64(90), + }, + { + Scope: alerting.ThresholdScope_THRESHOLD_SCOPE_NODE, Target: node.NodeID, + RuleId: thresholdTestRuleID, ParamName: "threshold", + Value: pointer.ToFloat64(500), // out of range + }, + }, + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + overrides, err := models.FindThresholdOverridesByRule(db.Querier, thresholdTestRuleID) + require.NoError(t, err) + assert.Empty(t, overrides, "the first update must not survive the second one failing") + }) +} diff --git a/managed/services/grafana/auth_server.go b/managed/services/grafana/auth_server.go index 7bb9927c306..b8659640e5a 100644 --- a/managed/services/grafana/auth_server.go +++ b/managed/services/grafana/auth_server.go @@ -68,6 +68,7 @@ var rules = map[string]role{ "/v1/alerting": viewer, "/v1/alerting/rules": editor, + "/v1/alerting/thresholds": admin, "/v1/advisors": editor, "/v1/advisors/checks:": editor, "/v1/advisors/failedServices": editor, diff --git a/managed/services/grafana/auth_server_test.go b/managed/services/grafana/auth_server_test.go index a009ab5d185..a29f3164765 100644 --- a/managed/services/grafana/auth_server_test.go +++ b/managed/services/grafana/auth_server_test.go @@ -71,11 +71,15 @@ func TestResolveRule(t *testing.T) { wantRole role }{ // Alerting: only listing templates is viewable; writes need editor. - {http.MethodGet, "/v1/alerting/templates", viewer}, // ListTemplates - {http.MethodPost, "/v1/alerting/templates", editor}, // CreateTemplate - {http.MethodPut, "/v1/alerting/templates/foo", editor}, // UpdateTemplate - {http.MethodDelete, "/v1/alerting/templates/foo", editor}, // DeleteTemplate - {http.MethodPost, "/v1/alerting/rules", editor}, // CreateRule + {http.MethodGet, "/v1/alerting/templates", viewer}, // ListTemplates + {http.MethodPost, "/v1/alerting/templates", editor}, // CreateTemplate + {http.MethodPut, "/v1/alerting/templates/foo", editor}, // UpdateTemplate + {http.MethodDelete, "/v1/alerting/templates/foo", editor}, // DeleteTemplate + {http.MethodPost, "/v1/alerting/rules", editor}, // CreateRule + {http.MethodGet, "/v1/alerting/thresholds", admin}, // ListThresholds + {http.MethodPost, "/v1/alerting/thresholds", admin}, // SetThreshold + {http.MethodDelete, "/v1/alerting/thresholds", admin}, // ClearThreshold + {http.MethodPost, "/v1/alerting/thresholds:batchUpdate", admin}, // BatchUpdateThresholds // No matching rule falls back to grafanaAdmin. {http.MethodGet, "/v1/unknown", grafanaAdmin}, } { diff --git a/managed/services/grafana/client.go b/managed/services/grafana/client.go index 87e466a0e3d..4e01258b8c6 100644 --- a/managed/services/grafana/client.go +++ b/managed/services/grafana/client.go @@ -767,6 +767,50 @@ func (c *Client) CreateAlertRule(ctx context.Context, folderUID, groupName, inte return nil } +// ListPMMRuleIDs returns the identity label of every Grafana alert rule that carries one, +// which is how PMM-created rules identify themselves. +// +// The label is read rather than the rule's UID because a copied rule gets a new UID while +// keeping the label, so this reports which rules still exist in terms of the identity PMM +// keys its threshold overrides on. +func (c *Client) ListPMMRuleIDs(ctx context.Context) (map[string]struct{}, error) { + authHeaders, err := auth.GetHeadersFromContext(ctx) + if err != nil { + return nil, err + } + + // The ruler returns every folder's groups keyed by folder title. Only the rule + // labels matter here, so the rest of the payload is left unmodelled. + type rulerRule struct { + Labels map[string]string `json:"labels"` + } + + type rulerGroup struct { + Rules []rulerRule `json:"rules"` + } + + var folders map[string][]rulerGroup + + err = c.do(ctx, http.MethodGet, "/api/ruler/grafana/api/v1/rules", "", authHeaders, nil, &folders) + if err != nil { + return nil, err + } + + ids := make(map[string]struct{}) + for _, groups := range folders { + for _, group := range groups { + for _, rule := range group.Rules { + id := rule.Labels[services.PMMRuleIDLabel] + if id != "" { + ids[id] = struct{}{} + } + } + } + } + + return ids, nil +} + func validateDurations(intervalD, forD string) error { i, err := time.ParseDuration(intervalD) if err != nil { diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index ef18ae9b458..d40048894af 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -41,6 +41,7 @@ import { SettingsUpdatedEvent, FrontendSettingsUpdatedEvent, TimeZoneUpdatedEvent, + OpenAlertThresholdsModalEvent, } from 'lib/events'; import { handleExternalLinks } from 'compat/links'; @@ -228,6 +229,13 @@ export const initialize = () => { }); }); + getAppEvents().subscribe(OpenAlertThresholdsModalEvent, (e) => + messenger.sendMessage({ + type: 'OPEN_ALERT_THRESHOLDS_MODAL', + payload: e.payload, + }) + ); + getAppEvents().subscribe(ServiceDeletedEvent, () => { messenger.sendMessage({ type: 'SERVICE_DELETED', diff --git a/ui/apps/pmm-compat/src/lib/events.ts b/ui/apps/pmm-compat/src/lib/events.ts index 44461b0e15d..8ba01e23153 100644 --- a/ui/apps/pmm-compat/src/lib/events.ts +++ b/ui/apps/pmm-compat/src/lib/events.ts @@ -24,3 +24,7 @@ export class FrontendSettingsUpdatedEvent extends BusEventBase { export class TimeZoneUpdatedEvent extends BusEventBase { static type = 'timezone-updated-event'; } + +export class OpenAlertThresholdsModalEvent extends BusEventBase { + static type = 'open-alert-thresholds-modal-event'; +} diff --git a/ui/apps/pmm/src/api/alerting.ts b/ui/apps/pmm/src/api/alerting.ts index 2cdfa28b9bc..3f961c1833e 100644 --- a/ui/apps/pmm/src/api/alerting.ts +++ b/ui/apps/pmm/src/api/alerting.ts @@ -4,9 +4,13 @@ import { AlertmanagerSilence, GrafanaAlertQuery, GrafanaRulerRuleDTO, + BatchUpdateThresholdsResponse, + ListThresholdsResponse, PrometheusAlertRulesResponse, + ThresholdScope, + ThresholdUpdate, } from 'types/alerting.types'; -import { grafanaApi } from './api'; +import { api, grafanaApi } from './api'; export const getPrometheusAlertRules = async () => { const response = await grafanaApi.get( @@ -55,3 +59,34 @@ export const getRulerRule = async (uid: string) => { ); return res.data; }; + +export const getThresholds = async ( + scope: ThresholdScope, + target: string, + ruleId?: string +) => { + const res = await api.get('alerting/thresholds', { + params: { scope, target, ruleId }, + }); + return res.data; +}; + +export const setThreshold = async (update: Required) => { + const res = await api.post('alerting/thresholds', update); + return res.data; +}; + +export const clearThreshold = async ( + update: Omit +) => { + const res = await api.delete('alerting/thresholds', { params: update }); + return res.data; +}; + +export const batchUpdateThresholds = async (updates: ThresholdUpdate[]) => { + const res = await api.post( + 'alerting/thresholds:batchUpdate', + { updates } + ); + return res.data; +}; diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.constants.tsx b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.constants.tsx new file mode 100644 index 00000000000..ff0b7a69711 --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.constants.tsx @@ -0,0 +1,85 @@ +import { TextInput } from '@percona/peak-ui'; +import type { MRT_ColumnDef } from '@percona/peak-ui'; +import type { AlertThresholdRow } from './AlertThresholds.types'; +import ResetValueCell from './reset-value-cell'; +import { Messages } from './AlertThresholds.messages'; +import { formatUnit } from './AlertThresholds.utils'; + +// Maps the backend ParamUnit enum to a display symbol. +export const UNIT_SYMBOLS: Record = { + PARAM_UNIT_PERCENTAGE: '%', + PARAM_UNIT_SECONDS: 's', +}; + +export const ALERT_THRESHOLDS_COLUMNS: MRT_ColumnDef[] = [ + { + accessorKey: 'ruleTitle', + header: Messages.table.columns.ruleTitle, + }, + { + accessorKey: 'summary', + header: Messages.table.columns.parameter, + Cell: ({ row: { original } }) => original.paramName, + }, + { + accessorKey: 'defaultValue', + header: Messages.table.columns.default, + enableColumnActions: false, + enableColumnFilter: false, + enableSorting: false, + muiTableHeadCellProps: { + sx: { + '.Mui-TableHeadCell-Content': { + height: 40, + }, + }, + }, + }, + { + accessorKey: 'effectiveValue', + header: Messages.table.columns.override, + enableColumnActions: false, + enableColumnFilter: false, + enableSorting: false, + muiTableHeadCellProps: { + sx: { + '.Mui-TableHeadCell-Content': { + height: 40, + }, + }, + }, + // Every row returned by the endpoint is overridable; the field is + // pre-filled with the effective value via react-hook-form defaults. + Cell: ({ row: { original } }) => ( + + ), + }, + { + id: 'unit', + size: 80, + grow: false, + header: Messages.table.columns.unit, + enableColumnActions: false, + muiTableHeadCellProps: { + sx: { + '.Mui-TableHeadCell-Content': { + height: 40, + }, + }, + }, + Cell: ({ row: { original } }) => formatUnit(original.unit), + }, + { + id: 'reset', + size: 80, + header: '', + enableColumnActions: false, + Cell: ({ row: { original } }) => , + }, +]; diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.messages.ts b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.messages.ts new file mode 100644 index 00000000000..75f44bc038f --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.messages.ts @@ -0,0 +1,22 @@ +export const Messages = { + title: (nodeName: string) => `Alert thresholds: ${nodeName}`, + loading: 'Loading thresholds…', + empty: 'No overridable thresholds for this node.', + actions: { + cancel: 'Cancel and close', + submit: 'Submit changes', + reset: 'Reset to default', + }, + table: { + columns: { + ruleTitle: 'Alert rule', + parameter: 'Parameter', + default: 'Default', + override: 'Override', + unit: 'Unit', + }, + }, + success: { + updated: 'Alert thresholds updated', + }, +}; diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.tsx b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.tsx new file mode 100644 index 00000000000..8535cd6e25a --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.tsx @@ -0,0 +1,184 @@ +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { Table } from '@percona/peak-ui'; +import type { OpenAlertThresholdsModalMessage } from '@pmm/shared'; +import { Modal } from 'components/modal'; +import { + useBatchUpdateNodeThresholds, + useNodeThresholds, +} from 'hooks/api/useNodeThresholds'; +import { usePrometheusAlertRules } from 'hooks/api/usePrometheusAlertRules'; +import messenger from 'lib/messenger'; +import { enqueueSnackbar } from 'notistack'; +import { useEffect, useMemo, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ALERT_THRESHOLDS_COLUMNS } from './AlertThresholds.constants'; +import { Messages } from './AlertThresholds.messages'; +import type { + AlertThresholdRow, + AlertThresholdsFormValues, +} from './AlertThresholds.types'; +import type { + ListThresholdsResponse, + PrometheusAlertRulesResponse, +} from 'types/alerting.types'; +import { + buildThresholdUpdates, + getRows, + getRuleTitles, +} from './AlertThresholds.utils'; + +const AlertThresholds = () => { + const [nodeId, setNodeId] = useState(); + const [nodeName, setNodeName] = useState(); + const [open, setIsOpen] = useState(false); + + const { data, isLoading } = useNodeThresholds(nodeId ?? '', { + enabled: open && !!nodeId, + }); + + const { data: rulesData } = usePrometheusAlertRules({ + enabled: open && !!nodeId, + }); + + // Rule titles live in Grafana, not in the thresholds response, so they are joined + // on the identity label PMM stamps on every rule it creates. + const ruleTitles = useMemo( + () => getRuleTitles(rulesData as PrometheusAlertRulesResponse), + [rulesData] + ); + + const rows = useMemo( + () => getRows(data as ListThresholdsResponse, ruleTitles), + [data, ruleTitles] + ); + + const initialValues = useMemo( + () => + rows.reduce((acc, row) => { + acc[row.id] = row.effectiveValue; + return acc; + }, {} as AlertThresholdsFormValues), + [rows] + ); + + const methods = useForm({ + defaultValues: initialValues, + }); + const { mutateAsync: applyThresholds } = useBatchUpdateNodeThresholds( + nodeId ?? '' + ); + + useEffect(() => { + methods.reset(initialValues); + }, [initialValues, methods]); + + // Deliberately has no dependency array, so it re-subscribes after every render. + // + // GrafanaProvider's cleanup calls messenger.unregister(), which empties the shared + // listener array rather than removing only its own listeners, and its effect depends + // on `navigate` - so an ordinary navigation discards this subscription along with + // everyone else's. Re-registering on each render is what puts it back; with `[]` the + // modal would work until the first navigation and then silently stop opening. + // + // The costs are a subscribe/unsubscribe cycle per render, and a narrow window between + // cleanup and re-subscribe in which an OPEN_ALERT_THRESHOLDS_MODAL message would be + // dropped. Both go away once unregister() is made the true inverse of register() - + // detaching the window listener and leaving the array to each component's own cleanup. + // Tracked as a follow-up; fixing it here would mean changing shared messenger + // behaviour that every other consumer relies on. + useEffect(() => { + const handler = messenger.addListener({ + type: 'OPEN_ALERT_THRESHOLDS_MODAL', + onMessage: (msg: OpenAlertThresholdsModalMessage) => { + setNodeId(msg.payload?.nodeId); + setNodeName(msg.payload?.nodeName); + setIsOpen(true); + }, + }); + + return () => messenger.removeListener(handler); + }); + + const handleClose = () => { + setNodeId(undefined); + setNodeName(undefined); + setIsOpen(false); + }; + + const handleSubmit = async (values: AlertThresholdsFormValues) => { + const updates = buildThresholdUpdates( + rows, + values, + 'THRESHOLD_SCOPE_NODE', + nodeId ?? '' + ); + + if (updates.length > 0) { + // One transactional call: either every row lands or none does. + await applyThresholds(updates); + enqueueSnackbar(Messages.success.updated, { variant: 'success' }); + } + + handleClose(); + }; + + if (!open || !nodeId) { + return null; + } + + return ( + + + + {isLoading ? ( + + {Messages.loading} + + ) : rows.length === 0 ? ( + + {Messages.empty} + + ) : ( + + )} + + + + + + + + ); +}; + +export default AlertThresholds; diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.types.ts b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.types.ts new file mode 100644 index 00000000000..e4736a34919 --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.types.ts @@ -0,0 +1,16 @@ +import type { Threshold } from 'types/alerting.types'; + +// A table row is a Threshold plus a stable composite id used as the react-hook-form +// field name, and the rule's title. +// +// The title is not part of the thresholds response: rule metadata churns on rename, +// so Grafana stays authoritative for it and the UI joins it on the `pmm_rule_id` +// label. Rows whose rule has since been deleted keep an empty title rather than +// disappearing. +export interface AlertThresholdRow extends Threshold { + id: string; + ruleTitle: string; +} + +// Form values: composite row id -> override value (string while editing). +export type AlertThresholdsFormValues = Record; diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.test.ts b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.test.ts new file mode 100644 index 00000000000..9f11adf77e4 --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.test.ts @@ -0,0 +1,205 @@ +import type { + ListThresholdsResponse, + PrometheusAlertRulesResponse, +} from 'types/alerting.types'; +import type { AlertThresholdRow } from './AlertThresholds.types'; +import { + buildThresholdUpdates, + getRows, + getRuleTitles, +} from './AlertThresholds.utils'; + +const NODE = 'THRESHOLD_SCOPE_NODE' as const; + +const rulesResponse = ( + rules: { name: string; labels?: Record }[] +): PrometheusAlertRulesResponse => + ({ + data: { groups: [{ rules }] }, + }) as PrometheusAlertRulesResponse; + +const row = (over: Partial = {}): AlertThresholdRow => ({ + id: 'rule-1:threshold:0', + ruleId: 'rule-1', + ruleTitle: 'CPU load', + paramName: 'threshold', + defaultValue: 80, + effectiveValue: 80, + isOverridden: false, + ...over, +}); + +describe('getRuleTitles', () => { + it('indexes rule names by the identity label PMM stamps on them', () => { + const titles = getRuleTitles( + rulesResponse([ + { name: 'CPU load', labels: { pmm_rule_id: 'rule-1' } }, + { name: 'Connections', labels: { pmm_rule_id: 'rule-2' } }, + ]) + ); + + expect(titles.get('rule-1')).toBe('CPU load'); + expect(titles.get('rule-2')).toBe('Connections'); + }); + + it('ignores rules that PMM did not create', () => { + const titles = getRuleTitles( + rulesResponse([{ name: 'Someone else rule' }]) + ); + + expect(titles.size).toBe(0); + }); +}); + +describe('getRows', () => { + // proto3 JSON omits zero values, so a threshold of 0 and a row that is not + // overridden both arrive with the field absent rather than 0/false. Left uncoerced + // the table would render blanks instead of numbers. + it('reads omitted numeric fields as zero rather than undefined', () => { + const data = { + thresholds: [{ ruleId: 'rule-1', paramName: 'threshold' }], + } as ListThresholdsResponse; + + const [first] = getRows(data, new Map()); + + expect(first.defaultValue).toBe(0); + expect(first.effectiveValue).toBe(0); + expect(first.isOverridden).toBe(false); + }); + + it('joins the rule title, and tolerates a rule that no longer exists', () => { + const data = { + thresholds: [ + { ruleId: 'rule-1', paramName: 'threshold' }, + { ruleId: 'deleted-rule', paramName: 'threshold' }, + ], + } as ListThresholdsResponse; + + const rows = getRows(data, new Map([['rule-1', 'CPU load']])); + + expect(rows[0].ruleTitle).toBe('CPU load'); + expect(rows[1].ruleTitle).toBe(''); + }); + + // Two rules duplicated in Grafana share a rule id, which the API explicitly + // permits, so rule and parameter together do not identify a row. Colliding ids + // would make one form field drive two rows. + it('gives duplicated rules distinct row ids', () => { + const data = { + thresholds: [ + { ruleId: 'rule-1', paramName: 'threshold' }, + { ruleId: 'rule-1', paramName: 'threshold' }, + ], + } as ListThresholdsResponse; + + const rows = getRows(data, new Map()); + + expect(rows[0].id).not.toBe(rows[1].id); + }); + + it('returns nothing when the response carries no thresholds', () => { + expect(getRows(undefined, new Map())).toEqual([]); + }); +}); + +describe('buildThresholdUpdates', () => { + it('sets a changed value', () => { + const rows = [row()]; + + expect( + buildThresholdUpdates(rows, { [rows[0].id]: 95 }, NODE, 'node-1') + ).toEqual([ + { + scope: NODE, + target: 'node-1', + ruleId: 'rule-1', + paramName: 'threshold', + value: 95, + }, + ]); + }); + + it('sends nothing when the value is unchanged', () => { + const rows = [row({ isOverridden: true, effectiveValue: 95 })]; + + expect( + buildThresholdUpdates(rows, { [rows[0].id]: 95 }, NODE, 'node-1') + ).toEqual([]); + }); + + // Omitting `value` clears the override. Writing the default as an override instead + // would pin the target to today's default and stop it following a later change to + // the rule. + it('clears by omitting the value when the field is emptied', () => { + const rows = [row({ isOverridden: true, effectiveValue: 95 })]; + + const updates = buildThresholdUpdates( + rows, + { [rows[0].id]: undefined }, + NODE, + 'node-1' + ); + + expect(updates).toHaveLength(1); + expect(updates[0]).not.toHaveProperty('value'); + }); + + it('clears when the default is typed back in', () => { + const rows = [row({ isOverridden: true, effectiveValue: 95 })]; + + const updates = buildThresholdUpdates( + rows, + { [rows[0].id]: 80 }, + NODE, + 'node-1' + ); + + expect(updates).toHaveLength(1); + expect(updates[0]).not.toHaveProperty('value'); + }); + + it('sends nothing when a row that was never overridden is left at the default', () => { + const rows = [row()]; + + expect( + buildThresholdUpdates(rows, { [rows[0].id]: 80 }, NODE, 'node-1') + ).toEqual([]); + }); + + it('treats an emptied string field as a clear, not as zero', () => { + const rows = [row({ isOverridden: true, effectiveValue: 95 })]; + + const updates = buildThresholdUpdates( + rows, + { [rows[0].id]: '' as unknown as number }, + NODE, + 'node-1' + ); + + expect(updates).toHaveLength(1); + expect(updates[0]).not.toHaveProperty('value'); + }); + + it('batches a set and a clear from one submission', () => { + const rows = [ + row({ id: 'a', ruleId: 'rule-1' }), + row({ + id: 'b', + ruleId: 'rule-2', + isOverridden: true, + effectiveValue: 95, + }), + ]; + + const updates = buildThresholdUpdates( + rows, + { a: 60, b: undefined }, + NODE, + 'node-1' + ); + + expect(updates).toHaveLength(2); + expect(updates[0]).toHaveProperty('value', 60); + expect(updates[1]).not.toHaveProperty('value'); + }); +}); diff --git a/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.ts b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.ts new file mode 100644 index 00000000000..aa436b8d9c7 --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/AlertThresholds.utils.ts @@ -0,0 +1,95 @@ +import type { + ListThresholdsResponse, + PrometheusAlertRulesResponse, + Threshold, + ThresholdUpdate, +} from 'types/alerting.types'; +import type { + AlertThresholdRow, + AlertThresholdsFormValues, +} from './AlertThresholds.types'; +import { UNIT_SYMBOLS } from './AlertThresholds.constants'; + +export const formatUnit = (unit?: string): string => + (unit && UNIT_SYMBOLS[unit]) || ''; + +// A rule can expose several overridable params, and two rules duplicated in Grafana +// share a rule id, so neither part alone identifies a row. The index disambiguates +// the duplicate case, which the API explicitly permits. +export const thresholdRowId = (t: Threshold, index: number): string => + `${t.ruleId}:${t.paramName}:${index}`; + +// Rule titles live in Grafana, not in the thresholds response, so they are joined +// on the identity label PMM stamps on every rule it creates. +export const getRuleTitles = ( + rulesData: PrometheusAlertRulesResponse +): Map => { + const titles = new Map(); + + for (const group of rulesData?.data?.groups ?? []) { + for (const rule of group.rules ?? []) { + const id = rule.labels?.pmm_rule_id; + if (id) { + titles.set(id, rule.name); + } + } + } + + return titles; +}; + +export const getRows = ( + data: ListThresholdsResponse | undefined, + ruleTitles: Map +) => + (data?.thresholds ?? []).map((t, index) => ({ + ...t, + // proto3 omits zero values, so absent means 0 rather than unknown. + defaultValue: t.defaultValue ?? 0, + effectiveValue: t.effectiveValue ?? 0, + isOverridden: t.isOverridden ?? false, + id: thresholdRowId(t, index), + ruleTitle: ruleTitles.get(t.ruleId) ?? '', + })); + +// Turns the submitted form into the smallest set of changes that expresses it. +// +// Emptying a field, or typing the default back in, returns the target to the rule +// default. That is only a change when an override exists today, and it is expressed by +// omitting `value` - writing the default as an override instead would pin the target to +// today's default and stop it following a later change to the rule. +export const buildThresholdUpdates = ( + rows: AlertThresholdRow[], + values: AlertThresholdsFormValues, + scope: ThresholdUpdate['scope'], + target: string +): ThresholdUpdate[] => { + const updates: ThresholdUpdate[] = []; + + for (const row of rows) { + const raw = values[row.id]; + const parsed = + raw === undefined || (raw as unknown) === '' ? undefined : Number(raw); + const cleared = parsed === undefined || Number.isNaN(parsed); + + const base = { + scope, + target, + ruleId: row.ruleId, + paramName: row.paramName, + }; + + if (cleared || parsed === row.defaultValue) { + if (row.isOverridden) { + updates.push(base); + } + continue; + } + + if (parsed !== row.effectiveValue) { + updates.push({ ...base, value: parsed }); + } + } + + return updates; +}; diff --git a/ui/apps/pmm/src/components/alert-thresholds/index.ts b/ui/apps/pmm/src/components/alert-thresholds/index.ts new file mode 100644 index 00000000000..c786945f8ff --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/index.ts @@ -0,0 +1 @@ +export { default } from './AlertThresholds'; diff --git a/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/ResetValueCell.tsx b/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/ResetValueCell.tsx new file mode 100644 index 00000000000..fe64741811c --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/ResetValueCell.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react'; +import type { + AlertThresholdRow, + AlertThresholdsFormValues, +} from '../AlertThresholds.types'; +import IconButton from '@mui/material/IconButton'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import { useFormContext } from 'react-hook-form'; +import { Messages } from '../AlertThresholds.messages'; + +interface Props { + row: AlertThresholdRow; +} + +const ResetValueCell: FC = ({ row }) => { + const { setValue } = useFormContext(); + + return ( + setValue(row.id, row.defaultValue)} + > + + + ); +}; + +export default ResetValueCell; diff --git a/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/index.ts b/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/index.ts new file mode 100644 index 00000000000..11ae2360c28 --- /dev/null +++ b/ui/apps/pmm/src/components/alert-thresholds/reset-value-cell/index.ts @@ -0,0 +1 @@ +export { default } from './ResetValueCell'; diff --git a/ui/apps/pmm/src/components/main/MainWithNav.tsx b/ui/apps/pmm/src/components/main/MainWithNav.tsx index 734c1536087..73bf1c6fe83 100644 --- a/ui/apps/pmm/src/components/main/MainWithNav.tsx +++ b/ui/apps/pmm/src/components/main/MainWithNav.tsx @@ -11,6 +11,7 @@ import { DelayedRender } from 'components/delayed-render'; import { SHOW_UPDATE_INFO_DELAY_MS } from 'lib/constants'; import { isRenderingServer } from '@pmm/shared'; import Header from './header/Header'; +import AlertThresholds from 'components/alert-thresholds'; const useMainNavVisible = () => { const { isLoggedIn } = useAuth(); @@ -52,6 +53,7 @@ export const MainWithNav = () => { + ); }; diff --git a/ui/apps/pmm/src/components/modal/Modal.tsx b/ui/apps/pmm/src/components/modal/Modal.tsx index 9be6fbc6abc..c4a50ea08ab 100644 --- a/ui/apps/pmm/src/components/modal/Modal.tsx +++ b/ui/apps/pmm/src/components/modal/Modal.tsx @@ -38,7 +38,11 @@ export const Modal: FC = ({ pb: 0, }} > - + {title} diff --git a/ui/apps/pmm/src/hooks/api/useNodeThresholds.ts b/ui/apps/pmm/src/hooks/api/useNodeThresholds.ts new file mode 100644 index 00000000000..9560529e01e --- /dev/null +++ b/ui/apps/pmm/src/hooks/api/useNodeThresholds.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + UseMutationOptions, + UseQueryOptions, +} from '@tanstack/react-query'; +import { batchUpdateThresholds, getThresholds } from 'api/alerting'; +import type { + BatchUpdateThresholdsResponse, + ListThresholdsResponse, + ThresholdUpdate, +} from 'types/alerting.types'; + +export const nodeThresholdsQueryKey = (nodeId: string) => [ + 'alerting:nodeThresholds', + nodeId, +]; + +// Asking for a target returns every overridable parameter for it, overridden or not, +// which is what the modal lists. Asking without one would return only existing +// overrides. +export const useNodeThresholds = ( + nodeId: string, + options?: Partial> +) => + useQuery({ + queryKey: nodeThresholdsQueryKey(nodeId), + queryFn: () => getThresholds('THRESHOLD_SCOPE_NODE', nodeId), + enabled: !!nodeId, + ...options, + }); + +// One transactional call for a whole form's worth of edits. Firing a request per row +// would leave the modal half-applied on a partial failure, with no way to report +// which rows took effect. +export const useBatchUpdateNodeThresholds = ( + nodeId: string, + options?: Partial< + UseMutationOptions + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['alerting:batchUpdateNodeThresholds', nodeId], + mutationFn: (updates: ThresholdUpdate[]) => batchUpdateThresholds(updates), + ...options, + onSuccess: async (data, variables, onMutate, context) => { + await options?.onSuccess?.(data, variables, onMutate, context); + await queryClient.invalidateQueries({ + queryKey: nodeThresholdsQueryKey(nodeId), + }); + }, + }); +}; diff --git a/ui/apps/pmm/src/types/alerting.types.ts b/ui/apps/pmm/src/types/alerting.types.ts index b9d5afdff97..e42dfb826d4 100644 --- a/ui/apps/pmm/src/types/alerting.types.ts +++ b/ui/apps/pmm/src/types/alerting.types.ts @@ -228,3 +228,50 @@ export interface GrafanaRulerRuleDTO { annotations?: GrafanaRulerAnnotations; labels?: GrafanaRulerLabels; } + +// Dynamic per-target alert thresholds (PMM backend /v1/alerting/thresholds API). +// Field names are camelCase because the shared `api` client applies +// axios-case-converter to the snake_case wire format. +export type ThresholdScope = + | 'THRESHOLD_SCOPE_UNSPECIFIED' + | 'THRESHOLD_SCOPE_NODE' + | 'THRESHOLD_SCOPE_SERVICE' + | 'THRESHOLD_SCOPE_CLUSTER'; + +// One overridable parameter of one rule, as it applies to one target. +// +// Numeric and boolean fields are optional because proto3 JSON omits zero values: +// a threshold of 0, or a row that is not overridden, arrives with the field absent +// rather than set to 0/false. +export interface Threshold { + ruleId: string; + paramName: string; + summary?: string; + // ParamUnit enum string, e.g. "PARAM_UNIT_PERCENTAGE". + unit?: string; + defaultValue?: number; + // Effective value for the target: the winning override, otherwise the default. + effectiveValue?: number; + isOverridden?: boolean; + // Scope and target the winning override was set at; absent when not overridden. + scope?: ThresholdScope; + target?: string; +} + +export interface ListThresholdsResponse { + thresholds?: Threshold[]; +} + +// One set-or-clear operation. Omitting `value` clears the override instead of +// setting it, returning the target to the rule default or to a broader override. +export interface ThresholdUpdate { + scope: ThresholdScope; + target: string; + ruleId: string; + paramName: string; + value?: number; +} + +export interface BatchUpdateThresholdsResponse { + thresholds?: Threshold[]; +} diff --git a/ui/packages/shared/src/messenger.ts b/ui/packages/shared/src/messenger.ts index 57c4b7afd82..a20c1f84330 100644 --- a/ui/packages/shared/src/messenger.ts +++ b/ui/packages/shared/src/messenger.ts @@ -44,6 +44,7 @@ export class CrossFrameMessenger { addListener(listener: MessageListener) { this.listeners.push(listener); + return listener; } removeListener(listener: MessageListener) { diff --git a/ui/packages/shared/src/types.ts b/ui/packages/shared/src/types.ts index ce3a9e9c9a0..c57fc84bbd4 100644 --- a/ui/packages/shared/src/types.ts +++ b/ui/packages/shared/src/types.ts @@ -14,7 +14,8 @@ export type MessageType = | 'FRONTEND_SETTINGS_CHANGED' | 'SERVICE_ADDED' | 'SERVICE_DELETED' - | 'TIMEZONE_CHANGED'; + | 'TIMEZONE_CHANGED' + | 'OPEN_ALERT_THRESHOLDS_MODAL'; export type LocationState = { fromGrafana?: boolean } | null; @@ -67,3 +68,8 @@ export type FrontendSettingsChangedMessage = Message<'FRONTEND_SETTINGS_CHANGED'>; export type ServiceAddedMessage = Message<'SERVICE_ADDED'>; + +export type OpenAlertThresholdsModalMessage = Message< + 'OPEN_ALERT_THRESHOLDS_MODAL', + { nodeId: string; nodeName: string } +>;