From 80147f1315b6e16ef048c2b80217be5d63ca3114 Mon Sep 17 00:00:00 2001 From: Shirly Radco Date: Thu, 12 Mar 2026 18:12:11 +0200 Subject: [PATCH] management: add CRD support and create alert rule API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AlertingRule, AlertRelabelConfig, and RelabeledRules CRD interfaces with the management client, router, server wiring, and POST /api/v1/alerting/rules endpoint. Signed-off-by: Shirly Radco Signed-off-by: João Vilaça Signed-off-by: Aviv Litman Co-authored-by: AI Assistant --- Dockerfile | 1 + Dockerfile.dev | 1 + Dockerfile.dev-mcp | 1 + Dockerfile.devspace | 1 + Dockerfile.konflux | 1 + Dockerfile.mcp | 1 + Makefile | 6 +- go.mod | 68 +-- go.sum | 175 ++++--- .../managementrouter/create_alert_rule.go | 54 +++ .../create_alert_rule_test.go | 212 +++++++++ .../managementrouter_suite_test.go | 13 + internal/managementrouter/query_filters.go | 35 ++ internal/managementrouter/router.go | 82 ++++ pkg/alert_rule/alert_rule.go | 85 ++++ pkg/k8s/alert_relabel_config.go | 102 ++++ pkg/k8s/alerting_rule.go | 107 +++++ pkg/k8s/client.go | 37 +- pkg/k8s/external_management.go | 49 ++ pkg/k8s/relabeled_rules.go | 447 ++++++++++++++++++ pkg/k8s/types.go | 60 +++ pkg/management/alert_rule_id_match.go | 15 + pkg/management/alert_rule_preconditions.go | 98 ++++ pkg/management/client_factory.go | 14 + pkg/management/create_platform_alert_rule.go | 134 ++++++ .../create_platform_alert_rule_test.go | 270 +++++++++++ .../create_user_defined_alert_rule.go | 136 ++++++ .../create_user_defined_alert_rule_test.go | 377 +++++++++++++++ pkg/management/errors.go | 44 ++ pkg/management/label_utils.go | 29 ++ pkg/management/management.go | 15 + pkg/management/testutils/k8s_client_mock.go | 419 ++++++++++++++++ pkg/management/types.go | 28 ++ pkg/managementlabels/management_labels.go | 20 + pkg/server.go | 30 +- 35 files changed, 3066 insertions(+), 101 deletions(-) create mode 100644 internal/managementrouter/create_alert_rule.go create mode 100644 internal/managementrouter/create_alert_rule_test.go create mode 100644 internal/managementrouter/managementrouter_suite_test.go create mode 100644 internal/managementrouter/query_filters.go create mode 100644 internal/managementrouter/router.go create mode 100644 pkg/alert_rule/alert_rule.go create mode 100644 pkg/k8s/alert_relabel_config.go create mode 100644 pkg/k8s/alerting_rule.go create mode 100644 pkg/k8s/external_management.go create mode 100644 pkg/k8s/relabeled_rules.go create mode 100644 pkg/management/alert_rule_id_match.go create mode 100644 pkg/management/alert_rule_preconditions.go create mode 100644 pkg/management/client_factory.go create mode 100644 pkg/management/create_platform_alert_rule.go create mode 100644 pkg/management/create_platform_alert_rule_test.go create mode 100644 pkg/management/create_user_defined_alert_rule.go create mode 100644 pkg/management/create_user_defined_alert_rule_test.go create mode 100644 pkg/management/errors.go create mode 100644 pkg/management/label_utils.go create mode 100644 pkg/management/management.go create mode 100644 pkg/management/testutils/k8s_client_mock.go create mode 100644 pkg/management/types.go create mode 100644 pkg/managementlabels/management_labels.go diff --git a/Dockerfile b/Dockerfile index c0e7f1bc7..f7de736f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN make install-backend COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ ENV GOEXPERIMENT=strictfipsruntime ENV CGO_ENABLED=1 diff --git a/Dockerfile.dev b/Dockerfile.dev index 557e5edca..fa279fa38 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -28,6 +28,7 @@ RUN go mod download COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ RUN go build -mod=mod -o plugin-backend cmd/plugin-backend.go diff --git a/Dockerfile.dev-mcp b/Dockerfile.dev-mcp index b2df023e2..49e66c6f3 100644 --- a/Dockerfile.dev-mcp +++ b/Dockerfile.dev-mcp @@ -31,6 +31,7 @@ RUN go mod download COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ RUN go build -mod=mod -o plugin-backend cmd/plugin-backend.go diff --git a/Dockerfile.devspace b/Dockerfile.devspace index 7af8b0d34..6ed4aa543 100644 --- a/Dockerfile.devspace +++ b/Dockerfile.devspace @@ -20,6 +20,7 @@ RUN make install-backend COPY config/ config/ COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ RUN make build-backend diff --git a/Dockerfile.konflux b/Dockerfile.konflux index ba20c4237..31e5923b4 100644 --- a/Dockerfile.konflux +++ b/Dockerfile.konflux @@ -28,6 +28,7 @@ RUN make install-backend COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ ENV GOEXPERIMENT=strictfipsruntime ENV CGO_ENABLED=1 diff --git a/Dockerfile.mcp b/Dockerfile.mcp index 33960459e..84add4f12 100644 --- a/Dockerfile.mcp +++ b/Dockerfile.mcp @@ -28,6 +28,7 @@ RUN make install-backend COPY cmd/ cmd/ COPY pkg/ pkg/ +COPY internal/ internal/ ENV GOOS=${TARGETOS:-linux} ENV GOARCH=${TARGETARCH} diff --git a/Makefile b/Makefile index 7c8d38cdc..6fe65b8cb 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,11 @@ start-backend: .PHONY: test-backend test-backend: - go test ./pkg/... -v + go test ./pkg/... ./internal/... -v + +.PHONY: test-e2e +test-e2e: + PLUGIN_URL=http://localhost:9001 go test -v -timeout=150m -count=1 ./test/e2e .PHONY: test-frontend test-frontend: diff --git a/go.mod b/go.mod index d831b6f40..00cb860bc 100644 --- a/go.mod +++ b/go.mod @@ -1,67 +1,79 @@ module github.com/openshift/monitoring-plugin -go 1.24.0 +go 1.25.0 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/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + 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 github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.87.0 github.com/prometheus-operator/prometheus-operator/pkg/client v0.87.0 - github.com/sirupsen/logrus v1.9.3 + github.com/prometheus/common v0.67.5 + github.com/prometheus/prometheus v0.310.0 + github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.34.2 - k8s.io/apimachinery v0.34.2 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 k8s.io/apiserver v0.34.2 - k8s.io/client-go v0.34.2 + k8s.io/client-go v0.35.0 ) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.22.1 // indirect - github.com/go-openapi/jsonreference v0.21.2 // indirect - github.com/go-openapi/swag v0.25.1 // indirect - github.com/go-openapi/swag/cmdutils v0.25.1 // indirect - github.com/go-openapi/swag/conv v0.25.1 // indirect - github.com/go-openapi/swag/fileutils v0.25.1 // indirect - github.com/go-openapi/swag/jsonname v0.25.1 // indirect - github.com/go-openapi/swag/jsonutils v0.25.1 // indirect - github.com/go-openapi/swag/loading v0.25.1 // indirect - github.com/go-openapi/swag/mangling v0.25.1 // indirect - github.com/go-openapi/swag/netutils v0.25.1 // indirect - github.com/go-openapi/swag/stringutils v0.25.1 // indirect - github.com/go-openapi/swag/typeutils v0.25.1 // indirect - github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250923004556-9e5a51aed1e8 // indirect + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/openshift/api v0.0.0-20251122153900-88cca31a44c9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/time v0.13.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 565b23852..a370c78c5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -12,40 +16,52 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= -github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= -github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= -github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= -github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= -github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= -github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= -github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= -github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= -github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= -github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= -github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= -github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= -github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= -github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= -github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= -github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= -github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= -github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= -github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= -github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= -github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= -github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -53,14 +69,18 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250923004556-9e5a51aed1e8 h1:ZI8gCoCjGzPsum4L21jHdQs8shFBIQih1TM9Rd/c+EQ= -github.com/google/pprof v0.0.0-20250923004556-9e5a51aed1e8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -69,6 +89,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -77,10 +101,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= -github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/openshift/api v0.0.0-20251122153900-88cca31a44c9 h1:RKbCmhOI6XOKMjoXLjANJ1ic7wd4dVV7nSfrn3csEuQ= github.com/openshift/api v0.0.0-20251122153900-88cca31a44c9/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= github.com/openshift/client-go v0.0.0-20251123231646-4685125c2287 h1:Spullg4rMMWUjYiBMvYMhyeZ+j36mYOrkSO7ad43xrA= @@ -96,23 +120,38 @@ github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.87.0 h github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.87.0/go.mod h1:WHiLZmOWVop/MoYvRD58LfnPeyE+dcITby/jQjg83Hw= github.com/prometheus-operator/prometheus-operator/pkg/client v0.87.0 h1:rrZriucuC8ZUOPr8Asvavb9pbzqXSsAeY79aH8xnXlc= github.com/prometheus-operator/prometheus-operator/pkg/client v0.87.0/go.mod h1:OMvC2XJGxPeEAKf5qB1u7DudV46HA8ePxYslRjxQcbk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -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/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/prometheus v0.310.0 h1:iS0Uul/dHjy8ifBnqo3YEOhRxlTOWantRoDWwmIowwA= +github.com/prometheus/prometheus v0.310.0/go.mod h1:rs6XoWKvgAStqxHxb2Twh1BR6rp7qw7fmUgW+gaXjbw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -124,43 +163,46 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -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/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +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= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -170,19 +212,18 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= -k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= -k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= -k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= -k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= -k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= diff --git a/internal/managementrouter/create_alert_rule.go b/internal/managementrouter/create_alert_rule.go new file mode 100644 index 000000000..ad282ed17 --- /dev/null +++ b/internal/managementrouter/create_alert_rule.go @@ -0,0 +1,54 @@ +package managementrouter + +import ( + "encoding/json" + "net/http" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/management" +) + +type CreateAlertRuleRequest struct { + AlertingRule *monitoringv1.Rule `json:"alertingRule,omitempty"` + PrometheusRule *management.PrometheusRuleOptions `json:"prometheusRule,omitempty"` +} + +type CreateAlertRuleResponse struct { + Id string `json:"id"` +} + +func (hr *httpRouter) CreateAlertRule(w http.ResponseWriter, req *http.Request) { + var payload CreateAlertRuleRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if payload.AlertingRule == nil { + writeError(w, http.StatusBadRequest, "alertingRule is required") + return + } + + alertRule := *payload.AlertingRule + + var ( + id string + err error + ) + + if payload.PrometheusRule != nil { + id, err = hr.managementClient.CreateUserDefinedAlertRule(req.Context(), alertRule, *payload.PrometheusRule) + } else { + id, err = hr.managementClient.CreatePlatformAlertRule(req.Context(), alertRule) + } + + if err != nil { + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(CreateAlertRuleResponse{Id: id}) +} diff --git a/internal/managementrouter/create_alert_rule_test.go b/internal/managementrouter/create_alert_rule_test.go new file mode 100644 index 000000000..a79217d49 --- /dev/null +++ b/internal/managementrouter/create_alert_rule_test.go @@ -0,0 +1,212 @@ +package managementrouter_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/internal/managementrouter" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("CreateAlertRule", func() { + var ( + router http.Handler + mockK8sRules *testutils.MockPrometheusRuleInterface + mockARules *testutils.MockAlertingRuleInterface + mockK8s *testutils.MockClient + ) + + BeforeEach(func() { + mockK8sRules = &testutils.MockPrometheusRuleInterface{} + mockARules = &testutils.MockAlertingRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockK8sRules + }, + AlertingRulesFunc: func() k8s.AlertingRuleInterface { + return mockARules + }, + NamespaceFunc: func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return false + }, + } + }, + } + }) + + Context("create new user defined alert rule", func() { + It("creates a new rule", func() { + mgmt := management.New(context.Background(), mockK8s) + router = managementrouter.New(mgmt) + + body := map[string]interface{}{ + "alertingRule": map[string]interface{}{ + "alert": "cpuHigh", + "expr": "vector(1)", + "for": "5m", + "labels": map[string]string{"severity": "warning"}, + "annotations": map[string]string{"summary": "cpu high"}, + }, + "prometheusRule": map[string]interface{}{ + "prometheusRuleName": "user-pr", + "prometheusRuleNamespace": "default", + }, + } + buf, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/alerting/rules", bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + var resp struct { + Id string `json:"id"` + } + Expect(json.NewDecoder(w.Body).Decode(&resp)).To(Succeed()) + Expect(resp.Id).NotTo(BeEmpty()) + + pr, found, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + allAlerts := []string{} + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + allAlerts = append(allAlerts, r.Alert) + } + } + Expect(allAlerts).To(ContainElement("cpuHigh")) + }) + + It("creates a new rule into a non-default group when groupName is provided", func() { + mgmt := management.New(context.Background(), mockK8s) + router = managementrouter.New(mgmt) + + body := map[string]interface{}{ + "alertingRule": map[string]interface{}{ + "alert": "cpuCustomGroup", + "expr": "vector(1)", + }, + "prometheusRule": map[string]interface{}{ + "prometheusRuleName": "user-pr", + "prometheusRuleNamespace": "default", + "groupName": "custom-group", + }, + } + buf, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/alerting/rules", bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + + pr, found, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + + var grp *monitoringv1.RuleGroup + for i := range pr.Spec.Groups { + if pr.Spec.Groups[i].Name == "custom-group" { + grp = &pr.Spec.Groups[i] + break + } + } + Expect(grp).NotTo(BeNil()) + alerts := []string{} + for _, r := range grp.Rules { + alerts = append(alerts, r.Alert) + } + Expect(alerts).To(ContainElement("cpuCustomGroup")) + }) + }) + + Context("invalid JSON body", func() { + It("fails for invalid JSON", func() { + mgmt := management.New(context.Background(), mockK8s) + router = managementrouter.New(mgmt) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/alerting/rules", bytes.NewBufferString("{")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("invalid request body")) + }) + }) + + Context("missing target PrometheusRule (name/namespace)", func() { + It("fails for missing target PR", func() { + mgmt := management.New(context.Background(), mockK8s) + router = managementrouter.New(mgmt) + + body := map[string]interface{}{ + "alertingRule": map[string]interface{}{ + "alert": "x", + "expr": "vector(1)", + }, + "prometheusRule": map[string]interface{}{ + // missing PR name/namespace + }, + } + buf, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/alerting/rules", bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("PrometheusRule Name and Namespace must be specified")) + }) + }) + + Context("target is platform-managed PR", func() { + It("rejects with MethodNotAllowed", func() { + mockNamespace := &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return name == "openshift-monitoring" + }, + } + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return mockNamespace + } + mgmt := management.New(context.Background(), mockK8s) + router = managementrouter.New(mgmt) + + body := map[string]interface{}{ + "alertingRule": map[string]interface{}{ + "alert": "x", + "expr": "vector(1)", + }, + "prometheusRule": map[string]interface{}{ + "prometheusRuleName": "platform-pr", + "prometheusRuleNamespace": "openshift-monitoring", + }, + } + buf, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/alerting/rules", bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + Expect(w.Body.String()).To(ContainSubstring("cannot add user-defined alert rule to a platform-managed PrometheusRule")) + }) + }) +}) diff --git a/internal/managementrouter/managementrouter_suite_test.go b/internal/managementrouter/managementrouter_suite_test.go new file mode 100644 index 000000000..3da1553b3 --- /dev/null +++ b/internal/managementrouter/managementrouter_suite_test.go @@ -0,0 +1,13 @@ +package managementrouter_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHTTPRouter(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "HTTPRouter Suite") +} diff --git a/internal/managementrouter/query_filters.go b/internal/managementrouter/query_filters.go new file mode 100644 index 000000000..f8e3e5e9d --- /dev/null +++ b/internal/managementrouter/query_filters.go @@ -0,0 +1,35 @@ +package managementrouter + +import ( + "fmt" + "net/url" + "strings" +) + +var validStates = map[string]bool{ + "": true, + "pending": true, + "firing": true, + "silenced": true, +} + +// parseStateAndLabels returns the optional state filter and label matches. +// Any query param other than "state" is treated as a label match. +// Returns an error if the state value is not one of the known states. +func parseStateAndLabels(q url.Values) (string, map[string]string, error) { + state := strings.ToLower(strings.TrimSpace(q.Get("state"))) + if !validStates[state] { + return "", nil, fmt.Errorf("invalid state filter %q: must be one of pending, firing, silenced", q.Get("state")) + } + + labels := make(map[string]string) + for key, vals := range q { + if key == "state" { + continue + } + if len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + labels[strings.TrimSpace(key)] = strings.TrimSpace(vals[0]) + } + } + return state, labels, nil +} diff --git a/internal/managementrouter/router.go b/internal/managementrouter/router.go new file mode 100644 index 000000000..944999b4c --- /dev/null +++ b/internal/managementrouter/router.go @@ -0,0 +1,82 @@ +package managementrouter + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/mux" + + "github.com/openshift/monitoring-plugin/pkg/management" +) + +type httpRouter struct { + managementClient management.Client +} + +func New(managementClient management.Client) *mux.Router { + httpRouter := &httpRouter{ + managementClient: managementClient, + } + + r := mux.NewRouter() + + r.HandleFunc("/api/v1/alerting/rules", httpRouter.CreateAlertRule).Methods(http.MethodPost) + + return r +} + +func writeError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + resp, _ := json.Marshal(map[string]string{"error": message}) + _, _ = w.Write(resp) +} + +func handleError(w http.ResponseWriter, err error) { + status, message := parseError(err) + writeError(w, status, message) +} + +func parseError(err error) (int, string) { + var nf *management.NotFoundError + if errors.As(err, &nf) { + return http.StatusNotFound, err.Error() + } + var ve *management.ValidationError + if errors.As(err, &ve) { + return http.StatusBadRequest, err.Error() + } + var na *management.NotAllowedError + if errors.As(err, &na) { + return http.StatusMethodNotAllowed, err.Error() + } + var ce *management.ConflictError + if errors.As(err, &ce) { + return http.StatusConflict, err.Error() + } + log.Printf("An unexpected error occurred: %v", err) + return http.StatusInternalServerError, fmt.Sprintf("An unexpected error occurred: %s", err.Error()) +} + +func parseParam(raw string, name string) (string, error) { + decoded, err := url.PathUnescape(raw) + if err != nil { + return "", fmt.Errorf("invalid %s encoding", name) + } + value := strings.TrimSpace(decoded) + if value == "" { + return "", fmt.Errorf("missing %s", name) + } + return value, nil +} + +func getParam(r *http.Request, name string) (string, error) { + vars := mux.Vars(r) + raw := vars[name] + return parseParam(raw, name) +} diff --git a/pkg/alert_rule/alert_rule.go b/pkg/alert_rule/alert_rule.go new file mode 100644 index 000000000..7cb217ec6 --- /dev/null +++ b/pkg/alert_rule/alert_rule.go @@ -0,0 +1,85 @@ +package alertrule + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/openshift/monitoring-plugin/pkg/managementlabels" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +var promLabelNameRegexp = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func GetAlertingRuleId(alertRule *monitoringv1.Rule) string { + var name string + var kind string + if alertRule.Alert != "" { + name = alertRule.Alert + kind = "alert" + } else if alertRule.Record != "" { + name = alertRule.Record + kind = "record" + } else { + return "" + } + + expr := normalizeExpr(alertRule.Expr.String()) + forDuration := "" + if alertRule.For != nil { + forDuration = strings.TrimSpace(string(*alertRule.For)) + } + + labelsBlock := normalizedBusinessLabelsBlock(alertRule.Labels) + + // Canonical payload is intentionally derived from rule spec (expr/for/labels) and identity (kind/name), + // and excludes annotations and openshift_io_* provenance/system labels. + canonicalPayload := strings.Join([]string{kind, name, expr, forDuration, labelsBlock}, "\n---\n") + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(canonicalPayload)) + + return "rid_" + base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func normalizeExpr(expr string) string { + // Collapse consecutive whitespace so cosmetic formatting changes do not churn ids. + return strings.Join(strings.Fields(strings.TrimSpace(expr)), " ") +} + +func normalizedBusinessLabelsBlock(in map[string]string) string { + if len(in) == 0 { + return "" + } + + lines := make([]string, 0, len(in)) + for k, v := range in { + key := strings.TrimSpace(k) + if key == "" { + continue + } + if strings.HasPrefix(key, "openshift_io_") || key == managementlabels.AlertNameLabel { + // Skip system labels + continue + } + if !promLabelNameRegexp.MatchString(strings.TrimSpace(key)) { + continue + } + if v == "" { + // Align with specHash behavior: drop empty values + continue + } + if !utf8.ValidString(v) { + continue + } + + lines = append(lines, fmt.Sprintf("%s=%s", key, v)) + } + + sort.Strings(lines) + return strings.Join(lines, "\n") +} diff --git a/pkg/k8s/alert_relabel_config.go b/pkg/k8s/alert_relabel_config.go new file mode 100644 index 000000000..0147629bb --- /dev/null +++ b/pkg/k8s/alert_relabel_config.go @@ -0,0 +1,102 @@ +package k8s + +import ( + "context" + "fmt" + + osmv1 "github.com/openshift/api/monitoring/v1" + osmv1client "github.com/openshift/client-go/monitoring/clientset/versioned" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/client-go/tools/cache" +) + +type alertRelabelConfigManager struct { + clientset *osmv1client.Clientset + arcInformer cache.SharedIndexInformer +} + +func newAlertRelabelConfigManager(ctx context.Context, clientset *osmv1client.Clientset) (*alertRelabelConfigManager, error) { + arcInformer := cache.NewSharedIndexInformer( + alertRelabelConfigListWatchForAllNamespaces(clientset), + &osmv1.AlertRelabelConfig{}, + 0, + cache.Indexers{}, + ) + + arcm := &alertRelabelConfigManager{ + clientset: clientset, + arcInformer: arcInformer, + } + + go arcm.arcInformer.Run(ctx.Done()) + + if !cache.WaitForNamedCacheSync("AlertRelabelConfig informer", ctx.Done(), arcm.arcInformer.HasSynced) { + return nil, fmt.Errorf("failed to sync AlertRelabelConfig informer") + } + + return arcm, nil +} + +func alertRelabelConfigListWatchForAllNamespaces(clientset *osmv1client.Clientset) *cache.ListWatch { + return cache.NewListWatchFromClient(clientset.MonitoringV1().RESTClient(), "alertrelabelconfigs", "", fields.Everything()) +} + +func (arcm *alertRelabelConfigManager) List(ctx context.Context, namespace string) ([]osmv1.AlertRelabelConfig, error) { + arcs := arcm.arcInformer.GetStore().List() + + alertRelabelConfigs := make([]osmv1.AlertRelabelConfig, 0, len(arcs)) + for _, item := range arcs { + arc, ok := item.(*osmv1.AlertRelabelConfig) + if !ok { + continue + } + if namespace != "" && arc.Namespace != namespace { + continue + } + alertRelabelConfigs = append(alertRelabelConfigs, *arc) + } + + return alertRelabelConfigs, nil +} + +func (arcm *alertRelabelConfigManager) Get(ctx context.Context, namespace string, name string) (*osmv1.AlertRelabelConfig, bool, error) { + arc, err := arcm.clientset.MonitoringV1().AlertRelabelConfigs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, false, nil + } + + return nil, false, err + } + + return arc, true, nil +} + +func (arcm *alertRelabelConfigManager) Create(ctx context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + created, err := arcm.clientset.MonitoringV1().AlertRelabelConfigs(arc.Namespace).Create(ctx, &arc, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create AlertRelabelConfig %s/%s: %w", arc.Namespace, arc.Name, err) + } + + return created, nil +} + +func (arcm *alertRelabelConfigManager) Update(ctx context.Context, arc osmv1.AlertRelabelConfig) error { + _, err := arcm.clientset.MonitoringV1().AlertRelabelConfigs(arc.Namespace).Update(ctx, &arc, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update AlertRelabelConfig %s/%s: %w", arc.Namespace, arc.Name, err) + } + + return nil +} + +func (arcm *alertRelabelConfigManager) Delete(ctx context.Context, namespace string, name string) error { + err := arcm.clientset.MonitoringV1().AlertRelabelConfigs(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("failed to delete AlertRelabelConfig %s: %w", name, err) + } + + return nil +} diff --git a/pkg/k8s/alerting_rule.go b/pkg/k8s/alerting_rule.go new file mode 100644 index 000000000..559f4b507 --- /dev/null +++ b/pkg/k8s/alerting_rule.go @@ -0,0 +1,107 @@ +package k8s + +import ( + "context" + "fmt" + + osmv1 "github.com/openshift/api/monitoring/v1" + osmv1client "github.com/openshift/client-go/monitoring/clientset/versioned" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/client-go/tools/cache" +) + +type alertingRuleManager struct { + clientset *osmv1client.Clientset + informer cache.SharedIndexInformer +} + +func newAlertingRuleManager(ctx context.Context, clientset *osmv1client.Clientset) (*alertingRuleManager, error) { + informer := cache.NewSharedIndexInformer( + alertingRuleListWatchClusterMonitoringNamespace(clientset), + &osmv1.AlertingRule{}, + 0, + cache.Indexers{}, + ) + + arm := &alertingRuleManager{ + clientset: clientset, + informer: informer, + } + + go arm.informer.Run(ctx.Done()) + + if !cache.WaitForNamedCacheSync("AlertingRule informer", ctx.Done(), arm.informer.HasSynced) { + return nil, errors.NewInternalError(fmt.Errorf("failed to sync AlertingRule informer")) + } + + return arm, nil +} + +func alertingRuleListWatchClusterMonitoringNamespace(clientset *osmv1client.Clientset) *cache.ListWatch { + return cache.NewListWatchFromClient(clientset.MonitoringV1().RESTClient(), "alertingrules", ClusterMonitoringNamespace, fields.Everything()) +} + +func (arm *alertingRuleManager) List(ctx context.Context) ([]osmv1.AlertingRule, error) { + items := arm.informer.GetStore().List() + + alertingRules := make([]osmv1.AlertingRule, 0, len(items)) + for _, item := range items { + ar, ok := item.(*osmv1.AlertingRule) + if !ok { + continue + } + alertingRules = append(alertingRules, *ar) + } + + return alertingRules, nil +} + +func (arm *alertingRuleManager) Get(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) { + ar, err := arm.clientset.MonitoringV1().AlertingRules(ClusterMonitoringNamespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, false, nil + } + + return nil, false, err + } + + return ar, true, nil +} + +func (arm *alertingRuleManager) Create(ctx context.Context, ar osmv1.AlertingRule) (*osmv1.AlertingRule, error) { + if ar.Namespace != "" && ar.Namespace != ClusterMonitoringNamespace { + return nil, fmt.Errorf("invalid namespace %q: AlertingRule manager only supports %q", ar.Namespace, ClusterMonitoringNamespace) + } + + created, err := arm.clientset.MonitoringV1().AlertingRules(ClusterMonitoringNamespace).Create(ctx, &ar, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create AlertingRule %s/%s: %w", ClusterMonitoringNamespace, ar.Name, err) + } + + return created, nil +} + +func (arm *alertingRuleManager) Update(ctx context.Context, ar osmv1.AlertingRule) error { + if ar.Namespace != "" && ar.Namespace != ClusterMonitoringNamespace { + return fmt.Errorf("invalid namespace %q: AlertingRule manager only supports %q", ar.Namespace, ClusterMonitoringNamespace) + } + + _, err := arm.clientset.MonitoringV1().AlertingRules(ClusterMonitoringNamespace).Update(ctx, &ar, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update AlertingRule %s/%s: %w", ClusterMonitoringNamespace, ar.Name, err) + } + + return nil +} + +func (arm *alertingRuleManager) Delete(ctx context.Context, name string) error { + err := arm.clientset.MonitoringV1().AlertingRules(ClusterMonitoringNamespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("failed to delete AlertingRule %s/%s: %w", ClusterMonitoringNamespace, name, err) + } + + return nil +} diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index 1fd6fbc4d..7d8904727 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -6,11 +6,12 @@ import ( osmv1client "github.com/openshift/client-go/monitoring/clientset/versioned" monitoringv1client "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" + "github.com/sirupsen/logrus" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) -//var log = logrus.WithField("module", "k8s") +var log = logrus.WithField("module", "k8s") var _ Client = (*client)(nil) @@ -20,8 +21,11 @@ type client struct { osmv1clientset *osmv1client.Clientset config *rest.Config - prometheusRuleManager *prometheusRuleManager - namespaceManager *namespaceManager + prometheusRuleManager *prometheusRuleManager + alertRelabelConfigManager *alertRelabelConfigManager + alertingRuleManager *alertingRuleManager + namespaceManager *namespaceManager + relabeledRulesManager *relabeledRulesManager } func NewClient(ctx context.Context, config *rest.Config) (Client, error) { @@ -52,11 +56,26 @@ func NewClient(ctx context.Context, config *rest.Config) (Client, error) { return nil, fmt.Errorf("failed to create PrometheusRule manager: %w", err) } + c.alertRelabelConfigManager, err = newAlertRelabelConfigManager(ctx, osmv1clientset) + if err != nil { + return nil, fmt.Errorf("failed to create alert relabel config manager: %w", err) + } + + c.alertingRuleManager, err = newAlertingRuleManager(ctx, osmv1clientset) + if err != nil { + return nil, fmt.Errorf("failed to create alerting rule manager: %w", err) + } + c.namespaceManager, err = newNamespaceManager(ctx, clientset) if err != nil { return nil, fmt.Errorf("failed to create namespace manager: %w", err) } + c.relabeledRulesManager, err = newRelabeledRulesManager(ctx, c.namespaceManager, c.alertRelabelConfigManager, monitoringv1clientset, clientset) + if err != nil { + return nil, fmt.Errorf("failed to create relabeled rules config manager: %w", err) + } + return c, nil } @@ -72,6 +91,18 @@ func (c *client) PrometheusRules() PrometheusRuleInterface { return c.prometheusRuleManager } +func (c *client) AlertRelabelConfigs() AlertRelabelConfigInterface { + return c.alertRelabelConfigManager +} + +func (c *client) AlertingRules() AlertingRuleInterface { + return c.alertingRuleManager +} + +func (c *client) RelabeledRules() RelabeledRulesInterface { + return c.relabeledRulesManager +} + func (c *client) Namespace() NamespaceInterface { return c.namespaceManager } diff --git a/pkg/k8s/external_management.go b/pkg/k8s/external_management.go new file mode 100644 index 000000000..7671c87e7 --- /dev/null +++ b/pkg/k8s/external_management.go @@ -0,0 +1,49 @@ +package k8s + +import ( + "reflect" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// External management detection keys +const ( + ArgocdArgoprojIoPrefix = "argocd.argoproj.io/" + AppKubernetesIoManagedBy = "app.kubernetes.io/managed-by" +) + +// IsManagedByGitOps returns true if the provided annotations/labels indicate GitOps (e.g., ArgoCD) management. +func IsManagedByGitOps(annotations map[string]string, labels map[string]string) bool { + for k := range annotations { + if strings.HasPrefix(k, ArgocdArgoprojIoPrefix) { + return true + } + } + for k := range labels { + if strings.HasPrefix(k, ArgocdArgoprojIoPrefix) { + return true + } + } + if v, ok := labels[AppKubernetesIoManagedBy]; ok { + vl := strings.ToLower(strings.TrimSpace(v)) + if vl == "openshift-gitops" || vl == "argocd-cluster" || vl == "argocd" || strings.Contains(vl, "gitops") { + return true + } + } + return false +} + +// IsExternallyManagedObject returns whether an object is GitOps-managed and/or operator-managed. +func IsExternallyManagedObject(obj metav1.Object) (gitOpsManaged bool, operatorManaged bool) { + if obj == nil { + return false, false + } + // Handle typed-nil underlying values + if rv := reflect.ValueOf(obj); rv.Kind() == reflect.Ptr && rv.IsNil() { + return false, false + } + gitOpsManaged = IsManagedByGitOps(obj.GetAnnotations(), obj.GetLabels()) + operatorManaged = len(obj.GetOwnerReferences()) > 0 + return +} diff --git a/pkg/k8s/relabeled_rules.go b/pkg/k8s/relabeled_rules.go new file mode 100644 index 000000000..5cc9d9d4e --- /dev/null +++ b/pkg/k8s/relabeled_rules.go @@ -0,0 +1,447 @@ +package k8s + +import ( + "context" + "crypto/sha256" + "fmt" + "strings" + "sync" + "time" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + monitoringv1client "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/model/relabel" + "gopkg.in/yaml.v2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +const ( + resyncPeriod = 15 * time.Minute + queueBaseDelay = 50 * time.Millisecond + queueMaxDelay = 3 * time.Minute + + AlertRelabelConfigSecretName = "alert-relabel-configs" + AlertRelabelConfigSecretKey = "config.yaml" + + PrometheusRuleLabelNamespace = "openshift_io_prometheus_rule_namespace" + PrometheusRuleLabelName = "openshift_io_prometheus_rule_name" + AlertRuleLabelId = "openshift_io_alert_rule_id" + + AlertRuleClassificationComponentKey = "openshift_io_alert_rule_component" + AlertRuleClassificationLayerKey = "openshift_io_alert_rule_layer" + + AppKubernetesIoComponent = "app.kubernetes.io/component" + AppKubernetesIoComponentAlertManagementApi = "alert-management-api" + AppKubernetesIoComponentMonitoringPlugin = "monitoring-plugin" +) + +type relabeledRulesManager struct { + queue workqueue.TypedRateLimitingInterface[string] + + namespaceManager NamespaceInterface + alertRelabelConfigs AlertRelabelConfigInterface + prometheusRulesInformer cache.SharedIndexInformer + secretInformer cache.SharedIndexInformer + configMapInformer cache.SharedIndexInformer + clientset kubernetes.Interface + + // relabeledRules stores the relabeled rules in memory + relabeledRules map[string]monitoringv1.Rule + relabelConfigs []*relabel.Config + mu sync.RWMutex +} + +func newRelabeledRulesManager(ctx context.Context, namespaceManager NamespaceInterface, alertRelabelConfigs AlertRelabelConfigInterface, monitoringv1clientset *monitoringv1client.Clientset, clientset *kubernetes.Clientset) (*relabeledRulesManager, error) { + prometheusRulesInformer := cache.NewSharedIndexInformer( + prometheusRuleListWatchForAllNamespaces(monitoringv1clientset), + &monitoringv1.PrometheusRule{}, + resyncPeriod, + cache.Indexers{}, + ) + + secretInformer := cache.NewSharedIndexInformer( + alertRelabelConfigSecretListWatch(clientset, ClusterMonitoringNamespace), + &corev1.Secret{}, + resyncPeriod, + cache.Indexers{}, + ) + + queue := workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.NewTypedItemExponentialFailureRateLimiter[string](queueBaseDelay, queueMaxDelay), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "relabeled-rules"}, + ) + + rrm := &relabeledRulesManager{ + queue: queue, + namespaceManager: namespaceManager, + alertRelabelConfigs: alertRelabelConfigs, + prometheusRulesInformer: prometheusRulesInformer, + secretInformer: secretInformer, + } + + _, err := rrm.prometheusRulesInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + promRule, ok := obj.(*monitoringv1.PrometheusRule) + if !ok { + return + } + log.Debugf("prometheus rule added: %s/%s", promRule.Namespace, promRule.Name) + rrm.queue.Add("prometheus-rule-sync") + }, + UpdateFunc: func(oldObj interface{}, newObj interface{}) { + promRule, ok := newObj.(*monitoringv1.PrometheusRule) + if !ok { + return + } + log.Debugf("prometheus rule updated: %s/%s", promRule.Namespace, promRule.Name) + rrm.queue.Add("prometheus-rule-sync") + }, + DeleteFunc: func(obj interface{}) { + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + obj = tombstone.Obj + } + + promRule, ok := obj.(*monitoringv1.PrometheusRule) + if !ok { + return + } + log.Debugf("prometheus rule deleted: %s/%s", promRule.Namespace, promRule.Name) + rrm.queue.Add("prometheus-rule-sync") + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to add event handler to prometheus rules informer: %w", err) + } + + _, err = rrm.secretInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + rrm.queue.Add("secret-sync") + }, + UpdateFunc: func(oldObj interface{}, newObj interface{}) { + rrm.queue.Add("secret-sync") + }, + DeleteFunc: func(obj interface{}) { + rrm.queue.Add("secret-sync") + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to add event handler to secret informer: %w", err) + } + + go rrm.prometheusRulesInformer.Run(ctx.Done()) + go rrm.secretInformer.Run(ctx.Done()) + + if !cache.WaitForNamedCacheSync("RelabeledRulesConfig informer", ctx.Done(), + rrm.prometheusRulesInformer.HasSynced, + rrm.secretInformer.HasSynced, + ) { + return nil, fmt.Errorf("failed to sync RelabeledRulesConfig informer") + } + + go rrm.worker(ctx) + rrm.queue.Add("initial-sync") + + return rrm, nil +} + +func alertRelabelConfigSecretListWatch(clientset *kubernetes.Clientset, namespace string) *cache.ListWatch { + return cache.NewListWatchFromClient( + clientset.CoreV1().RESTClient(), + "secrets", + namespace, + fields.OneTermEqualSelector("metadata.name", AlertRelabelConfigSecretName), + ) +} + +func (rrm *relabeledRulesManager) worker(ctx context.Context) { + for rrm.processNextWorkItem(ctx) { + } +} + +func (rrm *relabeledRulesManager) processNextWorkItem(ctx context.Context) bool { + key, quit := rrm.queue.Get() + if quit { + return false + } + + defer rrm.queue.Done(key) + + if err := rrm.sync(ctx); err != nil { + log.Errorf("error syncing relabeled rules: %v", err) + rrm.queue.AddRateLimited(key) + return true + } + + rrm.queue.Forget(key) + + return true +} + +func (rrm *relabeledRulesManager) sync(ctx context.Context) error { + relabelConfigs, err := rrm.loadRelabelConfigs() + if err != nil { + return fmt.Errorf("failed to load relabel configs: %w", err) + } + + rrm.mu.Lock() + rrm.relabelConfigs = relabelConfigs + rrm.mu.Unlock() + + alerts := rrm.collectAlerts(ctx, relabelConfigs) + + rrm.mu.Lock() + rrm.relabeledRules = alerts + rrm.mu.Unlock() + + log.Infof("Synced %d relabeled rules in memory", len(alerts)) + return nil +} + +func (rrm *relabeledRulesManager) loadRelabelConfigs() ([]*relabel.Config, error) { + storeKey := fmt.Sprintf("%s/%s", ClusterMonitoringNamespace, AlertRelabelConfigSecretName) + obj, exists, err := rrm.secretInformer.GetStore().GetByKey(storeKey) + if err != nil { + return nil, fmt.Errorf("failed to get secret from store: %w", err) + } + if !exists { + log.Infof("Alert relabel config secret %q not found", storeKey) + return nil, nil + } + + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil, fmt.Errorf("unexpected object type in secret store: %T", obj) + } + + configData, ok := secret.Data[AlertRelabelConfigSecretKey] + if !ok { + return nil, fmt.Errorf("no config data found in secret %q", AlertRelabelConfigSecretName) + } + + var raw []*relabel.Config + if err := yaml.Unmarshal(configData, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal relabel configs: %w", err) + } + + configs := make([]*relabel.Config, 0, len(raw)) + for i, config := range raw { + if config == nil { + log.Warnf("skipping nil relabel config entry at index %d", i) + continue + } + if config.NameValidationScheme == model.UnsetValidation { + config.NameValidationScheme = model.UTF8Validation + } + if err := config.Validate(model.UTF8Validation); err != nil { + return nil, fmt.Errorf("invalid relabel config at index %d: %w", i, err) + } + configs = append(configs, config) + } + + log.Infof("Loaded %d relabel configs from secret %s", len(configs), storeKey) + return configs, nil +} + +func (rrm *relabeledRulesManager) collectAlerts(ctx context.Context, relabelConfigs []*relabel.Config) map[string]monitoringv1.Rule { + alerts := make(map[string]monitoringv1.Rule) + seenIDs := make(map[string]struct{}) + + for _, obj := range rrm.prometheusRulesInformer.GetStore().List() { + promRule, ok := obj.(*monitoringv1.PrometheusRule) + if !ok { + continue + } + + // Skip deleted rules + if promRule.DeletionTimestamp != nil { + continue + } + + for _, group := range promRule.Spec.Groups { + for _, rule := range group.Rules { + // Only process alerting rules (skip recording rules) + if rule.Alert == "" { + continue + } + + // Compute a deterministic id from the rule spec. + // Do not trust any user-provided value in openshift_io_alert_rule_id since + // PrometheusRule content (including labels) can be tampered with. + alertRuleId := alertrule.GetAlertingRuleId(&rule) + if _, exists := seenIDs[alertRuleId]; exists { + // A second rule that computes to the same id is ambiguous/unsupported (a "true clone"). + // Don't silently overwrite the first rule in the cache. + log.Warnf("Duplicate alert rule id %q computed for %s/%s (alert=%q); skipping duplicate", alertRuleId, promRule.Namespace, promRule.Name, rule.Alert) + continue + } + seenIDs[alertRuleId] = struct{}{} + + if rule.Labels == nil { + rule.Labels = make(map[string]string) + } + + rule.Labels[managementlabels.AlertNameLabel] = rule.Alert + + if rrm.namespaceManager.IsClusterMonitoringNamespace(promRule.Namespace) { + lb := labels.NewBuilder(labels.FromMap(rule.Labels)) + keep := relabel.ProcessBuilder(lb, relabelConfigs...) + if !keep { + log.Infof("Skipping dropped alert %s from %s/%s", rule.Alert, promRule.Namespace, promRule.Name) + continue + } + + rule.Labels = lb.Labels().Map() + } + + rule.Labels[AlertRuleLabelId] = alertRuleId + rule.Labels[PrometheusRuleLabelNamespace] = promRule.Namespace + rule.Labels[PrometheusRuleLabelName] = promRule.Name + + if arName := alertingRuleOwner(promRule); arName != "" { + rule.Labels[managementlabels.AlertingRuleLabelName] = arName + } + + ruleManagedBy, relabelConfigManagedBy := rrm.determineManagedBy(ctx, promRule, alertRuleId) + if ruleManagedBy != "" { + rule.Labels[managementlabels.RuleManagedByLabel] = ruleManagedBy + } + if relabelConfigManagedBy != "" { + rule.Labels[managementlabels.RelabelConfigManagedByLabel] = relabelConfigManagedBy + } + + alerts[alertRuleId] = rule + } + } + } + + log.Debugf("Collected %d alerts", len(alerts)) + return alerts +} + +// alertingRuleOwner returns the name of the AlertingRule CR that generated +// this PrometheusRule, or "" if it was not generated by one. Detection is based +// on the ownerReferences set by the alerting-rules-controller. +func alertingRuleOwner(pr *monitoringv1.PrometheusRule) string { + for _, ref := range pr.OwnerReferences { + if ref.Kind == "AlertingRule" && ref.Controller != nil && *ref.Controller { + return ref.Name + } + } + return "" +} + +// GetAlertRelabelConfigName builds the AlertRelabelConfig name from a PrometheusRule name and alert rule ID +func GetAlertRelabelConfigName(promRuleName string, alertRuleId string) string { + return fmt.Sprintf("arc-%s-%s", sanitizeDNSName(promRuleName), shortHash(alertRuleId, 12)) +} + +// sanitizeDNSName lowercases and replaces invalid chars with '-', trims extra '-' +func sanitizeDNSName(in string) string { + if in == "" { + return "" + } + s := strings.ToLower(in) + // replace any char not [a-z0-9-] with '-' + out := make([]rune, 0, len(s)) + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + out = append(out, r) + } else { + out = append(out, '-') + } + } + // collapse multiple '-' and trim + res := strings.Trim(strings.ReplaceAll(string(out), "--", "-"), "-") + if res == "" { + return "arc" + } + return res +} + +func shortHash(id string, n int) string { + sum := sha256.Sum256([]byte(id)) + full := fmt.Sprintf("%x", sum[:]) + if n > len(full) { + return full + } + return full[:n] +} + +// determineManagedBy determines the openshift_io_rule_managed_by and openshift_io_relabel_config_managed_by label values +func (rrm *relabeledRulesManager) determineManagedBy(ctx context.Context, promRule *monitoringv1.PrometheusRule, alertRuleId string) (string, string) { + // Determine ruleManagedBy from PrometheusRule + var ruleManagedBy string + // If generated by AlertingRule CRD, do not mark as operator-managed; treat as user-via-platform + if alertingRuleOwner(promRule) == "" { + // Prefer operator-managed over GitOps when owner references indicate an operator + gitOpsManaged, operatorManaged := IsExternallyManagedObject(promRule) + if operatorManaged { + ruleManagedBy = managementlabels.ManagedByOperator + } else if gitOpsManaged { + ruleManagedBy = managementlabels.ManagedByGitOps + } + } + + // Determine relabelConfigManagedBy only for platform rules + isPlatform := rrm.namespaceManager.IsClusterMonitoringNamespace(promRule.Namespace) + var relabelConfigManagedBy string + if isPlatform && rrm.alertRelabelConfigs != nil { + arcName := GetAlertRelabelConfigName(promRule.Name, alertRuleId) + arc, found, err := rrm.alertRelabelConfigs.Get(ctx, promRule.Namespace, arcName) + if err == nil && found { + if IsManagedByGitOps(arc.Annotations, arc.Labels) { + relabelConfigManagedBy = managementlabels.ManagedByGitOps + } + } + } + + return ruleManagedBy, relabelConfigManagedBy +} + +// DetermineManagedBy determines the managed-by labels for a PrometheusRule alert rule. +func DetermineManagedBy(ctx context.Context, alertRelabelConfigs AlertRelabelConfigInterface, namespaceManager NamespaceInterface, promRule *monitoringv1.PrometheusRule, alertRuleId string) (string, string) { + rrm := &relabeledRulesManager{ + alertRelabelConfigs: alertRelabelConfigs, + namespaceManager: namespaceManager, + } + return rrm.determineManagedBy(ctx, promRule, alertRuleId) +} + +func (rrm *relabeledRulesManager) List(ctx context.Context) []monitoringv1.Rule { + rrm.mu.RLock() + defer rrm.mu.RUnlock() + + var result []monitoringv1.Rule + for _, rule := range rrm.relabeledRules { + result = append(result, rule) + } + + return result +} + +func (rrm *relabeledRulesManager) Get(ctx context.Context, id string) (monitoringv1.Rule, bool) { + rrm.mu.RLock() + defer rrm.mu.RUnlock() + + rule, ok := rrm.relabeledRules[id] + if !ok { + return monitoringv1.Rule{}, false + } + + return rule, true +} + +func (rrm *relabeledRulesManager) Config() []*relabel.Config { + rrm.mu.RLock() + defer rrm.mu.RUnlock() + + return append([]*relabel.Config{}, rrm.relabelConfigs...) +} diff --git a/pkg/k8s/types.go b/pkg/k8s/types.go index e22c38f57..102d5fccf 100644 --- a/pkg/k8s/types.go +++ b/pkg/k8s/types.go @@ -3,7 +3,9 @@ package k8s import ( "context" + osmv1 "github.com/openshift/api/monitoring/v1" monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "github.com/prometheus/prometheus/model/relabel" "k8s.io/apimachinery/pkg/types" ) @@ -22,6 +24,15 @@ type Client interface { // PrometheusRules returns the PrometheusRule interface PrometheusRules() PrometheusRuleInterface + // AlertRelabelConfigs returns the AlertRelabelConfig interface + AlertRelabelConfigs() AlertRelabelConfigInterface + + // AlertingRules returns the AlertingRule interface + AlertingRules() AlertingRuleInterface + + // RelabeledRules returns the RelabeledRules interface + RelabeledRules() RelabeledRulesInterface + // Namespace returns the Namespace interface Namespace() NamespaceInterface } @@ -44,6 +55,55 @@ type PrometheusRuleInterface interface { AddRule(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error } +// AlertRelabelConfigInterface defines operations for managing AlertRelabelConfigs +type AlertRelabelConfigInterface interface { + // List lists all AlertRelabelConfigs in the cluster + List(ctx context.Context, namespace string) ([]osmv1.AlertRelabelConfig, error) + + // Get retrieves an AlertRelabelConfig by namespace and name + Get(ctx context.Context, namespace string, name string) (*osmv1.AlertRelabelConfig, bool, error) + + // Create creates a new AlertRelabelConfig + Create(ctx context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) + + // Update updates an existing AlertRelabelConfig + Update(ctx context.Context, arc osmv1.AlertRelabelConfig) error + + // Delete deletes an AlertRelabelConfig by namespace and name + Delete(ctx context.Context, namespace string, name string) error +} + +// AlertingRuleInterface defines operations for managing AlertingRules +// in the cluster monitoring namespace +type AlertingRuleInterface interface { + // List lists all AlertingRules in the cluster + List(ctx context.Context) ([]osmv1.AlertingRule, error) + + // Get retrieves an AlertingRule by name + Get(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) + + // Create creates a new AlertingRule + Create(ctx context.Context, ar osmv1.AlertingRule) (*osmv1.AlertingRule, error) + + // Update updates an existing AlertingRule + Update(ctx context.Context, ar osmv1.AlertingRule) error + + // Delete deletes an AlertingRule by name + Delete(ctx context.Context, name string) error +} + +// RelabeledRulesInterface defines operations for managing relabeled rules +type RelabeledRulesInterface interface { + // List retrieves the relabeled rules for a given PrometheusRule + List(ctx context.Context) []monitoringv1.Rule + + // Get retrieves the relabeled rule for a given id + Get(ctx context.Context, id string) (monitoringv1.Rule, bool) + + // Config returns the list of alert relabel configs + Config() []*relabel.Config +} + // NamespaceInterface defines operations for Namespaces type NamespaceInterface interface { // IsClusterMonitoringNamespace checks if a namespace has the openshift.io/cluster-monitoring=true label diff --git a/pkg/management/alert_rule_id_match.go b/pkg/management/alert_rule_id_match.go new file mode 100644 index 000000000..8b0e83fa7 --- /dev/null +++ b/pkg/management/alert_rule_id_match.go @@ -0,0 +1,15 @@ +package management + +import ( + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +// ruleMatchesAlertRuleID returns true when the provided rule's computed, deterministic +// alert rule id matches the requested id. +// +// Note: we intentionally compute the id from the rule spec rather than trusting any +// label value, since labels can be user-controlled/tampered with. +func ruleMatchesAlertRuleID(rule monitoringv1.Rule, alertRuleId string) bool { + return alertRuleId != "" && alertRuleId == alertrule.GetAlertingRuleId(&rule) +} diff --git a/pkg/management/alert_rule_preconditions.go b/pkg/management/alert_rule_preconditions.go new file mode 100644 index 000000000..8edfb4318 --- /dev/null +++ b/pkg/management/alert_rule_preconditions.go @@ -0,0 +1,98 @@ +package management + +import ( + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + osmv1 "github.com/openshift/api/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +// Standardized NotAllowed errors +func notAllowedGitOpsEdit() error { + return &NotAllowedError{Message: "This alert is managed by GitOps; edit it in Git."} +} +func notAllowedGitOpsRemove() error { + return &NotAllowedError{Message: "This alert is managed by GitOps; remove it in Git."} +} +func notAllowedOperatorUpdate() error { + return &NotAllowedError{Message: "This alert is managed by an operator; it can't be updated and can only be silenced."} +} +func notAllowedOperatorDelete() error { + return &NotAllowedError{Message: "This alert is managed by an operator; it can't be deleted and can only be silenced."} +} + +// isRuleManagedByGitOpsLabel returns true if the relabeled rule indicates GitOps management via its managed-by label. +func isRuleManagedByGitOpsLabel(relabeled monitoringv1.Rule) bool { + if relabeled.Labels == nil { + return false + } + return relabeled.Labels[managementlabels.RuleManagedByLabel] == managementlabels.ManagedByGitOps +} + +// isRuleManagedByOperator returns true if the relabeled rule indicates operator management via its managed-by label. +func isRuleManagedByOperator(relabeled monitoringv1.Rule) bool { + return relabeled.Labels != nil && relabeled.Labels[managementlabels.RuleManagedByLabel] == managementlabels.ManagedByOperator +} + +// validateUserDeletePreconditions enforces common label-based constraints for user-source delete. +func validateUserDeletePreconditions(relabeled monitoringv1.Rule) error { + if isRuleManagedByGitOpsLabel(relabeled) { + return notAllowedGitOpsRemove() + } + if isRuleManagedByOperator(relabeled) { + return notAllowedOperatorDelete() + } + return nil +} + +// validateUserUpdatePreconditions enforces common constraints for user-source update. +func validateUserUpdatePreconditions(relabeled monitoringv1.Rule, pr *monitoringv1.PrometheusRule) error { + if isRuleManagedByGitOpsLabel(relabeled) { + return notAllowedGitOpsEdit() + } + if isRuleManagedByOperator(relabeled) { + return notAllowedOperatorUpdate() + } + // Authoritative operator-managed check on PR owner references if provided + if pr != nil { + if _, operatorManaged := k8s.IsExternallyManagedObject(pr); operatorManaged { + return notAllowedOperatorUpdate() + } + } + return nil +} + +// validatePlatformDeletePreconditions enforces constraints before mutating the owning AlertingRule. +func validatePlatformDeletePreconditions(ar *osmv1.AlertingRule) error { + // Block if owning AR is externally managed (GitOps or operator) + if ar != nil { + if gitOpsManaged, operatorManaged := k8s.IsExternallyManagedObject(ar); gitOpsManaged { + return notAllowedGitOpsRemove() + } else if operatorManaged { + return notAllowedOperatorDelete() + } + } + return nil +} + +// validatePlatformUpdatePreconditions enforces constraints before ARC-based update. +// pr may be nil if not fetched yet; arc may be nil if absent. +func validatePlatformUpdatePreconditions(relabeled monitoringv1.Rule, pr *monitoringv1.PrometheusRule, arc *osmv1.AlertRelabelConfig) error { + // Rule-level GitOps block + if isRuleManagedByGitOpsLabel(relabeled) { + return notAllowedGitOpsEdit() + } + // PR metadata GitOps block + if pr != nil { + if gitOpsManaged, _ := k8s.IsExternallyManagedObject(pr); gitOpsManaged { + return notAllowedGitOpsEdit() + } + } + // ARC metadata GitOps block + if arc != nil && k8s.IsManagedByGitOps(arc.Annotations, arc.Labels) { + return notAllowedGitOpsEdit() + } + return nil +} diff --git a/pkg/management/client_factory.go b/pkg/management/client_factory.go new file mode 100644 index 000000000..e71b7f93b --- /dev/null +++ b/pkg/management/client_factory.go @@ -0,0 +1,14 @@ +package management + +import ( + "context" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// New creates a new management client. +func New(ctx context.Context, k8sClient k8s.Client) Client { + return &client{ + k8sClient: k8sClient, + } +} diff --git a/pkg/management/create_platform_alert_rule.go b/pkg/management/create_platform_alert_rule.go new file mode 100644 index 000000000..a580528f9 --- /dev/null +++ b/pkg/management/create_platform_alert_rule.go @@ -0,0 +1,134 @@ +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" + + 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" +) + +func (c *client) CreatePlatformAlertRule(ctx context.Context, alertRule monitoringv1.Rule) (string, error) { + err := validatePlatformCreateInputs(alertRule) + if 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) + + existing, found, err := c.k8sClient.AlertingRules().Get(ctx, defaultAlertingRuleName) + if err != nil { + return "", fmt.Errorf("failed to get AlertingRule %s: %w", defaultAlertingRuleName, err) + } + + 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 err := addRuleToGroup(&updated.Spec, defaultPlatformGroupName, osmRule); err != nil { + return "", err + } + if err := c.k8sClient.AlertingRules().Update(ctx, *updated); err != nil { + return "", fmt.Errorf("failed to update AlertingRule %s: %w", defaultAlertingRuleName, err) + } + return newRuleId, 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 _, err := c.k8sClient.AlertingRules().Create(ctx, ar); err != nil { + return "", fmt.Errorf("failed to create AlertingRule %s: %w", defaultAlertingRuleName, err) + } + + return newRuleId, nil +} + +func validatePlatformCreateInputs(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/create_platform_alert_rule_test.go b/pkg/management/create_platform_alert_rule_test.go new file mode 100644 index 000000000..07c0c816b --- /dev/null +++ b/pkg/management/create_platform_alert_rule_test.go @@ -0,0 +1,270 @@ +package management_test + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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/util/intstr" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("CreatePlatformAlertRule", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + client management.Client + + baseRule monitoringv1.Rule + ) + + BeforeEach(func() { + ctx = context.Background() + mockK8s = &testutils.MockClient{} + client = management.New(ctx, mockK8s) + + baseRule = monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + For: (*monitoringv1.Duration)(stringPtr("5m")), + Labels: map[string]string{ + "severity": "warning", + }, + Annotations: map[string]string{ + "summary": "platform alert", + }, + } + }) + + Context("validation", func() { + It("returns error when alert name is empty", func() { + rule := baseRule + rule.Alert = " " + + _, err := client.CreatePlatformAlertRule(ctx, rule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert name is required")) + }) + + It("returns error when expr is empty", func() { + rule := baseRule + rule.Expr = intstr.FromString(" ") + + _, err := client.CreatePlatformAlertRule(ctx, rule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("expr is required")) + }) + + It("returns error when severity is invalid", func() { + rule := baseRule + rule.Labels = map[string]string{"severity": "fatal"} + + _, err := client.CreatePlatformAlertRule(ctx, rule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid severity")) + }) + }) + + Context("duplicate detection", func() { + It("returns conflict when same rule id already exists in relabeled rules", func() { + ruleID := alertrule.GetAlertingRuleId(&baseRule) + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + if id == ruleID { + return baseRule, true + } + return monitoringv1.Rule{}, false + }, + } + } + + _, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("exact config already exists")) + }) + }) + + Context("when target AlertingRule exists", func() { + It("returns NotAllowed when AlertingRule is GitOps-managed", func() { + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(ctx 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": "abc"}, + }, + }, true, nil + }, + } + } + + _, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("The AlertingRule is managed by GitOps")) + }) + + It("adds rule to default group and updates AlertingRule", func() { + var updated osmv1.AlertingRule + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(ctx 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: "ExistingAlert", + Expr: intstr.FromString("vector(1)"), + }, + }, + }, + }, + }, + }, true, nil + }, + UpdateFunc: func(ctx context.Context, ar osmv1.AlertingRule) error { + updated = ar + return nil + }, + } + } + + ruleID, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).NotTo(HaveOccurred()) + Expect(ruleID).To(Equal(alertrule.GetAlertingRuleId(&baseRule))) + Expect(updated.Name).To(Equal("platform-alert-rules")) + Expect(updated.Spec.Groups).To(HaveLen(1)) + Expect(updated.Spec.Groups[0].Name).To(Equal("platform-alert-rules")) + Expect(updated.Spec.Groups[0].Rules).To(HaveLen(2)) + Expect(updated.Spec.Groups[0].Rules[1].Labels).To(HaveKey(k8s.AlertRuleLabelId)) + }) + + It("returns conflict when same alert name exists in target group", func() { + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(ctx 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: "PlatformAlert", + Expr: intstr.FromString("vector(1)"), + }, + }, + }, + }, + }, + }, true, nil + }, + } + } + + _, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already exists in group")) + }) + }) + + Context("when target AlertingRule does not exist", func() { + It("creates AlertingRule in cluster monitoring namespace", func() { + var created osmv1.AlertingRule + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return nil, false, nil + }, + CreateFunc: func(ctx context.Context, ar osmv1.AlertingRule) (*osmv1.AlertingRule, error) { + created = ar + return &ar, nil + }, + } + } + + _, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).NotTo(HaveOccurred()) + Expect(created.Name).To(Equal("platform-alert-rules")) + Expect(created.Namespace).To(Equal(k8s.ClusterMonitoringNamespace)) + Expect(created.Spec.Groups).To(HaveLen(1)) + Expect(created.Spec.Groups[0].Name).To(Equal("platform-alert-rules")) + Expect(created.Spec.Groups[0].Rules).To(HaveLen(1)) + Expect(created.Spec.Groups[0].Rules[0].Labels).To(HaveKey(k8s.AlertRuleLabelId)) + }) + + It("returns wrapped error when AlertingRules Get fails", func() { + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + mockK8s.AlertingRulesFunc = func() k8s.AlertingRuleInterface { + return &testutils.MockAlertingRuleInterface{ + GetFunc: func(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) { + return nil, false, errors.New("get failed") + }, + } + } + + _, err := client.CreatePlatformAlertRule(ctx, baseRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get AlertingRule")) + Expect(err.Error()).To(ContainSubstring("get failed")) + }) + }) +}) diff --git a/pkg/management/create_user_defined_alert_rule.go b/pkg/management/create_user_defined_alert_rule.go new file mode 100644 index 000000000..ad2533a3b --- /dev/null +++ b/pkg/management/create_user_defined_alert_rule.go @@ -0,0 +1,136 @@ +package management + +import ( + "context" + "strings" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + DefaultGroupName = "user-defined-rules" +) + +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"} + } + + // 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.IsPlatformAlertRule(nn) { + return "", &NotAllowedError{Message: "cannot add user-defined alert rule to a platform-managed PrometheusRule"} + } + + // Enforce uniqueness within the target PrometheusRule: + // - "True clones" (different entries with identical definitions) are unsupported; they compute to the same rule ID. + pr, prFound, err := c.k8sClient.PrometheusRules().Get(ctx, nn.Namespace, nn.Name) + if err != nil { + return "", err + } + if prFound && pr != nil { + // Disallow adding to GitOps- or operator-managed PrometheusRule + 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."} + } + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + // Treat "true clones" as unsupported: identical definitions compute to the same id. + 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 { + 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 a.Expr.String() != 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 + } + return out +} diff --git a/pkg/management/create_user_defined_alert_rule_test.go b/pkg/management/create_user_defined_alert_rule_test.go new file mode 100644 index 000000000..b69e8544d --- /dev/null +++ b/pkg/management/create_user_defined_alert_rule_test.go @@ -0,0 +1,377 @@ +package management_test + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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" + + alertrule "github.com/openshift/monitoring-plugin/pkg/alert_rule" + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("CreateUserDefinedAlertRule", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + client management.Client + ) + + var ( + testRule = monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + For: (*monitoringv1.Duration)(stringPtr("5m")), + Labels: map[string]string{ + "severity": "warning", + }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + } + ) + + BeforeEach(func() { + ctx = context.Background() + mockK8s = &testutils.MockClient{} + client = management.New(ctx, mockK8s) + }) + + Context("when target PrometheusRule is GitOps-managed", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { return false }, + } + } + // No duplicate + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + // Existing PrometheusRule with GitOps annotation + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + GetFunc: func(ctx context.Context, namespace string, 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": "abc"}, + }, + }, true, nil + }, + } + } + }) + + It("returns NotAllowed with GitOps message", func() { + prOptions := management.PrometheusRuleOptions{Name: "user-pr", Namespace: "user-ns"} + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("This PrometheusRule is managed by GitOps; create the alert in Git.")) + }) + }) + + Context("when target PrometheusRule is operator-managed", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { return false }, + } + } + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + // Existing PrometheusRule with OwnerReferences + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + GetFunc: func(ctx context.Context, namespace string, name string) (*monitoringv1.PrometheusRule, bool, error) { + return &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + OwnerReferences: []metav1.OwnerReference{ + {Kind: "Deployment", Name: "some-operator"}, + }, + }, + }, true, nil + }, + } + } + }) + + It("returns NotAllowed for operator-managed PrometheusRule", func() { + prOptions := management.PrometheusRuleOptions{Name: "user-pr", Namespace: "user-ns"} + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("This PrometheusRule is managed by an operator; you cannot add alerts to it.")) + }) + }) + Context("when PrometheusRule Name is not specified", func() { + It("returns an error", func() { + prOptions := management.PrometheusRuleOptions{ + Namespace: "test-namespace", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PrometheusRule Name and Namespace must be specified")) + }) + }) + + Context("when PrometheusRule Namespace is not specified", func() { + It("returns an error", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-rule", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PrometheusRule Name and Namespace must be specified")) + }) + }) + + Context("when trying to add rule to platform-managed PrometheusRule", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return name == "openshift-monitoring" + }, + } + } + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + }) + + It("returns an error", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "platform-rule", + Namespace: "openshift-monitoring", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot add user-defined alert rule to a platform-managed PrometheusRule")) + }) + }) + + Context("when rule with same ID already exists", func() { + BeforeEach(func() { + ruleId := alertrule.GetAlertingRuleId(&testRule) + + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return false + }, + } + } + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + if id == ruleId { + return testRule, true + } + return monitoringv1.Rule{}, false + }, + } + } + }) + + It("returns an error", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule with exact config already exists")) + }) + }) + + Context("when AddRule fails", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return false + }, + } + } + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + AddRuleFunc: func(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + return errors.New("failed to add rule") + }, + } + } + }) + + It("returns the error", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add rule")) + }) + }) + + Context("when successfully creating a rule", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { + return false + }, + } + } + + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + AddRuleFunc: func(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + return nil + }, + } + } + }) + + It("returns the rule ID", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + } + + ruleId, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).NotTo(HaveOccurred()) + Expect(ruleId).NotTo(BeEmpty()) + Expect(ruleId).To(Equal(alertrule.GetAlertingRuleId(&testRule))) + }) + + It("uses default group name when not specified", func() { + var capturedGroupName string + + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + AddRuleFunc: func(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + capturedGroupName = groupName + return nil + }, + } + } + + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).NotTo(HaveOccurred()) + Expect(capturedGroupName).To(Equal("user-defined-rules")) + }) + + It("uses custom group name when specified", func() { + var capturedGroupName string + + mockK8s.PrometheusRulesFunc = func() k8s.PrometheusRuleInterface { + return &testutils.MockPrometheusRuleInterface{ + AddRuleFunc: func(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + capturedGroupName = groupName + return nil + }, + } + } + + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + GroupName: "custom-group", + } + + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).NotTo(HaveOccurred()) + Expect(capturedGroupName).To(Equal("custom-group")) + }) + }) + + Context("duplicate detection ignoring alert name", func() { + BeforeEach(func() { + mockK8s.NamespaceFunc = func() k8s.NamespaceInterface { + return &testutils.MockNamespaceInterface{ + IsClusterMonitoringNamespaceFunc: func(name string) bool { return false }, + } + } + // existing rule with different alert name but same spec (expr/for/labels) + existing := monitoringv1.Rule{} + (&testRule).DeepCopyInto(&existing) + existing.Alert = "OtherName" + mockK8s.RelabeledRulesFunc = func() k8s.RelabeledRulesInterface { + return &testutils.MockRelabeledRulesInterface{ + ListFunc: func(ctx context.Context) []monitoringv1.Rule { + return []monitoringv1.Rule{existing} + }, + GetFunc: func(ctx context.Context, id string) (monitoringv1.Rule, bool) { + return monitoringv1.Rule{}, false + }, + } + } + }) + + It("denies adding equivalent rule with different alert name", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "user-rule", + Namespace: "user-namespace", + } + _, err := client.CreateUserDefinedAlertRule(ctx, testRule, prOptions) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("equivalent spec already exists")) + }) + }) +}) + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/management/errors.go b/pkg/management/errors.go new file mode 100644 index 000000000..d0bec9127 --- /dev/null +++ b/pkg/management/errors.go @@ -0,0 +1,44 @@ +package management + +import "fmt" + +type NotFoundError struct { + Resource string + Id string + + AdditionalInfo string +} + +func (r *NotFoundError) Error() string { + s := fmt.Sprintf("%s with id %s not found", r.Resource, r.Id) + + if r.AdditionalInfo != "" { + s += fmt.Sprintf(": %s", r.AdditionalInfo) + } + + return s +} + +type NotAllowedError struct { + Message string +} + +func (r *NotAllowedError) Error() string { + return r.Message +} + +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +type ConflictError struct { + Message string +} + +func (e *ConflictError) Error() string { + return e.Message +} diff --git a/pkg/management/label_utils.go b/pkg/management/label_utils.go new file mode 100644 index 000000000..49a0e4fc8 --- /dev/null +++ b/pkg/management/label_utils.go @@ -0,0 +1,29 @@ +package management + +import ( + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/managementlabels" +) + +// isProtectedLabel returns true for labels we will not modify via ARC for platform rules. +// These carry provenance or rule identity and must remain intact. +var protectedLabels = map[string]bool{ + managementlabels.AlertNameLabel: true, + k8s.AlertRuleLabelId: true, +} + +func isProtectedLabel(label string) bool { + return protectedLabels[label] +} + +// isValidSeverity validates allowed severity values. +var validSeverities = map[string]bool{ + "critical": true, + "warning": true, + "info": true, + "none": true, +} + +func isValidSeverity(s string) bool { + return validSeverities[s] +} diff --git a/pkg/management/management.go b/pkg/management/management.go new file mode 100644 index 000000000..e310f4055 --- /dev/null +++ b/pkg/management/management.go @@ -0,0 +1,15 @@ +package management + +import ( + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +type client struct { + k8sClient k8s.Client +} + +func (c *client) IsPlatformAlertRule(prId types.NamespacedName) bool { + return c.k8sClient.Namespace().IsClusterMonitoringNamespace(prId.Namespace) +} diff --git a/pkg/management/testutils/k8s_client_mock.go b/pkg/management/testutils/k8s_client_mock.go new file mode 100644 index 000000000..0728f6319 --- /dev/null +++ b/pkg/management/testutils/k8s_client_mock.go @@ -0,0 +1,419 @@ +package testutils + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "github.com/prometheus/prometheus/model/relabel" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// MockClient is a mock implementation of k8s.Client interface +type MockClient struct { + TestConnectionFunc func(ctx context.Context) error + PrometheusRulesFunc func() k8s.PrometheusRuleInterface + AlertRelabelConfigsFunc func() k8s.AlertRelabelConfigInterface + AlertingRulesFunc func() k8s.AlertingRuleInterface + RelabeledRulesFunc func() k8s.RelabeledRulesInterface + NamespaceFunc func() k8s.NamespaceInterface +} + +// TestConnection mocks the TestConnection method +func (m *MockClient) TestConnection(ctx context.Context) error { + if m.TestConnectionFunc != nil { + return m.TestConnectionFunc(ctx) + } + return nil +} + +// PrometheusRules mocks the PrometheusRules method +func (m *MockClient) PrometheusRules() k8s.PrometheusRuleInterface { + if m.PrometheusRulesFunc != nil { + return m.PrometheusRulesFunc() + } + return &MockPrometheusRuleInterface{} +} + +// AlertRelabelConfigs mocks the AlertRelabelConfigs method +func (m *MockClient) AlertRelabelConfigs() k8s.AlertRelabelConfigInterface { + if m.AlertRelabelConfigsFunc != nil { + return m.AlertRelabelConfigsFunc() + } + return &MockAlertRelabelConfigInterface{} +} + +// AlertingRules mocks the AlertingRules method +func (m *MockClient) AlertingRules() k8s.AlertingRuleInterface { + if m.AlertingRulesFunc != nil { + return m.AlertingRulesFunc() + } + return &MockAlertingRuleInterface{} +} + +// RelabeledRules mocks the RelabeledRules method +func (m *MockClient) RelabeledRules() k8s.RelabeledRulesInterface { + if m.RelabeledRulesFunc != nil { + return m.RelabeledRulesFunc() + } + return &MockRelabeledRulesInterface{} +} + +// Namespace mocks the Namespace method +func (m *MockClient) Namespace() k8s.NamespaceInterface { + if m.NamespaceFunc != nil { + return m.NamespaceFunc() + } + return &MockNamespaceInterface{} +} + +// MockPrometheusRuleInterface is a mock implementation of k8s.PrometheusRuleInterface +type MockPrometheusRuleInterface struct { + ListFunc func() ([]monitoringv1.PrometheusRule, error) + GetFunc func(ctx context.Context, namespace string, name string) (*monitoringv1.PrometheusRule, bool, error) + UpdateFunc func(ctx context.Context, pr monitoringv1.PrometheusRule) error + DeleteFunc func(ctx context.Context, namespace string, name string) error + AddRuleFunc func(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error + + // Storage for test data + PrometheusRules map[string]*monitoringv1.PrometheusRule +} + +func (m *MockPrometheusRuleInterface) SetPrometheusRules(rules map[string]*monitoringv1.PrometheusRule) { + m.PrometheusRules = rules +} + +// List mocks the List method +func (m *MockPrometheusRuleInterface) List() ([]monitoringv1.PrometheusRule, error) { + if m.ListFunc != nil { + return m.ListFunc() + } + + var rules []monitoringv1.PrometheusRule + if m.PrometheusRules != nil { + for _, rule := range m.PrometheusRules { + rules = append(rules, *rule) + } + } + return rules, nil +} + +// Get mocks the Get method +func (m *MockPrometheusRuleInterface) Get(ctx context.Context, namespace string, name string) (*monitoringv1.PrometheusRule, bool, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx, namespace, name) + } + + key := namespace + "/" + name + if m.PrometheusRules != nil { + if rule, exists := m.PrometheusRules[key]; exists { + return rule, true, nil + } + } + + return nil, false, nil +} + +// Update mocks the Update method +func (m *MockPrometheusRuleInterface) Update(ctx context.Context, pr monitoringv1.PrometheusRule) error { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, pr) + } + + key := pr.Namespace + "/" + pr.Name + if m.PrometheusRules == nil { + m.PrometheusRules = make(map[string]*monitoringv1.PrometheusRule) + } + m.PrometheusRules[key] = &pr + return nil +} + +// Delete mocks the Delete method +func (m *MockPrometheusRuleInterface) Delete(ctx context.Context, namespace string, name string) error { + if m.DeleteFunc != nil { + return m.DeleteFunc(ctx, namespace, name) + } + + key := namespace + "/" + name + if m.PrometheusRules != nil { + delete(m.PrometheusRules, key) + } + return nil +} + +// AddRule mocks the AddRule method +func (m *MockPrometheusRuleInterface) AddRule(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + if m.AddRuleFunc != nil { + return m.AddRuleFunc(ctx, namespacedName, groupName, rule) + } + + key := namespacedName.Namespace + "/" + namespacedName.Name + if m.PrometheusRules == nil { + m.PrometheusRules = make(map[string]*monitoringv1.PrometheusRule) + } + + // Get or create PrometheusRule + pr, exists := m.PrometheusRules[key] + if !exists { + pr = &monitoringv1.PrometheusRule{ + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{}, + }, + } + pr.Name = namespacedName.Name + pr.Namespace = namespacedName.Namespace + m.PrometheusRules[key] = pr + } + + // Find or create the group + var group *monitoringv1.RuleGroup + for i := range pr.Spec.Groups { + if pr.Spec.Groups[i].Name == groupName { + group = &pr.Spec.Groups[i] + break + } + } + if group == nil { + pr.Spec.Groups = append(pr.Spec.Groups, monitoringv1.RuleGroup{ + Name: groupName, + Rules: []monitoringv1.Rule{}, + }) + group = &pr.Spec.Groups[len(pr.Spec.Groups)-1] + } + + // Add the new rule to the group + group.Rules = append(group.Rules, rule) + + return nil +} + +// MockAlertRelabelConfigInterface is a mock implementation of k8s.AlertRelabelConfigInterface +type MockAlertRelabelConfigInterface struct { + ListFunc func(ctx context.Context, namespace string) ([]osmv1.AlertRelabelConfig, error) + GetFunc func(ctx context.Context, namespace string, name string) (*osmv1.AlertRelabelConfig, bool, error) + CreateFunc func(ctx context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) + UpdateFunc func(ctx context.Context, arc osmv1.AlertRelabelConfig) error + DeleteFunc func(ctx context.Context, namespace string, name string) error + + // Storage for test data + AlertRelabelConfigs map[string]*osmv1.AlertRelabelConfig +} + +func (m *MockAlertRelabelConfigInterface) SetAlertRelabelConfigs(configs map[string]*osmv1.AlertRelabelConfig) { + m.AlertRelabelConfigs = configs +} + +// List mocks the List method +func (m *MockAlertRelabelConfigInterface) List(ctx context.Context, namespace string) ([]osmv1.AlertRelabelConfig, error) { + if m.ListFunc != nil { + return m.ListFunc(ctx, namespace) + } + + var configs []osmv1.AlertRelabelConfig + if m.AlertRelabelConfigs != nil { + for _, config := range m.AlertRelabelConfigs { + if namespace == "" || config.Namespace == namespace { + configs = append(configs, *config) + } + } + } + return configs, nil +} + +// Get mocks the Get method +func (m *MockAlertRelabelConfigInterface) Get(ctx context.Context, namespace string, name string) (*osmv1.AlertRelabelConfig, bool, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx, namespace, name) + } + + key := namespace + "/" + name + if m.AlertRelabelConfigs != nil { + if config, exists := m.AlertRelabelConfigs[key]; exists { + return config, true, nil + } + } + + return nil, false, nil +} + +// Create mocks the Create method +func (m *MockAlertRelabelConfigInterface) Create(ctx context.Context, arc osmv1.AlertRelabelConfig) (*osmv1.AlertRelabelConfig, error) { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, arc) + } + + key := arc.Namespace + "/" + arc.Name + if m.AlertRelabelConfigs == nil { + m.AlertRelabelConfigs = make(map[string]*osmv1.AlertRelabelConfig) + } + m.AlertRelabelConfigs[key] = &arc + return &arc, nil +} + +// Update mocks the Update method +func (m *MockAlertRelabelConfigInterface) Update(ctx context.Context, arc osmv1.AlertRelabelConfig) error { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, arc) + } + + key := arc.Namespace + "/" + arc.Name + if m.AlertRelabelConfigs == nil { + m.AlertRelabelConfigs = make(map[string]*osmv1.AlertRelabelConfig) + } + m.AlertRelabelConfigs[key] = &arc + return nil +} + +// Delete mocks the Delete method +func (m *MockAlertRelabelConfigInterface) Delete(ctx context.Context, namespace string, name string) error { + if m.DeleteFunc != nil { + return m.DeleteFunc(ctx, namespace, name) + } + + key := namespace + "/" + name + if m.AlertRelabelConfigs != nil { + delete(m.AlertRelabelConfigs, key) + } + return nil +} + +// MockAlertingRuleInterface is a mock implementation of k8s.AlertingRuleInterface +type MockAlertingRuleInterface struct { + ListFunc func(ctx context.Context) ([]osmv1.AlertingRule, error) + GetFunc func(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) + CreateFunc func(ctx context.Context, ar osmv1.AlertingRule) (*osmv1.AlertingRule, error) + UpdateFunc func(ctx context.Context, ar osmv1.AlertingRule) error + DeleteFunc func(ctx context.Context, name string) error + + // Storage for test data + AlertingRules map[string]*osmv1.AlertingRule +} + +func (m *MockAlertingRuleInterface) SetAlertingRules(rules map[string]*osmv1.AlertingRule) { + m.AlertingRules = rules +} + +// List mocks the List method +func (m *MockAlertingRuleInterface) List(ctx context.Context) ([]osmv1.AlertingRule, error) { + if m.ListFunc != nil { + return m.ListFunc(ctx) + } + + var rules []osmv1.AlertingRule + if m.AlertingRules != nil { + for _, rule := range m.AlertingRules { + if rule.Namespace == k8s.ClusterMonitoringNamespace { + rules = append(rules, *rule) + } + } + } + return rules, nil +} + +// Get mocks the Get method +func (m *MockAlertingRuleInterface) Get(ctx context.Context, name string) (*osmv1.AlertingRule, bool, error) { + if m.GetFunc != nil { + return m.GetFunc(ctx, name) + } + + key := k8s.ClusterMonitoringNamespace + "/" + name + if m.AlertingRules != nil { + if rule, exists := m.AlertingRules[key]; exists { + return rule, true, nil + } + } + + return nil, false, nil +} + +// Create mocks the Create method +func (m *MockAlertingRuleInterface) Create(ctx context.Context, ar osmv1.AlertingRule) (*osmv1.AlertingRule, error) { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, ar) + } + + key := ar.Namespace + "/" + ar.Name + if m.AlertingRules == nil { + m.AlertingRules = make(map[string]*osmv1.AlertingRule) + } + m.AlertingRules[key] = &ar + return &ar, nil +} + +// Update mocks the Update method +func (m *MockAlertingRuleInterface) Update(ctx context.Context, ar osmv1.AlertingRule) error { + if m.UpdateFunc != nil { + return m.UpdateFunc(ctx, ar) + } + + key := ar.Namespace + "/" + ar.Name + if m.AlertingRules == nil { + m.AlertingRules = make(map[string]*osmv1.AlertingRule) + } + m.AlertingRules[key] = &ar + return nil +} + +// Delete mocks the Delete method +func (m *MockAlertingRuleInterface) Delete(ctx context.Context, name string) error { + if m.DeleteFunc != nil { + return m.DeleteFunc(ctx, name) + } + + key := k8s.ClusterMonitoringNamespace + "/" + name + if m.AlertingRules != nil { + delete(m.AlertingRules, key) + } + return nil +} + +// MockRelabeledRulesInterface is a mock implementation of k8s.RelabeledRulesInterface +type MockRelabeledRulesInterface struct { + ListFunc func(ctx context.Context) []monitoringv1.Rule + GetFunc func(ctx context.Context, id string) (monitoringv1.Rule, bool) + ConfigFunc func() []*relabel.Config +} + +func (m *MockRelabeledRulesInterface) List(ctx context.Context) []monitoringv1.Rule { + if m.ListFunc != nil { + return m.ListFunc(ctx) + } + return []monitoringv1.Rule{} +} + +func (m *MockRelabeledRulesInterface) Get(ctx context.Context, id string) (monitoringv1.Rule, bool) { + if m.GetFunc != nil { + return m.GetFunc(ctx, id) + } + return monitoringv1.Rule{}, false +} + +func (m *MockRelabeledRulesInterface) Config() []*relabel.Config { + if m.ConfigFunc != nil { + return m.ConfigFunc() + } + return []*relabel.Config{} +} + +// MockNamespaceInterface is a mock implementation of k8s.NamespaceInterface +type MockNamespaceInterface struct { + IsClusterMonitoringNamespaceFunc func(name string) bool + + // Storage for test data + MonitoringNamespaces map[string]bool +} + +func (m *MockNamespaceInterface) SetMonitoringNamespaces(namespaces map[string]bool) { + m.MonitoringNamespaces = namespaces +} + +// IsClusterMonitoringNamespace mocks the IsClusterMonitoringNamespace method +func (m *MockNamespaceInterface) IsClusterMonitoringNamespace(name string) bool { + if m.IsClusterMonitoringNamespaceFunc != nil { + return m.IsClusterMonitoringNamespaceFunc(name) + } + return m.MonitoringNamespaces[name] +} diff --git a/pkg/management/types.go b/pkg/management/types.go new file mode 100644 index 000000000..5ec3fc055 --- /dev/null +++ b/pkg/management/types.go @@ -0,0 +1,28 @@ +package management + +import ( + "context" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +// Client is the interface for managing alert rules +type Client interface { + // CreateUserDefinedAlertRule creates a new user-defined alert rule + CreateUserDefinedAlertRule(ctx context.Context, alertRule monitoringv1.Rule, prOptions PrometheusRuleOptions) (alertRuleId string, err error) + + // CreatePlatformAlertRule creates a new platform alert rule + CreatePlatformAlertRule(ctx context.Context, alertRule monitoringv1.Rule) (alertRuleId string, err error) +} + +// PrometheusRuleOptions specifies options for selecting PrometheusRule resources and groups +type PrometheusRuleOptions struct { + // Name of the PrometheusRule resource where the alert rule will be added/listed from + Name string `json:"prometheusRuleName"` + + // Namespace of the PrometheusRule resource where the alert rule will be added/listed from + Namespace string `json:"prometheusRuleNamespace"` + + // GroupName of the RuleGroup within the PrometheusRule resource + GroupName string `json:"groupName"` +} diff --git a/pkg/managementlabels/management_labels.go b/pkg/managementlabels/management_labels.go new file mode 100644 index 000000000..42428fe0f --- /dev/null +++ b/pkg/managementlabels/management_labels.go @@ -0,0 +1,20 @@ +package managementlabels + +const ( + // Label keys + RuleManagedByLabel = "openshift_io_rule_managed_by" + RelabelConfigManagedByLabel = "openshift_io_relabel_config_managed_by" + AlertNameLabel = "alertname" + AlertingRuleLabelName = "openshift_io_alerting_rule_name" + + // label values + ManagedByOperator = "operator" + ManagedByGitOps = "gitops" +) + +// ARC-related label and annotation keys +const ( + ARCLabelPrometheusRuleNameKey = "monitoring.openshift.io/prometheusrule-name" + ARCLabelAlertNameKey = "monitoring.openshift.io/alertname" + ARCAnnotationAlertRuleIDKey = "monitoring.openshift.io/alertRuleId" +) diff --git a/pkg/server.go b/pkg/server.go index 552f06103..6ccc8f24b 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -21,7 +21,11 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" + "github.com/openshift/monitoring-plugin/internal/managementrouter" + "github.com/openshift/monitoring-plugin/pkg/management" "github.com/openshift/monitoring-plugin/pkg/proxy" + + "github.com/openshift/monitoring-plugin/pkg/k8s" ) var log = logrus.WithField("module", "server") @@ -146,7 +150,23 @@ func createHTTPServer(ctx context.Context, cfg *Config) (*http.Server, error) { k8sclient = nil } - router, pluginConfig := setupRoutes(cfg) + // Initialize management client if management API feature is enabled + var managementClient management.Client + if alertManagementAPIMode { + k8sClient, err := k8s.NewClient(ctx, k8sconfig) + if err != nil { + return nil, fmt.Errorf("failed to create k8s client for alert management API: %w", err) + } + + if err := k8sClient.TestConnection(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to kubernetes cluster for alert management API: %w", err) + } + + managementClient = management.New(ctx, k8sClient) + log.Info("alert management API enabled") + } + + router, pluginConfig := setupRoutes(cfg, managementClient) router.Use(corsHeaderMiddleware()) tlsConfig := &tls.Config{} @@ -237,7 +257,7 @@ func createHTTPServer(ctx context.Context, cfg *Config) (*http.Server, error) { return httpServer, nil } -func setupRoutes(cfg *Config) (*mux.Router, *PluginConfig) { +func setupRoutes(cfg *Config, managementClient management.Client) (*mux.Router, *PluginConfig) { configHandlerFunc, pluginConfig := configHandler(cfg) router := mux.NewRouter() @@ -248,6 +268,12 @@ func setupRoutes(cfg *Config) (*mux.Router, *PluginConfig) { router.PathPrefix("/features").HandlerFunc(featuresHandler(cfg)) router.PathPrefix("/config").HandlerFunc(configHandlerFunc) + + if managementClient != nil { + managementRouter := managementrouter.New(managementClient) + router.PathPrefix("/api/v1/alerting").Handler(managementRouter) + } + router.PathPrefix("/").Handler(filesHandler(http.Dir(cfg.StaticPath))) return router, pluginConfig