diff --git a/api/openapi.yaml b/api/openapi.yaml index 7e6901b90..c4011d821 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,6 +10,9 @@ info: servers: - url: /api/v1/alerting +security: + - bearerAuth: [] + paths: /rules: patch: @@ -158,6 +161,79 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /rules/preview: + post: + operationId: PreviewAlertRule + summary: Preview a single alert rule create or update + description: > + Calculates the changes required to create or update one alert rule + without modifying cluster resources. Provide alertingRule (and optional + prometheusRule) to preview creation; provide ruleId plus at least one + update field to preview an update. Works for externally managed + resources — the response indicates whether the API can persist the + change (writable) and lists the required changes. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewAlertRuleRequest" + responses: + "200": + description: Preview of the required changes + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewAlertRuleResponse" + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Missing or invalid authorization token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "403": + description: Forbidden (insufficient RBAC permissions) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "405": + description: Operation not allowed for this rule type + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Conflict (e.g. duplicate rule ID) + 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" + /rules/{ruleId}: parameters: - name: ruleId @@ -519,6 +595,113 @@ components: $ref: "#/components/schemas/UpdateAlertRuleResult" description: Per-rule update results. + PreviewAlertRuleRequest: + type: object + description: > + Preview a single alert rule create or update. For create preview, + set alertingRule and optionally prometheusRule (omit prometheusRule for + platform rules). For update preview, set ruleId and at least one of + labels, alertingRuleEnabled, or classification. + properties: + ruleId: + type: string + description: Stable alert rule ID for update preview. + alertingRule: + $ref: "#/components/schemas/AlertRuleSpec" + prometheusRule: + $ref: "#/components/schemas/PrometheusRuleTarget" + labels: + type: object + additionalProperties: + type: string + nullable: true + description: Label overrides for update preview. + alertingRuleEnabled: + type: boolean + nullable: true + description: Drop/restore toggle for update preview (platform rules only). + classification: + $ref: "#/components/schemas/AlertRuleClassificationUpdate" + + PreviewAlertRuleResponse: + type: object + required: + - writable + - resources + - desiredRule + properties: + writable: + type: boolean + description: Whether the Alerts Management API can persist this change. + managedBy: + type: string + enum: [gitops, operator] + description: External management source when the target is not writable. + resources: + type: array + items: + $ref: "#/components/schemas/PreviewResourceChange" + description: > + Kubernetes resources that would be created or modified by this + operation. A single alert operation may affect multiple resources. + desiredRule: + $ref: "#/components/schemas/AlertRuleSpec" + description: > + Effective resulting alert rule after applying the requested change. + Suitable for display without reconstructing from per-resource changes. + + PreviewResourceChange: + type: object + required: + - resource + - changes + properties: + resource: + $ref: "#/components/schemas/PreviewTargetResource" + changes: + type: array + items: + $ref: "#/components/schemas/RuleChange" + desiredObject: + type: object + description: > + Complete resulting Kubernetes object for this resource entry. + Omitted when the resource would be deleted. + + PreviewTargetResource: + type: object + required: + - apiVersion + - kind + - name + properties: + apiVersion: + type: string + kind: + type: string + namespace: + type: string + name: + type: string + + RuleChange: + type: object + required: + - field + - operation + properties: + field: + type: string + description: > + Semantic alert-rule field path (e.g. severity, labels.severity, for, rule). + operation: + type: string + enum: [add, replace, remove] + currentValue: + description: Value before the change, for replace or remove operations. + newValue: + description: Value after the change, for add or replace operations. + ErrorResponse: type: object required: @@ -527,3 +710,12 @@ components: error: type: string description: Human-readable error message. + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + OpenShift user bearer token in the Authorization header + (forwarded by the console bridge). diff --git a/docs/alert-management.md b/docs/alert-management.md index a8f84737b..c651fbeb8 100644 --- a/docs/alert-management.md +++ b/docs/alert-management.md @@ -45,9 +45,20 @@ The plugin intentionally reads from only the in-cluster Alertmanager endpoints. | Operation | Single | Bulk | |---|---|---| | Create | `POST /api/v1/alerting/rules` | n/a | +| Preview | `POST /api/v1/alerting/rules/preview` | n/a | | Update (labels, drop/restore, classification) | `PATCH /api/v1/alerting/rules/{ruleId}` | `PATCH /api/v1/alerting/rules` | | Delete | `DELETE /api/v1/alerting/rules/{ruleId}` | `DELETE /api/v1/alerting/rules` | +**Preview** (`POST /rules/preview`): +- Dry-run create or update without persisting cluster changes. +- Create preview: `alertingRule` plus optional `prometheusRule`. +- Update preview: `ruleId` plus at least one of `labels`, + `alertingRuleEnabled`, or `classification`. +- Response includes `writable`, optional `managedBy`, `resources[]` + (per-resource `changes[]` and `desiredObject`), and `desiredRule`. +- Externally managed rules return `writable: false` with `managedBy` set + so UIs can show the plan without implying the API will apply it. + **Single update** (`PATCH /rules/{ruleId}`): - Request body uses `UpdateAlertRuleRequest` (labels and/or classification, or `alertingRuleEnabled` alone for drop/restore). diff --git a/go.mod b/go.mod index bcc7afc8a..1d9e70946 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/evanphx/json-patch v4.12.0+incompatible github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 + github.com/oapi-codegen/runtime v1.7.0 github.com/openshift/api v0.0.0-20251122153900-88cca31a44c9 github.com/openshift/client-go v0.0.0-20251123231646-4685125c2287 github.com/openshift/library-go v0.0.0-20240905123346-5bdbfe35a6f5 @@ -23,6 +24,7 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -70,7 +72,7 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect - golang.org/x/time v0.13.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index f514ad649..2344d72e2 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,11 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= @@ -44,6 +47,7 @@ github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -129,6 +133,7 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= @@ -149,6 +154,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= @@ -195,6 +204,7 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -258,8 +268,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= -golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/managementrouter/api_generated.go b/internal/managementrouter/api_generated.go index 093ac1dee..3ba16862b 100644 --- a/internal/managementrouter/api_generated.go +++ b/internal/managementrouter/api_generated.go @@ -8,8 +8,48 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/oapi-codegen/runtime" ) +// Defines values for PreviewAlertRuleResponseManagedBy. +const ( + Gitops PreviewAlertRuleResponseManagedBy = "gitops" + Operator PreviewAlertRuleResponseManagedBy = "operator" +) + +// Valid indicates whether the value is a known member of the PreviewAlertRuleResponseManagedBy enum. +func (e PreviewAlertRuleResponseManagedBy) Valid() bool { + switch e { + case Gitops: + return true + case Operator: + return true + default: + return false + } +} + +// Defines values for RuleChangeOperation. +const ( + Add RuleChangeOperation = "add" + Remove RuleChangeOperation = "remove" + Replace RuleChangeOperation = "replace" +) + +// Valid indicates whether the value is a known member of the RuleChangeOperation enum. +func (e RuleChangeOperation) Valid() bool { + switch e { + case Add: + return true + case Remove: + return true + case Replace: + return true + default: + return false + } +} + // AlertRuleClassificationUpdate 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. type AlertRuleClassificationUpdate = AlertRuleClassificationPatch @@ -103,6 +143,62 @@ type ErrorResponse struct { Error string `json:"error"` } +// PreviewAlertRuleRequest Preview a single alert rule create or update. For create preview, set alertingRule and optionally prometheusRule (omit prometheusRule for platform rules). For update preview, set ruleId and at least one of labels, alertingRuleEnabled, or classification. +type PreviewAlertRuleRequest struct { + // AlertingRule Specification of a Prometheus alerting or recording rule. Maps to prometheus-operator Rule fields. + AlertingRule *AlertRuleSpec `json:"alertingRule,omitempty"` + + // AlertingRuleEnabled Drop/restore toggle for update preview (platform rules only). + AlertingRuleEnabled *bool `json:"alertingRuleEnabled,omitempty"` + + // Classification 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. + Classification *AlertRuleClassificationUpdate `json:"classification,omitempty"` + + // Labels Label overrides for update preview. + Labels *map[string]*string `json:"labels,omitempty"` + + // PrometheusRule Identifies the PrometheusRule resource and rule group where the alert rule will be stored. Required for user-defined alert rules. + PrometheusRule *PrometheusRuleTarget `json:"prometheusRule,omitempty"` + + // RuleId Stable alert rule ID for update preview. + RuleId *string `json:"ruleId,omitempty"` +} + +// PreviewAlertRuleResponse defines model for PreviewAlertRuleResponse. +type PreviewAlertRuleResponse struct { + // DesiredRule Specification of a Prometheus alerting or recording rule. Maps to prometheus-operator Rule fields. + DesiredRule AlertRuleSpec `json:"desiredRule"` + + // ManagedBy External management source when the target is not writable. + ManagedBy *PreviewAlertRuleResponseManagedBy `json:"managedBy,omitempty"` + + // Resources Kubernetes resources that would be created or modified by this operation. A single alert operation may affect multiple resources. + Resources []PreviewResourceChange `json:"resources"` + + // Writable Whether the Alerts Management API can persist this change. + Writable bool `json:"writable"` +} + +// PreviewAlertRuleResponseManagedBy External management source when the target is not writable. +type PreviewAlertRuleResponseManagedBy string + +// PreviewResourceChange defines model for PreviewResourceChange. +type PreviewResourceChange struct { + Changes []RuleChange `json:"changes"` + + // DesiredObject Complete resulting Kubernetes object for this resource entry. Omitted when the resource would be deleted. + DesiredObject *map[string]interface{} `json:"desiredObject,omitempty"` + Resource PreviewTargetResource `json:"resource"` +} + +// PreviewTargetResource defines model for PreviewTargetResource. +type PreviewTargetResource struct { + ApiVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace *string `json:"namespace,omitempty"` +} + // PrometheusRuleTarget Identifies the PrometheusRule resource and rule group where the alert rule will be stored. Required for user-defined alert rules. type PrometheusRuleTarget struct { // GroupName Name of the rule group within the PrometheusRule. Optional. @@ -115,6 +211,22 @@ type PrometheusRuleTarget struct { PrometheusRuleNamespace string `json:"prometheusRuleNamespace"` } +// RuleChange defines model for RuleChange. +type RuleChange struct { + // CurrentValue Value before the change, for replace or remove operations. + CurrentValue interface{} `json:"currentValue,omitempty"` + + // Field Semantic alert-rule field path (e.g. severity, labels.severity, for, rule). + Field string `json:"field"` + + // NewValue Value after the change, for add or replace operations. + NewValue interface{} `json:"newValue,omitempty"` + Operation RuleChangeOperation `json:"operation"` +} + +// RuleChangeOperation defines model for RuleChange.Operation. +type RuleChangeOperation string + // UpdateAlertRuleRequest Partial update for a single alert rule. At least one of labels, alertingRuleEnabled, or classification must be set. alertingRuleEnabled cannot be combined with labels or classification in the same request. type UpdateAlertRuleRequest struct { // AlertingRuleEnabled When false, drops the alert rule via an AlertRelabelConfig Drop action — the rule no longer appears in Prometheus query results. When true, restores a previously dropped rule. Only supported for platform alert rules. @@ -148,6 +260,9 @@ type BulkUpdateAlertRulesJSONRequestBody = BulkUpdateAlertRulesRequest // CreateAlertRuleJSONRequestBody defines body for CreateAlertRule for application/json ContentType. type CreateAlertRuleJSONRequestBody = CreateAlertRuleRequest +// PreviewAlertRuleJSONRequestBody defines body for PreviewAlertRule for application/json ContentType. +type PreviewAlertRuleJSONRequestBody = PreviewAlertRuleRequest + // UpdateAlertRuleJSONRequestBody defines body for UpdateAlertRule for application/json ContentType. type UpdateAlertRuleJSONRequestBody = UpdateAlertRuleRequest @@ -162,6 +277,9 @@ type ServerInterface interface { // Create an alert rule // (POST /rules) CreateAlertRule(w http.ResponseWriter, r *http.Request) + // Preview a single alert rule create or update + // (POST /rules/preview) + PreviewAlertRule(w http.ResponseWriter, r *http.Request) // Delete a single alert rule // (DELETE /rules/{ruleId}) DeleteAlertRule(w http.ResponseWriter, r *http.Request, ruleId string) @@ -218,9 +336,31 @@ func (siw *ServerInterfaceWrapper) CreateAlertRule(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// PreviewAlertRule operation middleware +func (siw *ServerInterfaceWrapper) PreviewAlertRule(w http.ResponseWriter, r *http.Request) { + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PreviewAlertRule(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DeleteAlertRule operation middleware func (siw *ServerInterfaceWrapper) DeleteAlertRule(w http.ResponseWriter, r *http.Request) { - ruleId := mux.Vars(r)["ruleId"] + var err error + + // ------------- Path parameter "ruleId" ------------- + var ruleId string + + err = runtime.BindStyledParameterWithOptions("simple", "ruleId", mux.Vars(r)["ruleId"], &ruleId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ruleId", Err: err}) + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.DeleteAlertRule(w, r, ruleId) @@ -235,7 +375,16 @@ func (siw *ServerInterfaceWrapper) DeleteAlertRule(w http.ResponseWriter, r *htt // UpdateAlertRule operation middleware func (siw *ServerInterfaceWrapper) UpdateAlertRule(w http.ResponseWriter, r *http.Request) { - ruleId := mux.Vars(r)["ruleId"] + var err error + + // ------------- Path parameter "ruleId" ------------- + var ruleId string + + err = runtime.BindStyledParameterWithOptions("simple", "ruleId", mux.Vars(r)["ruleId"], &ruleId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ruleId", Err: err}) + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.UpdateAlertRule(w, r, ruleId) @@ -367,6 +516,8 @@ func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.H r.HandleFunc(options.BaseURL+"/rules", wrapper.CreateAlertRule).Methods("POST") + r.HandleFunc(options.BaseURL+"/rules/preview", wrapper.PreviewAlertRule).Methods("POST") + r.HandleFunc(options.BaseURL+"/rules/{ruleId}", wrapper.DeleteAlertRule).Methods("DELETE") r.HandleFunc(options.BaseURL+"/rules/{ruleId}", wrapper.UpdateAlertRule).Methods("PATCH") diff --git a/internal/managementrouter/create_alert_rule.go b/internal/managementrouter/create_alert_rule.go index dead8d70f..a53554f6d 100644 --- a/internal/managementrouter/create_alert_rule.go +++ b/internal/managementrouter/create_alert_rule.go @@ -83,6 +83,48 @@ func alertRuleSpecToMonitoringV1(spec AlertRuleSpec) monitoringv1.Rule { return rule } +// monitoringV1RuleToAlertRuleSpec maps a prometheus-operator Rule to the API AlertRuleSpec. +func monitoringV1RuleToAlertRuleSpec(rule monitoringv1.Rule) AlertRuleSpec { + spec := AlertRuleSpec{} + if rule.Alert != "" { + alert := rule.Alert + spec.Alert = &alert + } + if rule.Record != "" { + record := rule.Record + spec.Record = &record + } + if rule.Expr.String() != "" { + expr := rule.Expr.String() + spec.Expr = &expr + } + if rule.For != nil { + forDuration := string(*rule.For) + spec.For = &forDuration + } + if rule.KeepFiringFor != nil { + keep := string(*rule.KeepFiringFor) + spec.KeepFiringFor = &keep + } + if len(rule.Labels) > 0 { + labels := copyStringMapForAPI(rule.Labels) + spec.Labels = &labels + } + if len(rule.Annotations) > 0 { + annotations := copyStringMapForAPI(rule.Annotations) + spec.Annotations = &annotations + } + return spec +} + +func copyStringMapForAPI(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + // prometheusRuleTargetToOptions maps the API-defined PrometheusRuleTarget to // the management layer's PrometheusRuleOptions. func prometheusRuleTargetToOptions(target PrometheusRuleTarget) management.PrometheusRuleOptions { diff --git a/internal/managementrouter/preview_alert_rule.go b/internal/managementrouter/preview_alert_rule.go new file mode 100644 index 000000000..e925aa81a --- /dev/null +++ b/internal/managementrouter/preview_alert_rule.go @@ -0,0 +1,144 @@ +package managementrouter + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/openshift/monitoring-plugin/pkg/management" +) + +// PreviewAlertRule implements ServerInterface. +func (hr *httpRouter) PreviewAlertRule(w http.ResponseWriter, req *http.Request) { + req.Body = http.MaxBytesReader(w, req.Body, maxRequestBodyBytes) + + var payload PreviewAlertRuleRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + ruleID := "" + if payload.RuleId != nil { + ruleID = strings.TrimSpace(*payload.RuleId) + } + + var ( + plan *management.RuleChangePlan + err error + ) + + if ruleID == "" { + if payload.AlertingRule == nil { + writeError(w, http.StatusBadRequest, "alertingRule is required for create preview") + return + } + alertRule := alertRuleSpecToMonitoringV1(*payload.AlertingRule) + createReq := management.PreviewCreateRequest{AlertRule: alertRule} + if payload.PrometheusRule != nil { + opts := prometheusRuleTargetToOptions(*payload.PrometheusRule) + createReq.PROptions = &opts + } + plan, err = hr.managementClient.PreviewAlertRuleCreate(req.Context(), createReq) + } else { + fields := alertRuleUpdateFields{ + Labels: payload.Labels, + AlertingRuleEnabled: payload.AlertingRuleEnabled, + Classification: payload.Classification, + } + if msg := validateAlertRuleUpdateFields(fields); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + updateReq := management.PreviewUpdateRequest{ + RuleID: ruleID, + AlertingRuleEnabled: payload.AlertingRuleEnabled, + } + if payload.Labels != nil { + updateReq.Labels = *payload.Labels + } + if payload.Classification != nil { + cl := payload.Classification + updateReq.Classification = &management.UpdateRuleClassificationRequest{RuleId: ruleID} + if cl.ComponentSet { + updateReq.Classification.Component = cl.Component + updateReq.Classification.ComponentSet = true + } + if cl.LayerSet { + updateReq.Classification.Layer = cl.Layer + updateReq.Classification.LayerSet = true + } + if cl.ComponentFromSet { + updateReq.Classification.ComponentFrom = cl.ComponentFrom + updateReq.Classification.ComponentFromSet = true + } + if cl.LayerFromSet { + updateReq.Classification.LayerFrom = cl.LayerFrom + updateReq.Classification.LayerFromSet = true + } + } + plan, err = hr.managementClient.PreviewAlertRuleUpdate(req.Context(), updateReq) + } + + if err != nil { + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(ruleChangePlanToResponse(plan)); err != nil { + log.WithError(err).Warn("failed to encode preview alert rule response") + } +} + +func ruleChangePlanToResponse(plan *management.RuleChangePlan) PreviewAlertRuleResponse { + desiredRule := monitoringV1RuleToAlertRuleSpec(plan.DesiredRule) + resp := PreviewAlertRuleResponse{ + Writable: plan.Writable, + Resources: make([]PreviewResourceChange, 0, len(plan.Resources)), + DesiredRule: desiredRule, + } + if plan.ManagedBy != nil { + switch *plan.ManagedBy { + case management.ManagedByGitOps: + v := Gitops + resp.ManagedBy = &v + case management.ManagedByOperator: + v := Operator + resp.ManagedBy = &v + } + } + for _, res := range plan.Resources { + entry := PreviewResourceChange{ + Resource: PreviewTargetResource{ + ApiVersion: res.Resource.APIVersion, + Kind: res.Resource.Kind, + Name: res.Resource.Name, + }, + Changes: make([]RuleChange, 0, len(res.Changes)), + } + if res.Resource.Namespace != "" { + ns := res.Resource.Namespace + entry.Resource.Namespace = &ns + } + if res.DesiredObject != nil { + entry.DesiredObject = &res.DesiredObject + } + for _, ch := range res.Changes { + rc := RuleChange{ + Field: ch.Field, + Operation: RuleChangeOperation(ch.Operation), + } + if ch.CurrentValue != nil { + rc.CurrentValue = ch.CurrentValue + } + if ch.NewValue != nil { + rc.NewValue = ch.NewValue + } + entry.Changes = append(entry.Changes, rc) + } + resp.Resources = append(resp.Resources, entry) + } + return resp +} diff --git a/internal/managementrouter/preview_alert_rule_test.go b/internal/managementrouter/preview_alert_rule_test.go new file mode 100644 index 000000000..3e0c7dff0 --- /dev/null +++ b/internal/managementrouter/preview_alert_rule_test.go @@ -0,0 +1,196 @@ +package managementrouter_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +func TestPreviewAlertRule_CreateUserDefined(t *testing.T) { + mockK8sRules := &testutils.MockPrometheusRuleInterface{} + mockK8s := &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { return mockK8sRules }, + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + } + router := newTestRouter(mockK8s) + + body := map[string]any{ + "alertingRule": map[string]any{ + "alert": "cpuHigh", + "expr": "vector(1)", + "labels": map[string]string{"severity": "warning"}, + }, + "prometheusRule": map[string]any{ + "prometheusRuleName": "user-pr", + "prometheusRuleNamespace": "default", + }, + } + buf, _ := json.Marshal(body) + req := bearerRequest(t, "/api/v1/alerting/rules/preview", buf) + req.Method = http.MethodPost + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Writable bool `json:"writable"` + DesiredRule struct { + Alert string `json:"alert"` + Labels map[string]string `json:"labels"` + } `json:"desiredRule"` + Resources []struct { + Resource struct { + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"resource"` + Changes []struct { + Field string `json:"field"` + Operation string `json:"operation"` + } `json:"changes"` + } `json:"resources"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Writable { + t.Fatal("expected writable=true") + } + if len(resp.Resources) != 1 || resp.Resources[0].Resource.Kind != "PrometheusRule" { + t.Fatalf("unexpected resources: %+v", resp.Resources) + } + if len(resp.Resources[0].Changes) != 1 || resp.Resources[0].Changes[0].Field != "rule" { + t.Fatalf("expected one rule add change, got %+v", resp.Resources[0].Changes) + } + if resp.DesiredRule.Alert != "cpuHigh" { + t.Fatalf("expected desiredRule.alert=cpuHigh, got %q", resp.DesiredRule.Alert) + } + + pr, found, _ := mockK8sRules.Get(context.Background(), "default", "user-pr") + if found && pr != nil { + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + if r.Alert == "cpuHigh" { + t.Fatal("preview must not persist create") + } + } + } + } +} + +func TestPreviewAlertRule_CreateGitOpsManaged(t *testing.T) { + mockK8s := &testutils.MockClient{ + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + GetFunc: func(_ context.Context, namespace, name string) (*monitoringv1.PrometheusRule, bool, error) { + return &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{"argocd.argoproj.io/tracking-id": "gitops"}, + }, + }, true, nil + }, + } + }, + } + router := newTestRouter(mockK8s) + + body := map[string]any{ + "alertingRule": map[string]any{"alert": "cpuHigh", "expr": "vector(1)"}, + "prometheusRule": map[string]any{ + "prometheusRuleName": "user-pr", + "prometheusRuleNamespace": "default", + }, + } + buf, _ := json.Marshal(body) + req := bearerRequest(t, "/api/v1/alerting/rules/preview", buf) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Writable bool `json:"writable"` + ManagedBy string `json:"managedBy"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Writable { + t.Fatal("expected writable=false") + } + if resp.ManagedBy != "gitops" { + t.Fatalf("expected managedBy=gitops, got %q", resp.ManagedBy) + } +} + +func TestPreviewAlertRule_InvalidCreateBody(t *testing.T) { + router := newTestRouter(&testutils.MockClient{}) + req := bearerRequest(t, "/api/v1/alerting/rules/preview", []byte(`{}`)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestPreviewAlertRule_UpdateMissingRule(t *testing.T) { + mockK8s := &testutils.MockClient{ + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{} + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + } + router := newTestRouter(mockK8s) + body := map[string]any{ + "ruleId": "missing", + "labels": map[string]string{"severity": "critical"}, + } + buf, _ := json.Marshal(body) + req := bearerRequest(t, "/api/v1/alerting/rules/preview", buf) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPreviewAlertRule_UpdateNoMutationFields(t *testing.T) { + router := newTestRouter(&testutils.MockClient{}) + body := map[string]any{"ruleId": "some-id"} + buf, _ := json.Marshal(body) + req := bearerRequest(t, "/api/v1/alerting/rules/preview", buf) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} diff --git a/pkg/management/create_platform_alert_rule.go b/pkg/management/create_platform_alert_rule.go index 7d49ea5ee..641096f7e 100644 --- a/pkg/management/create_platform_alert_rule.go +++ b/pkg/management/create_platform_alert_rule.go @@ -2,141 +2,21 @@ package management import ( "context" - "fmt" - "strings" - osmv1 "github.com/openshift/api/monitoring/v1" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/util/retry" - - alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" - "github.com/openshift/monitoring-plugin/pkg/k8s" -) - -const ( - defaultAlertingRuleName = "platform-alert-rules" - defaultPlatformGroupName = "platform-alert-rules" ) +// CreatePlatformAlertRule creates a new platform alert rule. func (c *client) CreatePlatformAlertRule(ctx context.Context, alertRule monitoringv1.Rule) (string, error) { - if err := validateAlertRuleInputs(alertRule); err != nil { - return "", err - } - - newRuleId := alertrule.GetAlertingRuleId(&alertRule) - - if _, found := c.k8sClient.RelabeledRules().Get(ctx, newRuleId); found { - return "", &ConflictError{Message: "alert rule with exact config already exists"} - } - - if alertRule.Labels == nil { - alertRule.Labels = map[string]string{} - } - alertRule.Labels[k8s.AlertRuleLabelId] = newRuleId - - osmRule := toOSMRule(alertRule) - - // RetryOnConflict handles the concurrent update (409) case that arises when - // multiple replicas perform a read-modify-write on the same AlertingRule. - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - existing, found, getErr := c.k8sClient.AlertingRules().Get(ctx, defaultAlertingRuleName) - if getErr != nil { - return fmt.Errorf("failed to get AlertingRule %s: %w", defaultAlertingRuleName, getErr) - } - - if found { - // Disallow adding to externally managed AlertingRules - if gitOpsManaged, operatorManaged := k8s.IsExternallyManagedObject(existing); gitOpsManaged { - return &NotAllowedError{Message: "The AlertingRule is managed by GitOps; create the alert in Git."} - } else if operatorManaged { - return &NotAllowedError{Message: "This AlertingRule is managed by an operator; you cannot add alerts to it."} - } - updated := existing.DeepCopy() - if addErr := addRuleToGroup(&updated.Spec, defaultPlatformGroupName, osmRule); addErr != nil { - return addErr - } - if updateErr := c.k8sClient.AlertingRules().Update(ctx, *updated); updateErr != nil { - return fmt.Errorf("failed to update AlertingRule %s: %w", defaultAlertingRuleName, updateErr) - } - return nil - } - - ar := osmv1.AlertingRule{ - ObjectMeta: metav1.ObjectMeta{ - Name: defaultAlertingRuleName, - Namespace: k8s.ClusterMonitoringNamespace, - }, - Spec: osmv1.AlertingRuleSpec{ - Groups: []osmv1.RuleGroup{ - { - Name: defaultPlatformGroupName, - Rules: []osmv1.Rule{osmRule}, - }, - }, - }, - } - - if _, createErr := c.k8sClient.AlertingRules().Create(ctx, ar); createErr != nil { - return fmt.Errorf("failed to create AlertingRule %s: %w", defaultAlertingRuleName, createErr) - } - return nil - }) + plan, err := c.planCreatePlatformAlertRule(ctx, alertRule) if err != nil { return "", err } - - return newRuleId, nil -} - -func validateAlertRuleInputs(alertRule monitoringv1.Rule) error { - alertName := strings.TrimSpace(alertRule.Alert) - if alertName == "" { - return &ValidationError{Message: "alert name is required"} - } - - if strings.TrimSpace(alertRule.Expr.String()) == "" { - return &ValidationError{Message: "expr is required"} - } - - if v, ok := alertRule.Labels["severity"]; ok && !isValidSeverity(v) { - return &ValidationError{Message: fmt.Sprintf("invalid severity %q: must be one of critical|warning|info|none", v)} - } - - return nil -} - -func addRuleToGroup(spec *osmv1.AlertingRuleSpec, groupName string, rule osmv1.Rule) error { - for i := range spec.Groups { - if spec.Groups[i].Name != groupName { - continue - } - for _, existing := range spec.Groups[i].Rules { - if existing.Alert == rule.Alert { - return &ConflictError{Message: fmt.Sprintf("alert rule %q already exists in group %q", rule.Alert, groupName)} - } - } - spec.Groups[i].Rules = append(spec.Groups[i].Rules, rule) - return nil - } - spec.Groups = append(spec.Groups, osmv1.RuleGroup{ - Name: groupName, - Rules: []osmv1.Rule{rule}, - }) - return nil -} - -func toOSMRule(rule monitoringv1.Rule) osmv1.Rule { - osmRule := osmv1.Rule{ - Alert: rule.Alert, - Expr: rule.Expr, - Labels: rule.Labels, - Annotations: rule.Annotations, + if err := enforceCreatePlatformWritable(plan); err != nil { + return "", err } - - if rule.For != nil { - osmRule.For = osmv1.Duration(*rule.For) + if err := c.executeCreatePlatformPlan(ctx, plan); err != nil { + return "", err } - - return osmRule + return plan.computedRuleID, nil } diff --git a/pkg/management/create_user_defined_alert_rule.go b/pkg/management/create_user_defined_alert_rule.go index fb4c030ef..dd76142c2 100644 --- a/pkg/management/create_user_defined_alert_rule.go +++ b/pkg/management/create_user_defined_alert_rule.go @@ -2,137 +2,21 @@ package management import ( "context" - "strings" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - "k8s.io/apimachinery/pkg/types" - - alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" - "github.com/openshift/monitoring-plugin/pkg/k8s" - "github.com/openshift/monitoring-plugin/pkg/managementlabels" -) - -const ( - DefaultGroupName = "user-defined-rules" ) +// CreateUserDefinedAlertRule creates a new user-defined alert rule. func (c *client) CreateUserDefinedAlertRule(ctx context.Context, alertRule monitoringv1.Rule, prOptions PrometheusRuleOptions) (string, error) { - if prOptions.Name == "" || prOptions.Namespace == "" { - return "", &ValidationError{Message: "PrometheusRule Name and Namespace must be specified"} - } - - if err := validateAlertRuleInputs(alertRule); err != nil { - return "", err - } - - // compute id from the rule content BEFORE mutating labels - computedRuleID := alertrule.GetAlertingRuleId(&alertRule) - // set/stamp the rule id label on user-defined rules - if alertRule.Labels == nil { - alertRule.Labels = map[string]string{} - } - alertRule.Labels[k8s.AlertRuleLabelId] = computedRuleID - - // Check if rule with the same ID already exists (fast path) - _, found := c.k8sClient.RelabeledRules().Get(ctx, computedRuleID) - if found { - return "", &ConflictError{Message: "alert rule with exact config already exists"} - } - - // Deny creating an equivalent rule (same spec: expr, for, labels including severity) even if alert name differs - if c.existsUserDefinedRuleWithSameSpec(ctx, alertRule) { - return "", &ConflictError{Message: "alert rule with equivalent spec already exists"} - } - - nn := types.NamespacedName{ - Name: prOptions.Name, - Namespace: prOptions.Namespace, - } - - if c.isPlatformManagedPrometheusRule(nn) { - return "", &NotAllowedError{Message: "cannot add user-defined alert rule to a platform-managed PrometheusRule; create an AlertingRule CR instead"} - } - - pr, prFound, err := c.k8sClient.PrometheusRules().Get(ctx, nn.Namespace, nn.Name) + plan, err := c.planCreateUserDefinedAlertRule(ctx, alertRule, prOptions) if err != nil { return "", err } - if prFound && pr != nil { - if gitOpsManaged, operatorManaged := k8s.IsExternallyManagedObject(pr); gitOpsManaged { - return "", &NotAllowedError{Message: "This PrometheusRule is managed by GitOps; create the alert in Git."} - } else if operatorManaged { - return "", &NotAllowedError{Message: "This PrometheusRule is managed by an operator; you cannot add alerts to it."} - } - // Enforce uniqueness: "true clones" (identical definitions) compute to the same rule ID. - for _, g := range pr.Spec.Groups { - for _, r := range g.Rules { - if r.Alert != "" && alertrule.GetAlertingRuleId(&r) == computedRuleID { - return "", &ConflictError{Message: "alert rule with exact config already exists"} - } - } - } - } - - if prOptions.GroupName == "" { - prOptions.GroupName = DefaultGroupName - } - - err = c.k8sClient.PrometheusRules().AddRule(ctx, nn, prOptions.GroupName, alertRule) - if err != nil { + if err := enforceCreateUserDefinedWritable(plan); err != nil { return "", err } - - return computedRuleID, nil -} - -// existsUserDefinedRuleWithSameSpec returns true if a rule with an equivalent -// specification already exists in the relabeled rules cache. -func (c *client) existsUserDefinedRuleWithSameSpec(ctx context.Context, candidate monitoringv1.Rule) bool { - for _, existing := range c.k8sClient.RelabeledRules().List(ctx) { - if rulesHaveEquivalentSpec(existing, candidate) { - return true - } - } - return false -} - -// rulesHaveEquivalentSpec compares two alert rules for equivalence based on -// expression, duration (for) and non-system labels (excluding openshift_io_* and alertname). -func rulesHaveEquivalentSpec(a, b monitoringv1.Rule) bool { - if alertrule.NormalizeExpr(a.Expr.String()) != alertrule.NormalizeExpr(b.Expr.String()) { - return false - } - var af, bf string - if a.For != nil { - af = string(*a.For) - } - if b.For != nil { - bf = string(*b.For) - } - if af != bf { - return false - } - al := filterBusinessLabels(a.Labels) - bl := filterBusinessLabels(b.Labels) - if len(al) != len(bl) { - return false - } - for k, v := range al { - if bl[k] != v { - return false - } - } - return true -} - -// filterBusinessLabels returns labels excluding system/provenance and identity labels. -func filterBusinessLabels(in map[string]string) map[string]string { - out := map[string]string{} - for k, v := range in { - if strings.HasPrefix(k, "openshift_io_") || k == managementlabels.AlertNameLabel { - continue - } - out[k] = v + if err := c.executeCreateUserDefinedPlan(ctx, plan); err != nil { + return "", err } - return out + return plan.computedRuleID, nil } diff --git a/pkg/management/plan_arc_mutation.go b/pkg/management/plan_arc_mutation.go new file mode 100644 index 000000000..e12492d84 --- /dev/null +++ b/pkg/management/plan_arc_mutation.go @@ -0,0 +1,151 @@ +package management + +import ( + "regexp" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +type arcMutationResult struct { + configs []osmv1.RelabelConfig + deleteARC bool + noOp bool +} + +// computeARCLabelMutation mirrors applyLabelChangesViaAlertRelabelConfig without persisting. +func computeARCLabelMutation( + originalRule monitoringv1.Rule, + alertRuleID string, + filteredLabels map[string]string, + existingArc *osmv1.AlertRelabelConfig, + arcFound bool, +) arcMutationResult { + original := copyStringMap(originalRule.Labels) + existingOverrides, existingDrops := collectExistingFromARC(arcFound, existingArc) + existingRuleDrops := getExistingRuleDrops(existingArc, alertRuleID) + effective := computeEffectiveLabels(original, existingOverrides, existingDrops) + + if len(filteredLabels) == 0 { + return arcMutationResult{noOp: true} + } + + desired := buildDesiredLabels(effective, filteredLabels) + nextChanges := buildNextLabelChanges(original, desired) + + if len(nextChanges) == 0 { + if !arcFound { + return arcMutationResult{noOp: true} + } + if len(existingRuleDrops) > 0 { + configs := buildRelabelConfigs(originalRule.Alert, original, alertRuleID, nil) + configs = appendPreservedRuleDrops(configs, existingRuleDrops) + return arcMutationResult{configs: configs} + } + return arcMutationResult{deleteARC: true} + } + + configs := buildRelabelConfigs(originalRule.Alert, original, alertRuleID, nextChanges) + configs = appendPreservedRuleDrops(configs, existingRuleDrops) + return arcMutationResult{configs: configs} +} + +func computeARCDropMutation( + originalRule monitoringv1.Rule, + alertRuleID string, + existingArc *osmv1.AlertRelabelConfig, + arcExists bool, +) arcMutationResult { + original := copyStringMap(originalRule.Labels) + stampOnly := buildRelabelConfigs(originalRule.Alert, original, alertRuleID, nil) + var stamp osmv1.RelabelConfig + if len(stampOnly) > 0 { + stamp = stampOnly[0] + } + + dropCfg := osmv1.RelabelConfig{ + SourceLabels: []osmv1.LabelName{k8s.AlertRuleLabelId}, + Regex: regexp.QuoteMeta(alertRuleID), + Action: "Drop", + } + + var next []osmv1.RelabelConfig + if arcExists && existingArc != nil { + next = append(next, existingArc.Spec.Configs...) + } + + changed := ensureStampAndDrop(&next, stamp, dropCfg, alertRuleID) + if !changed { + return arcMutationResult{noOp: true} + } + return arcMutationResult{configs: next} +} + +func computeARCRestoreMutation( + alertRuleID string, + existingArc *osmv1.AlertRelabelConfig, +) arcMutationResult { + if existingArc == nil { + return arcMutationResult{noOp: true} + } + filtered, removed := filterOutDrop(existingArc.Spec.Configs, alertRuleID) + if !removed { + return arcMutationResult{noOp: true} + } + if len(filtered) == 0 || isStampOnly(filtered) { + return arcMutationResult{deleteARC: true} + } + return arcMutationResult{configs: filtered} +} + +func buildDesiredAlertRelabelConfigObject( + arcNamespace, arcName, prName, alertName, alertRuleID string, + existingArc *osmv1.AlertRelabelConfig, + arcFound bool, + result arcMutationResult, +) *osmv1.AlertRelabelConfig { + if result.deleteARC || result.noOp && !arcFound { + return nil + } + if result.noOp && arcFound && existingArc != nil { + return existingArc.DeepCopy() + } + + if arcFound && existingArc != nil { + arc := existingArc.DeepCopy() + arc.Spec = osmv1.AlertRelabelConfigSpec{Configs: result.configs} + if arc.Labels == nil { + arc.Labels = map[string]string{} + } + arc.Labels[managementlabels.ARCLabelPrometheusRuleNameKey] = prName + arc.Labels[managementlabels.ARCLabelAlertNameKey] = alertName + if arc.Annotations == nil { + arc.Annotations = map[string]string{} + } + arc.Annotations[managementlabels.ARCAnnotationAlertRuleIDKey] = alertRuleID + return arc + } + + return &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: arcName, + Namespace: arcNamespace, + Labels: map[string]string{ + managementlabels.ARCLabelPrometheusRuleNameKey: prName, + managementlabels.ARCLabelAlertNameKey: alertName, + }, + Annotations: map[string]string{ + managementlabels.ARCAnnotationAlertRuleIDKey: alertRuleID, + }, + }, + Spec: osmv1.AlertRelabelConfigSpec{Configs: result.configs}, + } +} + +func diffEffectiveLabelChanges(before, after map[string]string) []RuleChange { + return diffLabelSemanticChanges("", before, after) +} diff --git a/pkg/management/plan_arc_mutation_test.go b/pkg/management/plan_arc_mutation_test.go new file mode 100644 index 000000000..5df347700 --- /dev/null +++ b/pkg/management/plan_arc_mutation_test.go @@ -0,0 +1,13 @@ +package management + +import "testing" + +func TestComputeARCRestoreMutation_NilARC(t *testing.T) { + result := computeARCRestoreMutation("rule-id", nil) + if !result.noOp { + t.Fatalf("expected no-op for nil ARC, got %+v", result) + } + if result.deleteARC { + t.Fatal("expected deleteARC=false for nil ARC") + } +} diff --git a/pkg/management/plan_create_platform.go b/pkg/management/plan_create_platform.go new file mode 100644 index 000000000..23ff53f9a --- /dev/null +++ b/pkg/management/plan_create_platform.go @@ -0,0 +1,231 @@ +package management + +import ( + "context" + "fmt" + "strings" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +const ( + defaultAlertingRuleName = "platform-alert-rules" + defaultPlatformGroupName = "platform-alert-rules" +) + +type createPlatformPlan struct { + computedRuleID string + osmRule osmv1.Rule + groupName string + groupIdx int + existingAR *osmv1.AlertingRule + arExists bool + writable bool + managedBy ManagementSource +} + +func (c *client) planCreatePlatformAlertRule(ctx context.Context, alertRule monitoringv1.Rule) (*createPlatformPlan, error) { + if err := validateAlertRuleInputs(alertRule); err != nil { + return nil, err + } + + computedRuleID := alertrule.GetAlertingRuleId(&alertRule) + if _, found := c.k8sClient.RelabeledRules().Get(ctx, computedRuleID); found { + return nil, &ConflictError{Message: "alert rule with exact config already exists"} + } + + preparedRule := alertRule + if preparedRule.Labels == nil { + preparedRule.Labels = map[string]string{} + } + preparedRule.Labels[k8s.AlertRuleLabelId] = computedRuleID + osmRule := toOSMRule(preparedRule) + + existing, found, err := c.k8sClient.AlertingRules().Get(ctx, defaultAlertingRuleName) + if err != nil { + return nil, fmt.Errorf("failed to get AlertingRule %s: %w", defaultAlertingRuleName, err) + } + + managedBy := ManagementSource("") + writable := true + groupIdx := 0 + if found && existing != nil { + managedBy = managedByFromObject(existing) + switch managedBy { + case ManagedByGitOps, ManagedByOperator: + writable = false + } + for i, g := range existing.Spec.Groups { + if g.Name == defaultPlatformGroupName { + groupIdx = i + for _, r := range g.Rules { + if r.Alert == osmRule.Alert { + return nil, &ConflictError{ + Message: fmt.Sprintf("alert rule %q already exists in group %q", osmRule.Alert, defaultPlatformGroupName), + } + } + } + break + } + } + if groupIdx == 0 && (len(existing.Spec.Groups) == 0 || existing.Spec.Groups[0].Name != defaultPlatformGroupName) { + groupIdx = len(existing.Spec.Groups) + } + } + + return &createPlatformPlan{ + computedRuleID: computedRuleID, + osmRule: osmRule, + groupName: defaultPlatformGroupName, + groupIdx: groupIdx, + existingAR: existing, + arExists: found, + writable: writable, + managedBy: managedBy, + }, nil +} + +func (p *createPlatformPlan) toRuleChangePlan() (*RuleChangePlan, error) { + rule := osmRuleToMonitoringV1(p.osmRule) + desiredAR := buildDesiredAlertingRuleWithAddedRule(p.existingAR, p.groupName, p.groupIdx, p.osmRule) + desiredAR.Namespace = k8s.ClusterMonitoringNamespace + desiredAR.Name = defaultAlertingRuleName + desiredObject, err := alertingRuleDesiredObject(desiredAR) + if err != nil { + return nil, err + } + return buildCreateRuleChangePlan( + p.writable, + p.managedBy, + alertingRuleRef(k8s.ClusterMonitoringNamespace, defaultAlertingRuleName), + rule, + desiredObject, + ), nil +} + +func osmRuleToMonitoringV1(r osmv1.Rule) monitoringv1.Rule { + rule := monitoringv1.Rule{ + Alert: r.Alert, + Expr: r.Expr, + Labels: r.Labels, + Annotations: r.Annotations, + } + if r.For != "" { + d := monitoringv1.Duration(r.For) + rule.For = &d + } + return rule +} + +func enforceCreatePlatformWritable(plan *createPlatformPlan) error { + if plan.writable { + return nil + } + switch plan.managedBy { + case ManagedByGitOps: + return &NotAllowedError{Message: "The AlertingRule is managed by GitOps; create the alert in Git."} + case ManagedByOperator: + return &NotAllowedError{Message: "This AlertingRule is managed by an operator; you cannot add alerts to it."} + default: + return &NotAllowedError{Message: "cannot create alert rule in the target AlertingRule"} + } +} + +func (c *client) executeCreatePlatformPlan(ctx context.Context, plan *createPlatformPlan) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, found, getErr := c.k8sClient.AlertingRules().Get(ctx, defaultAlertingRuleName) + if getErr != nil { + return fmt.Errorf("failed to get AlertingRule %s: %w", defaultAlertingRuleName, getErr) + } + if found { + if gitOpsManaged, operatorManaged := k8s.IsExternallyManagedObject(existing); gitOpsManaged { + return &NotAllowedError{Message: "The AlertingRule is managed by GitOps; create the alert in Git."} + } else if operatorManaged { + return &NotAllowedError{Message: "This AlertingRule is managed by an operator; you cannot add alerts to it."} + } + updated := existing.DeepCopy() + if addErr := addRuleToGroup(&updated.Spec, plan.groupName, plan.osmRule); addErr != nil { + return addErr + } + if updateErr := c.k8sClient.AlertingRules().Update(ctx, *updated); updateErr != nil { + return fmt.Errorf("failed to update AlertingRule %s: %w", defaultAlertingRuleName, updateErr) + } + return nil + } + + ar := osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultAlertingRuleName, + Namespace: k8s.ClusterMonitoringNamespace, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: plan.groupName, + Rules: []osmv1.Rule{plan.osmRule}, + }}, + }, + } + if _, createErr := c.k8sClient.AlertingRules().Create(ctx, ar); createErr != nil { + return fmt.Errorf("failed to create AlertingRule %s: %w", defaultAlertingRuleName, createErr) + } + return nil + }) +} + +func validateAlertRuleInputs(alertRule monitoringv1.Rule) error { + alertName := strings.TrimSpace(alertRule.Alert) + if alertName == "" { + return &ValidationError{Message: "alert name is required"} + } + + if strings.TrimSpace(alertRule.Expr.String()) == "" { + return &ValidationError{Message: "expr is required"} + } + + if v, ok := alertRule.Labels["severity"]; ok && !isValidSeverity(v) { + return &ValidationError{Message: fmt.Sprintf("invalid severity %q: must be one of critical|warning|info|none", v)} + } + + return nil +} + +func addRuleToGroup(spec *osmv1.AlertingRuleSpec, groupName string, rule osmv1.Rule) error { + for i := range spec.Groups { + if spec.Groups[i].Name != groupName { + continue + } + for _, existing := range spec.Groups[i].Rules { + if existing.Alert == rule.Alert { + return &ConflictError{Message: fmt.Sprintf("alert rule %q already exists in group %q", rule.Alert, groupName)} + } + } + spec.Groups[i].Rules = append(spec.Groups[i].Rules, rule) + return nil + } + spec.Groups = append(spec.Groups, osmv1.RuleGroup{ + Name: groupName, + Rules: []osmv1.Rule{rule}, + }) + return nil +} + +func toOSMRule(rule monitoringv1.Rule) osmv1.Rule { + osmRule := osmv1.Rule{ + Alert: rule.Alert, + Expr: rule.Expr, + Labels: rule.Labels, + Annotations: rule.Annotations, + } + + if rule.For != nil { + osmRule.For = osmv1.Duration(*rule.For) + } + + return osmRule +} diff --git a/pkg/management/plan_create_user_defined.go b/pkg/management/plan_create_user_defined.go new file mode 100644 index 000000000..35a5473e5 --- /dev/null +++ b/pkg/management/plan_create_user_defined.go @@ -0,0 +1,193 @@ +package management + +import ( + "context" + "strings" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +const DefaultGroupName = "user-defined-rules" + +type createUserDefinedPlan struct { + computedRuleID string + preparedRule monitoringv1.Rule + nn types.NamespacedName + groupName string + groupIdx int + existingPR *monitoringv1.PrometheusRule + writable bool + managedBy ManagementSource +} + +func (c *client) planCreateUserDefinedAlertRule( + ctx context.Context, + alertRule monitoringv1.Rule, + prOptions PrometheusRuleOptions, +) (*createUserDefinedPlan, error) { + if prOptions.Name == "" || prOptions.Namespace == "" { + return nil, &ValidationError{Message: "PrometheusRule Name and Namespace must be specified"} + } + + if err := validateAlertRuleInputs(alertRule); err != nil { + return nil, err + } + + computedRuleID := alertrule.GetAlertingRuleId(&alertRule) + preparedRule := alertRule + if preparedRule.Labels == nil { + preparedRule.Labels = map[string]string{} + } + preparedRule.Labels[k8s.AlertRuleLabelId] = computedRuleID + + if _, found := c.k8sClient.RelabeledRules().Get(ctx, computedRuleID); found { + return nil, &ConflictError{Message: "alert rule with exact config already exists"} + } + if c.existsUserDefinedRuleWithSameSpec(ctx, alertRule) { + return nil, &ConflictError{Message: "alert rule with equivalent spec already exists"} + } + + nn := types.NamespacedName{Name: prOptions.Name, Namespace: prOptions.Namespace} + if c.isPlatformManagedPrometheusRule(nn) { + return nil, &NotAllowedError{ + Message: "cannot add user-defined alert rule to a platform-managed PrometheusRule; create an AlertingRule CR instead", + } + } + + groupName := prOptions.GroupName + if groupName == "" { + groupName = DefaultGroupName + } + + pr, prFound, err := c.k8sClient.PrometheusRules().Get(ctx, nn.Namespace, nn.Name) + if err != nil { + return nil, err + } + + managedBy := ManagementSource("") + writable := true + if prFound && pr != nil { + managedBy = managedByFromObject(pr) + switch managedBy { + case ManagedByGitOps, ManagedByOperator: + writable = false + } + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + if r.Alert != "" && alertrule.GetAlertingRuleId(&r) == computedRuleID { + return nil, &ConflictError{Message: "alert rule with exact config already exists"} + } + } + } + } + + var prForGroup *monitoringv1.PrometheusRule + if prFound { + prForGroup = pr + } + groupIdx := resolvePrometheusRuleGroupIndex(prForGroup, groupName) + + return &createUserDefinedPlan{ + computedRuleID: computedRuleID, + preparedRule: preparedRule, + nn: nn, + groupName: groupName, + groupIdx: groupIdx, + existingPR: prForGroup, + writable: writable, + managedBy: managedBy, + }, nil +} + +func (p *createUserDefinedPlan) toRuleChangePlan() (*RuleChangePlan, error) { + desiredPR := buildDesiredPrometheusRuleWithAddedRule(p.existingPR, p.groupName, p.groupIdx, p.preparedRule) + if desiredPR.Namespace == "" { + desiredPR.Namespace = p.nn.Namespace + } + if desiredPR.Name == "" { + desiredPR.Name = p.nn.Name + } + desiredObject, err := prometheusRuleDesiredObject(desiredPR) + if err != nil { + return nil, err + } + return buildCreateRuleChangePlan( + p.writable, + p.managedBy, + prometheusRuleRef(p.nn.Namespace, p.nn.Name), + p.preparedRule, + desiredObject, + ), nil +} + +func enforceCreateUserDefinedWritable(plan *createUserDefinedPlan) error { + if plan.writable { + return nil + } + switch plan.managedBy { + case ManagedByGitOps: + return &NotAllowedError{Message: "This PrometheusRule is managed by GitOps; create the alert in Git."} + case ManagedByOperator: + return &NotAllowedError{Message: "This PrometheusRule is managed by an operator; you cannot add alerts to it."} + default: + return &NotAllowedError{Message: "cannot create alert rule in the target PrometheusRule"} + } +} + +func (c *client) executeCreateUserDefinedPlan(ctx context.Context, plan *createUserDefinedPlan) error { + return c.k8sClient.PrometheusRules().AddRule(ctx, plan.nn, plan.groupName, plan.preparedRule) +} + +// existsUserDefinedRuleWithSameSpec returns true if a rule with an equivalent +// specification already exists in the relabeled rules cache. +func (c *client) existsUserDefinedRuleWithSameSpec(ctx context.Context, candidate monitoringv1.Rule) bool { + for _, existing := range c.k8sClient.RelabeledRules().List(ctx) { + if rulesHaveEquivalentSpec(existing, candidate) { + return true + } + } + return false +} + +func rulesHaveEquivalentSpec(a, b monitoringv1.Rule) bool { + if alertrule.NormalizeExpr(a.Expr.String()) != alertrule.NormalizeExpr(b.Expr.String()) { + return false + } + var af, bf string + if a.For != nil { + af = string(*a.For) + } + if b.For != nil { + bf = string(*b.For) + } + if af != bf { + return false + } + al := filterBusinessLabels(a.Labels) + bl := filterBusinessLabels(b.Labels) + if len(al) != len(bl) { + return false + } + for k, v := range al { + if bl[k] != v { + return false + } + } + return true +} + +func filterBusinessLabels(in map[string]string) map[string]string { + out := map[string]string{} + for k, v := range in { + if strings.HasPrefix(k, "openshift_io_") || k == managementlabels.AlertNameLabel { + continue + } + out[k] = v + } + return out +} diff --git a/pkg/management/plan_desired_objects.go b/pkg/management/plan_desired_objects.go new file mode 100644 index 000000000..2ade632f3 --- /dev/null +++ b/pkg/management/plan_desired_objects.go @@ -0,0 +1,125 @@ +package management + +import ( + "encoding/json" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func objectToMap(obj any) (map[string]any, error) { + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + out := map[string]any{} + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func prometheusRuleDesiredObject(pr *monitoringv1.PrometheusRule) (map[string]any, error) { + if pr == nil { + return nil, nil + } + return objectToMap(pr.DeepCopy()) +} + +func buildDesiredPrometheusRuleWithRule( + pr *monitoringv1.PrometheusRule, + groupIdx, ruleIdx int, + desiredRule monitoringv1.Rule, +) *monitoringv1.PrometheusRule { + out := pr.DeepCopy() + out.Spec.Groups[groupIdx].Rules[ruleIdx] = desiredRule + return out +} + +func buildDesiredPrometheusRuleWithAddedRule( + pr *monitoringv1.PrometheusRule, + groupName string, + groupIdx int, + newRule monitoringv1.Rule, +) *monitoringv1.PrometheusRule { + if pr != nil { + out := pr.DeepCopy() + if groupIdx < len(out.Spec.Groups) { + out.Spec.Groups[groupIdx].Rules = append(out.Spec.Groups[groupIdx].Rules, newRule) + return out + } + out.Spec.Groups = append(out.Spec.Groups, monitoringv1.RuleGroup{ + Name: groupName, + Rules: []monitoringv1.Rule{newRule}, + }) + return out + } + return &monitoringv1.PrometheusRule{ + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{{ + Name: groupName, + Rules: []monitoringv1.Rule{newRule}, + }}, + }, + } +} + +func alertingRuleDesiredObject(ar *osmv1.AlertingRule) (map[string]any, error) { + if ar == nil { + return nil, nil + } + return objectToMap(ar.DeepCopy()) +} + +func buildDesiredAlertingRuleWithUpdatedLabels( + ar *osmv1.AlertingRule, + alertName string, + desiredLabels map[string]string, +) (*osmv1.AlertingRule, error) { + out := ar.DeepCopy() + target, found := findAlertByNameInAlertingRule(out, alertName) + if !found || target == nil { + return nil, &NotFoundError{Resource: "AlertRule", AdditionalInfo: "alert not found in AlertingRule"} + } + if len(desiredLabels) == 0 { + target.Labels = nil + } else { + target.Labels = copyStringMap(desiredLabels) + } + return out, nil +} + +func buildDesiredAlertingRuleWithAddedRule( + ar *osmv1.AlertingRule, + groupName string, + groupIdx int, + newRule osmv1.Rule, +) *osmv1.AlertingRule { + if ar != nil { + out := ar.DeepCopy() + if groupIdx < len(out.Spec.Groups) { + out.Spec.Groups[groupIdx].Rules = append(out.Spec.Groups[groupIdx].Rules, newRule) + return out + } + out.Spec.Groups = append(out.Spec.Groups, osmv1.RuleGroup{ + Name: groupName, + Rules: []osmv1.Rule{newRule}, + }) + return out + } + return &osmv1.AlertingRule{ + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: groupName, + Rules: []osmv1.Rule{newRule}, + }}, + }, + } +} + +func alertRelabelConfigDesiredObject(arc *osmv1.AlertRelabelConfig) (map[string]any, error) { + if arc == nil { + return nil, nil + } + return objectToMap(arc) +} diff --git a/pkg/management/plan_update.go b/pkg/management/plan_update.go new file mode 100644 index 000000000..b7fef971f --- /dev/null +++ b/pkg/management/plan_update.go @@ -0,0 +1,544 @@ +package management + +import ( + "context" + "fmt" + "strings" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/classification" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +// PreviewUpdateRequest holds update mutation fields for preview planning. +type PreviewUpdateRequest struct { + RuleID string + Labels map[string]*string + AlertingRuleEnabled *bool + Classification *UpdateRuleClassificationRequest +} + +func (c *client) planUpdateAlertRule(ctx context.Context, req PreviewUpdateRequest) (*RuleChangePlan, error) { + if req.RuleID == "" { + return nil, &ValidationError{Message: "ruleId is required"} + } + + hasLabels := req.Labels != nil + hasClassification := req.Classification != nil && + (req.Classification.ComponentSet || + req.Classification.LayerSet || + req.Classification.ComponentFromSet || + req.Classification.LayerFromSet) + hasEnabled := req.AlertingRuleEnabled != nil + + if !hasLabels && !hasClassification && !hasEnabled { + return nil, &ValidationError{ + Message: "one of alertingRuleEnabled (toggle drop/restore) or labels (set/unset) or classification is required", + } + } + if hasEnabled && (hasLabels || hasClassification) { + return nil, &ValidationError{ + Message: "alertingRuleEnabled cannot be combined with labels or classification in the same request", + } + } + if req.Classification != nil && !hasClassification && !hasLabels && !hasEnabled { + return nil, &ValidationError{Message: "classification must set at least one field"} + } + + if hasEnabled { + return c.planDropRestoreChange(ctx, req.RuleID, *req.AlertingRuleEnabled) + } + + classLabels := map[string]string{} + if hasClassification { + if err := validatePreviewClassificationRequest(*req.Classification); err != nil { + return nil, err + } + classLabels = buildClassificationLabels(*req.Classification) + } + + userLabels := map[string]string{} + if hasLabels { + for k, pv := range req.Labels { + if pv == nil || *pv == "" { + userLabels[k] = "" + } else { + userLabels[k] = *pv + } + } + } + + relabeled, found := c.k8sClient.RelabeledRules().Get(ctx, req.RuleID) + if !found { + return nil, &NotFoundError{Resource: "AlertRule", Id: req.RuleID} + } + + namespace := relabeled.Labels[k8s.PrometheusRuleLabelNamespace] + name := relabeled.Labels[k8s.PrometheusRuleLabelName] + nn := types.NamespacedName{Namespace: namespace, Name: name} + + if hasClassification && !c.isPlatformManagedPrometheusRule(nn) { + return nil, &NotAllowedError{Message: "classification updates are only supported for platform alert rules"} + } + + if c.isPlatformManagedPrometheusRule(nn) { + return c.planPlatformUpdate(ctx, req.RuleID, relabeled, classLabels, userLabels) + } + + merged := mergeLabelMaps(copyStringMap(userLabels), classLabels) + return c.planUserDefinedLabelUpdate(ctx, req.RuleID, relabeled, merged) +} + +func applyUserDefinedLabelMap(userLabels map[string]string, rawLabels map[string]string) error { + for k, v := range rawLabels { + if isProtectedLabel(k) || isPreviewProvenanceLabel(k) { + continue + } + if v == "" { + delete(userLabels, k) + continue + } + if k == "severity" && !isValidSeverity(v) { + return &ValidationError{ + Message: fmt.Sprintf("invalid severity %q: must be one of critical|warning|info|none", v), + } + } + userLabels[k] = v + } + return nil +} + +func enforceUserDefinedUpdateWritable(plan *RuleChangePlan) error { + if plan.Writable { + return nil + } + switch { + case plan.ManagedBy != nil && *plan.ManagedBy == ManagedByGitOps: + return notAllowedGitOpsEdit() + case plan.ManagedBy != nil && *plan.ManagedBy == ManagedByOperator: + return notAllowedOperatorUpdate() + default: + return &NotAllowedError{Message: "cannot update alert rule in the target PrometheusRule"} + } +} + +func validatePreviewClassificationRequest(req UpdateRuleClassificationRequest) error { + if req.Component != nil && !classification.ValidateComponent(*req.Component) { + return &ValidationError{Message: fmt.Sprintf("invalid component %q", *req.Component)} + } + if req.Layer != nil && !classification.ValidateLayer(*req.Layer) { + return &ValidationError{Message: fmt.Sprintf("invalid layer %q (allowed: cluster, namespace)", *req.Layer)} + } + if req.ComponentFrom != nil { + v := strings.TrimSpace(*req.ComponentFrom) + if v == "" { + return &ValidationError{Message: "openshift_io_alert_rule_component_from must not be empty or whitespace-only; set to null to remove"} + } + if !classification.ValidatePromLabelName(v) { + return &ValidationError{Message: fmt.Sprintf("invalid openshift_io_alert_rule_component_from %q (must be a valid Prometheus label name)", *req.ComponentFrom)} + } + } + if req.LayerFrom != nil { + v := strings.TrimSpace(*req.LayerFrom) + if v == "" { + return &ValidationError{Message: "openshift_io_alert_rule_layer_from must not be empty or whitespace-only; set to null to remove"} + } + if !classification.ValidatePromLabelName(v) { + return &ValidationError{Message: fmt.Sprintf("invalid openshift_io_alert_rule_layer_from %q (must be a valid Prometheus label name)", *req.LayerFrom)} + } + } + return nil +} + +func (c *client) planUserDefinedLabelUpdate( + ctx context.Context, + alertRuleID string, + relabeled monitoringv1.Rule, + rawLabels map[string]string, +) (*RuleChangePlan, error) { + namespace := relabeled.Labels[k8s.PrometheusRuleLabelNamespace] + name := relabeled.Labels[k8s.PrometheusRuleLabelName] + + managedBy := managedByFromRelabeledRule(relabeled) + writable := true + switch managedBy { + case ManagedByGitOps, ManagedByOperator: + writable = false + } + + pr, prFound, err := c.k8sClient.PrometheusRules().Get(ctx, namespace, name) + if err != nil { + return nil, err + } + if !prFound { + return nil, &NotFoundError{Resource: "PrometheusRule", Id: alertRuleID} + } + + if managedBy == "" { + if mb := managedByFromObject(pr); mb != "" { + managedBy = mb + writable = false + } + } + + if c.isPlatformManagedPrometheusRule(types.NamespacedName{Namespace: namespace, Name: name}) { + return nil, &NotAllowedError{Message: "cannot update alert rule in a platform-managed PrometheusRule"} + } + + sourceRule, err := getOriginalPlatformRuleFromPR(pr, namespace, name, alertRuleID) + if err != nil { + return nil, err + } + + userLabels := copyStringMap(sourceRule.Labels) + if err := applyUserDefinedLabelMap(userLabels, rawLabels); err != nil { + return nil, err + } + + groupIdx, ruleIdx, ok := findPrometheusRuleIndices(pr, alertRuleID) + if !ok { + return nil, &NotFoundError{ + Resource: "AlertRule", + Id: alertRuleID, + AdditionalInfo: fmt.Sprintf("in PrometheusRule %s/%s", namespace, name), + } + } + + desiredRule := ruleWithLabels(*sourceRule, userLabels) + desiredPR := buildDesiredPrometheusRuleWithRule(pr, groupIdx, ruleIdx, desiredRule) + desiredObject, err := prometheusRuleDesiredObject(desiredPR) + if err != nil { + return nil, err + } + + return buildRuleChangePlan( + writable, + managedBy, + []ResourceChangePlan{{ + Resource: prometheusRuleRef(namespace, name), + Changes: diffSemanticRuleChanges(*sourceRule, desiredRule), + DesiredObject: desiredObject, + }}, + desiredRule, + ), nil +} + +func (c *client) planPlatformUpdate( + ctx context.Context, + alertRuleID string, + relabeled monitoringv1.Rule, + classLabels map[string]string, + userLabels map[string]string, +) (*RuleChangePlan, error) { + filteredUser, err := filterAndValidatePlatformLabelChanges(userLabels) + if err != nil { + return nil, err + } + + namespace := relabeled.Labels[k8s.PrometheusRuleLabelNamespace] + name := relabeled.Labels[k8s.PrometheusRuleLabelName] + + pr, prFound, err := c.k8sClient.PrometheusRules().Get(ctx, namespace, name) + if err != nil { + return nil, err + } + var prMeta *monitoringv1.PrometheusRule + if prFound { + prMeta = pr + } + + originalRule, err := getOriginalPlatformRuleFromPR(prMeta, namespace, name, alertRuleID) + if err != nil { + return nil, err + } + + arName := relabeled.Labels[managementlabels.AlertingRuleLabelName] + if arName == "" { + arName = defaultAlertingRuleName + } + ar, arFound, arErr := c.getAlertingRule(ctx, arName) + if arErr != nil { + return nil, arErr + } + + route := resolvePlatformLabelRoute(ar, arFound) + existingArc, arcFound, err := c.loadARCForRule(ctx, relabeled, alertRuleID) + if err != nil { + return nil, err + } + allowance := evaluatePlatformPreviewAllowance( + relabeled, prMeta, ar, arFound, existingArc, arcFound, route, classLabels, filteredUser, + ) + + var resources []ResourceChangePlan + + arcLabels := copyStringMap(classLabels) + if route == platformLabelRouteAlertRelabelConfig && len(filteredUser) > 0 { + arcLabels = mergeLabelMaps(arcLabels, filteredUser) + } + if len(arcLabels) > 0 { + arcPlan, err := c.planARCResourceChange( + ctx, + alertRuleID, + relabeled, + *originalRule, + arcLabels, + ) + if err != nil { + return nil, err + } + if arcPlan != nil { + resources = append(resources, *arcPlan) + } + } + + if route == platformLabelRouteAlertingRule && len(filteredUser) > 0 { + arPlan, err := c.planAlertingRuleResourceChange( + ar, + originalRule.Alert, + alertRuleID, + filteredUser, + ) + if err != nil { + return nil, err + } + if arPlan != nil { + resources = append(resources, *arPlan) + } + } + + original := copyStringMap(originalRule.Labels) + existingOverrides, existingDrops := collectExistingFromARC(arcFound, existingArc) + effective := computeEffectiveLabels(original, existingOverrides, existingDrops) + + desiredEffective := effective + if len(classLabels) > 0 { + desiredEffective = buildDesiredLabels(desiredEffective, classLabels) + } + if len(filteredUser) > 0 { + desiredEffective = buildDesiredLabels(desiredEffective, filteredUser) + } + desiredRule := ruleWithLabels(*originalRule, desiredEffective) + + return buildRuleChangePlan(allowance.Writable, allowance.ManagedBy, resources, desiredRule), nil +} + +func (c *client) loadARCForRule( + ctx context.Context, + relabeled monitoringv1.Rule, + alertRuleID string, +) (*osmv1.AlertRelabelConfig, bool, error) { + arcNamespace, err := c.arcNamespaceForRule(types.NamespacedName{ + Namespace: relabeled.Labels[k8s.PrometheusRuleLabelNamespace], + Name: relabeled.Labels[k8s.PrometheusRuleLabelName], + }) + if err != nil { + return nil, false, err + } + prName := relabeled.Labels[k8s.PrometheusRuleLabelName] + arcName := k8s.GetAlertRelabelConfigName(prName, alertRuleID) + return c.k8sClient.AlertRelabelConfigs().Get(ctx, arcNamespace, arcName) +} + +func (c *client) planARCResourceChange( + ctx context.Context, + alertRuleID string, + relabeled monitoringv1.Rule, + originalRule monitoringv1.Rule, + filteredLabels map[string]string, +) (*ResourceChangePlan, error) { + arcNamespace, err := c.arcNamespaceForRule(types.NamespacedName{ + Namespace: relabeled.Labels[k8s.PrometheusRuleLabelNamespace], + Name: relabeled.Labels[k8s.PrometheusRuleLabelName], + }) + if err != nil { + return nil, err + } + + prName := relabeled.Labels[k8s.PrometheusRuleLabelName] + arcName := k8s.GetAlertRelabelConfigName(prName, alertRuleID) + + existingArc, arcFound, err := c.k8sClient.AlertRelabelConfigs().Get(ctx, arcNamespace, arcName) + if err != nil { + return nil, fmt.Errorf("failed to get AlertRelabelConfig %s/%s: %w", arcNamespace, arcName, err) + } + + original := copyStringMap(originalRule.Labels) + existingOverrides, existingDrops := collectExistingFromARC(arcFound, existingArc) + beforeEffective := computeEffectiveLabels(original, existingOverrides, existingDrops) + + mutation := computeARCLabelMutation(originalRule, alertRuleID, filteredLabels, existingArc, arcFound) + if mutation.noOp { + return nil, nil + } + + afterEffective := beforeEffective + if !mutation.deleteARC { + afterEffective = buildDesiredLabels(beforeEffective, filteredLabels) + } + + desiredARC := buildDesiredAlertRelabelConfigObject( + arcNamespace, arcName, prName, originalRule.Alert, alertRuleID, + existingArc, arcFound, mutation, + ) + desiredObject, err := alertRelabelConfigDesiredObject(desiredARC) + if err != nil { + return nil, err + } + + changes := diffEffectiveLabelChanges(beforeEffective, afterEffective) + if mutation.deleteARC { + changes = append(changes, RuleChange{ + Field: "resource", + Operation: RuleChangeOpRemove, + }) + } + + return &ResourceChangePlan{ + Resource: alertRelabelConfigRef(arcNamespace, arcName), + Changes: changes, + DesiredObject: desiredObject, + }, nil +} + +func (c *client) planAlertingRuleResourceChange( + ar *osmv1.AlertingRule, + originalAlertName string, + alertRuleID string, + filteredLabels map[string]string, +) (*ResourceChangePlan, error) { + target, found := findAlertByNameInAlertingRule(ar, originalAlertName) + if !found || target == nil { + return nil, &NotFoundError{ + Resource: "AlertRule", + Id: alertRuleID, + AdditionalInfo: fmt.Sprintf("alert %q not found in AlertingRule %s", originalAlertName, ar.Name), + } + } + + desired := copyStringMap(target.Labels) + for k, v := range filteredLabels { + if v == "" { + delete(desired, k) + } else { + desired[k] = v + } + } + + beforeRule := osmRuleToMonitoringV1(*target) + afterRule := beforeRule + afterRule.Labels = copyStringMap(desired) + + desiredAR, err := buildDesiredAlertingRuleWithUpdatedLabels(ar, originalAlertName, desired) + if err != nil { + return nil, err + } + desiredObject, err := alertingRuleDesiredObject(desiredAR) + if err != nil { + return nil, err + } + + changes := diffSemanticRuleChanges(beforeRule, afterRule) + if len(changes) == 0 { + return nil, nil + } + + return &ResourceChangePlan{ + Resource: alertingRuleRef(ar.Namespace, ar.Name), + Changes: changes, + DesiredObject: desiredObject, + }, nil +} + +func (c *client) planDropRestoreChange(ctx context.Context, alertRuleID string, enabled bool) (*RuleChangePlan, error) { + relabeled, found := c.k8sClient.RelabeledRules().Get(ctx, alertRuleID) + if !found || relabeled.Labels == nil { + return nil, &NotFoundError{Resource: "AlertRule", Id: alertRuleID} + } + + namespace := relabeled.Labels[k8s.PrometheusRuleLabelNamespace] + name := relabeled.Labels[k8s.PrometheusRuleLabelName] + nn := types.NamespacedName{Namespace: namespace, Name: name} + + if !c.isPlatformManagedPrometheusRule(nn) { + return nil, &NotAllowedError{Message: "drop/restore is only supported for platform alert rules"} + } + + arcNamespace, err := c.arcNamespaceForRule(nn) + if err != nil { + return nil, err + } + + pr, prFound, prErr := c.k8sClient.PrometheusRules().Get(ctx, namespace, name) + if prErr != nil { + return nil, fmt.Errorf("failed to get PrometheusRule %s/%s: %w", namespace, name, prErr) + } + if !prFound { + return nil, &NotFoundError{Resource: "PrometheusRule", Id: alertRuleID} + } + + originalRule, err := getOriginalPlatformRuleFromPR(pr, namespace, name, alertRuleID) + if err != nil { + return nil, err + } + + arName := relabeled.Labels[managementlabels.AlertingRuleLabelName] + if arName == "" { + arName = defaultAlertingRuleName + } + var ar *osmv1.AlertingRule + if fetched, arFound, arErr := c.getAlertingRule(ctx, arName); arErr != nil { + return nil, arErr + } else if arFound { + ar = fetched + } + + arcName := k8s.GetAlertRelabelConfigName(name, alertRuleID) + existingArc, arcExists, err := c.k8sClient.AlertRelabelConfigs().Get(ctx, arcNamespace, arcName) + if err != nil { + return nil, fmt.Errorf("failed to get AlertRelabelConfig %s/%s: %w", arcNamespace, arcName, err) + } + + managedBy := managedByFromRelabeledRule(relabeled) + writable := true + if err := validateDropRestorePreconditions(relabeled, pr, ar, relabelConfigIfFound(arcExists, existingArc)); err != nil { + allowance := allowanceFromPreconditionError(err) + writable = allowance.Writable + managedBy = allowance.ManagedBy + } + + prName := name + var mutation arcMutationResult + currentEnabled := true + if arcExists && existingArc != nil { + currentEnabled = len(getExistingRuleDrops(existingArc, alertRuleID)) == 0 + } + if enabled { + mutation = computeARCRestoreMutation(alertRuleID, existingArc) + } else { + mutation = computeARCDropMutation(*originalRule, alertRuleID, existingArc, arcExists) + } + + desiredARC := buildDesiredAlertRelabelConfigObject( + arcNamespace, arcName, prName, originalRule.Alert, alertRuleID, + existingArc, arcExists, mutation, + ) + desiredObject, err := alertRelabelConfigDesiredObject(desiredARC) + if err != nil { + return nil, err + } + + return buildDropRestoreRuleChangePlan( + writable, + managedBy, + alertRelabelConfigRef(arcNamespace, arcName), + *originalRule, + currentEnabled, + enabled, + desiredObject, + ), nil +} diff --git a/pkg/management/platform_mutation_route.go b/pkg/management/platform_mutation_route.go new file mode 100644 index 000000000..a9c899e2d --- /dev/null +++ b/pkg/management/platform_mutation_route.go @@ -0,0 +1,26 @@ +package management + +import ( + osmv1 "github.com/openshift/api/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +type platformLabelRoute int + +const ( + platformLabelRouteAlertRelabelConfig platformLabelRoute = iota + platformLabelRouteAlertingRule +) + +// resolvePlatformLabelRoute mirrors UpdatePlatformAlertRule label routing. +func resolvePlatformLabelRoute(ar *osmv1.AlertingRule, arFound bool) platformLabelRoute { + if arFound && ar != nil { + _, operatorManaged := k8s.IsExternallyManagedObject(ar) + if operatorManaged { + return platformLabelRouteAlertRelabelConfig + } + return platformLabelRouteAlertingRule + } + return platformLabelRouteAlertRelabelConfig +} diff --git a/pkg/management/platform_update_allowance.go b/pkg/management/platform_update_allowance.go new file mode 100644 index 000000000..a20da44a6 --- /dev/null +++ b/pkg/management/platform_update_allowance.go @@ -0,0 +1,146 @@ +package management + +import ( + "strings" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// PlatformUpdateTarget identifies which resource receives a platform label mutation. +type PlatformUpdateTarget int + +const ( + PlatformUpdateTargetAlertRelabelConfig PlatformUpdateTarget = iota + PlatformUpdateTargetAlertingRule +) + +type platformMutationKind int + +const ( + platformMutationLabel platformMutationKind = iota + platformMutationClassification +) + +// platformUpdateAllowance describes whether execute would permit a platform update. +type platformUpdateAllowance struct { + Writable bool + ManagedBy ManagementSource + Err error +} + +// evaluatePlatformUpdateAllowed mirrors execute-path ownership checks for one +// platform mutation target. Preview and UpdatePlatformAlertRule must use this +// helper so writable stays aligned with real writes. +func evaluatePlatformUpdateAllowed( + relabeled monitoringv1.Rule, + pr *monitoringv1.PrometheusRule, + ar *osmv1.AlertingRule, + arFound bool, + arc *osmv1.AlertRelabelConfig, + target PlatformUpdateTarget, + kind platformMutationKind, +) platformUpdateAllowance { + switch target { + case PlatformUpdateTargetAlertingRule: + if err := validateGitOpsPreconditions(relabeled, pr); err != nil { + return allowanceFromPreconditionError(err) + } + if arFound && ar != nil { + if gitOpsManaged, _ := k8s.IsExternallyManagedObject(ar); gitOpsManaged { + return platformUpdateAllowance{ + Writable: false, + ManagedBy: ManagedByGitOps, + Err: notAllowedGitOpsEdit(), + } + } + } + return platformUpdateAllowance{Writable: true} + + case PlatformUpdateTargetAlertRelabelConfig: + if kind == platformMutationClassification { + // applyClassificationViaARC does not run ownership preconditions today. + return platformUpdateAllowance{Writable: true} + } + if err := validatePlatformUpdatePreconditions(relabeled, nil, arc); err != nil { + return allowanceFromPreconditionError(err) + } + return platformUpdateAllowance{Writable: true} + + default: + return platformUpdateAllowance{Writable: true} + } +} + +func evaluatePlatformPreviewAllowance( + relabeled monitoringv1.Rule, + pr *monitoringv1.PrometheusRule, + ar *osmv1.AlertingRule, + arFound bool, + arc *osmv1.AlertRelabelConfig, + arcFound bool, + route platformLabelRoute, + classLabels map[string]string, + filteredUser map[string]string, +) platformUpdateAllowance { + result := platformUpdateAllowance{Writable: true} + mergeAllowance := func(part platformUpdateAllowance) { + if !part.Writable { + result.Writable = false + } + if part.ManagedBy != "" && result.ManagedBy == "" { + result.ManagedBy = part.ManagedBy + } + if part.Err != nil && result.Err == nil { + result.Err = part.Err + } + } + + hasClass := len(classLabels) > 0 + hasUser := len(filteredUser) > 0 + arcRef := relabelConfigIfFound(arcFound, arc) + + if hasClass { + mergeAllowance(evaluatePlatformUpdateAllowed( + relabeled, pr, ar, arFound, arcRef, + PlatformUpdateTargetAlertRelabelConfig, platformMutationClassification, + )) + } + if hasUser && route == platformLabelRouteAlertRelabelConfig { + mergeAllowance(evaluatePlatformUpdateAllowed( + relabeled, pr, ar, arFound, arcRef, + PlatformUpdateTargetAlertRelabelConfig, platformMutationLabel, + )) + } + if hasUser && route == platformLabelRouteAlertingRule { + mergeAllowance(evaluatePlatformUpdateAllowed( + relabeled, pr, ar, arFound, nil, + PlatformUpdateTargetAlertingRule, platformMutationLabel, + )) + } + if !hasClass && !hasUser { + mergeAllowance(evaluatePlatformUpdateAllowed( + relabeled, pr, ar, arFound, arcRef, + PlatformUpdateTargetAlertRelabelConfig, platformMutationLabel, + )) + } + return result +} + +func allowanceFromPreconditionError(err error) platformUpdateAllowance { + if err == nil { + return platformUpdateAllowance{Writable: true} + } + allowance := platformUpdateAllowance{Writable: false, Err: err} + if na, ok := err.(*NotAllowedError); ok { + switch { + case strings.Contains(na.Message, "GitOps"): + allowance.ManagedBy = ManagedByGitOps + case strings.Contains(na.Message, "operator"): + allowance.ManagedBy = ManagedByOperator + } + } + return allowance +} diff --git a/pkg/management/platform_update_parity_test.go b/pkg/management/platform_update_parity_test.go new file mode 100644 index 000000000..6f4e2f2c4 --- /dev/null +++ b/pkg/management/platform_update_parity_test.go @@ -0,0 +1,461 @@ +package management_test + +import ( + "context" + "errors" + "strings" + "testing" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +type platformParityFixture struct { + name string + relabeled monitoringv1.Rule + ruleID string + wantWritable bool + wantManaged management.ManagementSource + setup func(*testing.T, *testutils.MockClient) + preview management.PreviewUpdateRequest + execute func(management.Client) error +} + +func TestPlatformUpdatePreviewExecuteWritableParity(t *testing.T) { + info := "info" + component := "networking" + critical := "critical" + + fixtures := []platformParityFixture{ + { + name: "gitops_relabeled_rule_blocks_label_update", + relabeled: copyRuleWithLabels(upPlatformRule, managementlabels.RuleManagedByLabel, managementlabels.ManagedByGitOps), + ruleID: upPlatformRuleId, + wantWritable: false, + wantManaged: management.ManagedByGitOps, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, false) + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + }, + execute: func(client management.Client) error { + updated := copyRule(upOriginalPlatformRule) + updated.Labels["severity"] = "info" + return client.UpdatePlatformAlertRule(context.Background(), upPlatformRuleId, updated) + }, + }, + { + name: "gitops_arc_blocks_arc_label_path", + relabeled: copyRuleWithLabels(upPlatformRule, managementlabels.RuleManagedByLabel, managementlabels.ManagedByOperator), + ruleID: upPlatformRuleId, + wantWritable: false, + wantManaged: management.ManagedByGitOps, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, false) + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, ns, name string) (*osmv1.AlertRelabelConfig, bool, error) { + return &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: ns, + Annotations: map[string]string{"argocd.argoproj.io/tracking-id": "abc"}, + }, + }, true, nil + }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + }, + execute: func(client management.Client) error { + updated := copyRule(upOriginalPlatformRule) + updated.Labels["severity"] = "info" + return client.UpdatePlatformAlertRule(context.Background(), upPlatformRuleId, updated) + }, + }, + { + name: "operator_relabeled_rule_allows_arc_label_path", + relabeled: copyRuleWithLabels(upPlatformRule, managementlabels.RuleManagedByLabel, managementlabels.ManagedByOperator), + ruleID: upPlatformRuleId, + wantWritable: true, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, false) + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, _, _ string) (*osmv1.AlertRelabelConfig, bool, error) { + return nil, false, nil + }, + CreateFunc: func(_ context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + return &arc, nil + }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + }, + execute: func(client management.Client) error { + updated := copyRule(upOriginalPlatformRule) + updated.Labels["severity"] = "info" + return client.UpdatePlatformAlertRule(context.Background(), upPlatformRuleId, updated) + }, + }, + { + name: "writable_alerting_rule_allows_direct_label_path", + relabeled: upPlatformRule, + ruleID: upPlatformRuleId, + wantWritable: true, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, true) + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(_ context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return &osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k8s.ClusterMonitoringNamespace, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: "platform-alert-rules", + Rules: []osmv1.Rule{{ + Alert: upOriginalPlatformRule.Alert, + Expr: intstr.FromString(upOriginalPlatformRule.Expr.String()), + Labels: copyStringMap(upOriginalPlatformRule.Labels), + }}, + }}, + }, + }, true, nil + }, + UpdateFunc: func(_ context.Context, _ osmv1.AlertingRule) error { return nil }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + }, + execute: func(client management.Client) error { + updated := copyRule(upOriginalPlatformRule) + updated.Labels["severity"] = "info" + return client.UpdatePlatformAlertRule(context.Background(), upPlatformRuleId, updated) + }, + }, + { + name: "gitops_alerting_rule_blocks_direct_label_path", + relabeled: upPlatformRule, + ruleID: upPlatformRuleId, + wantWritable: false, + wantManaged: management.ManagedByGitOps, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, true) + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(_ context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return &osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k8s.ClusterMonitoringNamespace, + Annotations: map[string]string{ + "argocd.argoproj.io/tracking-id": "gitops", + }, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: "platform-alert-rules", + Rules: []osmv1.Rule{{ + Alert: upOriginalPlatformRule.Alert, + Expr: intstr.FromString(upOriginalPlatformRule.Expr.String()), + Labels: copyStringMap(upOriginalPlatformRule.Labels), + }}, + }}, + }, + }, true, nil + }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + }, + execute: func(client management.Client) error { + updated := copyRule(upOriginalPlatformRule) + updated.Labels["severity"] = "info" + return client.UpdatePlatformAlertRule(context.Background(), upPlatformRuleId, updated) + }, + }, + { + name: "combined_classification_and_labels_requires_both_targets_writable", + relabeled: upPlatformRule, + ruleID: upPlatformRuleId, + wantWritable: true, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, true) + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, _, _ string) (*osmv1.AlertRelabelConfig, bool, error) { + return nil, false, nil + }, + CreateFunc: func(_ context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + return &arc, nil + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(_ context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return &osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k8s.ClusterMonitoringNamespace, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: "platform-alert-rules", + Rules: []osmv1.Rule{{ + Alert: upOriginalPlatformRule.Alert, + Expr: intstr.FromString(upOriginalPlatformRule.Expr.String()), + Labels: copyStringMap(upOriginalPlatformRule.Labels), + }}, + }}, + }, + }, true, nil + }, + UpdateFunc: func(_ context.Context, _ osmv1.AlertingRule) error { return nil }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + Classification: &management.UpdateRuleClassificationRequest{ + RuleId: upPlatformRuleId, + Component: &component, + ComponentSet: true, + }, + }, + execute: func(client management.Client) error { + if err := client.UpdateAlertRuleClassification(context.Background(), management.UpdateRuleClassificationRequest{ + RuleId: upPlatformRuleId, + Component: &component, + ComponentSet: true, + }); err != nil { + return err + } + _, err := client.UpdateAlertRuleLabels(context.Background(), upPlatformRuleId, map[string]*string{ + "severity": &info, + }) + return err + }, + }, + { + name: "combined_update_blocked_when_gitops_arc_would_fail_labels", + relabeled: upPlatformRule, + ruleID: upPlatformRuleId, + wantWritable: false, + wantManaged: management.ManagedByGitOps, + setup: func(t *testing.T, mockK8s *testutils.MockClient) { + setupPreviewPlatformMocks(t, mockK8s, false) + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, ns, name string) (*osmv1.AlertRelabelConfig, bool, error) { + return &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: ns, + Annotations: map[string]string{"argocd.argoproj.io/tracking-id": "abc"}, + }, + }, true, nil + }, + } + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &info}, + Classification: &management.UpdateRuleClassificationRequest{ + RuleId: upPlatformRuleId, + Component: &component, + ComponentSet: true, + }, + }, + execute: func(client management.Client) error { + if err := client.UpdateAlertRuleClassification(context.Background(), management.UpdateRuleClassificationRequest{ + RuleId: upPlatformRuleId, + Component: &component, + ComponentSet: true, + }); err != nil { + return err + } + _, err := client.UpdateAlertRuleLabels(context.Background(), upPlatformRuleId, map[string]*string{ + "severity": &info, + }) + return err + }, + }, + { + name: "user_defined_gitops_parity", + ruleID: originalUserRuleId, + wantWritable: false, + wantManaged: management.ManagedByGitOps, + setup: func(_ *testing.T, mockK8s *testutils.MockClient) { + gitopsRule := copyRuleWithLabels(udUserRule, managementlabels.RuleManagedByLabel, managementlabels.ManagedByGitOps) + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + } + mockK8s.RelabeledRulesFunc = mockUDRelabeledGet(originalUserRuleId, gitopsRule) + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return makePRWithRule("user-namespace", "user-rule", originalUserRule) + } + }, + preview: management.PreviewUpdateRequest{ + RuleID: originalUserRuleId, + Labels: map[string]*string{"severity": &critical}, + }, + execute: func(client management.Client) error { + _, err := client.UpdateAlertRuleLabels(context.Background(), originalUserRuleId, map[string]*string{ + "severity": &critical, + }) + return err + }, + }, + } + + for _, tc := range fixtures { + t.Run(tc.name, func(t *testing.T) { + client, mockK8s := newUpdatePlatformClient(t) + if tc.setup != nil { + tc.setup(t, mockK8s) + } + if tc.relabeled.Labels != nil { + mockK8s.RelabeledRulesFunc = mockPlatformRelabeledGet(tc.ruleID, tc.relabeled) + } + + var arcMutated, arUpdated, prUpdated bool + wrapMockPersistFlags(mockK8s, &arcMutated, &arUpdated, &prUpdated) + + plan, err := client.PreviewAlertRuleUpdate(context.Background(), tc.preview) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + if plan.Writable != tc.wantWritable { + t.Fatalf("preview writable=%v, want %v (managedBy=%v)", plan.Writable, tc.wantWritable, plan.ManagedBy) + } + if !tc.wantWritable && tc.wantManaged != "" { + if plan.ManagedBy == nil || *plan.ManagedBy != tc.wantManaged { + t.Fatalf("preview managedBy=%v, want %q", plan.ManagedBy, tc.wantManaged) + } + } + if arcMutated || arUpdated || prUpdated { + t.Fatal("preview must not persist cluster changes") + } + + arcMutated = false + arUpdated = false + prUpdated = false + + execErr := tc.execute(client) + if tc.wantWritable { + if execErr != nil { + t.Fatalf("execute expected success, got %v", execErr) + } + } else { + if execErr == nil { + t.Fatal("execute expected failure for non-writable preview") + } + var na *management.NotAllowedError + if !errors.As(execErr, &na) { + t.Fatalf("execute expected NotAllowedError, got %v", execErr) + } + if tc.wantManaged == management.ManagedByGitOps && !strings.Contains(execErr.Error(), "GitOps") { + t.Fatalf("execute error should mention GitOps, got %v", execErr) + } + } + }) + } +} + +func wrapMockPersistFlags(mockK8s *testutils.MockClient, arcMutated, arUpdated, prUpdated *bool) { + if mockK8s.AlertRelabelConfigsFunc != nil { + origARC := mockK8s.AlertRelabelConfigsFunc + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + inner := origARC() + if mock, ok := inner.(*testutils.MockAlertRelabelConfigInterface); ok { + prevCreate := mock.CreateFunc + mock.CreateFunc = func(ctx context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + *arcMutated = true + if prevCreate != nil { + return prevCreate(ctx, arc) + } + return &arc, nil + } + prevUpdate := mock.UpdateFunc + mock.UpdateFunc = func(ctx context.Context, arc osmv1.AlertRelabelConfig) error { + *arcMutated = true + if prevUpdate != nil { + return prevUpdate(ctx, arc) + } + return nil + } + } + return inner + } + } + if mockK8s.AlertingRulesFunc != nil { + origAR := mockK8s.AlertingRulesFunc + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + inner := origAR() + if mock, ok := inner.(*testutils.MockAlertingRuleInterface); ok { + prevUpdate := mock.UpdateFunc + mock.UpdateFunc = func(ctx context.Context, ar osmv1.AlertingRule) error { + *arUpdated = true + if prevUpdate != nil { + return prevUpdate(ctx, ar) + } + return nil + } + } + return inner + } + } + if mockK8s.PrometheusRulesFunc != nil { + origPR := mockK8s.PrometheusRulesFunc + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + inner := origPR() + if mock, ok := inner.(*testutils.MockPrometheusRuleInterface); ok { + prevUpdate := mock.UpdateFunc + mock.UpdateFunc = func(ctx context.Context, pr monitoringv1.PrometheusRule) error { + *prUpdated = true + if prevUpdate != nil { + return prevUpdate(ctx, pr) + } + return nil + } + prevAddRule := mock.AddRuleFunc + mock.AddRuleFunc = func(ctx context.Context, nn types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + *prUpdated = true + if prevAddRule != nil { + return prevAddRule(ctx, nn, groupName, rule) + } + return nil + } + } + return inner + } + } +} diff --git a/pkg/management/preview_alert_rule.go b/pkg/management/preview_alert_rule.go new file mode 100644 index 000000000..71355d11e --- /dev/null +++ b/pkg/management/preview_alert_rule.go @@ -0,0 +1,35 @@ +package management + +import ( + "context" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +// PreviewCreateRequest identifies a create operation to preview. +type PreviewCreateRequest struct { + AlertRule monitoringv1.Rule + PROptions *PrometheusRuleOptions +} + +// PreviewAlertRule previews a single create or update without persisting changes. +func (c *client) PreviewAlertRuleCreate(ctx context.Context, req PreviewCreateRequest) (*RuleChangePlan, error) { + if req.PROptions != nil { + plan, err := c.planCreateUserDefinedAlertRule(ctx, req.AlertRule, *req.PROptions) + if err != nil { + return nil, err + } + return plan.toRuleChangePlan() + } + + plan, err := c.planCreatePlatformAlertRule(ctx, req.AlertRule) + if err != nil { + return nil, err + } + return plan.toRuleChangePlan() +} + +// PreviewAlertRuleUpdate previews a single update without persisting changes. +func (c *client) PreviewAlertRuleUpdate(ctx context.Context, req PreviewUpdateRequest) (*RuleChangePlan, error) { + return c.planUpdateAlertRule(ctx, req) +} diff --git a/pkg/management/preview_alert_rule_test.go b/pkg/management/preview_alert_rule_test.go new file mode 100644 index 000000000..31287386f --- /dev/null +++ b/pkg/management/preview_alert_rule_test.go @@ -0,0 +1,518 @@ +package management_test + +import ( + "context" + "strings" + "testing" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +func firstPreviewResource(t *testing.T, plan *management.RuleChangePlan) management.ResourceChangePlan { + t.Helper() + if len(plan.Resources) != 1 { + t.Fatalf("expected one resource, got %d", len(plan.Resources)) + } + return plan.Resources[0] +} + +func findPreviewResource(plan *management.RuleChangePlan, kind string) (management.ResourceChangePlan, bool) { + for _, res := range plan.Resources { + if res.Resource.Kind == kind { + return res, true + } + } + return management.ResourceChangePlan{}, false +} + +func TestPreviewCreateUserDefined_WritablePrometheusRule(t *testing.T) { + mockRules := &testutils.MockPrometheusRuleInterface{ + AddRuleFunc: func(context.Context, types.NamespacedName, string, monitoringv1.Rule) error { + t.Fatal("preview must not add a rule to PrometheusRule") + return nil + }, + UpdateFunc: func(context.Context, monitoringv1.PrometheusRule) error { + t.Fatal("preview must not update PrometheusRule") + return nil + }, + } + mockK8s := &testutils.MockClient{ + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { return mockRules }, + } + client := management.New(context.Background(), mockK8s) + + plan, err := client.PreviewAlertRuleCreate(context.Background(), management.PreviewCreateRequest{ + AlertRule: testRule, + PROptions: &management.PrometheusRuleOptions{Name: "user-pr", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleCreate: %v", err) + } + if !plan.Writable { + t.Fatal("expected writable=true") + } + res := firstPreviewResource(t, plan) + if res.Resource.Kind != "PrometheusRule" || res.Resource.Name != "user-pr" { + t.Fatalf("unexpected resource: %+v", res.Resource) + } + if len(res.Changes) != 1 || res.Changes[0].Operation != management.RuleChangeOpAdd { + t.Fatalf("expected single add change, got %+v", res.Changes) + } + if res.Changes[0].Field != "rule" { + t.Fatalf("expected field=rule, got %q", res.Changes[0].Field) + } + if res.DesiredObject == nil { + t.Fatal("expected desiredObject for PrometheusRule") + } + if plan.DesiredRule.Alert != testRule.Alert { + t.Fatalf("expected desiredRule.alert=%q, got %q", testRule.Alert, plan.DesiredRule.Alert) + } + if plan.DesiredRule.Labels["severity"] != "warning" { + t.Fatalf("expected desiredRule.labels.severity=warning, got %+v", plan.DesiredRule.Labels) + } +} + +func TestPreviewCreateUserDefined_GitOpsManaged(t *testing.T) { + mockK8s := &testutils.MockClient{ + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + GetFunc: func(_ context.Context, namespace, name string) (*monitoringv1.PrometheusRule, bool, error) { + return &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{"argocd.argoproj.io/tracking-id": "gitops"}, + }, + }, true, nil + }, + } + }, + } + client := management.New(context.Background(), mockK8s) + + plan, err := client.PreviewAlertRuleCreate(context.Background(), management.PreviewCreateRequest{ + AlertRule: testRule, + PROptions: &management.PrometheusRuleOptions{Name: "user-pr", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleCreate: %v", err) + } + if plan.Writable { + t.Fatal("expected writable=false for GitOps-managed PR") + } + if plan.ManagedBy == nil || *plan.ManagedBy != management.ManagedByGitOps { + t.Fatalf("expected managedBy=gitops, got %+v", plan.ManagedBy) + } + res := firstPreviewResource(t, plan) + if len(res.Changes) != 1 { + t.Fatalf("expected preview changes, got %+v", res.Changes) + } + if plan.DesiredRule.Alert == "" { + t.Fatal("expected populated desiredRule for create preview") + } + + _, err = client.CreateUserDefinedAlertRule(context.Background(), testRule, management.PrometheusRuleOptions{Name: "user-pr", Namespace: "default"}) + if err == nil || !strings.Contains(err.Error(), "GitOps") { + t.Fatalf("expected create to remain blocked, got %v", err) + } +} + +func TestPreviewCreateUserDefined_OperatorManaged(t *testing.T) { + mockK8s := &testutils.MockClient{ + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(string) bool { return false }, + } + }, + RelabeledRulesFunc: func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + }, + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + GetFunc: func(_ context.Context, namespace, name string) (*monitoringv1.PrometheusRule, bool, error) { + return &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + OwnerReferences: []metav1.OwnerReference{ + {Kind: "Deployment", Name: "op"}, + }, + }, + }, true, nil + }, + } + }, + } + client := management.New(context.Background(), mockK8s) + + plan, err := client.PreviewAlertRuleCreate(context.Background(), management.PreviewCreateRequest{ + AlertRule: testRule, + PROptions: &management.PrometheusRuleOptions{Name: "user-pr", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleCreate: %v", err) + } + if plan.Writable { + t.Fatal("expected writable=false for operator-managed PR") + } + if plan.ManagedBy == nil || *plan.ManagedBy != management.ManagedByOperator { + t.Fatalf("expected managedBy=operator, got %+v", plan.ManagedBy) + } +} + +func TestPreviewCreateUserDefined_InvalidRequest(t *testing.T) { + client := management.New(context.Background(), &testutils.MockClient{}) + _, err := client.PreviewAlertRuleCreate(context.Background(), management.PreviewCreateRequest{ + AlertRule: testRule, + PROptions: &management.PrometheusRuleOptions{Namespace: "default"}, + }) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestPreviewUpdateUserDefined_WritableSeverityChange(t *testing.T) { + client, mockK8s := newUpdateUserDefinedClient(t) + mockK8s.RelabeledRulesFunc = mockUDRelabeledGet(originalUserRuleId, udUserRule) + pr := makePRWithRule("user-namespace", "user-rule", originalUserRule) + var updateCalled bool + pr.UpdateFunc = func(context.Context, monitoringv1.PrometheusRule) error { + updateCalled = true + return nil + } + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { return pr } + + sev := "critical" + plan, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: originalUserRuleId, + Labels: map[string]*string{"severity": &sev}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + if !plan.Writable { + t.Fatal("expected writable=true") + } + res := firstPreviewResource(t, plan) + if len(res.Changes) != 1 { + t.Fatalf("expected one change, got %+v", res.Changes) + } + if res.Changes[0].Operation != management.RuleChangeOpReplace { + t.Fatalf("expected replace, got %q", res.Changes[0].Operation) + } + if res.Changes[0].Field != "labels.severity" { + t.Fatalf("expected labels.severity field, got %q", res.Changes[0].Field) + } + if res.Changes[0].CurrentValue != "warning" || res.Changes[0].NewValue != "critical" { + t.Fatalf("unexpected severity diff: %+v", res.Changes[0]) + } + if res.Resource.Kind != "PrometheusRule" { + t.Fatalf("expected PrometheusRule resource, got %q", res.Resource.Kind) + } + if res.DesiredObject == nil { + t.Fatal("expected desiredObject for PrometheusRule") + } + if plan.DesiredRule.Labels["severity"] != "critical" { + t.Fatalf("expected desiredRule severity=critical, got %+v", plan.DesiredRule.Labels) + } + if updateCalled { + t.Fatal("preview must not persist updates") + } +} + +func TestPreviewUpdateUserDefined_GitOpsManaged(t *testing.T) { + client, mockK8s := newUpdateUserDefinedClient(t) + gitopsRule := copyRuleWithLabels(udUserRule, managementlabels.RuleManagedByLabel, managementlabels.ManagedByGitOps) + mockK8s.RelabeledRulesFunc = mockUDRelabeledGet(originalUserRuleId, gitopsRule) + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return makePRWithRule("user-namespace", "user-rule", originalUserRule) + } + + sev := "critical" + plan, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: originalUserRuleId, + Labels: map[string]*string{"severity": &sev}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + if plan.Writable { + t.Fatal("expected writable=false") + } + if plan.ManagedBy == nil || *plan.ManagedBy != management.ManagedByGitOps { + t.Fatalf("expected managedBy=gitops, got %+v", plan.ManagedBy) + } + + _, err = client.UpdateAlertRuleLabels(context.Background(), originalUserRuleId, map[string]*string{"severity": &sev}) + if err == nil || !strings.Contains(err.Error(), "GitOps") { + t.Fatalf("expected update to remain blocked, got %v", err) + } +} + +func TestPreviewUpdateUserDefined_NoOp(t *testing.T) { + client, mockK8s := newUpdateUserDefinedClient(t) + mockK8s.RelabeledRulesFunc = mockUDRelabeledGet(originalUserRuleId, udUserRule) + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return makePRWithRule("user-namespace", "user-rule", originalUserRule) + } + + sev := "warning" + plan, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: originalUserRuleId, + Labels: map[string]*string{"severity": &sev}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + res := firstPreviewResource(t, plan) + if len(res.Changes) != 0 { + t.Fatalf("expected no changes for no-op update, got %+v", res.Changes) + } +} + +func TestPreviewUpdateUserDefined_NotFound(t *testing.T) { + client, mockK8s := newUpdateUserDefinedClient(t) + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{} + } + _, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: "missing-id", + Labels: map[string]*string{"severity": stringPtr("critical")}, + }) + if err == nil { + t.Fatal("expected not found error") + } +} + +func setupPreviewPlatformMocks(t *testing.T, mockK8s *testutils.MockClient, withAlertingRule bool) { + t.Helper() + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return name == "openshift-monitoring" + }, + } + } + mockK8s.RelabeledRulesFunc = mockPlatformRelabeledGet(upPlatformRuleId, upPlatformRule) + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return makePlatformPR("openshift-monitoring", "platform-rule", upOriginalPlatformRule) + } + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, _, _ string) (*osmv1.AlertRelabelConfig, bool, error) { + return nil, false, nil + }, + } + } + if withAlertingRule { + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(_ context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return &osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k8s.ClusterMonitoringNamespace, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: "platform-alert-rules", + Rules: []osmv1.Rule{{ + Alert: upOriginalPlatformRule.Alert, + Expr: intstr.FromString(upOriginalPlatformRule.Expr.String()), + Labels: copyStringMap(upOriginalPlatformRule.Labels), + }}, + }}, + }, + }, true, nil + }, + } + } + } +} + +func TestPreviewUpdatePlatform_SeverityViaAlertRelabelConfig(t *testing.T) { + client, mockK8s := newUpdatePlatformClient(t) + setupPreviewPlatformMocks(t, mockK8s, false) + + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, _, _ string) (*osmv1.AlertRelabelConfig, bool, error) { + return nil, false, nil + }, + CreateFunc: func(_ context.Context, _ osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + t.Fatal("preview must not persist ARC changes") + return nil, nil + }, + UpdateFunc: func(_ context.Context, _ osmv1.AlertRelabelConfig) error { + t.Fatal("preview must not persist ARC changes") + return nil + }, + } + } + + sev := "info" + plan, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &sev}, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + res, ok := findPreviewResource(plan, "AlertRelabelConfig") + if !ok { + t.Fatalf("expected AlertRelabelConfig resource, got %+v", plan.Resources) + } + if len(res.Changes) != 1 || res.Changes[0].Field != "severity" { + t.Fatalf("expected severity change on ARC, got %+v", res.Changes) + } + if res.Changes[0].CurrentValue != "critical" || res.Changes[0].NewValue != "info" { + t.Fatalf("unexpected severity diff: %+v", res.Changes[0]) + } + if res.DesiredObject == nil { + t.Fatal("expected desiredObject for AlertRelabelConfig") + } + spec, ok := res.DesiredObject["spec"].(map[string]any) + if !ok { + t.Fatalf("expected spec in desiredObject, got %+v", res.DesiredObject) + } + configs, ok := spec["configs"].([]any) + if !ok || len(configs) < 2 { + t.Fatalf("expected relabel configs in desiredObject, got %+v", spec["configs"]) + } +} + +func TestPreviewUpdatePlatform_ClassificationAndLabelsMultiResource(t *testing.T) { + client, mockK8s := newUpdatePlatformClient(t) + setupPreviewPlatformMocks(t, mockK8s, true) + + mockK8s.AlertRelabelConfigsFunc = func() k8s.AlertRelabelConfigInterface { + return &testutils.MockAlertRelabelConfigInterface{ + GetFunc: func(_ context.Context, _, _ string) (*osmv1.AlertRelabelConfig, bool, error) { + return nil, false, nil + }, + CreateFunc: func(_ context.Context, _ osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + t.Fatal("preview must not persist ARC changes") + return nil, nil + }, + UpdateFunc: func(_ context.Context, _ osmv1.AlertRelabelConfig) error { + t.Fatal("preview must not persist ARC changes") + return nil + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(_ context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return &osmv1.AlertingRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: k8s.ClusterMonitoringNamespace, + }, + Spec: osmv1.AlertingRuleSpec{ + Groups: []osmv1.RuleGroup{{ + Name: "platform-alert-rules", + Rules: []osmv1.Rule{{ + Alert: upOriginalPlatformRule.Alert, + Expr: intstr.FromString(upOriginalPlatformRule.Expr.String()), + Labels: copyStringMap(upOriginalPlatformRule.Labels), + }}, + }}, + }, + }, true, nil + }, + UpdateFunc: func(_ context.Context, _ osmv1.AlertingRule) error { + t.Fatal("preview must not persist AlertingRule changes") + return nil + }, + } + } + + component := "networking" + sev := "info" + plan, err := client.PreviewAlertRuleUpdate(context.Background(), management.PreviewUpdateRequest{ + RuleID: upPlatformRuleId, + Labels: map[string]*string{"severity": &sev}, + Classification: &management.UpdateRuleClassificationRequest{ + RuleId: upPlatformRuleId, + Component: &component, + ComponentSet: true, + }, + }) + if err != nil { + t.Fatalf("PreviewAlertRuleUpdate: %v", err) + } + if len(plan.Resources) != 2 { + t.Fatalf("expected two resources, got %d: %+v", len(plan.Resources), plan.Resources) + } + + arcRes, ok := findPreviewResource(plan, "AlertRelabelConfig") + if !ok { + t.Fatal("expected AlertRelabelConfig in preview resources") + } + if len(arcRes.Changes) == 0 { + t.Fatal("expected classification changes on ARC") + } + foundComponent := false + for _, ch := range arcRes.Changes { + if ch.Field == k8s.AlertRuleClassificationComponentKey { + foundComponent = true + } + } + if !foundComponent { + t.Fatalf("expected component change on ARC, got %+v", arcRes.Changes) + } + if arcRes.DesiredObject == nil { + t.Fatal("expected ARC desiredObject") + } + + arRes, ok := findPreviewResource(plan, "AlertingRule") + if !ok { + t.Fatal("expected AlertingRule in preview resources") + } + if len(arRes.Changes) != 1 || arRes.Changes[0].Field != "labels.severity" { + t.Fatalf("expected severity change on AlertingRule, got %+v", arRes.Changes) + } + if arRes.DesiredObject == nil { + t.Fatal("expected AlertingRule desiredObject") + } + + if plan.DesiredRule.Labels["severity"] != "info" { + t.Fatalf("expected desiredRule severity=info, got %+v", plan.DesiredRule.Labels) + } + if plan.DesiredRule.Labels[k8s.AlertRuleClassificationComponentKey] != "networking" { + t.Fatalf("expected desiredRule component=networking, got %+v", plan.DesiredRule.Labels) + } +} + +func copyStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/pkg/management/rule_change_plan.go b/pkg/management/rule_change_plan.go new file mode 100644 index 000000000..a1422f9c0 --- /dev/null +++ b/pkg/management/rule_change_plan.go @@ -0,0 +1,124 @@ +package management + +import ( + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +const ( + prometheusRuleAPIVersion = "monitoring.coreos.com/v1" + prometheusRuleKind = "PrometheusRule" + alertingRuleAPIVersion = "monitoring.openshift.io/v1" + alertingRuleKind = "AlertingRule" + alertRelabelConfigAPIVersion = "monitoring.openshift.io/v1" + alertRelabelConfigKind = "AlertRelabelConfig" +) + +// ManagementSource identifies an external management system owning a resource. +type ManagementSource string + +const ( + ManagedByGitOps ManagementSource = "gitops" + ManagedByOperator ManagementSource = "operator" +) + +// ResourceRef identifies a Kubernetes object targeted by a change plan. +type ResourceRef struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` +} + +// RuleChangeOperation is a semantic change operation for preview UI. +type RuleChangeOperation string + +const ( + RuleChangeOpAdd RuleChangeOperation = "add" + RuleChangeOpReplace RuleChangeOperation = "replace" + RuleChangeOpRemove RuleChangeOperation = "remove" +) + +// RuleChange describes one semantic before/after change for preview UI. +type RuleChange struct { + Field string `json:"field"` + Operation RuleChangeOperation `json:"operation"` + CurrentValue any `json:"currentValue,omitempty"` + NewValue any `json:"newValue,omitempty"` +} + +// ResourceChangePlan describes one Kubernetes resource mutation in a preview plan. +type ResourceChangePlan struct { + Resource ResourceRef `json:"resource"` + Changes []RuleChange `json:"changes"` + DesiredObject map[string]any `json:"desiredObject,omitempty"` +} + +// RuleChangePlan is the result of planning a create or update without persisting. +type RuleChangePlan struct { + Writable bool `json:"writable"` + ManagedBy *ManagementSource `json:"managedBy,omitempty"` + Resources []ResourceChangePlan `json:"resources"` + DesiredRule monitoringv1.Rule `json:"desiredRule"` +} + +func prometheusRuleRef(namespace, name string) ResourceRef { + return ResourceRef{ + APIVersion: prometheusRuleAPIVersion, + Kind: prometheusRuleKind, + Namespace: namespace, + Name: name, + } +} + +func alertingRuleRef(namespace, name string) ResourceRef { + return ResourceRef{ + APIVersion: alertingRuleAPIVersion, + Kind: alertingRuleKind, + Namespace: namespace, + Name: name, + } +} + +func alertRelabelConfigRef(namespace, name string) ResourceRef { + return ResourceRef{ + APIVersion: alertRelabelConfigAPIVersion, + Kind: alertRelabelConfigKind, + Namespace: namespace, + Name: name, + } +} + +func managedByFromRelabeledRule(relabeled monitoringv1.Rule) ManagementSource { + if isRuleManagedByGitOpsLabel(relabeled) { + return ManagedByGitOps + } + if isRuleManagedByOperator(relabeled) { + return ManagedByOperator + } + return "" +} + +func managedByFromObject(obj metav1.Object) ManagementSource { + if obj == nil { + return "" + } + gitOps, operator := k8s.IsExternallyManagedObject(obj) + if operator { + return ManagedByOperator + } + if gitOps { + return ManagedByGitOps + } + return "" +} + +func planManagedByPtr(source ManagementSource) *ManagementSource { + if source == "" { + return nil + } + s := source + return &s +} diff --git a/pkg/management/rule_changes.go b/pkg/management/rule_changes.go new file mode 100644 index 000000000..e3e741b0e --- /dev/null +++ b/pkg/management/rule_changes.go @@ -0,0 +1,338 @@ +package management + +import ( + "sort" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +func resolvePrometheusRuleGroupIndex(pr *monitoringv1.PrometheusRule, groupName string) int { + if pr == nil { + return 0 + } + for i, g := range pr.Spec.Groups { + if g.Name == groupName { + return i + } + } + return len(pr.Spec.Groups) +} + +func findPrometheusRuleIndices(pr *monitoringv1.PrometheusRule, alertRuleID string) (groupIdx, ruleIdx int, found bool) { + for gi := range pr.Spec.Groups { + for ri := range pr.Spec.Groups[gi].Rules { + if ruleMatchesAlertRuleID(pr.Spec.Groups[gi].Rules[ri], alertRuleID) { + return gi, ri, true + } + } + } + return 0, 0, false +} + +// sanitizeRuleForPreview returns a rule suitable for API desiredRule output, +// stripping relabeling provenance and system labels injected by the plugin. +func sanitizeRuleForPreview(rule monitoringv1.Rule) monitoringv1.Rule { + out := rule + if len(rule.Labels) > 0 { + out.Labels = copyStringMap(rule.Labels) + for k := range out.Labels { + if isPreviewProvenanceLabel(k) { + delete(out.Labels, k) + } + } + if len(out.Labels) == 0 { + out.Labels = nil + } + } + if len(rule.Annotations) > 0 { + out.Annotations = copyStringMap(rule.Annotations) + } + return out +} + +func isPreviewProvenanceLabel(key string) bool { + switch key { + case k8s.AlertRuleLabelId, + k8s.PrometheusRuleLabelNamespace, + k8s.PrometheusRuleLabelName, + managementlabels.AlertNameLabel, + managementlabels.RuleManagedByLabel, + managementlabels.RelabelConfigManagedByLabel: + return true + default: + return false + } +} + +func ruleToPreviewMap(rule monitoringv1.Rule) map[string]any { + sanitized := sanitizeRuleForPreview(rule) + out := map[string]any{} + if sanitized.Alert != "" { + out["alert"] = sanitized.Alert + } + if sanitized.Record != "" { + out["record"] = sanitized.Record + } + if sanitized.Expr.String() != "" { + out["expr"] = sanitized.Expr.String() + } + if sanitized.For != nil { + out["for"] = string(*sanitized.For) + } + if sanitized.KeepFiringFor != nil { + out["keepFiringFor"] = string(*sanitized.KeepFiringFor) + } + if len(sanitized.Labels) > 0 { + out["labels"] = copyStringMap(sanitized.Labels) + } + if len(sanitized.Annotations) > 0 { + out["annotations"] = copyStringMap(sanitized.Annotations) + } + return out +} + +func addRuleSemanticChange(rule monitoringv1.Rule) []RuleChange { + desired := sanitizeRuleForPreview(rule) + return []RuleChange{{ + Field: "rule", + Operation: RuleChangeOpAdd, + NewValue: ruleToPreviewMap(desired), + }} +} + +func diffSemanticRuleChanges(before, after monitoringv1.Rule) []RuleChange { + b := sanitizeRuleForPreview(before) + a := sanitizeRuleForPreview(after) + + var changes []RuleChange + changes = append(changes, diffScalarField("alert", b.Alert, a.Alert)...) + changes = append(changes, diffScalarField("record", b.Record, a.Record)...) + changes = append(changes, diffScalarField("expr", b.Expr.String(), a.Expr.String())...) + + bFor, aFor := durationString(b.For), durationString(a.For) + changes = append(changes, diffScalarField("for", bFor, aFor)...) + + bKeep, aKeep := nonEmptyDurationString(b.KeepFiringFor), nonEmptyDurationString(a.KeepFiringFor) + changes = append(changes, diffScalarField("keepFiringFor", bKeep, aKeep)...) + + changes = append(changes, diffLabelSemanticChanges("labels", b.Labels, a.Labels)...) + changes = append(changes, diffLabelSemanticChanges("annotations", b.Annotations, a.Annotations)...) + + return changes +} + +func diffLabelSemanticChanges(prefix string, before, after map[string]string) []RuleChange { + var keys []string + seen := map[string]struct{}{} + for k := range before { + if isProtectedLabel(k) || isPreviewProvenanceLabel(k) { + continue + } + keys = append(keys, k) + seen[k] = struct{}{} + } + for k := range after { + if isProtectedLabel(k) || isPreviewProvenanceLabel(k) { + continue + } + if _, ok := seen[k]; !ok { + keys = append(keys, k) + } + } + sort.Strings(keys) + + fieldPrefix := prefix + if prefix != "" { + fieldPrefix = prefix + "." + } + + var changes []RuleChange + for _, k := range keys { + bv, bOK := before[k] + av, aOK := after[k] + field := fieldPrefix + k + switch { + case bOK && aOK && bv == av: + continue + case bOK && !aOK: + changes = append(changes, RuleChange{ + Field: field, + Operation: RuleChangeOpRemove, + CurrentValue: bv, + }) + case !bOK && aOK: + changes = append(changes, RuleChange{ + Field: field, + Operation: RuleChangeOpAdd, + NewValue: av, + }) + case bOK && aOK && bv != av: + changes = append(changes, RuleChange{ + Field: field, + Operation: RuleChangeOpReplace, + CurrentValue: bv, + NewValue: av, + }) + } + } + return changes +} + +func diffScalarField(field, before, after string) []RuleChange { + if before == after { + return nil + } + if before == "" && after != "" { + return []RuleChange{{ + Field: field, + Operation: RuleChangeOpAdd, + NewValue: after, + }} + } + if before != "" && after == "" { + return []RuleChange{{ + Field: field, + Operation: RuleChangeOpRemove, + CurrentValue: before, + }} + } + return []RuleChange{{ + Field: field, + Operation: RuleChangeOpReplace, + CurrentValue: before, + NewValue: after, + }} +} + +func alertingRuleEnabledChange(currentEnabled, desiredEnabled bool) []RuleChange { + if currentEnabled == desiredEnabled { + return nil + } + if desiredEnabled { + return []RuleChange{{ + Field: "alertingRuleEnabled", + Operation: RuleChangeOpReplace, + CurrentValue: false, + NewValue: true, + }} + } + return []RuleChange{{ + Field: "alertingRuleEnabled", + Operation: RuleChangeOpReplace, + CurrentValue: true, + NewValue: false, + }} +} + +func durationString(d *monitoringv1.Duration) string { + if d == nil { + return "" + } + return string(*d) +} + +func nonEmptyDurationString(d *monitoringv1.NonEmptyDuration) string { + if d == nil { + return "" + } + return string(*d) +} + +func ruleWithLabels(base monitoringv1.Rule, labels map[string]string) monitoringv1.Rule { + out := base + if len(labels) == 0 { + out.Labels = nil + return out + } + out.Labels = copyStringMap(labels) + return out +} + +func buildRuleChangePlan( + writable bool, + managedBy ManagementSource, + resources []ResourceChangePlan, + desiredRule monitoringv1.Rule, +) *RuleChangePlan { + return &RuleChangePlan{ + Writable: writable, + ManagedBy: planManagedByPtr(managedBy), + Resources: resources, + DesiredRule: sanitizeRuleForPreview(desiredRule), + } +} + +func buildCreateRuleChangePlan( + writable bool, + managedBy ManagementSource, + target ResourceRef, + rule monitoringv1.Rule, + desiredObject map[string]any, +) *RuleChangePlan { + return buildRuleChangePlan( + writable, + managedBy, + []ResourceChangePlan{{ + Resource: target, + Changes: addRuleSemanticChange(rule), + DesiredObject: desiredObject, + }}, + rule, + ) +} + +func buildDropRestoreRuleChangePlan( + writable bool, + managedBy ManagementSource, + target ResourceRef, + rule monitoringv1.Rule, + currentEnabled bool, + desiredEnabled bool, + desiredObject map[string]any, +) *RuleChangePlan { + changes := alertingRuleEnabledChange(currentEnabled, desiredEnabled) + if desiredObject == nil && !desiredEnabled { + changes = append(changes, RuleChange{ + Field: "resource", + Operation: RuleChangeOpRemove, + }) + } + return buildRuleChangePlan( + writable, + managedBy, + []ResourceChangePlan{{ + Resource: target, + Changes: changes, + DesiredObject: desiredObject, + }}, + rule, + ) +} + +func hasResourceChanges(plan *RuleChangePlan) bool { + if plan == nil { + return false + } + for _, res := range plan.Resources { + if len(res.Changes) > 0 { + return true + } + } + return false +} + +func mergeLabelMaps(into, from map[string]string) map[string]string { + if len(from) == 0 { + return into + } + if into == nil { + into = map[string]string{} + } + for k, v := range from { + into[k] = v + } + return into +} diff --git a/pkg/management/types.go b/pkg/management/types.go index e150832c8..ae4342d04 100644 --- a/pkg/management/types.go +++ b/pkg/management/types.go @@ -46,6 +46,12 @@ type Client interface { UpdateAlertRuleClassification(ctx context.Context, req UpdateRuleClassificationRequest) error // BulkUpdateAlertRuleClassification updates classification for multiple rule ids BulkUpdateAlertRuleClassification(ctx context.Context, items []UpdateRuleClassificationRequest) []error + + // PreviewAlertRuleCreate previews creating one alert rule without persisting. + PreviewAlertRuleCreate(ctx context.Context, req PreviewCreateRequest) (*RuleChangePlan, error) + + // PreviewAlertRuleUpdate previews updating one alert rule without persisting. + PreviewAlertRuleUpdate(ctx context.Context, req PreviewUpdateRequest) (*RuleChangePlan, error) } // PrometheusRuleOptions specifies options for selecting PrometheusRule resources and groups diff --git a/pkg/management/update_alert_rule_labels.go b/pkg/management/update_alert_rule_labels.go index af19db231..718fc2fde 100644 --- a/pkg/management/update_alert_rule_labels.go +++ b/pkg/management/update_alert_rule_labels.go @@ -56,6 +56,26 @@ func (c *client) updatePlatformRuleLabels(ctx context.Context, alertRuleId strin // updateUserRuleLabels merges label changes onto the source rule (from the // PrometheusRule, not the relabeled cache) and updates the PrometheusRule directly. func (c *client) updateUserRuleLabels(ctx context.Context, alertRuleId string, relabeled monitoringv1.Rule, labels map[string]*string) (string, error) { + userLabels := make(map[string]string, len(labels)) + for k, pv := range labels { + if pv == nil || *pv == "" { + userLabels[k] = "" + } else { + userLabels[k] = *pv + } + } + + plan, err := c.planUserDefinedLabelUpdate(ctx, alertRuleId, relabeled, userLabels) + if err != nil { + return "", err + } + if err := enforceUserDefinedUpdateWritable(plan); err != nil { + return "", err + } + if !hasResourceChanges(plan) { + return alertRuleId, nil + } + namespace := relabeled.Labels[k8s.PrometheusRuleLabelNamespace] name := relabeled.Labels[k8s.PrometheusRuleLabelName] @@ -72,19 +92,12 @@ func (c *client) updateUserRuleLabels(ctx context.Context, alertRuleId string, r return "", err } - userLabels := copyStringMap(sourceRule.Labels) - for k, pv := range labels { - if isProtectedLabel(k) { - continue - } - if pv == nil || *pv == "" { - delete(userLabels, k) - } else { - userLabels[k] = *pv - } + mergedLabels := copyStringMap(sourceRule.Labels) + if err := applyUserDefinedLabelMap(mergedLabels, userLabels); err != nil { + return "", err } updatedRule := *sourceRule - updatedRule.Labels = userLabels + updatedRule.Labels = mergedLabels return c.UpdateUserDefinedAlertRule(ctx, alertRuleId, updatedRule) } diff --git a/pkg/management/update_alert_rule_labels_test.go b/pkg/management/update_alert_rule_labels_test.go index a669ad4ae..6151c1ecb 100644 --- a/pkg/management/update_alert_rule_labels_test.go +++ b/pkg/management/update_alert_rule_labels_test.go @@ -7,6 +7,7 @@ import ( monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" ) func TestUpdateAlertRuleLabels_IgnoresProtectedLabelsOnUserRule(t *testing.T) { @@ -44,3 +45,44 @@ func TestUpdateAlertRuleLabels_IgnoresProtectedLabelsOnUserRule(t *testing.T) { t.Errorf("expected severity=critical, got %q", savedLabels["severity"]) } } + +// TestUpdateAlertRuleLabels_IgnoresProvenanceLabelsOnUserRule guards against +// regressions where a labels update request could overwrite the internal +// provenance labels (PrometheusRule namespace/name, managed-by markers) that +// isPreviewProvenanceLabel hides from preview diffs but isProtectedLabel does +// not cover on its own. +func TestUpdateAlertRuleLabels_IgnoresProvenanceLabelsOnUserRule(t *testing.T) { + client, mockK8s := newUpdateUserDefinedClient(t) + mockK8s.RelabeledRulesFunc = mockUDRelabeledGet(originalUserRuleId, udUserRule) + + var savedPR *monitoringv1.PrometheusRule + pr := makePRWithRule("user-namespace", "user-rule", originalUserRule) + pr.UpdateFunc = func(_ context.Context, p monitoringv1.PrometheusRule) error { + savedPR = &p + return nil + } + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { return pr } + + bogusManagedBy := "operator" + critical := "critical" + labels := map[string]*string{ + managementlabels.RuleManagedByLabel: &bogusManagedBy, + "severity": &critical, + } + + _, err := client.UpdateAlertRuleLabels(context.Background(), originalUserRuleId, labels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if savedPR == nil { + t.Fatal("expected PR to be updated") + } + + savedLabels := savedPR.Spec.Groups[0].Rules[0].Labels + if savedLabels[managementlabels.RuleManagedByLabel] == bogusManagedBy { + t.Errorf("provenance label %q must not be overridden by the request", managementlabels.RuleManagedByLabel) + } + if savedLabels["severity"] != "critical" { + t.Errorf("expected severity=critical, got %q", savedLabels["severity"]) + } +} diff --git a/pkg/management/update_platform_alert_rule.go b/pkg/management/update_platform_alert_rule.go index ea8a8c87f..6c7b06204 100644 --- a/pkg/management/update_platform_alert_rule.go +++ b/pkg/management/update_platform_alert_rule.go @@ -66,11 +66,12 @@ func (c *client) UpdatePlatformAlertRule(ctx context.Context, alertRuleId string if arErr != nil { return arErr } - if arFound && ar != nil { - if gitOpsManaged, operatorManaged := k8s.IsExternallyManagedObject(ar); gitOpsManaged { - return notAllowedGitOpsEdit() - } else if operatorManaged { - return c.applyLabelChangesViaAlertRelabelConfig(ctx, arcNamespace, alertRuleId, *originalRule, alertRule.Labels) + if resolvePlatformLabelRoute(ar, arFound) == platformLabelRouteAlertingRule { + if allowance := evaluatePlatformUpdateAllowed( + rule, prMeta, ar, arFound, nil, + PlatformUpdateTargetAlertingRule, platformMutationLabel, + ); allowance.Err != nil { + return allowance.Err } return c.updateAlertingRuleLabels(ctx, ar, originalRule.Alert, alertRuleId, alertRule.Labels, arName) } @@ -81,7 +82,7 @@ func (c *client) UpdatePlatformAlertRule(ctx context.Context, alertRuleId string func filterAndValidatePlatformLabelChanges(labels map[string]string) (map[string]string, error) { filtered := make(map[string]string) for k, v := range labels { - if !isProtectedLabel(k) { + if !isProtectedLabel(k) && !isPreviewProvenanceLabel(k) { filtered[k] = v } } @@ -174,8 +175,11 @@ func (c *client) applyLabelChangesViaAlertRelabelConfig(ctx context.Context, nam if err != nil { return fmt.Errorf("failed to get AlertRelabelConfig %s/%s: %w", namespace, arcName, err) } - if err := validatePlatformUpdatePreconditions(relabeled, nil, relabelConfigIfFound(found, existingArc)); err != nil { - return err + if allowance := evaluatePlatformUpdateAllowed( + relabeled, nil, nil, false, relabelConfigIfFound(found, existingArc), + PlatformUpdateTargetAlertRelabelConfig, platformMutationLabel, + ); allowance.Err != nil { + return allowance.Err } original := copyStringMap(originalRule.Labels) diff --git a/pkg/management/update_platform_alert_rule_test.go b/pkg/management/update_platform_alert_rule_test.go index fb8bf8d15..a53319a63 100644 --- a/pkg/management/update_platform_alert_rule_test.go +++ b/pkg/management/update_platform_alert_rule_test.go @@ -600,13 +600,21 @@ func TestUpdatePlatformAlertRule_IgnoresAlertNameChange(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if createdARC != nil { - for _, cfg := range createdARC.Spec.Configs { - if string(cfg.TargetLabel) == managementlabels.AlertNameLabel { - t.Errorf("protected label %q must not be overridden by the request", managementlabels.AlertNameLabel) - } + if createdARC == nil { + t.Fatal("expected ARC to be created for label change") + } + hasNewLabel := false + for _, cfg := range createdARC.Spec.Configs { + if string(cfg.TargetLabel) == managementlabels.AlertNameLabel { + t.Errorf("protected label %q must not be overridden by the request", managementlabels.AlertNameLabel) + } + if string(cfg.TargetLabel) == "new_label" && cfg.Replacement == "new_value" { + hasNewLabel = true } } + if !hasNewLabel { + t.Error("expected new_label override in ARC configs") + } } // ============================================================ diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index cbd2e98eb..5b0fbd9d6 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -111,6 +111,88 @@ func createRuleViaAPI(ctx context.Context, f *framework.Framework, payload manag // mustCreateRule is a test convenience wrapper around createRuleViaAPI that // builds the request from individual parameters and calls t.Fatal on error. +func tryPreviewAlertRule( + ctx context.Context, + f *framework.Framework, + token string, + payload managementrouter.PreviewAlertRuleRequest, +) (int, *managementrouter.PreviewAlertRuleResponse, error) { + reqBody, err := json.Marshal(payload) + if err != nil { + return 0, nil, fmt.Errorf("marshal preview request: %w", err) + } + + previewURL, err := url.JoinPath(f.PluginURL, "api/v1/alerting/rules/preview") + if err != nil { + return 0, nil, fmt.Errorf("build preview URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, previewURL, bytes.NewBuffer(reqBody)) + if err != nil { + return 0, nil, fmt.Errorf("create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := f.HTTPClient().Do(req) + if err != nil { + return 0, nil, fmt.Errorf("make preview request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.ReadAll(resp.Body) + return resp.StatusCode, nil, nil + } + + var previewResp managementrouter.PreviewAlertRuleResponse + if err := json.NewDecoder(resp.Body).Decode(&previewResp); err != nil { + return resp.StatusCode, nil, fmt.Errorf("decode preview response: %w", err) + } + return resp.StatusCode, &previewResp, nil +} + +func previewCreatePayload(namespace, alertName, prName string) managementrouter.PreviewAlertRuleRequest { + expr := fmt.Sprintf("absent(nonexistent{e2e_preview_create=%q})", alertName) + return managementrouter.PreviewAlertRuleRequest{ + AlertingRule: &managementrouter.AlertRuleSpec{ + Alert: &alertName, + Expr: &expr, + Labels: &map[string]string{ + "severity": "info", + }, + }, + PrometheusRule: &managementrouter.PrometheusRuleTarget{ + PrometheusRuleName: prName, + PrometheusRuleNamespace: namespace, + }, + } +} + +func previewUpdateProbeRequest(ruleID string) managementrouter.PreviewAlertRuleRequest { + labelVal := "true" + return managementrouter.PreviewAlertRuleRequest{ + RuleId: &ruleID, + Labels: &map[string]*string{"e2e_preview_probe": &labelVal}, + } +} + +func alertNamesInPrometheusRule(ctx context.Context, f *framework.Framework, namespace, prName string) ([]string, error) { + pr, err := f.Monitoringv1clientset.MonitoringV1().PrometheusRules(namespace).Get(ctx, prName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + var names []string + for _, group := range pr.Spec.Groups { + for _, rule := range group.Rules { + if rule.Alert != "" { + names = append(names, rule.Alert) + } + } + } + return names, nil +} + func mustCreateRule(ctx context.Context, t *testing.T, f *framework.Framework, namespace, alertName, prName string) string { t.Helper() diff --git a/test/e2e/preview_alert_rule_test.go b/test/e2e/preview_alert_rule_test.go new file mode 100644 index 000000000..0921c69ca --- /dev/null +++ b/test/e2e/preview_alert_rule_test.go @@ -0,0 +1,297 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/monitoring-plugin/test/e2e/framework" +) + +// TestPreviewAlertRule_Create_NoPersistence verifies create preview returns a +// valid plan and does not persist changes to the PrometheusRule. +func TestPreviewAlertRule_Create_NoPersistence(t *testing.T) { + f, err := framework.New() + if err != nil { + t.Fatalf("Failed to create framework: %v", err) + } + + ctx := context.Background() + + ns, cleanup, err := f.CreateUserNamespace(ctx, "test-preview-create") + if err != nil { + t.Fatalf("Failed to create namespace: %v", err) + } + defer func() { _ = cleanup() }() + + beforeNames, err := alertNamesInPrometheusRule(ctx, f, ns, "e2e-preview-create-pr") + if err != nil { + require.True(t, errors.IsNotFound(err), "unexpected error listing PR before preview: %v", err) + beforeNames = nil + } + + payload := previewCreatePayload(ns, "PreviewCreateAlert", "e2e-preview-create-pr") + status, resp, err := tryPreviewAlertRule(ctx, f, f.BearerToken, payload) + if err != nil { + t.Fatalf("preview create request failed: %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", status) + } + require.NotNil(t, resp) + require.True(t, resp.Writable) + require.NotEmpty(t, resp.Resources) + require.NotNil(t, resp.DesiredRule.Alert) + require.Equal(t, "PreviewCreateAlert", *resp.DesiredRule.Alert) + + err = framework.Poll(time.Second, 20*time.Second, func() error { + afterNames, err := alertNamesInPrometheusRule(ctx, f, ns, "e2e-preview-create-pr") + if err != nil { + if errors.IsNotFound(err) && len(beforeNames) == 0 { + return nil + } + return err + } + if len(afterNames) != len(beforeNames) { + return fmt.Errorf("expected %d alerts, got %d: before=%v after=%v", + len(beforeNames), len(afterNames), beforeNames, afterNames) + } + for i := range beforeNames { + if beforeNames[i] != afterNames[i] { + return fmt.Errorf("alert set changed: before=%v after=%v", beforeNames, afterNames) + } + } + return nil + }) + require.NoError(t, err) +} + +// TestRBAC_PreviewAlertRule_Create verifies create preview enforces Kubernetes +// RBAC when the target PrometheusRule already exists. +func TestRBAC_PreviewAlertRule_Create(t *testing.T) { + f, err := framework.New() + if err != nil { + t.Fatalf("Failed to create framework: %v", err) + } + + ctx := context.Background() + + nsY, cleanupY, err := f.CreateUserNamespace(ctx, "test-preview-rbac-create-y") + if err != nil { + t.Fatalf("Failed to create namespace Y: %v", err) + } + defer func() { _ = cleanupY() }() + + nsZ, cleanupZ, err := f.CreateUserNamespace(ctx, "test-preview-rbac-create-z") + if err != nil { + t.Fatalf("Failed to create namespace Z: %v", err) + } + defer func() { _ = cleanupZ() }() + + anonymousUser, err := f.CreateAnonymousUser(ctx, "e2e-preview-create-anon", "default") + if err != nil { + t.Fatalf("Failed to create anonymous user: %v", err) + } + defer func() { _ = anonymousUser.Cleanup() }() + + scopedUser, err := f.CreateScopedUser(ctx, "e2e-preview-create-scoped", nsY, + "monitoring.coreos.com", []string{"prometheusrules"}, []string{"get", "create", "update", "patch"}) + if err != nil { + t.Fatalf("Failed to create scoped user: %v", err) + } + defer func() { _ = scopedUser.Cleanup() }() + + _ = mustCreateRule(ctx, t, f, nsY, "PreviewRBACSeedY", "e2e-preview-rbac-pr") + _ = mustCreateRule(ctx, t, f, nsZ, "PreviewRBACSeedZ", "e2e-preview-rbac-pr") + + cases := []struct { + name string + token string + namespace string + alertName string + wantStatus int + }{ + {"AnonymousUser_DeniedNamespaceY", anonymousUser.Token, nsY, "PreviewRBACAlertA", http.StatusForbidden}, + {"AnonymousUser_DeniedNamespaceZ", anonymousUser.Token, nsZ, "PreviewRBACAlertAZ", http.StatusForbidden}, + {"ScopedUser_SucceedsNamespaceY", scopedUser.Token, nsY, "PreviewRBACAlertBY", http.StatusOK}, + {"ScopedUser_DeniedNamespaceZ", scopedUser.Token, nsZ, "PreviewRBACAlertBZ", http.StatusForbidden}, + {"ClusterAdmin_SucceedsNamespaceZ", f.BearerToken, nsZ, "PreviewRBACAlertCZ", http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status := previewCreateWithToken(ctx, t, f, tc.token, tc.namespace, tc.alertName) + if status != tc.wantStatus { + t.Fatalf("Expected status %d, got %d", tc.wantStatus, status) + } + }) + } +} + +// TestPreviewAlertRule_Update_NoPersistence verifies update preview returns a +// valid plan and does not persist label changes. +func TestPreviewAlertRule_Update_NoPersistence(t *testing.T) { + f, err := framework.New() + if err != nil { + t.Fatalf("Failed to create framework: %v", err) + } + + ctx := context.Background() + + ns, cleanup, err := f.CreateUserNamespace(ctx, "test-preview-update") + if err != nil { + t.Fatalf("Failed to create namespace: %v", err) + } + defer func() { _ = cleanup() }() + + ruleID := mustCreateRule(ctx, t, f, ns, "PreviewUpdateAlert", "e2e-preview-update-pr") + waitForPreviewUpdateCacheSync(ctx, t, f, f.BearerToken, ruleID) + + labelVal := "preview-only" + payload := previewUpdateProbeRequest(ruleID) + payload.Labels = &map[string]*string{"e2e_preview_label": &labelVal} + + status, resp, err := tryPreviewAlertRule(ctx, f, f.BearerToken, payload) + if err != nil { + t.Fatalf("preview update request failed: %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", status) + } + require.NotNil(t, resp) + require.True(t, resp.Writable) + require.NotEmpty(t, resp.Resources) + + err = framework.Poll(time.Second, 20*time.Second, func() error { + pr, err := f.Monitoringv1clientset.MonitoringV1().PrometheusRules(ns).Get( + ctx, "e2e-preview-update-pr", metav1.GetOptions{}, + ) + if err != nil { + return err + } + for _, group := range pr.Spec.Groups { + for _, rule := range group.Rules { + if rule.Alert != "PreviewUpdateAlert" { + continue + } + if _, ok := rule.Labels["e2e_preview_label"]; ok { + return fmt.Errorf("preview label was persisted on PrometheusRule") + } + return nil + } + } + return fmt.Errorf("alert PreviewUpdateAlert not found") + }) + require.NoError(t, err) +} + +// TestRBAC_PreviewAlertRule_Update verifies update preview enforces Kubernetes +// RBAC across anonymous, namespace-scoped, and cluster-admin personas. +func TestRBAC_PreviewAlertRule_Update(t *testing.T) { + f, err := framework.New() + if err != nil { + t.Fatalf("Failed to create framework: %v", err) + } + + ctx := context.Background() + + nsY, cleanupY, err := f.CreateUserNamespace(ctx, "test-preview-rbac-upd-y") + if err != nil { + t.Fatalf("Failed to create namespace Y: %v", err) + } + defer func() { _ = cleanupY() }() + + nsZ, cleanupZ, err := f.CreateUserNamespace(ctx, "test-preview-rbac-upd-z") + if err != nil { + t.Fatalf("Failed to create namespace Z: %v", err) + } + defer func() { _ = cleanupZ() }() + + anonymousUser, err := f.CreateAnonymousUser(ctx, "e2e-preview-upd-anon", "default") + if err != nil { + t.Fatalf("Failed to create anonymous user: %v", err) + } + defer func() { _ = anonymousUser.Cleanup() }() + + scopedUser, err := f.CreateScopedUser(ctx, "e2e-preview-upd-scoped", nsY, + "monitoring.coreos.com", []string{"prometheusrules"}, []string{"get", "create", "update", "patch"}) + if err != nil { + t.Fatalf("Failed to create scoped user: %v", err) + } + defer func() { _ = scopedUser.Cleanup() }() + + ruleInY := mustCreateRule(ctx, t, f, nsY, "PreviewRBACUpdateY", "e2e-preview-rbac-upd-pr") + ruleInZ := mustCreateRule(ctx, t, f, nsZ, "PreviewRBACUpdateZ", "e2e-preview-rbac-upd-pr") + ruleInY2 := mustCreateRule(ctx, t, f, nsY, "PreviewRBACUpdateY2", "e2e-preview-rbac-upd-pr") + + for _, ruleID := range []string{ruleInY, ruleInY2, ruleInZ} { + waitForPreviewUpdateCacheSync(ctx, t, f, anonymousUser.Token, ruleID) + } + + cases := []struct { + name string + token string + ruleID string + wantStatus int + }{ + {"AnonymousUser_DeniedNamespaceY", anonymousUser.Token, ruleInY, http.StatusForbidden}, + {"AnonymousUser_DeniedNamespaceZ", anonymousUser.Token, ruleInZ, http.StatusForbidden}, + {"ScopedUser_SucceedsNamespaceY", scopedUser.Token, ruleInY, http.StatusOK}, + {"ScopedUser_DeniedNamespaceZ", scopedUser.Token, ruleInZ, http.StatusForbidden}, + {"ClusterAdmin_SucceedsNamespaceZ", f.BearerToken, ruleInZ, http.StatusOK}, + {"ClusterAdmin_SucceedsNamespaceY", f.BearerToken, ruleInY2, http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status := previewUpdateWithToken(ctx, t, f, tc.token, tc.ruleID) + if status != tc.wantStatus { + t.Fatalf("Expected HTTP status %d, got %d", tc.wantStatus, status) + } + }) + } +} + +func previewCreateWithToken(ctx context.Context, t *testing.T, f *framework.Framework, token, namespace, alertName string) int { + t.Helper() + payload := previewCreatePayload(namespace, alertName, "e2e-preview-rbac-pr") + status, _, err := tryPreviewAlertRule(ctx, f, token, payload) + if err != nil { + t.Fatalf("preview create for %s in %s failed: %v", alertName, namespace, err) + } + return status +} + +func previewUpdateWithToken(ctx context.Context, t *testing.T, f *framework.Framework, token, ruleID string) int { + t.Helper() + status, _, err := tryPreviewAlertRule(ctx, f, token, previewUpdateProbeRequest(ruleID)) + if err != nil { + t.Fatalf("preview update for rule %s failed: %v", ruleID, err) + } + return status +} + +func waitForPreviewUpdateCacheSync(ctx context.Context, t *testing.T, f *framework.Framework, token, ruleID string) { + t.Helper() + err := framework.Poll(time.Second, 30*time.Second, func() error { + status, _, err := tryPreviewAlertRule(ctx, f, token, previewUpdateProbeRequest(ruleID)) + if err != nil { + return err + } + if status == http.StatusForbidden || status == http.StatusOK { + return nil + } + return fmt.Errorf("HTTP status %d, waiting for cache sync", status) + }) + if err != nil { + t.Fatalf("preview-update cache sync timed out for %s: %v", ruleID, err) + } +} diff --git a/test/e2e/single_alert_rule_test.go b/test/e2e/single_alert_rule_test.go index b834c82b2..a8b539a79 100644 --- a/test/e2e/single_alert_rule_test.go +++ b/test/e2e/single_alert_rule_test.go @@ -143,6 +143,8 @@ func TestRBAC_UpdateAlertRule_Single(t *testing.T) { ruleInY2 := mustCreateRule(ctx, t, f, nsY, "RBACUpd1AlertY2", "e2e-rbac-upd1-pr") waitForSingleUpdateCacheSync(ctx, t, f, anonymousUser.Token, ruleInY) + waitForSingleUpdateCacheSync(ctx, t, f, anonymousUser.Token, ruleInY2) + waitForSingleUpdateCacheSync(ctx, t, f, anonymousUser.Token, ruleInZ) cases := []struct { name string @@ -183,7 +185,6 @@ func TestDeleteAlertRule_Single(t *testing.T) { keepID := mustCreateRule(ctx, t, f, ns, "KeepSingleAlert", "e2e-delete-single-pr") deleteID := mustCreateRule(ctx, t, f, ns, "DeleteSingleAlert", "e2e-delete-single-pr") - _ = keepID err = framework.Poll(time.Second, time.Minute, func() error { status, err := tryDeleteAlertRuleSingle(ctx, f, f.BearerToken, deleteID) @@ -199,6 +200,20 @@ func TestDeleteAlertRule_Single(t *testing.T) { t.Fatalf("single delete failed: %v", err) } + err = framework.Poll(time.Second, 20*time.Second, func() error { + status, _, err := tryPreviewAlertRule(ctx, f, f.BearerToken, previewUpdateProbeRequest(keepID)) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("sibling rule %s not resolvable via API after delete: expected HTTP 200, got %d", keepID, status) + } + return nil + }) + if err != nil { + t.Fatalf("sibling rule API resolution after single delete: %v", err) + } + err = framework.Poll(time.Second, 20*time.Second, func() error { promRule, err := f.Monitoringv1clientset.MonitoringV1().PrometheusRules(ns).Get( ctx, "e2e-delete-single-pr", metav1.GetOptions{},