Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,52 @@ servers:

paths:
/rules:
patch:
operationId: BulkUpdateAlertRules
summary: Bulk update alert rules
description: >
Updates one or more alert rules by their stable IDs. Each rule is
updated independently; per-rule status is returned in the response
so partial success is visible to the caller.
Supports label overrides, drop/restore toggles (platform rules only),
and classification label updates.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BulkUpdateAlertRulesRequest"
responses:
"200":
description: Update results (may include per-rule errors)
content:
application/json:
schema:
$ref: "#/components/schemas/BulkUpdateAlertRulesResponse"
"400":
description: Invalid request body
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Missing or invalid authorization token
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"413":
description: Request body exceeds the 1 MB limit
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Unexpected server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: BulkDeleteUserDefinedAlertRules
summary: Bulk delete user-defined alert rules
Expand Down Expand Up @@ -203,6 +249,9 @@ components:
description: The stable alert rule ID that was processed.
statusCode:
type: integer
format: int32
minimum: 100
maximum: 599
description: HTTP status code for this rule's deletion result.
message:
type: string
Expand All @@ -219,6 +268,97 @@ components:
$ref: "#/components/schemas/DeleteAlertRuleResult"
description: Per-rule deletion results.

AlertRuleClassificationUpdate:
type: object
description: >
Partial update for alert rule classification labels.
Each field supports three states: omitted (leave unchanged),
null (clear the override), or a string value (set the override).
The three-state semantics require a custom JSON decoder; the Go
type AlertRuleClassificationPatch is used at runtime instead of
the generated struct.
x-go-type: AlertRuleClassificationPatch
properties:
openshift_io_alert_rule_component:
type: string
nullable: true
description: Component classification label override.
openshift_io_alert_rule_layer:
type: string
nullable: true
description: Layer classification label override.
openshift_io_alert_rule_component_from:
type: string
nullable: true
description: Dynamic component source label key.
openshift_io_alert_rule_layer_from:
type: string
nullable: true
description: Dynamic layer source label key.

BulkUpdateAlertRulesRequest:
type: object
required:
- ruleIds
properties:
ruleIds:
type: array
minItems: 1
maxItems: 100
items:
type: string
description: List of stable alert rule IDs to update (at most 100 per request).
labels:
type: object
additionalProperties:
type: string
nullable: true
description: >
Label key/value pairs to set. A null or empty-string value removes
the label. Omitting this field leaves existing labels unchanged.
alertingRuleEnabled:
type: boolean
nullable: true
description: >
When false, drops (silences) the platform alert rule.

@PeterYurkovich PeterYurkovich Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is accurate to state that dropping an alert silences it, they are two separate actions

When true, restores a previously dropped rule.
Not applicable to user-defined rules; if set on a user-defined
rule alongside other update fields (labels, classification) that
succeed, the toggle rejection is silently absorbed and the
overall per-rule result is still 204.
classification:
$ref: "#/components/schemas/AlertRuleClassificationUpdate"

UpdateAlertRuleResult:
type: object
required:
- id
- statusCode
properties:
id:
type: string
description: The stable alert rule ID that was processed.
statusCode:
type: integer
format: int32
minimum: 100
maximum: 599
description: HTTP status code for this rule's update result.
message:
type: string
description: Error message if update failed; omitted on success.

BulkUpdateAlertRulesResponse:
type: object
required:
- rules
properties:
rules:
type: array
items:
$ref: "#/components/schemas/UpdateAlertRuleResult"
description: Per-rule update results.

ErrorResponse:
type: object
required:
Expand Down
223 changes: 223 additions & 0 deletions internal/managementrouter/alert_rule_bulk_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package managementrouter

import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"

monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1"

"github.com/openshift/monitoring-plugin/pkg/management"
)

func (hr *httpRouter) BulkUpdateAlertRules(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, maxRequestBodyBytes)

body, err := io.ReadAll(req.Body)
if err != nil {
writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
return
}

// BulkUpdateAlertRulesRequest.Classification is typed as
// *AlertRuleClassificationPatch (via x-go-type in the spec), so the
// three-state omitted/null/string semantics are preserved on decode.
var payload BulkUpdateAlertRulesRequest
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we include the error in the response as well so that rather than trying to guess at what went wrong the caller can directly make an adjustment

return
}

if len(payload.RuleIds) == 0 {
writeError(w, http.StatusBadRequest, "ruleIds is required")
return
}
if len(payload.RuleIds) > maxBulkUpdateRuleIds {
writeError(w, http.StatusBadRequest, "ruleIds exceeds maximum of 100")
return
}

if payload.AlertingRuleEnabled == nil && payload.Labels == nil && payload.Classification == nil {
writeError(w, http.StatusBadRequest, "alertingRuleEnabled (toggle drop/restore) or labels (set/unset) or classification is required")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
writeError(w, http.StatusBadRequest, "alertingRuleEnabled (toggle drop/restore) or labels (set/unset) or classification is required")
writeError(w, http.StatusBadRequest, "one of alertingRuleEnabled (toggle drop/restore) or labels (set/unset) or classification is required")

return
}

