Compare commits

...

4 Commits

Author SHA1 Message Date
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
11 changed files with 542 additions and 0 deletions

View File

@@ -24556,6 +24556,77 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller. Cloning fails with 400 when the stored source
no longer passes create validation, for example when a channel it references
has been deleted.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -14202,6 +14202,17 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

View File

@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},

View File

@@ -7,6 +7,7 @@ type Handler interface {
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
CloneRuleByID(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)

View File

@@ -29,6 +29,9 @@ type Ruler interface {
// model to store structured data.
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
// EditRule replaces the rule identified by id with the given JSON string.
// TODO: same as CreateRule — accept PostableRule instead of raw string.
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error

View File

@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.CloneRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -2,10 +2,12 @@ package signozruler
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -18,6 +20,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -128,6 +131,39 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
return provider.manager.CreateRule(ctx, ruleStr)
}
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return nil, err
}
postableRule, err := storedRule.ToPostableRuleForCloning()
if err != nil {
return nil, err
}
if err := postableRule.Validate(); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "source rule %s is not valid", id.StringValue())
}
body, err := json.Marshal(postableRule)
if err != nil {
return nil, err
}
return provider.manager.CreateRule(ctx, string(body))
}
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
return provider.manager.EditRule(ctx, ruleStr, id)
}

View File

@@ -3,6 +3,9 @@ package ruletypes
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/types"
@@ -10,6 +13,8 @@ import (
"github.com/uptrace/bun"
)
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
type StorableRule struct {
// The alias must stay rule: the list filter compiler emits rule.<col> refs.
bun.BaseModel `bun:"table:rule,alias:rule"`
@@ -48,6 +53,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
return stats
}
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
postable := &PostableRule{}
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
return nil, err
}
postable.AlertName = nextCloneAlertName(postable.AlertName)
return postable, nil
}
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
type RuleAlert struct {
AlertName string
@@ -72,3 +86,19 @@ type RuleStore interface {
UpdateRuleView(context.Context, *RuleView) error
DeleteRuleView(context.Context, valuer.UUID, valuer.UUID) error
}
func nextCloneAlertName(name string) string {
base, count := name, 0
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
if count++; count > 1 {
return fmt.Sprintf("%s - Copy (%d)", base, count)
}
return base + " - Copy"
}

View File

@@ -0,0 +1,115 @@
package ruletypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNextCloneAlertName(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{name: "PlainName_CopySuffixAppended", input: "High CPU", expected: "High CPU - Copy"},
{name: "CopySuffix_BumpedToTwo", input: "High CPU - Copy", expected: "High CPU - Copy (2)"},
{name: "NumberedSuffix_Incremented", input: "High CPU - Copy (2)", expected: "High CPU - Copy (3)"},
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
{name: "LongName_NotTruncated", input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters", expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, nextCloneAlertName(testCase.input))
})
}
}
func TestStorableRule_ToPostableRuleForCloning(t *testing.T) {
v1Stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"description": "watches cpu",
"ruleType": "threshold_rule",
"version": "v5",
"evalWindow": "10m",
"frequency": "2m",
"disabled": true,
"source": "https://signoz.example.com/alerts/new",
"labels": {"team": "infra", "severity": "warning"},
"annotations": {"summary": "cpu above {{$threshold}}"},
"preferredChannels": ["slack-infra"],
"condition": {
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu_usage", "timeAggregation": "avg", "spaceAggregation": "max"}]}}]
},
"selectedQueryName": "A",
"op": "1",
"target": 90,
"matchType": "1",
"alertOnAbsent": true,
"absentFor": 5
}
}`
v2Stored := `{
"alert": "cpu high - Copy",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "promql_rule",
"schemaVersion": "v2alpha1",
"version": "v5",
"disabled": false,
"labels": {"team": "infra"},
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
"panelType": "graph",
"queryType": "promql"
},
"selectedQueryName": "A",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}},
"notificationSettings": {"groupBy": ["service.name"], "renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing"]}, "usePolicy": true}
}`
testCases := []struct {
name string
stored string
expectedName string
expectErr bool
}{
{name: "V1Rule_Cloned_NameSuffixedRestIntact", stored: v1Stored, expectedName: "cpu high - Copy"},
{name: "V2Alpha1Rule_Cloned_NameBumpedRestIntact", stored: v2Stored, expectedName: "cpu high - Copy (2)"},
{name: "InvalidJSON_Error", stored: `{"alert": `, expectErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &StorableRule{Data: testCase.stored}
cloned, err := rule.ToPostableRuleForCloning()
if testCase.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
expected := &PostableRule{}
require.NoError(t, json.Unmarshal([]byte(testCase.stored), expected))
expected.AlertName = testCase.expectedName
marshaled, err := json.Marshal(cloned)
require.NoError(t, err)
roundTripped := &PostableRule{}
require.NoError(t, json.Unmarshal(marshaled, roundTripped))
assert.Equal(t, expected, roundTripped)
})
}
}

View File

@@ -0,0 +1,161 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.alerts import delete_all_rules
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
CHANNEL_NAME = f"clone-rule-channel-{uuid.uuid4()}"
SOURCE_RULE = {
"alert": "clone me",
"description": "source rule for clone",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [CHANNEL_NAME]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "clone_rule_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
},
"version": "v5",
"schemaVersion": "v2alpha1",
}
def test_clone_rule(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
delete_all_rules(signoz, token)
create_notification_channel({"name": CHANNEL_NAME, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]})
source_id = create_alert_rule(SOURCE_RULE)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
source = response.json()["data"]
# clone keeps everything but the id and name
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone = response.json()["data"]
assert clone["id"] != source_id
assert clone["alert"] == "clone me - Copy"
for field in ["description", "alertType", "ruleType", "disabled", "labels", "annotations", "condition", "evaluation", "notificationSettings"]:
assert clone[field] == source[field], field
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["alert"] == "clone me - Copy"
assert response.json()["data"]["state"] == "disabled"
assert response.json()["data"]["createdBy"] == USER_ADMIN_EMAIL
# cloning a copy bumps the counter
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
second_clone = response.json()["data"]
assert second_clone["alert"] == "clone me - Copy (2)"
assert second_clone["condition"] == source["condition"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
# a source whose channel was deleted since no longer passes create validation
stale_channel_name = f"clone-rule-stale-{uuid.uuid4()}"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json={"name": stale_channel_name, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
stale_channel_id = response.json()["data"]["id"]
stale_rule = {
**SOURCE_RULE,
"alert": "stale channel",
"condition": {**SOURCE_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{**SOURCE_RULE["condition"]["thresholds"]["spec"][0], "channels": [stale_channel_name]}]}},
"notificationSettings": {**SOURCE_RULE["notificationSettings"], "usePolicy": True},
}
stale_rule_id = create_alert_rule(stale_rule)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{stale_channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{stale_rule_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert stale_channel_name in response.json()["error"]["message"]
for rule_id in [clone["id"], second_clone["id"]]:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text