Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
d2b961819b feat(ruletypes): publish v2alpha1-only rule schema with required fields
- restrict the published OpenAPI schema for rules to the v2alpha1
  contract: drop v1-only fields (evalWindow, frequency,
  preferredChannels, version; condition.{target,op,matchType,targetUnit})
  and mark schemaVersion, evaluation, notificationSettings and
  condition.thresholds required
- default version to "v5" server-side so clients no longer send it
- make notificationSettings.renotify a pointer so an explicitly disabled
  renotify ({"enabled": false}) round-trips through the read path
- regenerate docs/api/openapi.yml and frontend generated clients; the
  frontend keeps sending legacy fields on the wire for v1 editor flows

Runtime JSON handling still accepts stored v1 rules; server-side removal
of schemaVersion v1 follows the storage migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:57:07 +05:30
9 changed files with 243 additions and 127 deletions

View File

@@ -7551,35 +7551,28 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
labels:
additionalProperties:
type: string
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
type: string
source:
type: string
version:
enum:
- v2alpha1
type: string
required:
- alert
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesQueryType:
enum:
@@ -7629,12 +7622,8 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
id:
type: string
labels:
@@ -7643,15 +7632,11 @@ components:
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
type: string
source:
enum:
- v2alpha1
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
@@ -7660,8 +7645,6 @@ components:
type: string
updatedBy:
type: string
version:
type: string
required:
- id
- state
@@ -7669,6 +7652,9 @@ components:
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesRuleCondition:
properties:
@@ -7681,10 +7667,6 @@ components:
type: string
compositeQuery:
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
matchType:
$ref: '#/components/schemas/RuletypesMatchType'
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
requireMinPoints:
type: boolean
requiredNumPoints:
@@ -7693,16 +7675,11 @@ components:
$ref: '#/components/schemas/RuletypesSeasonality'
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
type: string
thresholds:
$ref: '#/components/schemas/RuletypesRuleThresholdData'
required:
- compositeQuery
- thresholds
type: object
RuletypesRuleThresholdData:
discriminator:

View File