var haveToggle bool
var enabled bool
if payload.AlertingRuleEnabled != nil {
enabled = *payload.AlertingRuleEnabled
haveToggle = true
}

results := make([]UpdateAlertRuleResult, 0, len(payload.RuleIds))

for _, rawId := range payload.RuleIds {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are looping through up to 100 alert rules I think rather than repeatedly trying to perform an update only to get a permission failure it would be best to either cache the permission failures or perform the permission checks ahead of time

id := strings.TrimSpace(rawId)
if id == "" {
msg := "missing ruleId"
results = append(results, UpdateAlertRuleResult{
Id: rawId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this just be a whitespace only string?

StatusCode: int32(http.StatusBadRequest),
Message: &msg,
})
continue
}

notAllowedEnabled := false
if haveToggle {
var derr error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other checks use err, I don't see any reason to have this one have a different name

if !enabled {
derr = hr.managementClient.DropPlatformAlertRule(req.Context(), id)
} else {
derr = hr.managementClient.RestorePlatformAlertRule(req.Context(), id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it not possible to drop/restore user ar's? For example if an ar is gitops or operator managed?

}
if derr != nil {
var na *management.NotAllowedError
if errors.As(derr, &na) {
notAllowedEnabled = true
} else {
status, message := parseError(derr)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}
}
}

if payload.Classification != nil {
cl := payload.Classification
update := management.UpdateRuleClassificationRequest{RuleId: id}
if cl.ComponentSet {
update.Component = cl.Component
update.ComponentSet = true
}
if cl.LayerSet {
update.Layer = cl.Layer
update.LayerSet = true
}
if cl.ComponentFromSet {
update.ComponentFrom = cl.ComponentFrom
update.ComponentFromSet = true
}
if cl.LayerFromSet {
update.LayerFrom = cl.LayerFrom
update.LayerFromSet = true
}

if update.ComponentSet || update.LayerSet || update.ComponentFromSet || update.LayerFromSet {
if err := hr.managementClient.UpdateAlertRuleClassification(req.Context(), update); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think looking more at the code it would be best to have the label update code and the drop/restore code also flow through a single function similar to UpdateAlertRuleClassification for the classification rather than being split into platform and user functions which are called in this http method. The difference between user and platform seems that it is being mixed in with whether something is gitops or operator managed. I think having a single point of entry for those actions which can perform all of the validation and route the actions to user or platform would help clarify the codepaths quite a bit

status, message := parseError(err)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}
}
}

if payload.Labels != nil {
currentRule, err := hr.managementClient.GetRuleById(req.Context(), id)
if err != nil {
status, message := parseError(err)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}

// platformLabels uses "" to signal "drop this label"; the management
// layer's UpdatePlatformAlertRule interprets "" as a delete directive.
// userLabels is the fully-merged map for user-defined rules where we
// simply omit deleted keys rather than set them to "".
platformLabels := make(map[string]string)
userLabels := make(map[string]string)
for k, v := range currentRule.Labels {
userLabels[k] = v
}
for k, pv := range *payload.Labels {
if pv == nil || *pv == "" {
platformLabels[k] = ""
delete(userLabels, k)
} else {
platformLabels[k] = *pv
userLabels[k] = *pv
}
}

updatedPlatformRule := monitoringv1.Rule{Labels: platformLabels}

err = hr.managementClient.UpdatePlatformAlertRule(req.Context(), id, updatedPlatformRule)
if err != nil {
var ve *management.ValidationError
var nf *management.NotFoundError
if errors.As(err, &ve) || errors.As(err, &nf) {
status, message := parseError(err)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}

var na *management.NotAllowedError
if errors.As(err, &na) {
updatedUserRule := currentRule
updatedUserRule.Labels = userLabels

newRuleId, err := hr.managementClient.UpdateUserDefinedAlertRule(req.Context(), id, updatedUserRule)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we determine if an alert rule is a platform or user defined ar before just trying platform and then falling back to user on a not allowed error?

if err != nil {
status, message := parseError(err)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}
results = append(results, UpdateAlertRuleResult{
Id: newRuleId,
StatusCode: int32(http.StatusNoContent),
})
continue
}

status, message := parseError(err)
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(status),
Message: &message,
})
continue
}
}

if notAllowedEnabled && payload.Labels == nil && payload.Classification == nil {
results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(http.StatusMethodNotAllowed),
})
continue
}

results = append(results, UpdateAlertRuleResult{
Id: id,
StatusCode: int32(http.StatusNoContent),
})
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(BulkUpdateAlertRulesResponse{Rules: results}); err != nil {
log.WithError(err).Warn("failed to encode bulk update response")
}
}
Loading