@@ -8644,6 +8644,9 @@ export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
export enum RuletypesPostableRuleDTOSchemaVersion {
v2alpha1 = 'v2alpha1',
}
export enum RuletypesSeasonalityDTO {
hourly = 'hourly',
daily = 'daily',
@@ -8678,8 +8681,6 @@ export interface RuletypesRuleConditionDTO {
*/
algorithm?: string;
compositeQuery: RuletypesAlertCompositeQueryDTO;
matchType?: RuletypesMatchTypeDTO;
op?: RuletypesCompareOperatorDTO;
/**
* @type boolean
*/
@@ -8693,16 +8694,7 @@ export interface RuletypesRuleConditionDTO {
* @type string
*/
selectedQueryName?: string;
/**
* @type number,null
* @format double
*/
target?: number | null;
/**
* @type string
*/
targetUnit?: string;
thresholds?: RuletypesRuleThresholdDataDTO;
thresholds: RuletypesRuleThresholdDataDTO;
}
export enum RuletypesRuleTypeDTO {
@@ -8729,43 +8721,27 @@ export interface RuletypesPostableRuleDTO {
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
evalWindow?: string;
evaluation?: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
*/
frequency?: string;
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type object
*/
labels?: RuletypesPostableRuleDTOLabels;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array
*/
preferredChannels?: string[];
notificationSettings: RuletypesNotificationSettingsDTO;
ruleType: RuletypesRuleTypeDTO;
/**
* @enum v2alpha1
* @type string
*/
schemaVersion?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
*/
version?: string;
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
export enum RuletypesRuleDTOSchemaVersion {
v2alpha1 = 'v2alpha1',
}
export interface RuletypesRuleDTO {
/**
* @type string
@@ -8794,15 +8770,7 @@ export interface RuletypesRuleDTO {
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
evalWindow?: string;
evaluation?: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
*/
frequency?: string;
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
*/
@@ -8811,20 +8779,13 @@ export interface RuletypesRuleDTO {
* @type object
*/
labels?: RuletypesRuleDTOLabels;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array
*/
preferredChannels?: string[];
notificationSettings: RuletypesNotificationSettingsDTO;
ruleType: RuletypesRuleTypeDTO;
/**
* @enum v2alpha1
* @type string
*/
schemaVersion?: string;
/**
* @type string
*/
source?: string;
schemaVersion: RuletypesRuleDTOSchemaVersion;
state: RuletypesAlertStateDTO;
/**
* @type string
@@ -8835,10 +8796,6 @@ export interface RuletypesRuleDTO {
* @type string
*/
updatedBy?: string;
/**
* @type string
*/
version?: string;
}
export enum RuletypesThresholdKindDTO {

View File

@@ -71,12 +71,13 @@ function ActionsMenu({
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
} as unknown as RuletypesPostableRuleDTO).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {
onEdit(newRule as AlertRule);
}
return newRule;
}),
{
loading: 'Cloning alert...',

View File

@@ -1,8 +1,10 @@
import {
RuletypesAlertStateDTO,
RuletypesAlertTypeDTO,
RuletypesEvaluationRollingDTOKind,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleDTOSchemaVersion,
RuletypesRuleTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
@@ -16,6 +18,10 @@ const baseCondition: RuletypesRuleConditionDTO = {
panelType: RuletypesPanelTypeDTO.graph,
queries: null,
},
thresholds: {
kind: 'basic',
spec: [{ name: 'critical', target: 90, matchType: '1', op: '1' }],
},
} as unknown as RuletypesRuleConditionDTO;
const make = (
@@ -34,9 +40,12 @@ const make = (
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'info' },
annotations: {},
source: '',
evalWindow: '5m0s',
frequency: '1m0s',
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
evaluation: {
kind: RuletypesEvaluationRollingDTOKind.rolling,
spec: { evalWindow: '5m0s', frequency: '1m0s' },
},
notificationSettings: {},
ruleType: RuletypesRuleTypeDTO.threshold_rule,
...overrides,
});

View File

@@ -61,6 +61,22 @@ function stripUndefinedLabels(
return out;
}
// why: the published DTO carries only the v2alpha1 contract, but the server
// still accepts the legacy v1 fields (evalWindow, frequency,
// preferredChannels, version, and a free-form schemaVersion) that the v1
// editor flows send. LegacyRuleFields keeps those on the wire until
// schemaVersion v1 is removed from the backend.
type LegacyRuleFields = {
evalWindow?: string;
frequency?: string;
preferredChannels?: string[];
version?: string;
schemaVersion?: string;
description?: string;
notificationSettings?: RuletypesPostableRuleDTO['notificationSettings'];
evaluation?: RuletypesPostableRuleDTO['evaluation'];
};
// why: local PostableAlertRuleV2/AlertDef diverge from RuletypesPostableRuleDTO
// in several spots that match by string value but not by nominal TS type —
// condition.{op,matchType}, evaluation.kind, notificationSettings.renotify.alertStates.
@@ -69,24 +85,24 @@ function stripUndefinedLabels(
export function toPostableRuleDTO(
local: PostableAlertRuleV2,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
const legacy = local as unknown as LegacyRuleFields;
const payload: Record<string, unknown> = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
condition: local.condition,
annotations: local.annotations,
labels: stripUndefinedLabels(local.labels),
evalWindow: (local as unknown as RuletypesPostableRuleDTO).evalWindow,
frequency: (local as unknown as RuletypesPostableRuleDTO).frequency,
preferredChannels: (local as unknown as RuletypesPostableRuleDTO)
.preferredChannels,
evalWindow: legacy.evalWindow,
frequency: legacy.frequency,
preferredChannels: legacy.preferredChannels,
notificationSettings: local.notificationSettings,
evaluation: local.evaluation,
schemaVersion: local.schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
description: legacy.description,
};
return payload as unknown as RuletypesPostableRuleDTO;
}
@@ -94,7 +110,8 @@ export function toPostableRuleDTO(
export function toPostableRuleDTOFromAlertDef(
local: AlertDef,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
const legacy = local as unknown as LegacyRuleFields;
const payload: Record<string, unknown> = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
@@ -104,16 +121,15 @@ export function toPostableRuleDTOFromAlertDef(
evalWindow: local.evalWindow,
frequency: local.frequency,
preferredChannels: local.preferredChannels,
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
.notificationSettings,
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
notificationSettings: legacy.notificationSettings,
evaluation: legacy.evaluation,
schemaVersion: legacy.schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
description: legacy.description,
};
return payload;
return payload as unknown as RuletypesPostableRuleDTO;
}
export function fromRuleDTOToPostableRuleV2(

View File

@@ -76,9 +76,12 @@ type PostableRule struct {
}
type NotificationSettings struct {
GroupBy []string `json:"groupBy,omitempty"`
Renotify Renotify `json:"renotify,omitzero"`
UsePolicy bool `json:"usePolicy,omitempty"`
GroupBy []string `json:"groupBy,omitempty"`
// Renotify is a pointer so that an explicitly disabled renotify
// ({"enabled": false}) survives the read path; a value struct with
// omitzero would drop it from every response.
Renotify *Renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy,omitempty"`
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
}
@@ -92,7 +95,7 @@ type Renotify struct {
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {
var renotifyInterval time.Duration
var noDataRenotifyInterval time.Duration
if ns.Renotify.Enabled {
if ns.Renotify != nil && ns.Renotify.Enabled {
if slices.Contains(ns.Renotify.AlertStates, StateNoData) {
noDataRenotifyInterval = ns.Renotify.ReNotifyInterval.Duration()
}
@@ -204,10 +207,12 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
}
// Validate states after unmarshaling
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
if ns.Renotify != nil {
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
}
}
}
return nil
@@ -220,6 +225,12 @@ func (r *PostableRule) processRuleDefaults() {
r.SchemaVersion = DefaultSchemaVersion
}
// v5 is the only supported query version; default it so clients
// don't have to send it.
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
@@ -271,7 +282,7 @@ func (r *PostableRule) processRuleDefaults() {
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: Renotify{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
@@ -557,7 +568,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else {
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
if r.NotificationSettings.Renotify != nil && r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}

View File

@@ -2,6 +2,7 @@ package ruletypes
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -1327,3 +1328,105 @@ func TestAnomalyNegationEval(t *testing.T) {
})
}
}
// TestRenotifyRoundTrip ensures notificationSettings.renotify survives the
// v2 read path (stored JSON -> GettableRule -> NewRule -> marshal) exactly as
// stored, including an explicitly disabled renotify with no interval.
func TestRenotifyRoundTrip(t *testing.T) {
base := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": %s
}`
cases := []struct {
name string
settings string
wantRenotify string
}{
{
name: "absent renotify stays absent",
settings: `{"usePolicy": false}`,
wantRenotify: "",
},
{
name: "explicitly disabled renotify is echoed",
settings: `{"renotify": {"enabled": false}}`,
wantRenotify: `{"enabled":false}`,
},
{
name: "enabled renotify with states is echoed",
settings: `{"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}`,
wantRenotify: `{"enabled":true,"interval":"30m","alertStates":["firing"]}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stored := strings.Replace(base, "%s", tc.settings, 1)
g := GettableRule{}
if err := json.Unmarshal([]byte(stored), &g); err != nil {
t.Fatalf("unmarshal stored rule: %v", err)
}
out, err := json.Marshal(NewRule(&g))
if err != nil {
t.Fatalf("marshal v2 rule: %v", err)
}
var resp struct {
NotificationSettings struct {
Renotify json.RawMessage `json:"renotify"`
} `json:"notificationSettings"`
}
if err := json.Unmarshal(out, &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
got := string(resp.NotificationSettings.Renotify)
if got != tc.wantRenotify {
t.Errorf("renotify round-trip mismatch: got %q, want %q", got, tc.wantRenotify)
}
})
}
}
// TestVersionDefaultsToV5 ensures rules posted without a version parse and
// validate with version defaulted to v5.
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
rule := PostableRule{}
if err := json.Unmarshal([]byte(content), &rule); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rule.Version != "v5" {
t.Errorf("expected version to default to v5, got %q", rule.Version)
}
if err := rule.Validate(); err != nil {
t.Errorf("expected rule without version to validate, got %v", err)
}
}

View File

@@ -0,0 +1,46 @@
package ruletypes
import (
"github.com/swaggest/jsonschema-go"
)
var (
_ jsonschema.Preparer = (*PostableRule)(nil)
_ jsonschema.Preparer = (*RuleCondition)(nil)
)
// PrepareJSONSchema restricts the published rule schema to the v2alpha1
// contract: the v1-only fields (evalWindow, frequency, preferredChannels) and
// the query version (always "v5", defaulted by the server) are omitted, and
// the fields the server requires for schemaVersion v2alpha1 are marked
// required. Runtime JSON handling is unchanged and continues to load stored
// v1 rules until schemaVersion v1 is removed.
//
// Rule and GettableRule embed PostableRule, so this also applies to the
// published read models through method promotion.
func (r *PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
for _, name := range []string{"evalWindow", "frequency", "preferredChannels", "version", "source"} {
delete(schema.Properties, name)
}
schema.Required = append(schema.Required, "schemaVersion", "evaluation", "notificationSettings")
if prop, ok := schema.Properties["schemaVersion"]; ok && prop.TypeObject != nil {
prop.TypeObject.WithEnum(SchemaVersionV2Alpha1)
}
return nil
}
// PrepareJSONSchema removes the v1-only condition fields (target, op,
// matchType, targetUnit — expressed per threshold entry in v2alpha1) from the
// published schema and marks thresholds required.
func (rc *RuleCondition) PrepareJSONSchema(schema *jsonschema.Schema) error {
for _, name := range []string{"target", "op", "matchType", "targetUnit"} {
delete(schema.Properties, name)
}
schema.Required = append(schema.Required, "thresholds")
return nil
}

View File

@@ -169,12 +169,10 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "alert",
},
// only "v5" is allowed
// only "v5" is allowed; missing/empty defaults to "v5"
{
name: "missing version",
json: removeField(validV1Builder(), "version"),
wantErr: true,
errSubstr: "version",
name: "missing version defaults to v5",
json: removeField(validV1Builder(), "version"),
},
{
name: "wrong version v4",
@@ -189,10 +187,8 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "version",
},
{
name: "empty version",
json: patchJSON(validV1Builder(), `{"version": ""}`),
wantErr: true,
errSubstr: "version",
name: "empty version defaults to v5",
json: patchJSON(validV1Builder(), `{"version": ""}`),
},
// alert type, capital case to avoid breaking changes