diff --git a/Makefile b/Makefile index ce54b206..9c670688 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ lint-frontend: lint-backend: go mod tidy go fmt ./cmd/ - go fmt ./pkg/ + go fmt ./pkg/... ./internal/... .PHONY: install-backend install-backend: @@ -57,7 +57,7 @@ start-backend: .PHONY: test-backend test-backend: - go test ./pkg/... -v + go test ./pkg/... ./internal/... -v .PHONY: build-image build-image: diff --git a/cmd/plugin-backend.go b/cmd/plugin-backend.go index 82e76f4b..0d1a3b16 100644 --- a/cmd/plugin-backend.go +++ b/cmd/plugin-backend.go @@ -8,15 +8,16 @@ import ( "strconv" "strings" - server "github.com/openshift/monitoring-plugin/pkg" "github.com/sirupsen/logrus" + + server "github.com/openshift/monitoring-plugin/pkg" ) var ( portArg = flag.Int("port", 0, "server port to listen on (default: 9443)\nports 9444 and 9445 reserved for other use") certArg = flag.String("cert", "", "cert file path to enable TLS (disabled by default)") keyArg = flag.String("key", "", "private key file path to enable TLS (disabled by default)") - featuresArg = flag.String("features", "", "enabled features, comma separated.\noptions: ['acm-alerting', 'incidents', 'dev-config', 'perses-dashboards']") + featuresArg = flag.String("features", "", "enabled features, comma separated.\noptions: ['acm-alerting', 'incidents', 'dev-config', 'perses-dashboards', 'management-api']") staticPathArg = flag.String("static-path", "", "static files path to serve frontend (default: './web/dist')") configPathArg = flag.String("config-path", "", "config files path (default: './config')") pluginConfigArg = flag.String("plugin-config-path", "", "plugin yaml configuration") diff --git a/go.mod b/go.mod index c63c87f8..4107fae3 100644 --- a/go.mod +++ b/go.mod @@ -4,57 +4,79 @@ go 1.24.0 require ( github.com/evanphx/json-patch v4.12.0+incompatible + github.com/go-playground/form/v4 v4.3.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 + github.com/onsi/ginkgo/v2 v2.22.0 + github.com/onsi/gomega v1.36.1 + 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/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.31.1 - k8s.io/apiserver v0.30.3 - k8s.io/client-go v0.31.1 + k8s.io/api v0.34.2 + k8s.io/apimachinery v0.34.2 + k8s.io/apiserver v0.34.2 + k8s.io/client-go v0.34.2 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // 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-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.2.0 // 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-20241029153458-d1b30febd7db // indirect github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // 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/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.9.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/oauth2 v0.31.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.13.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apimachinery v0.31.1 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240808142205-8e686545bdb8 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/controller-runtime v0.22.3 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 4bc90faf..975b1a05 100644 --- a/go.sum +++ b/go.sum @@ -2,50 +2,69 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= -github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +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/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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk= +github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo= 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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= @@ -54,19 +73,22 @@ 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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +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.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw= -github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +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/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= +github.com/openshift/client-go v0.0.0-20251123231646-4685125c2287/go.mod h1:liCuDDdOsPSZIDP0QuTveFhF7ldXuvnPhBd/OTsJdJc= github.com/openshift/library-go v0.0.0-20240905123346-5bdbfe35a6f5 h1:CyPTfZvr+HvwXbix9kieI55HeFn4a5DBaxJ3DNFinhg= github.com/openshift/library-go v0.0.0-20240905123346-5bdbfe35a6f5/go.mod h1:/wmao3qtqOQ484HDka9cWP7SIvOQOdzpmhyXkF2YdzE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -74,38 +96,46 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.87.0 h1:QK37j5ZUtBwbyZkF4BBAs3bQQ1gYKG8e+g1BdNZBr/M= +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.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/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/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= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 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/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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.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= @@ -113,58 +143,63 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h 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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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/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.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/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= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 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.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= -k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= -k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= -k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.30.3 h1:QZJndA9k2MjFqpnyYv/PH+9PE0SHhx3hBho4X0vE65g= -k8s.io/apiserver v0.30.3/go.mod h1:6Oa88y1CZqnzetd2JdepO0UXzQX4ZnOekx2/PtEjrOg= -k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= -k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +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/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/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-20240808142205-8e686545bdb8 h1:1Wof1cGQgA5pqgo8MxKPtf+qN6Sh/0JzznmeGPm1HnE= -k8s.io/kube-openapi v0.0.0-20240808142205-8e686545bdb8/go.mod h1:Os6V6dZwLNii3vxFpxcNaTmH8LJJBkOTg1N0tOA0fvA= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= +sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/managementrouter/alerts_get.go b/internal/managementrouter/alerts_get.go new file mode 100644 index 00000000..4d185705 --- /dev/null +++ b/internal/managementrouter/alerts_get.go @@ -0,0 +1,51 @@ +package managementrouter + +import ( + "encoding/json" + "net/http" + + "github.com/go-playground/form/v4" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +type GetAlertsQueryParams struct { + Labels map[string]string `form:"labels"` + State string `form:"state"` +} + +type GetAlertsResponse struct { + Data GetAlertsResponseData `json:"data"` + Status string `json:"status"` +} + +type GetAlertsResponseData struct { + Alerts []k8s.PrometheusAlert `json:"alerts"` +} + +func (hr *httpRouter) GetAlerts(w http.ResponseWriter, req *http.Request) { + var params GetAlertsQueryParams + + if err := form.NewDecoder().Decode(¶ms, req.URL.Query()); err != nil { + writeError(w, http.StatusBadRequest, "Invalid query parameters: "+err.Error()) + return + } + + alerts, err := hr.managementClient.GetAlerts(req.Context(), k8s.GetAlertsRequest{ + Labels: params.Labels, + State: params.State, + }) + if err != nil { + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(GetAlertsResponse{ + Data: GetAlertsResponseData{ + Alerts: alerts, + }, + Status: "success", + }) +} diff --git a/internal/managementrouter/alerts_get_test.go b/internal/managementrouter/alerts_get_test.go new file mode 100644 index 00000000..3c612c87 --- /dev/null +++ b/internal/managementrouter/alerts_get_test.go @@ -0,0 +1,129 @@ +package managementrouter_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "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("GetAlerts", func() { + var ( + mockK8s *testutils.MockClient + mockPrometheusAlerts *testutils.MockPrometheusAlertsInterface + mockManagement management.Client + router http.Handler + ) + + BeforeEach(func() { + By("setting up mock clients") + mockPrometheusAlerts = &testutils.MockPrometheusAlertsInterface{} + mockK8s = &testutils.MockClient{ + PrometheusAlertsFunc: func() k8s.PrometheusAlertsInterface { + return mockPrometheusAlerts + }, + } + + mockManagement = management.NewWithCustomMapper(context.Background(), mockK8s, &testutils.MockMapperClient{}) + router = managementrouter.New(mockManagement) + }) + + Context("when getting all alerts without filters", func() { + It("should return all active alerts", func() { + By("setting up test alerts") + testAlerts := []k8s.PrometheusAlert{ + { + Labels: map[string]string{ + "alertname": "HighCPUUsage", + "severity": "warning", + "namespace": "default", + }, + Annotations: map[string]string{ + "description": "CPU usage is high", + }, + State: "firing", + ActiveAt: time.Now(), + }, + { + Labels: map[string]string{ + "alertname": "LowMemory", + "severity": "critical", + "namespace": "monitoring", + }, + Annotations: map[string]string{ + "description": "Memory is running low", + }, + State: "firing", + ActiveAt: time.Now(), + }, + } + mockPrometheusAlerts.SetActiveAlerts(testAlerts) + + By("making the request") + req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/alerts", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + By("verifying the response") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response managementrouter.GetAlertsResponse + err := json.NewDecoder(w.Body).Decode(&response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Data.Alerts).To(HaveLen(2)) + Expect(response.Data.Alerts[0].Labels["alertname"]).To(Equal("HighCPUUsage")) + Expect(response.Data.Alerts[1].Labels["alertname"]).To(Equal("LowMemory")) + }) + + It("should return empty array when no alerts exist", func() { + By("setting up empty alerts") + mockPrometheusAlerts.SetActiveAlerts([]k8s.PrometheusAlert{}) + + By("making the request") + req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/alerts", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + By("verifying the response") + Expect(w.Code).To(Equal(http.StatusOK)) + + var response managementrouter.GetAlertsResponse + err := json.NewDecoder(w.Body).Decode(&response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Data.Alerts).To(BeEmpty()) + }) + }) + + Context("when handling errors", func() { + It("should return 500 when GetAlerts fails", func() { + By("configuring mock to return error") + mockPrometheusAlerts.GetAlertsFunc = func(ctx context.Context, req k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, error) { + return nil, fmt.Errorf("connection error") + } + + By("making the request") + req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/alerts", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + By("verifying error response") + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(w.Body.String()).To(ContainSubstring("An unexpected error occurred")) + }) + }) + +}) diff --git a/internal/managementrouter/health_get.go b/internal/managementrouter/health_get.go new file mode 100644 index 00000000..b010375e --- /dev/null +++ b/internal/managementrouter/health_get.go @@ -0,0 +1,16 @@ +package managementrouter + +import ( + "encoding/json" + "net/http" +) + +type GetHealthResponse struct { + Status string `json:"status"` +} + +func (hr *httpRouter) GetHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(GetHealthResponse{Status: "ok"}) +} diff --git a/internal/managementrouter/health_get_test.go b/internal/managementrouter/health_get_test.go new file mode 100644 index 00000000..80aa1c9b --- /dev/null +++ b/internal/managementrouter/health_get_test.go @@ -0,0 +1,48 @@ +package managementrouter_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/monitoring-plugin/internal/managementrouter" +) + +var _ = Describe("GetHealth", func() { + var router http.Handler + + BeforeEach(func() { + By("setting up the HTTP router") + router = managementrouter.New(nil) + }) + + Context("when calling the health endpoint", func() { + It("should return 200 OK status code", func() { + By("making the request") + req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/health", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + By("verifying the status code") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("should return correct JSON structure with status ok", func() { + By("making the request") + req := httptest.NewRequest(http.MethodGet, "/api/v1/alerting/health", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + By("verifying the response body") + var response managementrouter.GetHealthResponse + err := json.NewDecoder(w.Body).Decode(&response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Status).To(Equal("ok")) + }) + }) +}) diff --git a/internal/managementrouter/managementrouter_suite_test.go b/internal/managementrouter/managementrouter_suite_test.go new file mode 100644 index 00000000..3da1553b --- /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/router.go b/internal/managementrouter/router.go new file mode 100644 index 00000000..794fa5d1 --- /dev/null +++ b/internal/managementrouter/router.go @@ -0,0 +1,75 @@ +package managementrouter + +import ( + "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/health", httpRouter.GetHealth).Methods(http.MethodGet) + r.HandleFunc("/api/v1/alerting/alerts", httpRouter.GetAlerts).Methods(http.MethodGet) + r.HandleFunc("/api/v1/alerting/rules", httpRouter.BulkDeleteUserDefinedAlertRules).Methods(http.MethodDelete) + r.HandleFunc("/api/v1/alerting/rules/{ruleId}", httpRouter.DeleteUserDefinedAlertRuleById).Methods(http.MethodDelete) + + return r +} + +func writeError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(`{"error":"` + message + `"}`)) +} + +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 na *management.NotAllowedError + if errors.As(err, &na) { + return http.StatusMethodNotAllowed, err.Error() + } + log.Printf("An unexpected error occurred: %v", err) + return http.StatusInternalServerError, "An unexpected error occurred" +} + +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/internal/managementrouter/user_defined_alert_rule_bulk_delete.go b/internal/managementrouter/user_defined_alert_rule_bulk_delete.go new file mode 100644 index 00000000..eea8ee19 --- /dev/null +++ b/internal/managementrouter/user_defined_alert_rule_bulk_delete.go @@ -0,0 +1,60 @@ +package managementrouter + +import ( + "encoding/json" + "net/http" +) + +type BulkDeleteUserDefinedAlertRulesRequest struct { + RuleIds []string `json:"ruleIds"` +} + +type BulkDeleteUserDefinedAlertRulesResponse struct { + Rules []DeleteUserDefinedAlertRulesResponse `json:"rules"` +} + +func (hr *httpRouter) BulkDeleteUserDefinedAlertRules(w http.ResponseWriter, req *http.Request) { + var payload BulkDeleteUserDefinedAlertRulesRequest + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if len(payload.RuleIds) == 0 { + writeError(w, http.StatusBadRequest, "ruleIds is required") + return + } + + results := make([]DeleteUserDefinedAlertRulesResponse, 0, len(payload.RuleIds)) + + for _, rawId := range payload.RuleIds { + id, err := parseParam(rawId, "ruleId") + if err != nil { + results = append(results, DeleteUserDefinedAlertRulesResponse{ + Id: rawId, + StatusCode: http.StatusBadRequest, + Message: err.Error(), + }) + continue + } + + if err := hr.managementClient.DeleteUserDefinedAlertRuleById(req.Context(), id); err != nil { + status, message := parseError(err) + results = append(results, DeleteUserDefinedAlertRulesResponse{ + Id: id, + StatusCode: status, + Message: message, + }) + continue + } + results = append(results, DeleteUserDefinedAlertRulesResponse{ + Id: id, + StatusCode: http.StatusNoContent, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(BulkDeleteUserDefinedAlertRulesResponse{ + Rules: results, + }) +} diff --git a/internal/managementrouter/user_defined_alert_rule_bulk_delete_test.go b/internal/managementrouter/user_defined_alert_rule_bulk_delete_test.go new file mode 100644 index 00000000..15b6f7ac --- /dev/null +++ b/internal/managementrouter/user_defined_alert_rule_bulk_delete_test.go @@ -0,0 +1,245 @@ +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/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("BulkDeleteUserDefinedAlertRules", func() { + var ( + router http.Handler + mockK8sRules *testutils.MockPrometheusRuleInterface + mockK8s *testutils.MockClient + mockMapper *testutils.MockMapperClient + ) + + BeforeEach(func() { + mockK8sRules = &testutils.MockPrometheusRuleInterface{} + + userPR := monitoringv1.PrometheusRule{} + userPR.Name = "user-pr" + userPR.Namespace = "default" + userPR.Spec.Groups = []monitoringv1.RuleGroup{ + { + Name: "g1", + Rules: []monitoringv1.Rule{{Alert: "u1"}, {Alert: "u2"}}, + }, + } + + platformPR := monitoringv1.PrometheusRule{} + platformPR.Name = "platform-pr" + platformPR.Namespace = "openshift-monitoring" + platformPR.Spec.Groups = []monitoringv1.RuleGroup{ + { + Name: "pg1", + Rules: []monitoringv1.Rule{{Alert: "platform1"}}, + }, + } + + mockK8sRules.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "default/user-pr": &userPR, + "openshift-monitoring/platform-pr": &platformPR, + }) + + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockK8sRules + }, + } + + mockMapper = &testutils.MockMapperClient{ + GetAlertingRuleIdFunc: func(rule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(rule.Alert) + }, + FindAlertRuleByIdFunc: func(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + id := string(alertRuleId) + pr := mapper.PrometheusRuleId{ + Namespace: "default", + Name: "user-pr", + } + if id == "platform1" { + pr.Namespace = "openshift-monitoring" + pr.Name = "platform-pr" + } + return &pr, nil + }, + } + + mgmt := management.NewWithCustomMapper(context.Background(), mockK8s, mockMapper) + router = managementrouter.New(mgmt) + }) + + Context("when deleting multiple rules", func() { + It("returns deleted and failed for mixed ruleIds and updates rules", func() { + body := map[string]interface{}{"ruleIds": []string{"u1", "platform1", ""}} + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules", bytes.NewReader(buf)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp struct { + Rules []struct { + Id string `json:"id"` + StatusCode int `json:"status_code"` + Message string `json:"message"` + } `json:"rules"` + } + Expect(json.NewDecoder(w.Body).Decode(&resp)).To(Succeed()) + Expect(resp.Rules).To(HaveLen(3)) + // u1 -> success + Expect(resp.Rules[0].Id).To(Equal("u1")) + Expect(resp.Rules[0].StatusCode).To(Equal(http.StatusNoContent)) + Expect(resp.Rules[0].Message).To(BeEmpty()) + // platform1 -> not allowed + Expect(resp.Rules[1].Id).To(Equal("platform1")) + Expect(resp.Rules[1].StatusCode).To(Equal(http.StatusMethodNotAllowed)) + Expect(resp.Rules[1].Message).To(ContainSubstring("cannot delete alert rule from a platform-managed PrometheusRule")) + // "" -> bad request (missing id) + Expect(resp.Rules[2].Id).To(Equal("")) + Expect(resp.Rules[2].StatusCode).To(Equal(http.StatusBadRequest)) + Expect(resp.Rules[2].Message).To(ContainSubstring("missing ruleId")) + + prUser, _, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(err).NotTo(HaveOccurred()) + userRuleNames := []string{} + for _, g := range prUser.Spec.Groups { + for _, r := range g.Rules { + userRuleNames = append(userRuleNames, r.Alert) + } + } + Expect(userRuleNames).NotTo(ContainElement("u1")) + Expect(userRuleNames).To(ContainElement("u2")) + + prPlatform, _, err := mockK8sRules.Get(context.Background(), "openshift-monitoring", "platform-pr") + Expect(err).NotTo(HaveOccurred()) + foundPlatform := false + for _, g := range prPlatform.Spec.Groups { + for _, r := range g.Rules { + if r.Alert == "platform1" { + foundPlatform = true + } + } + } + Expect(foundPlatform).To(BeTrue()) + }) + + It("succeeds for user rule and fails for platform rule (mixed case)", func() { + body := map[string]interface{}{"ruleIds": []string{"u1", "platform1"}} + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules", bytes.NewReader(buf)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp struct { + Rules []struct { + Id string `json:"id"` + StatusCode int `json:"status_code"` + Message string `json:"message"` + } `json:"rules"` + } + Expect(json.NewDecoder(w.Body).Decode(&resp)).To(Succeed()) + Expect(resp.Rules).To(HaveLen(2)) + Expect(resp.Rules[0].Id).To(Equal("u1")) + Expect(resp.Rules[0].StatusCode).To(Equal(http.StatusNoContent)) + Expect(resp.Rules[1].Id).To(Equal("platform1")) + Expect(resp.Rules[1].StatusCode).To(Equal(http.StatusMethodNotAllowed)) + Expect(resp.Rules[1].Message).To(ContainSubstring("cannot delete alert rule from a platform-managed PrometheusRule")) + + // Ensure only user rule was removed + prUser, _, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(err).NotTo(HaveOccurred()) + userRuleNames := []string{} + for _, g := range prUser.Spec.Groups { + for _, r := range g.Rules { + userRuleNames = append(userRuleNames, r.Alert) + } + } + Expect(userRuleNames).NotTo(ContainElement("u1")) + Expect(userRuleNames).To(ContainElement("u2")) + + // Platform rule remains intact + prPlatform, _, err := mockK8sRules.Get(context.Background(), "openshift-monitoring", "platform-pr") + Expect(err).NotTo(HaveOccurred()) + foundPlatform := false + for _, g := range prPlatform.Spec.Groups { + for _, r := range g.Rules { + if r.Alert == "platform1" { + foundPlatform = true + } + } + } + Expect(foundPlatform).To(BeTrue()) + }) + + It("returns all deleted when all user ruleIds succeed", func() { + body := map[string]interface{}{"ruleIds": []string{"u1", "u2"}} + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules", bytes.NewReader(buf)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp struct { + Rules []struct { + Id string `json:"id"` + StatusCode int `json:"status_code"` + Message string `json:"message"` + } `json:"rules"` + } + Expect(json.NewDecoder(w.Body).Decode(&resp)).To(Succeed()) + Expect(resp.Rules).To(HaveLen(2)) + Expect(resp.Rules[0].Id).To(Equal("u1")) + Expect(resp.Rules[0].StatusCode).To(Equal(http.StatusNoContent)) + Expect(resp.Rules[1].Id).To(Equal("u2")) + Expect(resp.Rules[1].StatusCode).To(Equal(http.StatusNoContent)) + + // User PrometheusRule should be deleted after removing the last rule + _, found, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse()) + + // Platform PrometheusRule remains present + _, found, err = mockK8sRules.Get(context.Background(), "openshift-monitoring", "platform-pr") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + }) + }) + + Context("when request body is invalid", func() { + It("returns 400", func() { + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules", bytes.NewBufferString("{")) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("invalid request body")) + }) + }) + + Context("when ruleIds is empty", func() { + It("returns 400", func() { + body := map[string]interface{}{"ruleIds": []string{}} + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules", bytes.NewReader(buf)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("ruleIds is required")) + }) + }) +}) diff --git a/internal/managementrouter/user_defined_alert_rule_delete_by_id.go b/internal/managementrouter/user_defined_alert_rule_delete_by_id.go new file mode 100644 index 00000000..778f7f47 --- /dev/null +++ b/internal/managementrouter/user_defined_alert_rule_delete_by_id.go @@ -0,0 +1,26 @@ +package managementrouter + +import ( + "net/http" +) + +type DeleteUserDefinedAlertRulesResponse struct { + Id string `json:"id"` + StatusCode int `json:"status_code"` + Message string `json:"message,omitempty"` +} + +func (hr *httpRouter) DeleteUserDefinedAlertRuleById(w http.ResponseWriter, req *http.Request) { + ruleId, err := getParam(req, "ruleId") + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := hr.managementClient.DeleteUserDefinedAlertRuleById(req.Context(), ruleId); err != nil { + handleError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/managementrouter/user_defined_alert_rule_delete_by_id_test.go b/internal/managementrouter/user_defined_alert_rule_delete_by_id_test.go new file mode 100644 index 00000000..9b93bebf --- /dev/null +++ b/internal/managementrouter/user_defined_alert_rule_delete_by_id_test.go @@ -0,0 +1,173 @@ +package managementrouter_test + +import ( + "context" + "fmt" + "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/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("DeleteUserDefinedAlertRuleById", func() { + var ( + router http.Handler + mockK8sRules *testutils.MockPrometheusRuleInterface + mockK8s *testutils.MockClient + mockMapper *testutils.MockMapperClient + ) + + BeforeEach(func() { + mockK8sRules = &testutils.MockPrometheusRuleInterface{} + + userPR := monitoringv1.PrometheusRule{} + userPR.Name = "user-pr" + userPR.Namespace = "default" + userPR.Spec.Groups = []monitoringv1.RuleGroup{ + { + Name: "g1", + Rules: []monitoringv1.Rule{{Alert: "u1"}, {Alert: "u2"}}, + }, + } + + platformPR := monitoringv1.PrometheusRule{} + platformPR.Name = "platform-pr" + platformPR.Namespace = "openshift-monitoring" + platformPR.Spec.Groups = []monitoringv1.RuleGroup{ + { + Name: "pg1", + Rules: []monitoringv1.Rule{{Alert: "p1"}}, + }, + } + + mockK8sRules.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "default/user-pr": &userPR, + "openshift-monitoring/platform-pr": &platformPR, + }) + + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockK8sRules + }, + } + }) + + Context("when ruleId is missing or blank", func() { + It("returns 400 with missing ruleId message", func() { + mgmt := management.NewWithCustomMapper(context.Background(), mockK8s, mockMapper) + router = managementrouter.New(mgmt) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules/%20", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("missing ruleId")) + }) + }) + + Context("when deletion succeeds", func() { + It("deletes a user-defined rule and keeps the other intact", func() { + mockMapper = &testutils.MockMapperClient{ + GetAlertingRuleIdFunc: func(rule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(rule.Alert) + }, + FindAlertRuleByIdFunc: func(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + pr := mapper.PrometheusRuleId{ + Namespace: "default", + Name: "user-pr", + } + return &pr, nil + }, + } + + mgmt := management.NewWithCustomMapper(context.Background(), mockK8s, mockMapper) + router = managementrouter.New(mgmt) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules/u1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + pr, found, err := mockK8sRules.Get(context.Background(), "default", "user-pr") + Expect(found).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + ruleNames := []string{} + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + ruleNames = append(ruleNames, r.Alert) + } + } + Expect(ruleNames).NotTo(ContainElement("u1")) + Expect(ruleNames).To(ContainElement("u2")) + }) + }) + + Context("when rule is not found", func() { + It("returns 404 with expected message", func() { + mockMapper = &testutils.MockMapperClient{ + FindAlertRuleByIdFunc: func(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, fmt.Errorf("alert rule not found") + }, + } + mgmt := management.NewWithCustomMapper(context.Background(), mockK8s, mockMapper) + router = managementrouter.New(mgmt) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules/missing", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(ContainSubstring("AlertRule with id missing not found")) + }) + }) + + Context("when platform rule", func() { + It("rejects platform rule deletion and PR remains unchanged", func() { + mockMapper = &testutils.MockMapperClient{ + GetAlertingRuleIdFunc: func(rule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(rule.Alert) + }, + FindAlertRuleByIdFunc: func(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + pr := mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "platform-pr", + } + return &pr, nil + }, + } + + mgmt := management.NewWithCustomMapper(context.Background(), mockK8s, mockMapper) + router = managementrouter.New(mgmt) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/alerting/rules/p1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + Expect(w.Body.String()).To(ContainSubstring("cannot delete alert rule from a platform-managed PrometheusRule")) + + pr, found, err := mockK8sRules.Get(context.Background(), "openshift-monitoring", "platform-pr") + Expect(found).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + for _, g := range pr.Spec.Groups { + for _, r := range g.Rules { + if r.Alert == "p1" { + found = true + } + } + } + Expect(found).To(BeTrue()) + }) + }) +}) diff --git a/pkg/k8s/alert_relabel_config.go b/pkg/k8s/alert_relabel_config.go new file mode 100644 index 00000000..8ce3501e --- /dev/null +++ b/pkg/k8s/alert_relabel_config.go @@ -0,0 +1,70 @@ +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" +) + +type alertRelabelConfigManager struct { + clientset *osmv1client.Clientset +} + +func newAlertRelabelConfigManager(clientset *osmv1client.Clientset) AlertRelabelConfigInterface { + return &alertRelabelConfigManager{ + clientset: clientset, + } +} + +func (arcm *alertRelabelConfigManager) List(ctx context.Context, namespace string) ([]osmv1.AlertRelabelConfig, error) { + arcs, err := arcm.clientset.MonitoringV1().AlertRelabelConfigs(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + return arcs.Items, 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, fmt.Errorf("failed to get AlertRelabelConfig %s/%s: %w", namespace, name, 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/alert_relabel_config_informer.go b/pkg/k8s/alert_relabel_config_informer.go new file mode 100644 index 00000000..eccbd36d --- /dev/null +++ b/pkg/k8s/alert_relabel_config_informer.go @@ -0,0 +1,62 @@ +package k8s + +import ( + "context" + "log" + + osmv1 "github.com/openshift/api/monitoring/v1" + osmv1client "github.com/openshift/client-go/monitoring/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +type alertRelabelConfigInformer struct { + clientset *osmv1client.Clientset +} + +func newAlertRelabelConfigInformer(clientset *osmv1client.Clientset) AlertRelabelConfigInformerInterface { + return &alertRelabelConfigInformer{ + clientset: clientset, + } +} + +func (arci *alertRelabelConfigInformer) Run(ctx context.Context, callbacks AlertRelabelConfigInformerCallback) error { + options := metav1.ListOptions{ + Watch: true, + } + + watcher, err := arci.clientset.MonitoringV1().AlertRelabelConfigs("").Watch(ctx, options) + if err != nil { + return err + } + defer watcher.Stop() + + ch := watcher.ResultChan() + for event := range ch { + arc, ok := event.Object.(*osmv1.AlertRelabelConfig) + if !ok { + log.Printf("Unexpected type: %v", event.Object) + continue + } + + switch event.Type { + case watch.Added: + if callbacks.OnAdd != nil { + callbacks.OnAdd(arc) + } + case watch.Modified: + if callbacks.OnUpdate != nil { + callbacks.OnUpdate(arc) + } + case watch.Deleted: + if callbacks.OnDelete != nil { + callbacks.OnDelete(arc) + } + case watch.Error: + log.Printf("Error occurred while watching AlertRelabelConfig: %s\n", event.Object) + } + } + + log.Fatalf("AlertRelabelConfig watcher channel closed unexpectedly") + return nil +} diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go new file mode 100644 index 00000000..e016eb5f --- /dev/null +++ b/pkg/k8s/client.go @@ -0,0 +1,91 @@ +package k8s + +import ( + "context" + "fmt" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + osmv1client "github.com/openshift/client-go/monitoring/clientset/versioned" + monitoringv1client "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" +) + +var _ Client = (*client)(nil) + +type client struct { + clientset *kubernetes.Clientset + monitoringv1clientset *monitoringv1client.Clientset + osmv1clientset *osmv1client.Clientset + config *rest.Config + + prometheusAlerts PrometheusAlertsInterface + + prometheusRuleManager PrometheusRuleInterface + prometheusRuleInformer PrometheusRuleInformerInterface + + alertRelabelConfigManager AlertRelabelConfigInterface + alertRelabelConfigInformer AlertRelabelConfigInformerInterface +} + +func newClient(_ context.Context, config *rest.Config) (Client, error) { + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create clientset: %w", err) + } + + monitoringv1clientset, err := monitoringv1client.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create monitoringv1 clientset: %w", err) + } + + osmv1clientset, err := osmv1client.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create osmv1 clientset: %w", err) + } + + c := &client{ + clientset: clientset, + monitoringv1clientset: monitoringv1clientset, + osmv1clientset: osmv1clientset, + config: config, + } + + c.prometheusAlerts = newPrometheusAlerts(clientset, config) + + c.prometheusRuleManager = newPrometheusRuleManager(monitoringv1clientset) + c.prometheusRuleInformer = newPrometheusRuleInformer(monitoringv1clientset) + + c.alertRelabelConfigManager = newAlertRelabelConfigManager(osmv1clientset) + c.alertRelabelConfigInformer = newAlertRelabelConfigInformer(osmv1clientset) + + return c, nil +} + +func (c *client) TestConnection(_ context.Context) error { + _, err := c.clientset.Discovery().ServerVersion() + if err != nil { + return fmt.Errorf("failed to connect to cluster: %w", err) + } + return nil +} + +func (c *client) PrometheusAlerts() PrometheusAlertsInterface { + return c.prometheusAlerts +} + +func (c *client) PrometheusRules() PrometheusRuleInterface { + return c.prometheusRuleManager +} + +func (c *client) PrometheusRuleInformer() PrometheusRuleInformerInterface { + return c.prometheusRuleInformer +} + +func (c *client) AlertRelabelConfigs() AlertRelabelConfigInterface { + return c.alertRelabelConfigManager +} + +func (c *client) AlertRelabelConfigInformer() AlertRelabelConfigInformerInterface { + return c.alertRelabelConfigInformer +} diff --git a/pkg/k8s/new.go b/pkg/k8s/new.go new file mode 100644 index 00000000..5542d455 --- /dev/null +++ b/pkg/k8s/new.go @@ -0,0 +1,12 @@ +package k8s + +import ( + "context" + + "k8s.io/client-go/rest" +) + +// NewClient creates a new Kubernetes client with the given options +func NewClient(ctx context.Context, config *rest.Config) (Client, error) { + return newClient(ctx, config) +} diff --git a/pkg/k8s/prometheus_alerts.go b/pkg/k8s/prometheus_alerts.go new file mode 100644 index 00000000..e659c8a9 --- /dev/null +++ b/pkg/k8s/prometheus_alerts.go @@ -0,0 +1,257 @@ +package k8s + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + prometheusRouteNamespace = "openshift-monitoring" + prometheusRouteName = "prometheus-k8s" + prometheusAPIPath = "/v1/alerts" +) + +var ( + prometheusRoutePath = fmt.Sprintf("/apis/route.openshift.io/v1/namespaces/%s/routes/%s", prometheusRouteNamespace, prometheusRouteName) +) + +type prometheusAlerts struct { + clientset *kubernetes.Clientset + config *rest.Config +} + +// GetAlertsRequest holds parameters for filtering alerts +type GetAlertsRequest struct { + // Labels filters alerts by labels + Labels map[string]string + // State filters alerts by state: "firing", "pending", or "" for all states + State string +} + +type PrometheusAlert struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + State string `json:"state"` + ActiveAt time.Time `json:"activeAt"` + Value string `json:"value"` +} + +type prometheusAlertsResponse struct { + Status string `json:"status"` + Data struct { + Alerts []PrometheusAlert `json:"alerts"` + } `json:"data"` +} + +type prometheusRoute struct { + Spec struct { + Host string `json:"host"` + Path string `json:"path"` + } `json:"spec"` +} + +func newPrometheusAlerts(clientset *kubernetes.Clientset, config *rest.Config) PrometheusAlertsInterface { + return &prometheusAlerts{ + clientset: clientset, + config: config, + } +} + +func (pa prometheusAlerts) GetAlerts(ctx context.Context, req GetAlertsRequest) ([]PrometheusAlert, error) { + raw, err := pa.getAlertsViaProxy(ctx) + if err != nil { + return nil, err + } + + var alertsResp prometheusAlertsResponse + if err := json.Unmarshal(raw, &alertsResp); err != nil { + return nil, fmt.Errorf("decode prometheus response: %w", err) + } + + if alertsResp.Status != "success" { + return nil, fmt.Errorf("prometheus API returned non-success status: %s", alertsResp.Status) + } + + out := make([]PrometheusAlert, 0, len(alertsResp.Data.Alerts)) + for _, a := range alertsResp.Data.Alerts { + // Filter alerts based on state if provided + if req.State != "" && a.State != req.State { + continue + } + + // Filter alerts based on labels if provided + if !labelsMatch(&req, &a) { + continue + } + + out = append(out, a) + } + return out, nil +} + +func (pa prometheusAlerts) getAlertsViaProxy(ctx context.Context) ([]byte, error) { + url, err := pa.buildPrometheusURL(ctx) + if err != nil { + return nil, err + } + + client, err := pa.createHTTPClient() + if err != nil { + return nil, err + } + + return pa.executeRequest(ctx, client, url) +} + +func (pa prometheusAlerts) buildPrometheusURL(ctx context.Context) (string, error) { + route, err := pa.fetchPrometheusRoute(ctx) + if err != nil { + return "", err + } + + return fmt.Sprintf("https://%s%s%s", route.Spec.Host, route.Spec.Path, prometheusAPIPath), nil +} + +func (pa prometheusAlerts) fetchPrometheusRoute(ctx context.Context) (*prometheusRoute, error) { + routeData, err := pa.clientset.CoreV1().RESTClient(). + Get(). + AbsPath(prometheusRoutePath). + DoRaw(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get prometheus route: %w", err) + } + + var route prometheusRoute + if err := json.Unmarshal(routeData, &route); err != nil { + return nil, fmt.Errorf("failed to parse route: %w", err) + } + + return &route, nil +} + +func (pa prometheusAlerts) createHTTPClient() (*http.Client, error) { + tlsConfig, err := pa.buildTLSConfig() + if err != nil { + return nil, err + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} + +func (pa prometheusAlerts) buildTLSConfig() (*tls.Config, error) { + caCertPool, err := pa.loadCACertPool() + if err != nil { + return nil, err + } + + return &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: caCertPool, + }, nil +} + +func (pa prometheusAlerts) loadCACertPool() (*x509.CertPool, error) { + caCertPool, err := x509.SystemCertPool() + if err != nil { + caCertPool = x509.NewCertPool() + } + + if len(pa.config.CAData) > 0 { + caCertPool.AppendCertsFromPEM(pa.config.CAData) + return caCertPool, nil + } + + if pa.config.CAFile != "" { + caCert, err := os.ReadFile(pa.config.CAFile) + if err != nil { + return nil, fmt.Errorf("read CA cert file: %w", err) + } + caCertPool.AppendCertsFromPEM(caCert) + } + + return caCertPool, nil +} + +func (pa prometheusAlerts) executeRequest(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := pa.createAuthenticatedRequest(ctx, url) + if err != nil { + return nil, err + } + + return pa.performRequest(client, req) +} + +func (pa prometheusAlerts) createAuthenticatedRequest(ctx context.Context, url string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + token, err := pa.loadBearerToken() + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + return req, nil +} + +func (pa prometheusAlerts) loadBearerToken() (string, error) { + if pa.config.BearerToken != "" { + return pa.config.BearerToken, nil + } + + if pa.config.BearerTokenFile == "" { + return "", fmt.Errorf("no bearer token or token file configured") + } + + tokenBytes, err := os.ReadFile(pa.config.BearerTokenFile) + if err != nil { + return "", fmt.Errorf("load bearer token file: %w", err) + } + + return string(tokenBytes), nil +} + +func (pa prometheusAlerts) performRequest(client *http.Client, req *http.Request) ([]byte, error) { + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func labelsMatch(req *GetAlertsRequest, alert *PrometheusAlert) bool { + for key, value := range req.Labels { + if alertValue, exists := alert.Labels[key]; !exists || alertValue != value { + return false + } + } + + return true +} diff --git a/pkg/k8s/prometheus_rule.go b/pkg/k8s/prometheus_rule.go new file mode 100644 index 00000000..eb924613 --- /dev/null +++ b/pkg/k8s/prometheus_rule.go @@ -0,0 +1,127 @@ +package k8s + +import ( + "context" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + monitoringv1client "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type prometheusRuleManager struct { + clientset *monitoringv1client.Clientset +} + +func newPrometheusRuleManager(clientset *monitoringv1client.Clientset) PrometheusRuleInterface { + return &prometheusRuleManager{ + clientset: clientset, + } +} + +func (prm *prometheusRuleManager) List(ctx context.Context, namespace string) ([]monitoringv1.PrometheusRule, error) { + prs, err := prm.clientset.MonitoringV1().PrometheusRules(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + return prs.Items, nil +} + +func (prm *prometheusRuleManager) Get(ctx context.Context, namespace string, name string) (*monitoringv1.PrometheusRule, bool, error) { + pr, err := prm.clientset.MonitoringV1().PrometheusRules(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, false, nil + } + + return nil, false, fmt.Errorf("failed to get PrometheusRule %s/%s: %w", namespace, name, err) + } + + return pr, true, nil +} + +func (prm *prometheusRuleManager) Update(ctx context.Context, pr monitoringv1.PrometheusRule) error { + _, err := prm.clientset.MonitoringV1().PrometheusRules(pr.Namespace).Update(ctx, &pr, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update PrometheusRule %s/%s: %w", pr.Namespace, pr.Name, err) + } + + return nil +} + +func (prm *prometheusRuleManager) Delete(ctx context.Context, namespace string, name string) error { + err := prm.clientset.MonitoringV1().PrometheusRules(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("failed to delete PrometheusRule %s: %w", name, err) + } + + return nil +} + +func (prm *prometheusRuleManager) AddRule(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + pr, err := prm.getOrCreatePrometheusRule(ctx, namespacedName) + if err != nil { + return err + } + + // 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) + + _, err = prm.clientset.MonitoringV1().PrometheusRules(namespacedName.Namespace).Update(ctx, pr, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update PrometheusRule %s/%s: %w", namespacedName.Namespace, namespacedName.Name, err) + } + + return nil +} + +func (prm *prometheusRuleManager) getOrCreatePrometheusRule(ctx context.Context, namespacedName types.NamespacedName) (*monitoringv1.PrometheusRule, error) { + pr, err := prm.clientset.MonitoringV1().PrometheusRules(namespacedName.Namespace).Get(ctx, namespacedName.Name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return prm.createPrometheusRule(ctx, namespacedName) + } + + return nil, fmt.Errorf("failed to get PrometheusRule %s/%s: %w", namespacedName.Namespace, namespacedName.Name, err) + } + + return pr, nil +} + +func (prm *prometheusRuleManager) createPrometheusRule(ctx context.Context, namespacedName types.NamespacedName) (*monitoringv1.PrometheusRule, error) { + pr := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{}, + }, + } + + pr, err := prm.clientset.MonitoringV1().PrometheusRules(namespacedName.Namespace).Create(ctx, pr, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create PrometheusRule %s/%s: %w", namespacedName.Namespace, namespacedName.Name, err) + } + + return pr, nil +} diff --git a/pkg/k8s/prometheus_rule_informer.go b/pkg/k8s/prometheus_rule_informer.go new file mode 100644 index 00000000..c0e7a716 --- /dev/null +++ b/pkg/k8s/prometheus_rule_informer.go @@ -0,0 +1,62 @@ +package k8s + +import ( + "context" + "log" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + monitoringv1client "github.com/prometheus-operator/prometheus-operator/pkg/client/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +type prometheusRuleInformer struct { + clientset *monitoringv1client.Clientset +} + +func newPrometheusRuleInformer(clientset *monitoringv1client.Clientset) PrometheusRuleInformerInterface { + return &prometheusRuleInformer{ + clientset: clientset, + } +} + +func (pri *prometheusRuleInformer) Run(ctx context.Context, callbacks PrometheusRuleInformerCallback) error { + options := metav1.ListOptions{ + Watch: true, + } + + watcher, err := pri.clientset.MonitoringV1().PrometheusRules("").Watch(ctx, options) + if err != nil { + return err + } + defer watcher.Stop() + + ch := watcher.ResultChan() + for event := range ch { + pr, ok := event.Object.(*monitoringv1.PrometheusRule) + if !ok { + log.Printf("Unexpected type: %v", event.Object) + continue + } + + switch event.Type { + case watch.Added: + if callbacks.OnAdd != nil { + callbacks.OnAdd(pr) + } + case watch.Modified: + if callbacks.OnUpdate != nil { + callbacks.OnUpdate(pr) + } + case watch.Deleted: + if callbacks.OnDelete != nil { + callbacks.OnDelete(pr) + } + case watch.Error: + log.Printf("Error occurred while watching PrometheusRule: %s\n", event.Object) + } + } + + log.Fatalf("PrometheusRule watcher channel closed unexpectedly") + return nil +} diff --git a/pkg/k8s/types.go b/pkg/k8s/types.go new file mode 100644 index 00000000..c3579841 --- /dev/null +++ b/pkg/k8s/types.go @@ -0,0 +1,115 @@ +package k8s + +import ( + "context" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" +) + +// ClientOptions holds configuration options for creating a Kubernetes client +type ClientOptions struct { + // KubeconfigPath specifies the path to the kubeconfig file for remote connections + // If empty, will try default locations or in-cluster config + KubeconfigPath string +} + +// Client defines the contract for Kubernetes client operations +type Client interface { + // TestConnection tests the connection to the Kubernetes cluster + TestConnection(ctx context.Context) error + + // PrometheusAlerts retrieves active Prometheus alerts + PrometheusAlerts() PrometheusAlertsInterface + + // PrometheusRules returns the PrometheusRule interface + PrometheusRules() PrometheusRuleInterface + + // PrometheusRuleInformer returns the PrometheusRuleInformer interface + PrometheusRuleInformer() PrometheusRuleInformerInterface + + // AlertRelabelConfigs returns the AlertRelabelConfig interface + AlertRelabelConfigs() AlertRelabelConfigInterface + + // AlertRelabelConfigInformer returns the AlertRelabelConfigInformer interface + AlertRelabelConfigInformer() AlertRelabelConfigInformerInterface +} + +// PrometheusAlertsInterface defines operations for managing PrometheusAlerts +type PrometheusAlertsInterface interface { + // GetAlerts retrieves Prometheus alerts with optional state filtering + GetAlerts(ctx context.Context, req GetAlertsRequest) ([]PrometheusAlert, error) +} + +// PrometheusRuleInterface defines operations for managing PrometheusRules +type PrometheusRuleInterface interface { + // List lists all PrometheusRules in the cluster + List(ctx context.Context, namespace string) ([]monitoringv1.PrometheusRule, error) + + // Get retrieves a PrometheusRule by namespace and name + Get(ctx context.Context, namespace string, name string) (*monitoringv1.PrometheusRule, bool, error) + + // Update updates an existing PrometheusRule + Update(ctx context.Context, pr monitoringv1.PrometheusRule) error + + // Delete deletes a PrometheusRule by namespace and name + Delete(ctx context.Context, namespace string, name string) error + + // AddRule adds a new rule to the specified PrometheusRule + AddRule(ctx context.Context, namespacedName types.NamespacedName, groupName string, rule monitoringv1.Rule) error +} + +// PrometheusRuleInformerInterface defines operations for PrometheusRules informers +type PrometheusRuleInformerInterface interface { + // Run starts the informer and sets up the provided callbacks for add, update, and delete events + Run(ctx context.Context, callbacks PrometheusRuleInformerCallback) error +} + +// PrometheusRuleInformerCallback holds the callback functions for informer events +type PrometheusRuleInformerCallback struct { + // OnAdd is called when a new PrometheusRule is added + OnAdd func(pr *monitoringv1.PrometheusRule) + + // OnUpdate is called when an existing PrometheusRule is updated + OnUpdate func(pr *monitoringv1.PrometheusRule) + + // OnDelete is called when a PrometheusRule is deleted + OnDelete func(pr *monitoringv1.PrometheusRule) +} + +// 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 +} + +// AlertRelabelConfigInformerInterface defines operations for AlertRelabelConfig informers +type AlertRelabelConfigInformerInterface interface { + // Run starts the informer and sets up the provided callbacks for add, update, and delete events + Run(ctx context.Context, callbacks AlertRelabelConfigInformerCallback) error +} + +// AlertRelabelConfigInformerCallback holds the callback functions for informer events +type AlertRelabelConfigInformerCallback struct { + // OnAdd is called when a new AlertRelabelConfig is added + OnAdd func(arc *osmv1.AlertRelabelConfig) + + // OnUpdate is called when an existing AlertRelabelConfig is updated + OnUpdate func(arc *osmv1.AlertRelabelConfig) + + // OnDelete is called when an AlertRelabelConfig is deleted + OnDelete func(arc *osmv1.AlertRelabelConfig) +} 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 00000000..226b371f --- /dev/null +++ b/pkg/management/create_user_defined_alert_rule.go @@ -0,0 +1,46 @@ +package management + +import ( + "context" + "errors" + + 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 "", errors.New("PrometheusRule Name and Namespace must be specified") + } + + nn := types.NamespacedName{ + Name: prOptions.Name, + Namespace: prOptions.Namespace, + } + + if IsPlatformAlertRule(nn) { + return "", errors.New("cannot add user-defined alert rule to a platform-managed PrometheusRule") + } + + // Check if rule with the same ID already exists + ruleId := c.mapper.GetAlertingRuleId(&alertRule) + _, err := c.mapper.FindAlertRuleById(ruleId) + if err == nil { + return "", errors.New("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 string(c.mapper.GetAlertingRuleId(&alertRule)), nil +} 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 00000000..f45355e6 --- /dev/null +++ b/pkg/management/create_user_defined_alert_rule_test.go @@ -0,0 +1,310 @@ +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" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("CreateUserDefinedAlertRule", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + Context("when creating a user-defined alert rule", func() { + It("should successfully create with default group name", func() { + By("setting up test data") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + } + + prOptions := management.PrometheusRuleOptions{ + Name: "test-rule", + Namespace: "test-namespace", + } + + ruleId := "test-rule-id" + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(ruleId) + } + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("not found") + } + + addRuleCalled := false + var capturedGroupName string + mockPR.AddRuleFunc = func(ctx context.Context, nn types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + addRuleCalled = true + capturedGroupName = groupName + Expect(nn.Name).To(Equal("test-rule")) + Expect(nn.Namespace).To(Equal("test-namespace")) + Expect(rule.Alert).To(Equal("TestAlert")) + return nil + } + + By("creating the alert rule") + returnedId, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the result") + Expect(err).ToNot(HaveOccurred()) + Expect(returnedId).To(Equal(ruleId)) + Expect(addRuleCalled).To(BeTrue()) + Expect(capturedGroupName).To(Equal("user-defined-rules")) + }) + + It("should successfully create with custom group name", func() { + By("setting up test data") + alertRule := monitoringv1.Rule{ + Alert: "CustomGroupAlert", + Expr: intstr.FromString("memory_usage > 90"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "custom-rule", + Namespace: "custom-namespace", + GroupName: "custom-group", + } + + ruleId := "custom-rule-id" + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(ruleId) + } + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("not found") + } + + var capturedGroupName string + mockPR.AddRuleFunc = func(ctx context.Context, nn types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + capturedGroupName = groupName + return nil + } + + By("creating the alert rule") + returnedId, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the result") + Expect(err).ToNot(HaveOccurred()) + Expect(returnedId).To(Equal(ruleId)) + Expect(capturedGroupName).To(Equal("custom-group")) + }) + + It("should return error when namespace is missing", func() { + By("setting up test data with missing namespace") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "test-rule", + Namespace: "", + } + + By("attempting to create the alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PrometheusRule Name and Namespace must be specified")) + }) + + It("should return error when name is missing", func() { + By("setting up test data with missing name") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "", + Namespace: "test-namespace", + } + + By("attempting to create the alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PrometheusRule Name and Namespace must be specified")) + }) + + It("should return error when trying to add to platform-managed PrometheusRule", func() { + By("setting up test data with platform-managed PrometheusRule name") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + } + + By("attempting to create the alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot add user-defined alert rule to a platform-managed PrometheusRule")) + }) + + It("should return error when rule with same config already exists", func() { + By("setting up test data") + alertRule := monitoringv1.Rule{ + Alert: "DuplicateAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "test-rule", + Namespace: "test-namespace", + } + + ruleId := "duplicate-rule-id" + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(ruleId) + } + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + // Return success, indicating the rule already exists + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "test-rule", + }, nil + } + + By("attempting to create the duplicate alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule with exact config already exists")) + }) + + It("should return error when AddRule fails", func() { + By("setting up test data") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "test-rule", + Namespace: "test-namespace", + } + + ruleId := "test-rule-id" + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(ruleId) + } + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("not found") + } + + expectedError := errors.New("failed to add rule to kubernetes") + mockPR.AddRuleFunc = func(ctx context.Context, nn types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + return expectedError + } + + By("attempting to create the alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error is propagated") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(expectedError)) + }) + }) + + Context("when dealing with edge cases", func() { + It("should handle alert rule with no labels or annotations", func() { + By("setting up minimal alert rule") + alertRule := monitoringv1.Rule{ + Alert: "MinimalAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "minimal-rule", + Namespace: "test-namespace", + } + + ruleId := "minimal-rule-id" + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(ruleId) + } + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("not found") + } + + addRuleCalled := false + mockPR.AddRuleFunc = func(ctx context.Context, nn types.NamespacedName, groupName string, rule monitoringv1.Rule) error { + addRuleCalled = true + Expect(rule.Labels).To(BeNil()) + Expect(rule.Annotations).To(BeNil()) + return nil + } + + By("creating the minimal alert rule") + returnedId, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the result") + Expect(err).ToNot(HaveOccurred()) + Expect(returnedId).To(Equal(ruleId)) + Expect(addRuleCalled).To(BeTrue()) + }) + + It("should reject PrometheusRules in openshift- prefixed namespaces", func() { + By("setting up test data with openshift- namespace prefix") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prOptions := management.PrometheusRuleOptions{ + Name: "custom-rule", + Namespace: "openshift-user-namespace", + } + + By("attempting to create the alert rule") + _, err := client.CreateUserDefinedAlertRule(ctx, alertRule, prOptions) + + By("verifying the error") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot add user-defined alert rule to a platform-managed PrometheusRule")) + }) + }) +}) diff --git a/pkg/management/delete_user_defined_alert_rule_by_id.go b/pkg/management/delete_user_defined_alert_rule_by_id.go new file mode 100644 index 00000000..18ac94b0 --- /dev/null +++ b/pkg/management/delete_user_defined_alert_rule_by_id.go @@ -0,0 +1,85 @@ +package management + +import ( + "context" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +func (c *client) DeleteUserDefinedAlertRuleById(ctx context.Context, alertRuleId string) error { + prId, err := c.mapper.FindAlertRuleById(mapper.PrometheusAlertRuleId(alertRuleId)) + if err != nil { + return &NotFoundError{Resource: "AlertRule", Id: alertRuleId} + } + + if IsPlatformAlertRule(types.NamespacedName(*prId)) { + return &NotAllowedError{Message: "cannot delete alert rule from a platform-managed PrometheusRule"} + } + + pr, found, err := c.k8sClient.PrometheusRules().Get(ctx, prId.Namespace, prId.Name) + if err != nil { + return err + } + + if !found { + return &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", prId.Namespace, prId.Name)} + } + + updated := false + var newGroups []monitoringv1.RuleGroup + + for _, group := range pr.Spec.Groups { + newRules := c.filterRulesById(group.Rules, alertRuleId, &updated) + + // Only keep groups that still have rules + if len(newRules) > 0 { + group.Rules = newRules + newGroups = append(newGroups, group) + } else if len(newRules) != len(group.Rules) { + // Group became empty due to rule deletion + updated = true + } + } + + if updated { + if len(newGroups) == 0 { + // No groups left, delete the entire PrometheusRule + err = c.k8sClient.PrometheusRules().Delete(ctx, pr.Namespace, pr.Name) + if err != nil { + return fmt.Errorf("failed to delete PrometheusRule %s/%s: %w", pr.Namespace, pr.Name, err) + } + } else { + // Update PrometheusRule with remaining groups + pr.Spec.Groups = newGroups + err = c.k8sClient.PrometheusRules().Update(ctx, *pr) + if err != nil { + return fmt.Errorf("failed to update PrometheusRule %s/%s: %w", pr.Namespace, pr.Name, err) + } + } + return nil + } + + return &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", pr.Namespace, pr.Name)} +} + +func (c *client) filterRulesById(rules []monitoringv1.Rule, alertRuleId string, updated *bool) []monitoringv1.Rule { + var newRules []monitoringv1.Rule + + for _, rule := range rules { + if c.shouldDeleteRule(rule, alertRuleId) { + *updated = true + continue + } + newRules = append(newRules, rule) + } + + return newRules +} + +func (c *client) shouldDeleteRule(rule monitoringv1.Rule, alertRuleId string) bool { + return alertRuleId == string(c.mapper.GetAlertingRuleId(&rule)) +} diff --git a/pkg/management/delete_user_defined_alert_rule_by_id_test.go b/pkg/management/delete_user_defined_alert_rule_by_id_test.go new file mode 100644 index 00000000..879d8730 --- /dev/null +++ b/pkg/management/delete_user_defined_alert_rule_by_id_test.go @@ -0,0 +1,527 @@ +package management_test + +import ( + "context" + "errors" + "fmt" + + . "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/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("DeleteUserDefinedAlertRuleById", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + Context("when deleting a user-defined alert rule", func() { + It("should delete rule from multi-rule PrometheusRule and update", func() { + By("setting up PrometheusRule with 3 rules in 2 groups") + rule1 := monitoringv1.Rule{ + Alert: "Alert1", + Expr: intstr.FromString("up == 0"), + } + rule2 := monitoringv1.Rule{ + Alert: "Alert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + rule3 := monitoringv1.Rule{ + Alert: "Alert3", + Expr: intstr.FromString("memory_usage > 90"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-rule", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1, rule2}, + }, + { + Name: "group2", + Rules: []monitoringv1.Rule{rule3}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/multi-rule": prometheusRule, + }) + + alertRuleId := "alert2-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "multi-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "Alert2" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("deleting the middle rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + Expect(err).ToNot(HaveOccurred()) + + By("verifying PrometheusRule was updated, not deleted") + updatedPR, found, err := mockPR.Get(ctx, "test-namespace", "multi-rule") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(updatedPR.Spec.Groups).To(HaveLen(2)) + Expect(updatedPR.Spec.Groups[0].Rules).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[0].Rules[0].Alert).To(Equal("Alert1")) + Expect(updatedPR.Spec.Groups[1].Rules).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[1].Rules[0].Alert).To(Equal("Alert3")) + }) + + It("should delete entire PrometheusRule when deleting the last rule", func() { + By("setting up PrometheusRule with single rule") + rule := monitoringv1.Rule{ + Alert: "OnlyAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "single-rule", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/single-rule": prometheusRule, + }) + + alertRuleId := "only-alert-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "single-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + + deleteCalled := false + mockPR.DeleteFunc = func(ctx context.Context, namespace, name string) error { + deleteCalled = true + Expect(namespace).To(Equal("test-namespace")) + Expect(name).To(Equal("single-rule")) + return nil + } + + By("deleting the only rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + Expect(err).ToNot(HaveOccurred()) + + By("verifying PrometheusRule was deleted") + Expect(deleteCalled).To(BeTrue()) + }) + + It("should remove empty group when deleting its only rule", func() { + By("setting up PrometheusRule with 2 groups, one with single rule") + rule1 := monitoringv1.Rule{ + Alert: "Alert1", + Expr: intstr.FromString("up == 0"), + } + rule2 := monitoringv1.Rule{ + Alert: "Alert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + rule3 := monitoringv1.Rule{ + Alert: "SingleRuleInGroup", + Expr: intstr.FromString("memory_usage > 90"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-group", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1, rule2}, + }, + { + Name: "group2", + Rules: []monitoringv1.Rule{rule3}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/multi-group": prometheusRule, + }) + + alertRuleId := "single-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "multi-group", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "SingleRuleInGroup" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("deleting the single rule from group2") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + Expect(err).ToNot(HaveOccurred()) + + By("verifying group2 was removed and group1 remains") + updatedPR, found, err := mockPR.Get(ctx, "test-namespace", "multi-group") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedPR.Spec.Groups).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[0].Name).To(Equal("group1")) + Expect(updatedPR.Spec.Groups[0].Rules).To(HaveLen(2)) + }) + + It("should delete only the exact matching rule", func() { + By("setting up PrometheusRule with similar rules") + rule1 := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + } + rule2 := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "similar-rules", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1, rule2}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/similar-rules": prometheusRule, + }) + + targetRuleId := "target-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "similar-rules", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + // Only rule1 matches the target ID + if alertRule.Alert == "TestAlert" && alertRule.Labels["severity"] == "warning" { + return mapper.PrometheusAlertRuleId(targetRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("deleting the specific rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, targetRuleId) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the exact matching rule was deleted") + updatedPR, found, err := mockPR.Get(ctx, "test-namespace", "similar-rules") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedPR.Spec.Groups[0].Rules).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[0].Rules[0].Labels["severity"]).To(Equal("critical")) + }) + }) + + Context("when handling errors", func() { + It("should return error when rule not found in mapper", func() { + By("configuring mapper to return error") + alertRuleId := "nonexistent-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("alert rule not found") + } + + By("attempting to delete the rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + + By("verifying error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("AlertRule with id nonexistent-rule-id not found")) + }) + + It("should return error when trying to delete from platform-managed PrometheusRule", func() { + By("configuring mapper to return platform PrometheusRule") + alertRuleId := "platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-alerts", + }, nil + } + + By("attempting to delete the rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + + By("verifying error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot delete alert rule from a platform-managed PrometheusRule")) + }) + + It("should return error when PrometheusRule Get fails", func() { + By("configuring Get to return error") + alertRuleId := "test-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "test-rule", + }, nil + } + + mockPR.GetFunc = func(ctx context.Context, namespace, name string) (*monitoringv1.PrometheusRule, bool, error) { + return nil, false, errors.New("failed to get PrometheusRule") + } + + By("attempting to delete the rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + + By("verifying error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get PrometheusRule")) + }) + + It("should return error when PrometheusRule Update fails", func() { + By("setting up PrometheusRule with 2 rules") + rule1 := monitoringv1.Rule{ + Alert: "Alert1", + Expr: intstr.FromString("up == 0"), + } + rule2 := monitoringv1.Rule{ + Alert: "Alert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rule", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1, rule2}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/test-rule": prometheusRule, + }) + + alertRuleId := "alert2-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "test-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "Alert2" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + mockPR.UpdateFunc = func(ctx context.Context, pr monitoringv1.PrometheusRule) error { + return fmt.Errorf("kubernetes update error") + } + + By("attempting to delete the rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + + By("verifying error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to update PrometheusRule")) + Expect(err.Error()).To(ContainSubstring("kubernetes update error")) + }) + + It("should return error when PrometheusRule Delete fails", func() { + By("setting up PrometheusRule with single rule") + rule := monitoringv1.Rule{ + Alert: "OnlyAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "single-rule", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/single-rule": prometheusRule, + }) + + alertRuleId := "only-alert-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "single-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + + mockPR.DeleteFunc = func(ctx context.Context, namespace, name string) error { + return fmt.Errorf("kubernetes delete error") + } + + By("attempting to delete the rule") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + + By("verifying error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to delete PrometheusRule")) + Expect(err.Error()).To(ContainSubstring("kubernetes delete error")) + }) + }) + + Context("when handling edge cases", func() { + It("should handle PrometheusRule with multiple groups correctly", func() { + By("setting up PrometheusRule with 3 groups") + rule1 := monitoringv1.Rule{ + Alert: "Alert1", + Expr: intstr.FromString("up == 0"), + } + rule2 := monitoringv1.Rule{ + Alert: "Alert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + rule3 := monitoringv1.Rule{ + Alert: "Alert3", + Expr: intstr.FromString("memory_usage > 90"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-group", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1}, + }, + { + Name: "group2", + Rules: []monitoringv1.Rule{rule2}, + }, + { + Name: "group3", + Rules: []monitoringv1.Rule{rule3}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/multi-group": prometheusRule, + }) + + alertRuleId := "alert2-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "test-namespace", + Name: "multi-group", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "Alert2" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("deleting rule from middle group") + err := client.DeleteUserDefinedAlertRuleById(ctx, alertRuleId) + Expect(err).ToNot(HaveOccurred()) + + By("verifying middle group was removed") + updatedPR, found, err := mockPR.Get(ctx, "test-namespace", "multi-group") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedPR.Spec.Groups).To(HaveLen(2)) + Expect(updatedPR.Spec.Groups[0].Name).To(Equal("group1")) + Expect(updatedPR.Spec.Groups[1].Name).To(Equal("group3")) + }) + }) +}) diff --git a/pkg/management/errors.go b/pkg/management/errors.go new file mode 100644 index 00000000..a175acdc --- /dev/null +++ b/pkg/management/errors.go @@ -0,0 +1,20 @@ +package management + +import "fmt" + +type NotFoundError struct { + Resource string + Id string +} + +func (r *NotFoundError) Error() string { + return fmt.Sprintf("%s with id %s not found", r.Resource, r.Id) +} + +type NotAllowedError struct { + Message string +} + +func (r *NotAllowedError) Error() string { + return r.Message +} diff --git a/pkg/management/get_alerts.go b/pkg/management/get_alerts.go new file mode 100644 index 00000000..ec0c3976 --- /dev/null +++ b/pkg/management/get_alerts.go @@ -0,0 +1,53 @@ +package management + +import ( + "context" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +func (c *client) GetAlerts(ctx context.Context, req k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, error) { + alerts, err := c.k8sClient.PrometheusAlerts().GetAlerts(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get prometheus alerts: %w", err) + } + + var result []k8s.PrometheusAlert + for _, alert := range alerts { + // Apply relabel configurations to the alert + updatedAlert, err := c.updateAlertBasedOnRelabelConfig(&alert) + if err != nil { + // Alert was dropped by relabel config, skip it + continue + } + result = append(result, updatedAlert) + } + + return result, nil +} + +func (c *client) updateAlertBasedOnRelabelConfig(alert *k8s.PrometheusAlert) (k8s.PrometheusAlert, error) { + // Create a temporary rule to match relabel configs + rule := &monitoringv1.Rule{ + Alert: alert.Labels["alertname"], + Labels: alert.Labels, + } + + configs := c.mapper.GetAlertRelabelConfigSpec(rule) + + updatedLabels, err := applyRelabelConfigs(string(rule.Alert), alert.Labels, configs) + if err != nil { + return k8s.PrometheusAlert{}, err + } + + alert.Labels = updatedLabels + // Update severity if it was changed + if severity, exists := updatedLabels["severity"]; exists { + alert.Labels["severity"] = severity + } + + return *alert, nil +} diff --git a/pkg/management/get_alerts_test.go b/pkg/management/get_alerts_test.go new file mode 100644 index 00000000..428303b3 --- /dev/null +++ b/pkg/management/get_alerts_test.go @@ -0,0 +1,122 @@ +package management_test + +import ( + "context" + "errors" + "time" + + . "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" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("GetAlerts", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockAlerts *testutils.MockPrometheusAlertsInterface + mockMapper *testutils.MockMapperClient + client management.Client + testTime time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + testTime = time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + mockAlerts = &testutils.MockPrometheusAlertsInterface{} + mockK8s = &testutils.MockClient{ + PrometheusAlertsFunc: func() k8s.PrometheusAlertsInterface { + return mockAlerts + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + It("should return alerts unchanged when no relabel configs exist", func() { + mockAlerts.SetActiveAlerts([]k8s.PrometheusAlert{ + {Labels: map[string]string{"alertname": "HighCPU", "severity": "warning"}, State: "firing", ActiveAt: testTime}, + {Labels: map[string]string{"alertname": "HighMemory", "severity": "critical"}, State: "pending", ActiveAt: testTime}, + }) + mockMapper.GetAlertRelabelConfigSpecFunc = func(*monitoringv1.Rule) []osmv1.RelabelConfig { return nil } + + result, err := client.GetAlerts(ctx, k8s.GetAlertsRequest{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].Labels["alertname"]).To(Equal("HighCPU")) + Expect(result[1].Labels["alertname"]).To(Equal("HighMemory")) + }) + + It("should apply Replace relabel actions correctly", func() { + mockAlerts.SetActiveAlerts([]k8s.PrometheusAlert{ + { + Labels: map[string]string{"alertname": "TestAlert", "severity": "warning", "team": "platform"}, + State: "firing", + }, + }) + mockMapper.GetAlertRelabelConfigSpecFunc = func(rule *monitoringv1.Rule) []osmv1.RelabelConfig { + return []osmv1.RelabelConfig{ + {TargetLabel: "severity", Replacement: "critical", Action: "Replace"}, + {TargetLabel: "team", Replacement: "infrastructure", Action: "Replace"}, + {TargetLabel: "reviewed", Replacement: "true", Action: "Replace"}, + } + } + + result, err := client.GetAlerts(ctx, k8s.GetAlertsRequest{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Labels).To(HaveKeyWithValue("severity", "critical")) + Expect(result[0].Labels).To(HaveKeyWithValue("team", "infrastructure")) + Expect(result[0].Labels).To(HaveKeyWithValue("reviewed", "true")) + }) + + It("should filter out alerts with Drop action", func() { + mockAlerts.SetActiveAlerts([]k8s.PrometheusAlert{ + {Labels: map[string]string{"alertname": "KeepAlert", "severity": "warning"}, State: "firing", ActiveAt: testTime}, + {Labels: map[string]string{"alertname": "DropAlert", "severity": "info"}, State: "firing", ActiveAt: testTime}, + }) + mockMapper.GetAlertRelabelConfigSpecFunc = func(rule *monitoringv1.Rule) []osmv1.RelabelConfig { + if rule.Alert == "DropAlert" { + return []osmv1.RelabelConfig{{Action: "Drop"}} + } + return nil + } + + result, err := client.GetAlerts(ctx, k8s.GetAlertsRequest{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Labels["alertname"]).To(Equal("KeepAlert")) + }) + + It("should propagate errors and handle edge cases", func() { + By("propagating errors from PrometheusAlerts interface") + mockAlerts.GetAlertsFunc = func(context.Context, k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, error) { + return nil, errors.New("prometheus error") + } + _, err := client.GetAlerts(ctx, k8s.GetAlertsRequest{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("prometheus error")) + + By("handling nil labels with Replace action") + mockAlerts.GetAlertsFunc = nil + mockAlerts.SetActiveAlerts([]k8s.PrometheusAlert{ + {Labels: map[string]string{"alertname": "TestAlert", "severity": "warning"}, State: "firing", ActiveAt: testTime}, + }) + mockMapper.GetAlertRelabelConfigSpecFunc = func(*monitoringv1.Rule) []osmv1.RelabelConfig { + return []osmv1.RelabelConfig{{TargetLabel: "team", Replacement: "infra", Action: "Replace"}} + } + result, err := client.GetAlerts(ctx, k8s.GetAlertsRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].Labels).To(HaveKeyWithValue("team", "infra")) + }) +}) diff --git a/pkg/management/get_rule_by_id.go b/pkg/management/get_rule_by_id.go new file mode 100644 index 00000000..524aeaeb --- /dev/null +++ b/pkg/management/get_rule_by_id.go @@ -0,0 +1,56 @@ +package management + +import ( + "context" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +func (c *client) GetRuleById(ctx context.Context, alertRuleId string) (monitoringv1.Rule, error) { + prId, err := c.mapper.FindAlertRuleById(mapper.PrometheusAlertRuleId(alertRuleId)) + if err != nil { + return monitoringv1.Rule{}, err + } + + pr, found, err := c.k8sClient.PrometheusRules().Get(ctx, prId.Namespace, prId.Name) + if err != nil { + return monitoringv1.Rule{}, err + } + + if !found { + return monitoringv1.Rule{}, &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", prId.Namespace, prId.Name)} + } + + var rule *monitoringv1.Rule + + for groupIdx := range pr.Spec.Groups { + for ruleIdx := range pr.Spec.Groups[groupIdx].Rules { + foundRule := &pr.Spec.Groups[groupIdx].Rules[ruleIdx] + if c.mapper.GetAlertingRuleId(foundRule) == mapper.PrometheusAlertRuleId(alertRuleId) { + rule = foundRule + break + } + } + } + + if rule != nil { + return c.updateRuleBasedOnRelabelConfig(rule) + } + + return monitoringv1.Rule{}, fmt.Errorf("alert rule with id %s not found in PrometheusRule %s/%s", alertRuleId, prId.Namespace, prId.Name) +} + +func (c *client) updateRuleBasedOnRelabelConfig(rule *monitoringv1.Rule) (monitoringv1.Rule, error) { + configs := c.mapper.GetAlertRelabelConfigSpec(rule) + + updatedLabels, err := applyRelabelConfigs(string(rule.Alert), rule.Labels, configs) + if err != nil { + return monitoringv1.Rule{}, err + } + + rule.Labels = updatedLabels + return *rule, nil +} diff --git a/pkg/management/get_rule_by_id_test.go b/pkg/management/get_rule_by_id_test.go new file mode 100644 index 00000000..27e61d94 --- /dev/null +++ b/pkg/management/get_rule_by_id_test.go @@ -0,0 +1,186 @@ +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/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var ErrAlertRuleNotFound = errors.New("alert rule not found") + +var _ = Describe("GetRuleById", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + Context("when retrieving an alert rule by ID", func() { + It("should successfully return the rule when it exists", func() { + By("setting up a PrometheusRule with multiple rules") + rule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + rule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu > 80"), + Annotations: map[string]string{ + "summary": "High CPU usage", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rules", + Namespace: "monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1}, + }, + { + Name: "group2", + Rules: []monitoringv1.Rule{rule2}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "monitoring/test-rules": prometheusRule, + }) + + alertRuleId := "test-rule-id-2" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "monitoring", + Name: "test-rules", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "TestAlert2" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("retrieving the rule by ID") + rule, err := client.GetRuleById(ctx, alertRuleId) + Expect(err).ToNot(HaveOccurred()) + Expect(rule).ToNot(BeNil()) + + By("verifying the returned rule is correct") + Expect(rule.Alert).To(Equal("TestAlert2")) + Expect(rule.Expr.String()).To(Equal("cpu > 80")) + Expect(rule.Annotations).To(HaveKeyWithValue("summary", "High CPU usage")) + }) + + It("should return an error when the mapper cannot find the rule", func() { + alertRuleId := "nonexistent-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, ErrAlertRuleNotFound + } + + By("attempting to retrieve a nonexistent rule") + _, err := client.GetRuleById(ctx, alertRuleId) + + By("verifying an error is returned") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(ErrAlertRuleNotFound)) + }) + + It("should return an error when the PrometheusRule does not exist", func() { + alertRuleId := "test-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "monitoring", + Name: "nonexistent-rule", + }, nil + } + + By("attempting to retrieve a rule from a nonexistent PrometheusRule") + _, err := client.GetRuleById(ctx, alertRuleId) + + By("verifying an error is returned") + Expect(err).To(HaveOccurred()) + }) + + It("should return an error when the rule ID is not found in the PrometheusRule", func() { + By("setting up a PrometheusRule without the target rule") + rule1 := monitoringv1.Rule{ + Alert: "DifferentAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rules", + Namespace: "monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "monitoring/test-rules": prometheusRule, + }) + + alertRuleId := "nonexistent-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "monitoring", + Name: "test-rules", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId("different-id") + } + + By("attempting to retrieve the rule") + _, err := client.GetRuleById(ctx, alertRuleId) + + By("verifying an error is returned") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule with id")) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + }) +}) diff --git a/pkg/management/list_rules.go b/pkg/management/list_rules.go new file mode 100644 index 00000000..24d92a8c --- /dev/null +++ b/pkg/management/list_rules.go @@ -0,0 +1,133 @@ +package management + +import ( + "context" + "errors" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +const alertRuleIdLabel = "alert_rule_id" + +func (c *client) ListRules(ctx context.Context, prOptions PrometheusRuleOptions, arOptions AlertRuleOptions) ([]monitoringv1.Rule, error) { + if prOptions.Name != "" && prOptions.Namespace == "" { + return nil, errors.New("PrometheusRule Namespace must be specified when Name is provided") + } + + // Name and Namespace specified + if prOptions.Name != "" && prOptions.Namespace != "" { + pr, found, err := c.k8sClient.PrometheusRules().Get(ctx, prOptions.Namespace, prOptions.Name) + if err != nil { + return nil, fmt.Errorf("failed to get PrometheusRule %s/%s: %w", prOptions.Namespace, prOptions.Name, err) + } + if !found { + return nil, &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", prOptions.Namespace, prOptions.Name)} + } + return c.extractAndFilterRules(*pr, &prOptions, &arOptions), nil + } + + // Name not specified + allPrometheusRules, err := c.k8sClient.PrometheusRules().List(ctx, prOptions.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to list PrometheusRules: %w", err) + } + + var allRules []monitoringv1.Rule + for _, pr := range allPrometheusRules { + rules := c.extractAndFilterRules(pr, &prOptions, &arOptions) + allRules = append(allRules, rules...) + } + + return allRules, nil +} + +func (c *client) extractAndFilterRules(pr monitoringv1.PrometheusRule, prOptions *PrometheusRuleOptions, arOptions *AlertRuleOptions) []monitoringv1.Rule { + var rules []monitoringv1.Rule + + for _, group := range pr.Spec.Groups { + // Filter by group name if specified + if prOptions.GroupName != "" && group.Name != prOptions.GroupName { + continue + } + + for _, rule := range group.Rules { + // Skip recording rules (only process alert rules) + if rule.Alert == "" { + continue + } + + // Apply alert rule filters + if !c.matchesAlertRuleFilters(rule, pr, arOptions) { + continue + } + + // Parse and update the rule based on relabeling configurations + r := c.parseRule(rule) + if r != nil { + rules = append(rules, *r) + } + } + } + + return rules +} + +func (c *client) matchesAlertRuleFilters(rule monitoringv1.Rule, pr monitoringv1.PrometheusRule, arOptions *AlertRuleOptions) bool { + // Filter by alert name + if arOptions.Name != "" && string(rule.Alert) != arOptions.Name { + return false + } + + // Filter by source (platform or user-defined) + if arOptions.Source != "" { + prId := types.NamespacedName{Name: pr.Name, Namespace: pr.Namespace} + isPlatform := IsPlatformAlertRule(prId) + + if arOptions.Source == "platform" && !isPlatform { + return false + } + if arOptions.Source == "user-defined" && isPlatform { + return false + } + } + + // Filter by labels + if len(arOptions.Labels) > 0 { + for key, value := range arOptions.Labels { + ruleValue, exists := rule.Labels[key] + if !exists || ruleValue != value { + return false + } + } + } + + return true +} + +func (c *client) parseRule(rule monitoringv1.Rule) *monitoringv1.Rule { + alertRuleId := c.mapper.GetAlertingRuleId(&rule) + if alertRuleId == "" { + return nil + } + + _, err := c.mapper.FindAlertRuleById(mapper.PrometheusAlertRuleId(alertRuleId)) + if err != nil { + return nil + } + + rule, err = c.updateRuleBasedOnRelabelConfig(&rule) + if err != nil { + return nil + } + + if rule.Labels == nil { + rule.Labels = make(map[string]string) + } + rule.Labels[alertRuleIdLabel] = string(alertRuleId) + + return &rule +} diff --git a/pkg/management/list_rules_test.go b/pkg/management/list_rules_test.go new file mode 100644 index 00000000..3003801b --- /dev/null +++ b/pkg/management/list_rules_test.go @@ -0,0 +1,451 @@ +package management_test + +import ( + "context" + + . "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/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("ListRules", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + It("should list rules from a specific PrometheusRule", func() { + testRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rule", + Namespace: "test-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "test-group", + Rules: []monitoringv1.Rule{testRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "test-namespace/test-rule": prometheusRule, + }) + + options := management.PrometheusRuleOptions{ + Name: "test-rule", + Namespace: "test-namespace", + GroupName: "test-group", + } + + rules, err := client.ListRules(ctx, options, management.AlertRuleOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(1)) + Expect(rules[0].Alert).To(Equal("TestAlert")) + Expect(rules[0].Expr.String()).To(Equal("up == 0")) + }) + + It("should list rules from all namespaces", func() { + testRule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + } + + testRule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + + prometheusRule1 := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rule1", + Namespace: "namespace1", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{testRule1}, + }, + }, + }, + } + + prometheusRule2 := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rule2", + Namespace: "namespace2", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group2", + Rules: []monitoringv1.Rule{testRule2}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "namespace1/rule1": prometheusRule1, + "namespace2/rule2": prometheusRule2, + }) + + options := management.PrometheusRuleOptions{} + + rules, err := client.ListRules(ctx, options, management.AlertRuleOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(2)) + + alertNames := []string{rules[0].Alert, rules[1].Alert} + Expect(alertNames).To(ContainElement("TestAlert1")) + Expect(alertNames).To(ContainElement("TestAlert2")) + }) + + It("should list all rules from a specific namespace", func() { + // Setup test data in the same namespace but different PrometheusRules + testRule1 := monitoringv1.Rule{ + Alert: "NamespaceAlert1", + Expr: intstr.FromString("memory_usage > 90"), + } + + testRule2 := monitoringv1.Rule{ + Alert: "NamespaceAlert2", + Expr: intstr.FromString("disk_usage > 85"), + } + + testRule3 := monitoringv1.Rule{ + Alert: "OtherNamespaceAlert", + Expr: intstr.FromString("network_error_rate > 0.1"), + } + + // PrometheusRule in target namespace + prometheusRule1 := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rule1", + Namespace: "target-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{testRule1}, + }, + }, + }, + } + + // Another PrometheusRule in the same target namespace + prometheusRule2 := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rule2", + Namespace: "target-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group2", + Rules: []monitoringv1.Rule{testRule2}, + }, + }, + }, + } + + // PrometheusRule in a different namespace (should not be included) + prometheusRule3 := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rule3", + Namespace: "other-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group3", + Rules: []monitoringv1.Rule{testRule3}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "target-namespace/rule1": prometheusRule1, + "target-namespace/rule2": prometheusRule2, + "other-namespace/rule3": prometheusRule3, + }) + + options := management.PrometheusRuleOptions{ + Namespace: "target-namespace", + } + + rules, err := client.ListRules(ctx, options, management.AlertRuleOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(2)) + + alertNames := []string{rules[0].Alert, rules[1].Alert} + Expect(alertNames).To(ContainElement("NamespaceAlert1")) + Expect(alertNames).To(ContainElement("NamespaceAlert2")) + Expect(alertNames).ToNot(ContainElement("OtherNamespaceAlert")) + }) + + Context("AlertRuleOptions filtering", func() { + var prometheusRule *monitoringv1.PrometheusRule + + BeforeEach(func() { + prometheusRule = &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alerts", + Namespace: "monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "critical-alerts", + Rules: []monitoringv1.Rule{ + { + Alert: "HighCPUUsage", + Expr: intstr.FromString("cpu_usage > 90"), + Labels: map[string]string{ + "severity": "critical", + "component": "node", + }, + }, + { + Alert: "HighCPUUsage", + Expr: intstr.FromString("cpu_usage > 80"), + Labels: map[string]string{ + "severity": "warning", + "component": "node", + }, + }, + { + Alert: "DiskSpaceLow", + Expr: intstr.FromString("disk_usage > 95"), + Labels: map[string]string{ + "severity": "critical", + "component": "storage", + }, + }, + }, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "monitoring/test-alerts": prometheusRule, + }) + }) + + It("should filter by alert name", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-alerts", + Namespace: "monitoring", + } + arOptions := management.AlertRuleOptions{ + Name: "HighCPUUsage", + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(2)) + Expect(rules[0].Alert).To(Equal("HighCPUUsage")) + Expect(rules[1].Alert).To(Equal("HighCPUUsage")) + }) + + It("should filter by label severity", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-alerts", + Namespace: "monitoring", + } + arOptions := management.AlertRuleOptions{ + Labels: map[string]string{ + "severity": "critical", + }, + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(2)) + + alertNames := []string{rules[0].Alert, rules[1].Alert} + Expect(alertNames).To(ContainElement("HighCPUUsage")) + Expect(alertNames).To(ContainElement("DiskSpaceLow")) + + for _, rule := range rules { + Expect(rule.Labels["severity"]).To(Equal("critical")) + } + }) + + It("should filter by multiple labels", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-alerts", + Namespace: "monitoring", + } + arOptions := management.AlertRuleOptions{ + Labels: map[string]string{ + "severity": "critical", + "component": "storage", + }, + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(1)) + Expect(rules[0].Alert).To(Equal("DiskSpaceLow")) + Expect(rules[0].Labels["severity"]).To(Equal("critical")) + Expect(rules[0].Labels["component"]).To(Equal("storage")) + }) + + It("should filter by source platform", func() { + platformRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{ + { + Alert: "PlatformAlert", + Expr: intstr.FromString("platform_metric > 0"), + }, + }, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "monitoring/test-alerts": prometheusRule, + "openshift-monitoring/openshift-platform-alerts": platformRule, + }) + + prOptions := management.PrometheusRuleOptions{} + arOptions := management.AlertRuleOptions{ + Source: "platform", + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(1)) + Expect(rules[0].Alert).To(Equal("PlatformAlert")) + }) + + It("should filter by source user-defined", func() { + platformRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{ + { + Alert: "PlatformAlert", + Expr: intstr.FromString("platform_metric > 0"), + }, + }, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "monitoring/test-alerts": prometheusRule, + "openshift-monitoring/openshift-platform-alerts": platformRule, + }) + + prOptions := management.PrometheusRuleOptions{} + arOptions := management.AlertRuleOptions{ + Source: "user-defined", + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(3)) + + alertNames := []string{rules[0].Alert, rules[1].Alert, rules[2].Alert} + Expect(alertNames).To(ContainElement("HighCPUUsage")) + Expect(alertNames).To(ContainElement("DiskSpaceLow")) + Expect(alertNames).ToNot(ContainElement("PlatformAlert")) + }) + + It("should combine multiple filters", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-alerts", + Namespace: "monitoring", + } + arOptions := management.AlertRuleOptions{ + Name: "HighCPUUsage", + Labels: map[string]string{ + "severity": "critical", + }, + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(HaveLen(1)) + Expect(rules[0].Alert).To(Equal("HighCPUUsage")) + Expect(rules[0].Labels["severity"]).To(Equal("critical")) + }) + + It("should return empty list when no rules match filters", func() { + prOptions := management.PrometheusRuleOptions{ + Name: "test-alerts", + Namespace: "monitoring", + } + arOptions := management.AlertRuleOptions{ + Name: "NonExistentAlert", + } + + rules, err := client.ListRules(ctx, prOptions, arOptions) + + Expect(err).ToNot(HaveOccurred()) + Expect(rules).To(BeEmpty()) + }) + }) +}) diff --git a/pkg/management/management.go b/pkg/management/management.go new file mode 100644 index 00000000..7135755b --- /dev/null +++ b/pkg/management/management.go @@ -0,0 +1,19 @@ +package management + +import ( + "strings" + + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +type client struct { + k8sClient k8s.Client + mapper mapper.Client +} + +func IsPlatformAlertRule(prId types.NamespacedName) bool { + return strings.HasPrefix(prId.Namespace, "openshift-") +} diff --git a/pkg/management/management_suite_test.go b/pkg/management/management_suite_test.go new file mode 100644 index 00000000..6cf1a308 --- /dev/null +++ b/pkg/management/management_suite_test.go @@ -0,0 +1,13 @@ +package management_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestManagement(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Management Suite") +} diff --git a/pkg/management/mapper/mapper.go b/pkg/management/mapper/mapper.go new file mode 100644 index 00000000..4941270b --- /dev/null +++ b/pkg/management/mapper/mapper.go @@ -0,0 +1,286 @@ +package mapper + +import ( + "context" + "crypto/sha256" + "fmt" + "log" + "regexp" + "slices" + "sort" + "strings" + "sync" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +type mapper struct { + k8sClient k8s.Client + mu sync.RWMutex + + prometheusRules map[PrometheusRuleId][]PrometheusAlertRuleId + alertRelabelConfigs map[AlertRelabelConfigId][]osmv1.RelabelConfig +} + +var _ Client = (*mapper)(nil) + +func (m *mapper) GetAlertingRuleId(alertRule *monitoringv1.Rule) PrometheusAlertRuleId { + var kind, name string + if alertRule.Alert != "" { + kind = "alert" + name = alertRule.Alert + } else if alertRule.Record != "" { + kind = "record" + name = alertRule.Record + } else { + return "" + } + + expr := alertRule.Expr.String() + forDuration := "" + if alertRule.For != nil { + forDuration = string(*alertRule.For) + } + + var sortedLabels []string + if alertRule.Labels != nil { + for key, value := range alertRule.Labels { + sortedLabels = append(sortedLabels, fmt.Sprintf("%s=%s", key, value)) + } + sort.Strings(sortedLabels) + } + + var sortedAnnotations []string + if alertRule.Annotations != nil { + for key, value := range alertRule.Annotations { + sortedAnnotations = append(sortedAnnotations, fmt.Sprintf("%s=%s", key, value)) + } + sort.Strings(sortedAnnotations) + } + + // Build the hash input string + hashInput := strings.Join([]string{ + kind, + name, + expr, + forDuration, + strings.Join(sortedLabels, ","), + strings.Join(sortedAnnotations, ","), + }, "\n") + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(hashInput)) + + return PrometheusAlertRuleId(fmt.Sprintf("%s/%x", name, hash)) +} + +func (m *mapper) FindAlertRuleById(alertRuleId PrometheusAlertRuleId) (*PrometheusRuleId, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for id, rules := range m.prometheusRules { + if slices.Contains(rules, alertRuleId) { + return &id, nil + } + } + + // If the PrometheusRuleId is not found, return an error + return nil, fmt.Errorf("alert rule with id %s not found", alertRuleId) +} + +func (m *mapper) WatchPrometheusRules(ctx context.Context) { + go func() { + callbacks := k8s.PrometheusRuleInformerCallback{ + OnAdd: func(pr *monitoringv1.PrometheusRule) { + m.AddPrometheusRule(pr) + }, + OnUpdate: func(pr *monitoringv1.PrometheusRule) { + m.AddPrometheusRule(pr) + }, + OnDelete: func(pr *monitoringv1.PrometheusRule) { + m.DeletePrometheusRule(pr) + }, + } + + err := m.k8sClient.PrometheusRuleInformer().Run(ctx, callbacks) + if err != nil { + log.Fatalf("Failed to run PrometheusRule informer: %v", err) + } + }() +} + +func (m *mapper) AddPrometheusRule(pr *monitoringv1.PrometheusRule) { + m.mu.Lock() + defer m.mu.Unlock() + + promRuleId := PrometheusRuleId(types.NamespacedName{Namespace: pr.Namespace, Name: pr.Name}) + delete(m.prometheusRules, promRuleId) + + rules := make([]PrometheusAlertRuleId, 0) + for _, group := range pr.Spec.Groups { + for _, rule := range group.Rules { + if rule.Alert != "" { + ruleId := m.GetAlertingRuleId(&rule) + if ruleId != "" { + rules = append(rules, ruleId) + } + } + } + } + + m.prometheusRules[promRuleId] = rules +} + +func (m *mapper) DeletePrometheusRule(pr *monitoringv1.PrometheusRule) { + m.mu.Lock() + defer m.mu.Unlock() + + delete(m.prometheusRules, PrometheusRuleId(types.NamespacedName{Namespace: pr.Namespace, Name: pr.Name})) +} + +func (m *mapper) WatchAlertRelabelConfigs(ctx context.Context) { + go func() { + callbacks := k8s.AlertRelabelConfigInformerCallback{ + OnAdd: func(arc *osmv1.AlertRelabelConfig) { + m.AddAlertRelabelConfig(arc) + }, + OnUpdate: func(arc *osmv1.AlertRelabelConfig) { + m.AddAlertRelabelConfig(arc) + }, + OnDelete: func(arc *osmv1.AlertRelabelConfig) { + m.DeleteAlertRelabelConfig(arc) + }, + } + + err := m.k8sClient.AlertRelabelConfigInformer().Run(ctx, callbacks) + if err != nil { + log.Fatalf("Failed to run AlertRelabelConfig informer: %v", err) + } + }() +} + +func (m *mapper) AddAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) { + m.mu.Lock() + defer m.mu.Unlock() + + arcId := AlertRelabelConfigId(types.NamespacedName{Namespace: arc.Namespace, Name: arc.Name}) + + // Clean up old entries + delete(m.alertRelabelConfigs, arcId) + + configs := make([]osmv1.RelabelConfig, 0) + + for _, config := range arc.Spec.Configs { + if slices.Contains(config.SourceLabels, "alertname") { + alertname := parseAlertnameFromRelabelConfig(config) + if alertname != "" { + configs = append(configs, config) + } + } + } + + if len(configs) > 0 { + m.alertRelabelConfigs[arcId] = configs + } +} + +func parseAlertnameFromRelabelConfig(config osmv1.RelabelConfig) string { + separator := config.Separator + if separator == "" { + separator = ";" + } + + regex := config.Regex + if regex == "" { + return "" + } + + values := strings.Split(regex, separator) + if len(values) != len(config.SourceLabels) { + return "" + } + + // Find the alertname value from source labels + for i, labelName := range config.SourceLabels { + if string(labelName) == "alertname" { + return values[i] + } + } + + return "" +} + +func (m *mapper) DeleteAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) { + m.mu.Lock() + defer m.mu.Unlock() + + arcId := AlertRelabelConfigId(types.NamespacedName{Namespace: arc.Namespace, Name: arc.Name}) + delete(m.alertRelabelConfigs, arcId) +} + +func (m *mapper) GetAlertRelabelConfigSpec(alertRule *monitoringv1.Rule) []osmv1.RelabelConfig { + m.mu.RLock() + defer m.mu.RUnlock() + + if alertRule == nil { + return nil + } + + var matchingConfigs []osmv1.RelabelConfig + + // Iterate through all AlertRelabelConfigs + for _, configs := range m.alertRelabelConfigs { + for _, config := range configs { + if m.configMatchesAlert(config, alertRule) { + matchingConfigs = append(matchingConfigs, config) + } + } + } + + return matchingConfigs +} + +// configMatchesAlert checks if a RelabelConfig matches the given alert rule's labels +func (m *mapper) configMatchesAlert(config osmv1.RelabelConfig, alertRule *monitoringv1.Rule) bool { + separator := config.Separator + if separator == "" { + separator = ";" + } + + var labelValues []string + for _, labelName := range config.SourceLabels { + labelValue := "" + + if string(labelName) == "alertname" { + if alertRule.Alert != "" { + labelValue = alertRule.Alert + } + } else { + if alertRule.Labels != nil { + if val, exists := alertRule.Labels[string(labelName)]; exists { + labelValue = val + } + } + } + + labelValues = append(labelValues, labelValue) + } + + ruleLabels := strings.Join(labelValues, separator) + + regex := config.Regex + if regex == "" { + regex = "(.*)" + } + + matched, err := regexp.MatchString(regex, ruleLabels) + if err != nil { + return false + } + + return matched +} diff --git a/pkg/management/mapper/mapper_suite_test.go b/pkg/management/mapper/mapper_suite_test.go new file mode 100644 index 00000000..ad8ae2bb --- /dev/null +++ b/pkg/management/mapper/mapper_suite_test.go @@ -0,0 +1,13 @@ +package mapper_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMapper(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Mapper Suite") +} diff --git a/pkg/management/mapper/mapper_test.go b/pkg/management/mapper/mapper_test.go new file mode 100644 index 00000000..fff7158c --- /dev/null +++ b/pkg/management/mapper/mapper_test.go @@ -0,0 +1,855 @@ +package mapper_test + +import ( + . "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/types" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("Mapper", func() { + var ( + mockK8sClient *testutils.MockClient + mapperClient mapper.Client + ) + + BeforeEach(func() { + mockK8sClient = &testutils.MockClient{} + mapperClient = mapper.New(mockK8sClient) + }) + + createPrometheusRule := func(namespace, name string, alertRules []monitoringv1.Rule) *monitoringv1.PrometheusRule { + return &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "test-group", + Rules: alertRules, + }, + }, + }, + } + } + + Describe("GetAlertingRuleId", func() { + Context("when generating IDs for alert rules", func() { + It("should generate a non-empty ID for a simple alert rule", func() { + By("creating a simple alert rule") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + By("generating the rule ID") + ruleId := mapperClient.GetAlertingRuleId(&alertRule) + + By("verifying the result") + Expect(ruleId).NotTo(BeEmpty()) + Expect(string(ruleId)).To(HaveLen(len(alertRule.Alert) + 1 + 64)) // alertname + separator + SHA256 hash should be 64 characters + }) + + It("should generate different IDs for different alert rules", func() { + By("creating two different alert rules") + alertRule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + } + alertRule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu > 80"), + } + + By("generating rule IDs") + ruleId1 := mapperClient.GetAlertingRuleId(&alertRule1) + ruleId2 := mapperClient.GetAlertingRuleId(&alertRule2) + + By("verifying the results") + Expect(ruleId1).NotTo(BeEmpty()) + Expect(ruleId2).NotTo(BeEmpty()) + Expect(ruleId1).NotTo(Equal(ruleId2)) + }) + + It("should generate the same ID for identical alert rules", func() { + By("creating two identical alert rules") + alertRule1 := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + alertRule2 := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + By("generating rule IDs") + ruleId1 := mapperClient.GetAlertingRuleId(&alertRule1) + ruleId2 := mapperClient.GetAlertingRuleId(&alertRule2) + + By("verifying the results") + Expect(ruleId1).NotTo(BeEmpty()) + Expect(ruleId2).NotTo(BeEmpty()) + Expect(ruleId1).To(Equal(ruleId2)) + }) + + It("should return empty string for rules without alert or record name", func() { + By("creating a rule without alert or record name") + alertRule := monitoringv1.Rule{ + Expr: intstr.FromString("up == 0"), + } + + By("generating the rule ID") + ruleId := mapperClient.GetAlertingRuleId(&alertRule) + + By("verifying the result") + Expect(ruleId).To(BeEmpty()) + }) + }) + }) + + Describe("FindAlertRuleById", func() { + Context("when the alert rule exists", func() { + It("should return the correct PrometheusRuleId", func() { + By("creating test alert rule") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + + By("creating PrometheusRule") + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule}) + + By("adding the PrometheusRule to the mapper") + mapperClient.AddPrometheusRule(pr) + + By("getting the generated rule ID") + ruleId := mapperClient.GetAlertingRuleId(&alertRule) + Expect(ruleId).NotTo(BeEmpty()) + + By("testing FindAlertRuleById") + foundPrometheusRuleId, err := mapperClient.FindAlertRuleById(ruleId) + + By("verifying results") + Expect(err).NotTo(HaveOccurred()) + expectedPrometheusRuleId := mapper.PrometheusRuleId(types.NamespacedName{ + Namespace: "test-namespace", + Name: "test-rule", + }) + Expect(*foundPrometheusRuleId).To(Equal(expectedPrometheusRuleId)) + }) + + It("should return the correct PrometheusRuleId when alert rule is one of multiple in the same PrometheusRule", func() { + By("creating multiple test alert rules") + alertRule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + } + alertRule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu > 80"), + } + + By("creating PrometheusRule with multiple rules") + pr := createPrometheusRule("multi-namespace", "multi-rule", []monitoringv1.Rule{alertRule1, alertRule2}) + + By("adding the PrometheusRule to the mapper") + mapperClient.AddPrometheusRule(pr) + + By("getting the generated rule IDs") + ruleId1 := mapperClient.GetAlertingRuleId(&alertRule1) + ruleId2 := mapperClient.GetAlertingRuleId(&alertRule2) + Expect(ruleId1).NotTo(BeEmpty()) + Expect(ruleId2).NotTo(BeEmpty()) + Expect(ruleId1).NotTo(Equal(ruleId2)) + + By("testing FindAlertRuleById for both rules") + expectedPrometheusRuleId := mapper.PrometheusRuleId(types.NamespacedName{ + Namespace: "multi-namespace", + Name: "multi-rule", + }) + + foundPrometheusRuleId1, err1 := mapperClient.FindAlertRuleById(ruleId1) + Expect(err1).NotTo(HaveOccurred()) + Expect(*foundPrometheusRuleId1).To(Equal(expectedPrometheusRuleId)) + + foundPrometheusRuleId2, err2 := mapperClient.FindAlertRuleById(ruleId2) + Expect(err2).NotTo(HaveOccurred()) + Expect(*foundPrometheusRuleId2).To(Equal(expectedPrometheusRuleId)) + }) + }) + + Context("when the alert rule does not exist", func() { + It("should return an error when no rules are mapped", func() { + By("setting up test data") + nonExistentRuleId := mapper.PrometheusAlertRuleId("non-existent-rule-id") + + By("testing the method") + _, err := mapperClient.FindAlertRuleById(nonExistentRuleId) + + By("verifying results") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule with id non-existent-rule-id not found")) + }) + + It("should return an error when rules are mapped but the target rule is not found", func() { + By("creating and adding a valid alert rule") + alertRule := monitoringv1.Rule{ + Alert: "ValidAlert", + Expr: intstr.FromString("up == 0"), + } + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule}) + mapperClient.AddPrometheusRule(pr) + + By("trying to find a non-existent rule ID") + nonExistentRuleId := mapper.PrometheusAlertRuleId("definitely-non-existent-rule-id") + + By("testing the method") + _, err := mapperClient.FindAlertRuleById(nonExistentRuleId) + + By("verifying results") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule with id definitely-non-existent-rule-id not found")) + }) + }) + }) + + Describe("AddPrometheusRule", func() { + Context("when adding PrometheusRules", func() { + It("should successfully add a PrometheusRule with alert rules", func() { + By("creating a PrometheusRule with alert rules") + alertRule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + } + alertRule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu > 80"), + } + + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule1, alertRule2}) + + By("adding the PrometheusRule") + mapperClient.AddPrometheusRule(pr) + + By("verifying the rules can be found") + ruleId1 := mapperClient.GetAlertingRuleId(&alertRule1) + foundPr1, err1 := mapperClient.FindAlertRuleById(ruleId1) + Expect(err1).ToNot(HaveOccurred()) + Expect(foundPr1.Namespace).To(Equal("test-namespace")) + Expect(foundPr1.Name).To(Equal("test-rule")) + + ruleId2 := mapperClient.GetAlertingRuleId(&alertRule2) + foundPr2, err2 := mapperClient.FindAlertRuleById(ruleId2) + Expect(err2).ToNot(HaveOccurred()) + Expect(foundPr2.Namespace).To(Equal("test-namespace")) + Expect(foundPr2.Name).To(Equal("test-rule")) + }) + + It("should update existing PrometheusRule when added again", func() { + By("creating and adding initial PrometheusRule") + alertRule1 := monitoringv1.Rule{ + Alert: "TestAlert1", + Expr: intstr.FromString("up == 0"), + } + pr1 := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule1}) + mapperClient.AddPrometheusRule(pr1) + + By("creating updated PrometheusRule with different alerts") + alertRule2 := monitoringv1.Rule{ + Alert: "TestAlert2", + Expr: intstr.FromString("cpu > 80"), + } + pr2 := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule2}) + mapperClient.AddPrometheusRule(pr2) + + By("verifying old rule is no longer found") + ruleId1 := mapperClient.GetAlertingRuleId(&alertRule1) + _, err1 := mapperClient.FindAlertRuleById(ruleId1) + Expect(err1).To(HaveOccurred()) + + By("verifying new rule is found") + ruleId2 := mapperClient.GetAlertingRuleId(&alertRule2) + foundPr, err2 := mapperClient.FindAlertRuleById(ruleId2) + Expect(err2).ToNot(HaveOccurred()) + Expect(foundPr.Namespace).To(Equal("test-namespace")) + }) + + It("should ignore recording rules (not alert rules)", func() { + By("creating a PrometheusRule with recording rule") + recordingRule := monitoringv1.Rule{ + Record: "test:recording:rule", + Expr: intstr.FromString("sum(up)"), + } + + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{recordingRule}) + + By("adding the PrometheusRule") + mapperClient.AddPrometheusRule(pr) + + By("verifying the recording rule is not found") + ruleId := mapperClient.GetAlertingRuleId(&recordingRule) + _, err := mapperClient.FindAlertRuleById(ruleId) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("DeletePrometheusRule", func() { + Context("when deleting PrometheusRules", func() { + It("should successfully delete a PrometheusRule", func() { + By("creating and adding a PrometheusRule") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule}) + mapperClient.AddPrometheusRule(pr) + + By("verifying the rule exists") + ruleId := mapperClient.GetAlertingRuleId(&alertRule) + _, err := mapperClient.FindAlertRuleById(ruleId) + Expect(err).ToNot(HaveOccurred()) + + By("deleting the PrometheusRule") + mapperClient.DeletePrometheusRule(pr) + + By("verifying the rule is no longer found") + _, err = mapperClient.FindAlertRuleById(ruleId) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should handle deleting non-existent PrometheusRule gracefully", func() { + By("creating a PrometheusRule that was never added") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + } + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule}) + + By("deleting the non-existent PrometheusRule") + Expect(func() { + mapperClient.DeletePrometheusRule(pr) + }).NotTo(Panic()) + + By("verifying mapper still works after delete attempt") + // Add a different rule to verify the mapper is still functional + alertRule2 := monitoringv1.Rule{ + Alert: "AnotherAlert", + Expr: intstr.FromString("cpu > 80"), + } + pr2 := createPrometheusRule("test-namespace", "another-rule", []monitoringv1.Rule{alertRule2}) + mapperClient.AddPrometheusRule(pr2) + + ruleId := mapperClient.GetAlertingRuleId(&alertRule2) + foundPr, err := mapperClient.FindAlertRuleById(ruleId) + Expect(err).ToNot(HaveOccurred()) + Expect(foundPr.Name).To(Equal("another-rule")) + }) + }) + }) + + Describe("AddAlertRelabelConfig", func() { + Context("when adding AlertRelabelConfigs", func() { + It("should successfully add an AlertRelabelConfig", func() { + By("creating an AlertRelabelConfig") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname", "severity"}, + Separator: ";", + Regex: "TestAlert;critical", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + + By("adding the AlertRelabelConfig") + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying it can be retrieved") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + Labels: map[string]string{ + "severity": "critical", + }, + } + configs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(configs).To(HaveLen(1)) + Expect(configs[0].SourceLabels).To(ContainElement(osmv1.LabelName("alertname"))) + Expect(configs[0].Regex).To(Equal("TestAlert;critical")) + }) + + It("should ignore configs without alertname in SourceLabels", func() { + By("creating an AlertRelabelConfig without alertname") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"severity", "namespace"}, + Separator: ";", + Regex: "critical;default", + TargetLabel: "priority", + Replacement: "high", + Action: "Replace", + }, + }, + }, + } + + By("adding the AlertRelabelConfig") + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying it returns empty for an alert") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + Labels: map[string]string{ + "severity": "critical", + "namespace": "default", + }, + } + specs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(specs).To(BeEmpty()) + }) + + It("should update existing AlertRelabelConfig when added again", func() { + By("creating and adding initial AlertRelabelConfig") + arc1 := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "Alert1", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc1) + + By("creating updated AlertRelabelConfig") + arc2 := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "Alert2", + TargetLabel: "severity", + Replacement: "critical", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc2) + + By("verifying the updated config is retrieved") + alertRule := &monitoringv1.Rule{ + Alert: "Alert2", + } + configs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(configs).To(HaveLen(1)) + Expect(configs[0].Regex).To(Equal("Alert2")) + }) + + It("should handle multiple relabel configs in single AlertRelabelConfig", func() { + By("creating AlertRelabelConfig with multiple configs") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "Alert1", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "Alert2", + TargetLabel: "priority", + Replacement: "high", + Action: "Replace", + }, + }, + }, + } + + By("adding the AlertRelabelConfig") + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying Alert1 gets its matching config") + alertRule1 := &monitoringv1.Rule{ + Alert: "Alert1", + } + specs1 := mapperClient.GetAlertRelabelConfigSpec(alertRule1) + Expect(specs1).To(HaveLen(1)) + Expect(specs1[0].TargetLabel).To(Equal("severity")) + + By("verifying Alert2 gets its matching config") + alertRule2 := &monitoringv1.Rule{ + Alert: "Alert2", + } + specs2 := mapperClient.GetAlertRelabelConfigSpec(alertRule2) + Expect(specs2).To(HaveLen(1)) + Expect(specs2[0].TargetLabel).To(Equal("priority")) + }) + + It("should handle configs with empty regex", func() { + By("creating AlertRelabelConfig with empty regex") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + + By("adding the AlertRelabelConfig") + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying it's ignored (empty regex)") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + } + specs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(specs).To(BeEmpty()) + }) + + It("should handle configs where regex values don't match source labels count", func() { + By("creating AlertRelabelConfig with mismatched regex/labels") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname", "severity"}, + Separator: ";", + Regex: "OnlyOneValue", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + + By("adding the AlertRelabelConfig") + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying it's ignored (mismatch)") + alertRule := &monitoringv1.Rule{ + Alert: "OnlyOneValue", + Labels: map[string]string{ + "severity": "critical", + }, + } + specs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(specs).To(BeEmpty()) + }) + }) + }) + + Describe("DeleteAlertRelabelConfig", func() { + Context("when deleting AlertRelabelConfigs", func() { + It("should successfully delete an AlertRelabelConfig", func() { + By("creating and adding an AlertRelabelConfig") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "TestAlert", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc) + + By("verifying it exists") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + } + specs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(specs).To(HaveLen(1)) + + By("deleting the AlertRelabelConfig") + mapperClient.DeleteAlertRelabelConfig(arc) + + By("verifying it's no longer found") + specs = mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(specs).To(BeEmpty()) + }) + + It("should handle deleting non-existent AlertRelabelConfig gracefully", func() { + By("creating an AlertRelabelConfig that was never added") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{}, + }, + } + + By("deleting the non-existent AlertRelabelConfig") + Expect(func() { + mapperClient.DeleteAlertRelabelConfig(arc) + }).NotTo(Panic()) + + By("verifying mapper still works after delete attempt") + // Add a different AlertRelabelConfig to verify the mapper is still functional + arc2 := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "another-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "TestAlert", + TargetLabel: "severity", + Replacement: "critical", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc2) + + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + } + configs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + Expect(configs).To(HaveLen(1)) + Expect(configs[0].Regex).To(Equal("TestAlert")) + }) + }) + }) + + Describe("GetAlertRelabelConfigSpec", func() { + Context("when retrieving AlertRelabelConfig specs", func() { + It("should return specs for existing AlertRelabelConfig", func() { + By("creating and adding an AlertRelabelConfig") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname", "severity"}, + Separator: ";", + Regex: "TestAlert;critical", + TargetLabel: "priority", + Replacement: "high", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc) + + By("retrieving the configs") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + Labels: map[string]string{ + "severity": "critical", + }, + } + configs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + + By("verifying the configs") + Expect(configs).To(HaveLen(1)) + Expect(configs[0].TargetLabel).To(Equal("priority")) + Expect(configs[0].Replacement).To(Equal("high")) + Expect(configs[0].SourceLabels).To(ContainElements(osmv1.LabelName("alertname"), osmv1.LabelName("severity"))) + Expect(configs[0].Regex).To(Equal("TestAlert;critical")) + }) + + It("should return empty for alert that doesn't match any config", func() { + By("trying to get specs for an alert that doesn't match") + alertRule := &monitoringv1.Rule{ + Alert: "NonMatchingAlert", + Labels: map[string]string{ + "severity": "info", + }, + } + specs := mapperClient.GetAlertRelabelConfigSpec(alertRule) + + By("verifying empty is returned") + Expect(specs).To(BeEmpty()) + }) + + It("should return copies of specs (not original pointers)", func() { + By("creating and adding an AlertRelabelConfig") + arc := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "TestAlert", + TargetLabel: "severity", + Replacement: "warning", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc) + + By("retrieving configs twice") + alertRule := &monitoringv1.Rule{ + Alert: "TestAlert", + } + configs1 := mapperClient.GetAlertRelabelConfigSpec(alertRule) + configs2 := mapperClient.GetAlertRelabelConfigSpec(alertRule) + + By("verifying they are independent copies") + Expect(configs1).To(HaveLen(1)) + Expect(configs2).To(HaveLen(1)) + // Modify one and verify the other is unchanged + configs1[0].Replacement = "modified" + Expect(configs2[0].Replacement).To(Equal("warning")) + }) + }) + }) + + Describe("GetAlertRelabelConfigSpec with matching alerts", func() { + Context("when alert rule matches AlertRelabelConfig", func() { + It("should return matching configs from all AlertRelabelConfigs", func() { + By("creating and adding a PrometheusRule") + alertRule := monitoringv1.Rule{ + Alert: "TestAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + pr := createPrometheusRule("test-namespace", "test-rule", []monitoringv1.Rule{alertRule}) + mapperClient.AddPrometheusRule(pr) + + By("creating and adding first AlertRelabelConfig") + arc1 := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc-1", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Separator: ";", + Regex: "TestAlert", + TargetLabel: "priority", + Replacement: "high", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc1) + + By("creating and adding second AlertRelabelConfig") + arc2 := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-arc-2", + Namespace: "test-namespace", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname", "severity"}, + Separator: ";", + Regex: "TestAlert;critical", + TargetLabel: "team", + Replacement: "platform", + Action: "Replace", + }, + }, + }, + } + mapperClient.AddAlertRelabelConfig(arc2) + + By("getting matching configs for the alert") + configs := mapperClient.GetAlertRelabelConfigSpec(&alertRule) + + By("verifying both configs are returned") + Expect(configs).To(HaveLen(2)) + // Verify first config + targetLabels := []string{configs[0].TargetLabel, configs[1].TargetLabel} + Expect(targetLabels).To(ContainElements("priority", "team")) + }) + }) + }) +}) diff --git a/pkg/management/mapper/new.go b/pkg/management/mapper/new.go new file mode 100644 index 00000000..aa5a3708 --- /dev/null +++ b/pkg/management/mapper/new.go @@ -0,0 +1,16 @@ +package mapper + +import ( + osmv1 "github.com/openshift/api/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// New creates a new instance of the mapper client. +func New(k8sClient k8s.Client) Client { + return &mapper{ + k8sClient: k8sClient, + prometheusRules: make(map[PrometheusRuleId][]PrometheusAlertRuleId), + alertRelabelConfigs: make(map[AlertRelabelConfigId][]osmv1.RelabelConfig), + } +} diff --git a/pkg/management/mapper/types.go b/pkg/management/mapper/types.go new file mode 100644 index 00000000..f662a4d8 --- /dev/null +++ b/pkg/management/mapper/types.go @@ -0,0 +1,48 @@ +package mapper + +import ( + "context" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" +) + +// PrometheusRuleId is a unique identifier for a PrometheusRule resource in Kubernetes, represented by its NamespacedName. +type PrometheusRuleId types.NamespacedName + +// AlertRelabelConfigId is a unique identifier for an AlertRelabelConfig resource in Kubernetes, represented by its NamespacedName. +type AlertRelabelConfigId types.NamespacedName + +// PrometheusAlertRuleId is a hash-based identifier for an alerting rule within a PrometheusRule, represented by a string. +type PrometheusAlertRuleId string + +// Client defines the interface for mapping between Prometheus alerting rules and their unique identifiers. +type Client interface { + // GetAlertingRuleId returns the unique identifier for a given alerting rule. + GetAlertingRuleId(alertRule *monitoringv1.Rule) PrometheusAlertRuleId + + // FindAlertRuleById returns the PrometheusRuleId for a given alerting rule ID. + FindAlertRuleById(alertRuleId PrometheusAlertRuleId) (*PrometheusRuleId, error) + + // WatchPrometheusRules starts watching for changes to PrometheusRules. + WatchPrometheusRules(ctx context.Context) + + // AddPrometheusRule adds or updates a PrometheusRule in the mapper. + AddPrometheusRule(pr *monitoringv1.PrometheusRule) + + // DeletePrometheusRule removes a PrometheusRule from the mapper. + DeletePrometheusRule(pr *monitoringv1.PrometheusRule) + + // WatchAlertRelabelConfigs starts watching for changes to AlertRelabelConfigs. + WatchAlertRelabelConfigs(ctx context.Context) + + // AddAlertRelabelConfig adds or updates an AlertRelabelConfig in the mapper. + AddAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) + + // DeleteAlertRelabelConfig removes an AlertRelabelConfig from the mapper. + DeleteAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) + + // GetAlertRelabelConfigSpec returns the RelabelConfigs that match the given alert rule's labels. + GetAlertRelabelConfigSpec(alertRule *monitoringv1.Rule) []osmv1.RelabelConfig +} diff --git a/pkg/management/new.go b/pkg/management/new.go new file mode 100644 index 00000000..a4c827df --- /dev/null +++ b/pkg/management/new.go @@ -0,0 +1,24 @@ +package management + +import ( + "context" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +// New creates a new management client +func New(ctx context.Context, k8sClient k8s.Client) Client { + m := mapper.New(k8sClient) + m.WatchPrometheusRules(ctx) + m.WatchAlertRelabelConfigs(ctx) + + return NewWithCustomMapper(ctx, k8sClient, m) +} + +func NewWithCustomMapper(ctx context.Context, k8sClient k8s.Client, m mapper.Client) Client { + return &client{ + k8sClient: k8sClient, + mapper: m, + } +} diff --git a/pkg/management/relabel_config.go b/pkg/management/relabel_config.go new file mode 100644 index 00000000..552d37d5 --- /dev/null +++ b/pkg/management/relabel_config.go @@ -0,0 +1,46 @@ +package management + +import ( + "fmt" + + osmv1 "github.com/openshift/api/monitoring/v1" +) + +// applyRelabelConfigs applies relabel configurations to a set of labels. +// Returns the updated labels or an error if the alert/rule should be dropped. +func applyRelabelConfigs(name string, labels map[string]string, configs []osmv1.RelabelConfig) (map[string]string, error) { + if labels == nil { + labels = make(map[string]string) + } + + updatedLabels := make(map[string]string, len(labels)) + for k, v := range labels { + updatedLabels[k] = v + } + + for _, config := range configs { + // TODO: (machadovilaca) Implement all relabeling actions + // 'Replace', 'Keep', 'Drop', 'HashMod', 'LabelMap', 'LabelDrop', or 'LabelKeep' + + switch config.Action { + case "Drop": + return nil, fmt.Errorf("alert/rule %s has been dropped by relabeling configuration", name) + case "Replace": + updatedLabels[config.TargetLabel] = config.Replacement + case "Keep": + // Keep action is a no-op in this context since the alert/rule is already matched + case "HashMod": + // HashMod action is not implemented yet + case "LabelMap": + // LabelMap action is not implemented yet + case "LabelDrop": + // LabelDrop action is not implemented yet + case "LabelKeep": + // LabelKeep action is not implemented yet + default: + // Unsupported action, ignore + } + } + + return updatedLabels, nil +} diff --git a/pkg/management/relabel_config_test.go b/pkg/management/relabel_config_test.go new file mode 100644 index 00000000..1271fb20 --- /dev/null +++ b/pkg/management/relabel_config_test.go @@ -0,0 +1,171 @@ +package management + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + osmv1 "github.com/openshift/api/monitoring/v1" +) + +var _ = Describe("applyRelabelConfigs", func() { + Context("when Drop action is applied", func() { + It("should return error", func() { + initialLabels := map[string]string{ + "severity": "critical", + } + configs := []osmv1.RelabelConfig{ + { + Action: "Drop", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).To(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Context("when Replace action is applied", func() { + It("should update existing label", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "severity", + Replacement: "critical", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "critical", + })) + }) + + It("should add new label", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "team", + Replacement: "platform", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "warning", + "team": "platform", + })) + }) + + It("should work with nil labels", func() { + configs := []osmv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "severity", + Replacement: "critical", + }, + } + + result, err := applyRelabelConfigs("TestAlert", nil, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "critical", + })) + }) + }) + + Context("when multiple Replace actions are applied", func() { + It("should apply all replacements", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "severity", + Replacement: "critical", + }, + { + Action: "Replace", + TargetLabel: "team", + Replacement: "platform", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "critical", + "team": "platform", + })) + }) + }) + + Context("when Keep action is applied", func() { + It("should be a no-op", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{ + { + Action: "Keep", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "warning", + })) + }) + }) + + Context("when unknown action is applied", func() { + It("should be ignored", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{ + { + Action: "UnknownAction", + }, + } + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "warning", + })) + }) + }) + + Context("when no configs are provided", func() { + It("should return unchanged labels", func() { + initialLabels := map[string]string{ + "severity": "warning", + } + configs := []osmv1.RelabelConfig{} + + result, err := applyRelabelConfigs("TestAlert", initialLabels, configs) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "severity": "warning", + })) + }) + }) +}) diff --git a/pkg/management/testutils/k8s_client_mock.go b/pkg/management/testutils/k8s_client_mock.go new file mode 100644 index 00000000..7849c5a0 --- /dev/null +++ b/pkg/management/testutils/k8s_client_mock.go @@ -0,0 +1,337 @@ +package testutils + +import ( + "context" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// MockClient is a mock implementation of k8s.Client interface +type MockClient struct { + TestConnectionFunc func(ctx context.Context) error + PrometheusAlertsFunc func() k8s.PrometheusAlertsInterface + PrometheusRulesFunc func() k8s.PrometheusRuleInterface + PrometheusRuleInformerFunc func() k8s.PrometheusRuleInformerInterface + AlertRelabelConfigsFunc func() k8s.AlertRelabelConfigInterface + AlertRelabelConfigInformerFunc func() k8s.AlertRelabelConfigInformerInterface +} + +// TestConnection mocks the TestConnection method +func (m *MockClient) TestConnection(ctx context.Context) error { + if m.TestConnectionFunc != nil { + return m.TestConnectionFunc(ctx) + } + return nil +} + +// PrometheusAlerts mocks the PrometheusAlerts method +func (m *MockClient) PrometheusAlerts() k8s.PrometheusAlertsInterface { + if m.PrometheusAlertsFunc != nil { + return m.PrometheusAlertsFunc() + } + return &MockPrometheusAlertsInterface{} +} + +// PrometheusRules mocks the PrometheusRules method +func (m *MockClient) PrometheusRules() k8s.PrometheusRuleInterface { + if m.PrometheusRulesFunc != nil { + return m.PrometheusRulesFunc() + } + return &MockPrometheusRuleInterface{} +} + +// PrometheusRuleInformer mocks the PrometheusRuleInformer method +func (m *MockClient) PrometheusRuleInformer() k8s.PrometheusRuleInformerInterface { + if m.PrometheusRuleInformerFunc != nil { + return m.PrometheusRuleInformerFunc() + } + return &MockPrometheusRuleInformerInterface{} +} + +// AlertRelabelConfigs mocks the AlertRelabelConfigs method +func (m *MockClient) AlertRelabelConfigs() k8s.AlertRelabelConfigInterface { + if m.AlertRelabelConfigsFunc != nil { + return m.AlertRelabelConfigsFunc() + } + return &MockAlertRelabelConfigInterface{} +} + +// AlertRelabelConfigInformer mocks the AlertRelabelConfigInformer method +func (m *MockClient) AlertRelabelConfigInformer() k8s.AlertRelabelConfigInformerInterface { + if m.AlertRelabelConfigInformerFunc != nil { + return m.AlertRelabelConfigInformerFunc() + } + return &MockAlertRelabelConfigInformerInterface{} +} + +// MockPrometheusAlertsInterface is a mock implementation of k8s.PrometheusAlertsInterface +type MockPrometheusAlertsInterface struct { + GetAlertsFunc func(ctx context.Context, req k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, error) + + // Storage for test data + ActiveAlerts []k8s.PrometheusAlert +} + +func (m *MockPrometheusAlertsInterface) SetActiveAlerts(alerts []k8s.PrometheusAlert) { + m.ActiveAlerts = alerts +} + +// GetAlerts mocks the GetAlerts method +func (m *MockPrometheusAlertsInterface) GetAlerts(ctx context.Context, req k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, error) { + if m.GetAlertsFunc != nil { + return m.GetAlertsFunc(ctx, req) + } + + if m.ActiveAlerts != nil { + return m.ActiveAlerts, nil + } + return []k8s.PrometheusAlert{}, nil +} + +// MockPrometheusRuleInterface is a mock implementation of k8s.PrometheusRuleInterface +type MockPrometheusRuleInterface struct { + ListFunc func(ctx context.Context, namespace string) ([]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(ctx context.Context, namespace string) ([]monitoringv1.PrometheusRule, error) { + if m.ListFunc != nil { + return m.ListFunc(ctx, namespace) + } + + var rules []monitoringv1.PrometheusRule + if m.PrometheusRules != nil { + for _, rule := range m.PrometheusRules { + if namespace == "" || rule.Namespace == namespace { + 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 +} + +// MockPrometheusRuleInformerInterface is a mock implementation of k8s.PrometheusRuleInformerInterface +type MockPrometheusRuleInformerInterface struct { + RunFunc func(ctx context.Context, callbacks k8s.PrometheusRuleInformerCallback) error +} + +// Run mocks the Run method +func (m *MockPrometheusRuleInformerInterface) Run(ctx context.Context, callbacks k8s.PrometheusRuleInformerCallback) error { + if m.RunFunc != nil { + return m.RunFunc(ctx, callbacks) + } + + // Default implementation - just wait for context to be cancelled + <-ctx.Done() + return ctx.Err() +} + +// 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 +} + +// MockAlertRelabelConfigInformerInterface is a mock implementation of k8s.AlertRelabelConfigInformerInterface +type MockAlertRelabelConfigInformerInterface struct { + RunFunc func(ctx context.Context, callbacks k8s.AlertRelabelConfigInformerCallback) error +} + +// Run mocks the Run method +func (m *MockAlertRelabelConfigInformerInterface) Run(ctx context.Context, callbacks k8s.AlertRelabelConfigInformerCallback) error { + if m.RunFunc != nil { + return m.RunFunc(ctx, callbacks) + } + + // Default implementation - just wait for context to be cancelled + <-ctx.Done() + return ctx.Err() +} diff --git a/pkg/management/testutils/mapper_mock.go b/pkg/management/testutils/mapper_mock.go new file mode 100644 index 00000000..e353a3d5 --- /dev/null +++ b/pkg/management/testutils/mapper_mock.go @@ -0,0 +1,82 @@ +package testutils + +import ( + "context" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +var _ mapper.Client = &MockMapperClient{} + +// MockMapperClient is a simple mock for the mapper.Client interface +type MockMapperClient struct { + GetAlertingRuleIdFunc func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId + FindAlertRuleByIdFunc func(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) + WatchPrometheusRulesFunc func(ctx context.Context) + AddPrometheusRuleFunc func(pr *monitoringv1.PrometheusRule) + DeletePrometheusRuleFunc func(pr *monitoringv1.PrometheusRule) + WatchAlertRelabelConfigsFunc func(ctx context.Context) + AddAlertRelabelConfigFunc func(arc *osmv1.AlertRelabelConfig) + DeleteAlertRelabelConfigFunc func(arc *osmv1.AlertRelabelConfig) + GetAlertRelabelConfigSpecFunc func(alertRule *monitoringv1.Rule) []osmv1.RelabelConfig +} + +func (m *MockMapperClient) GetAlertingRuleId(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if m.GetAlertingRuleIdFunc != nil { + return m.GetAlertingRuleIdFunc(alertRule) + } + return mapper.PrometheusAlertRuleId("mock-id") +} + +func (m *MockMapperClient) FindAlertRuleById(alertRuleId mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + if m.FindAlertRuleByIdFunc != nil { + return m.FindAlertRuleByIdFunc(alertRuleId) + } + return nil, nil +} + +func (m *MockMapperClient) WatchPrometheusRules(ctx context.Context) { + if m.WatchPrometheusRulesFunc != nil { + m.WatchPrometheusRulesFunc(ctx) + } +} + +func (m *MockMapperClient) AddPrometheusRule(pr *monitoringv1.PrometheusRule) { + if m.AddPrometheusRuleFunc != nil { + m.AddPrometheusRuleFunc(pr) + } +} + +func (m *MockMapperClient) DeletePrometheusRule(pr *monitoringv1.PrometheusRule) { + if m.DeletePrometheusRuleFunc != nil { + m.DeletePrometheusRuleFunc(pr) + } +} + +func (m *MockMapperClient) WatchAlertRelabelConfigs(ctx context.Context) { + if m.WatchAlertRelabelConfigsFunc != nil { + m.WatchAlertRelabelConfigsFunc(ctx) + } +} + +func (m *MockMapperClient) AddAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) { + if m.AddAlertRelabelConfigFunc != nil { + m.AddAlertRelabelConfigFunc(arc) + } +} + +func (m *MockMapperClient) DeleteAlertRelabelConfig(arc *osmv1.AlertRelabelConfig) { + if m.DeleteAlertRelabelConfigFunc != nil { + m.DeleteAlertRelabelConfigFunc(arc) + } +} + +func (m *MockMapperClient) GetAlertRelabelConfigSpec(alertRule *monitoringv1.Rule) []osmv1.RelabelConfig { + if m.GetAlertRelabelConfigSpecFunc != nil { + return m.GetAlertRelabelConfigSpecFunc(alertRule) + } + return nil +} diff --git a/pkg/management/types.go b/pkg/management/types.go new file mode 100644 index 00000000..f5d4e4c4 --- /dev/null +++ b/pkg/management/types.go @@ -0,0 +1,57 @@ +package management + +import ( + "context" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + + "github.com/openshift/monitoring-plugin/pkg/k8s" +) + +// Client is the interface for managing alert rules +type Client interface { + // ListRules lists all alert rules in the specified PrometheusRule resource + ListRules(ctx context.Context, prOptions PrometheusRuleOptions, arOptions AlertRuleOptions) ([]monitoringv1.Rule, error) + + // GetRuleById retrieves a specific alert rule by its ID + GetRuleById(ctx context.Context, alertRuleId string) (monitoringv1.Rule, error) + + // CreateUserDefinedAlertRule creates a new user-defined alert rule + CreateUserDefinedAlertRule(ctx context.Context, alertRule monitoringv1.Rule, prOptions PrometheusRuleOptions) (alertRuleId string, err error) + + // UpdateUserDefinedAlertRule updates an existing user-defined alert rule by its ID + UpdateUserDefinedAlertRule(ctx context.Context, alertRuleId string, alertRule monitoringv1.Rule) error + + // DeleteUserDefinedAlertRuleById deletes a user-defined alert rule by its ID + DeleteUserDefinedAlertRuleById(ctx context.Context, alertRuleId string) error + + // UpdatePlatformAlertRule updates an existing platform alert rule by its ID + // Platform alert rules can only have the labels updated through AlertRelabelConfigs + UpdatePlatformAlertRule(ctx context.Context, alertRuleId string, alertRule monitoringv1.Rule) error + + // GetAlerts retrieves Prometheus alerts + GetAlerts(ctx context.Context, req k8s.GetAlertsRequest) ([]k8s.PrometheusAlert, 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"` +} + +type AlertRuleOptions struct { + // Name filters alert rules by alert name + Name string `json:"name,omitempty"` + + // Source filters alert rules by source type (platform or user-defined) + Source string `json:"source,omitempty"` + + // Labels filters alert rules by arbitrary label key-value pairs + Labels map[string]string `json:"labels,omitempty"` +} diff --git a/pkg/management/update_platform_alert_rule.go b/pkg/management/update_platform_alert_rule.go new file mode 100644 index 00000000..4270ce4e --- /dev/null +++ b/pkg/management/update_platform_alert_rule.go @@ -0,0 +1,171 @@ +package management + +import ( + "context" + "errors" + "fmt" + "strings" + + osmv1 "github.com/openshift/api/monitoring/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +const openshiftMonitoringNamespace = "openshift-monitoring" + +func (c *client) UpdatePlatformAlertRule(ctx context.Context, alertRuleId string, alertRule monitoringv1.Rule) error { + prId, err := c.mapper.FindAlertRuleById(mapper.PrometheusAlertRuleId(alertRuleId)) + if err != nil { + return err + } + + if !IsPlatformAlertRule(types.NamespacedName(*prId)) { + return errors.New("cannot update non-platform alert rule from " + prId.Namespace + "/" + prId.Name) + } + + originalRule, err := c.getOriginalPlatformRule(ctx, prId, alertRuleId) + if err != nil { + return err + } + + labelChanges := calculateLabelChanges(originalRule.Labels, alertRule.Labels) + if len(labelChanges) == 0 { + return errors.New("no label changes detected; platform alert rules can only have labels updated") + } + + return c.applyLabelChangesViaAlertRelabelConfig(ctx, alertRuleId, originalRule.Alert, labelChanges) +} + +func (c *client) getOriginalPlatformRule(ctx context.Context, prId *mapper.PrometheusRuleId, alertRuleId string) (*monitoringv1.Rule, error) { + pr, found, err := c.k8sClient.PrometheusRules().Get(ctx, prId.Namespace, prId.Name) + if err != nil { + return nil, fmt.Errorf("failed to get PrometheusRule %s/%s: %w", prId.Namespace, prId.Name, err) + } + + if !found { + return nil, &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", prId.Namespace, prId.Name)} + } + + for groupIdx := range pr.Spec.Groups { + for ruleIdx := range pr.Spec.Groups[groupIdx].Rules { + rule := &pr.Spec.Groups[groupIdx].Rules[ruleIdx] + if c.shouldUpdateRule(*rule, alertRuleId) { + return rule, nil + } + } + } + + return nil, fmt.Errorf("alert rule with id %s not found in PrometheusRule %s/%s", alertRuleId, prId.Namespace, prId.Name) +} + +type labelChange struct { + action string + sourceLabel string + targetLabel string + value string +} + +func calculateLabelChanges(originalLabels, newLabels map[string]string) []labelChange { + var changes []labelChange + + for key, newValue := range newLabels { + originalValue, exists := originalLabels[key] + if !exists || originalValue != newValue { + changes = append(changes, labelChange{ + action: "Replace", + targetLabel: key, + value: newValue, + }) + } + } + + for key := range originalLabels { + // alertname is a special label that is used to identify the alert rule + // and should not be dropped + if key == "alertname" { + continue + } + + if _, exists := newLabels[key]; !exists { + changes = append(changes, labelChange{ + action: "LabelDrop", + sourceLabel: key, + }) + } + } + + return changes +} + +func (c *client) applyLabelChangesViaAlertRelabelConfig(ctx context.Context, alertRuleId string, alertName string, changes []labelChange) error { + arcName := fmt.Sprintf("alertmanagement-%s", strings.ToLower(strings.ReplaceAll(alertRuleId, "/", "-"))) + + existingArc, found, err := c.k8sClient.AlertRelabelConfigs().Get(ctx, openshiftMonitoringNamespace, arcName) + if err != nil { + return fmt.Errorf("failed to get AlertRelabelConfig %s/%s: %w", openshiftMonitoringNamespace, arcName, err) + } + + relabelConfigs := c.buildRelabelConfigs(alertName, changes) + + var arc *osmv1.AlertRelabelConfig + if found { + arc = existingArc + arc.Spec = osmv1.AlertRelabelConfigSpec{ + Configs: relabelConfigs, + } + + err = c.k8sClient.AlertRelabelConfigs().Update(ctx, *arc) + if err != nil { + return fmt.Errorf("failed to update AlertRelabelConfig %s/%s: %w", arc.Namespace, arc.Name, err) + } + } else { + arc = &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: arcName, + Namespace: openshiftMonitoringNamespace, + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: relabelConfigs, + }, + } + + _, err = c.k8sClient.AlertRelabelConfigs().Create(ctx, *arc) + if err != nil { + return fmt.Errorf("failed to create AlertRelabelConfig %s/%s: %w", arc.Namespace, arc.Name, err) + } + } + + return nil +} + +func (c *client) buildRelabelConfigs(alertName string, changes []labelChange) []osmv1.RelabelConfig { + var configs []osmv1.RelabelConfig + + for _, change := range changes { + switch change.action { + case "Replace": + config := osmv1.RelabelConfig{ + SourceLabels: []osmv1.LabelName{"alertname", osmv1.LabelName(change.targetLabel)}, + Regex: fmt.Sprintf("%s;.*", alertName), + TargetLabel: change.targetLabel, + Replacement: change.value, + Action: "Replace", + } + configs = append(configs, config) + case "LabelDrop": + config := osmv1.RelabelConfig{ + SourceLabels: []osmv1.LabelName{"alertname"}, + Regex: alertName, + TargetLabel: change.sourceLabel, + Replacement: "", + Action: "Replace", + } + configs = append(configs, config) + } + } + + return configs +} diff --git a/pkg/management/update_platform_alert_rule_test.go b/pkg/management/update_platform_alert_rule_test.go new file mode 100644 index 00000000..a89eedc9 --- /dev/null +++ b/pkg/management/update_platform_alert_rule_test.go @@ -0,0 +1,400 @@ +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" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("UpdatePlatformAlertRule", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockARC *testutils.MockAlertRelabelConfigInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockARC = &testutils.MockAlertRelabelConfigInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + AlertRelabelConfigsFunc: func() k8s.AlertRelabelConfigInterface { + return mockARC + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + Context("when updating a platform alert rule", func() { + It("should create an AlertRelabelConfig to update labels", func() { + By("setting up the existing platform rule") + existingRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + "team": "platform", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "openshift-monitoring/openshift-platform-alerts": prometheusRule, + }) + + alertRuleId := "test-platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-alerts", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "PlatformAlert" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating labels through AlertRelabelConfig") + updatedRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + "team": "platform", + "owner": "sre", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).ToNot(HaveOccurred()) + + By("verifying AlertRelabelConfig was created") + arcs, err := mockARC.List(ctx, "openshift-monitoring") + Expect(err).ToNot(HaveOccurred()) + Expect(arcs).To(HaveLen(1)) + + arc := arcs[0] + Expect(arc.Namespace).To(Equal("openshift-monitoring")) + Expect(arc.Name).To(Equal("alertmanagement-test-platform-rule-id")) + + By("verifying relabel configs include label updates with alertname matching") + Expect(arc.Spec.Configs).To(HaveLen(2)) + + severityUpdate := false + ownerAdd := false + for _, config := range arc.Spec.Configs { + Expect(config.Action).To(Equal("Replace")) + Expect(config.SourceLabels).To(ContainElement(osmv1.LabelName("alertname"))) + Expect(config.Regex).To(ContainSubstring("PlatformAlert")) + + if config.TargetLabel == "severity" && config.Replacement == "critical" { + severityUpdate = true + Expect(config.SourceLabels).To(ContainElement(osmv1.LabelName("severity"))) + } + if config.TargetLabel == "owner" && config.Replacement == "sre" { + ownerAdd = true + Expect(config.SourceLabels).To(ContainElement(osmv1.LabelName("owner"))) + } + } + Expect(severityUpdate).To(BeTrue()) + Expect(ownerAdd).To(BeTrue()) + }) + + It("should update existing AlertRelabelConfig when one already exists", func() { + By("setting up the existing platform rule and AlertRelabelConfig") + existingRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + existingARC := &osmv1.AlertRelabelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-platform-rule-id-relabel", + Namespace: "openshift-monitoring", + }, + Spec: osmv1.AlertRelabelConfigSpec{ + Configs: []osmv1.RelabelConfig{ + { + SourceLabels: []osmv1.LabelName{"alertname"}, + Regex: "PlatformAlert", + Action: "Keep", + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "openshift-monitoring/openshift-platform-alerts": prometheusRule, + }) + mockARC.SetAlertRelabelConfigs(map[string]*osmv1.AlertRelabelConfig{ + "openshift-monitoring/alertmanagement-test-platform-rule-id": existingARC, + }) + + alertRuleId := "test-platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-alerts", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "PlatformAlert" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating labels through existing AlertRelabelConfig") + updatedRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).ToNot(HaveOccurred()) + + By("verifying existing AlertRelabelConfig was updated") + arc, found, err := mockARC.Get(ctx, "openshift-monitoring", "alertmanagement-test-platform-rule-id") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(arc.Spec.Configs).To(HaveLen(1)) + Expect(arc.Spec.Configs[0].Action).To(Equal("Replace")) + Expect(arc.Spec.Configs[0].SourceLabels).To(ContainElement(osmv1.LabelName("alertname"))) + Expect(arc.Spec.Configs[0].TargetLabel).To(Equal("severity")) + Expect(arc.Spec.Configs[0].Replacement).To(Equal("critical")) + }) + + It("should handle label removal", func() { + By("setting up the existing platform rule with multiple labels") + existingRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + "team": "platform", + "owner": "sre", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "openshift-monitoring/openshift-platform-alerts": prometheusRule, + }) + + alertRuleId := "test-platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-alerts", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "PlatformAlert" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating with fewer labels") + updatedRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).ToNot(HaveOccurred()) + + By("verifying AlertRelabelConfig includes label removal actions") + arcs, err := mockARC.List(ctx, "openshift-monitoring") + Expect(err).ToNot(HaveOccurred()) + Expect(arcs).To(HaveLen(1)) + + arc := arcs[0] + Expect(arc.Spec.Configs).To(HaveLen(2)) + + labelRemovalCount := 0 + for _, config := range arc.Spec.Configs { + if config.Replacement == "" && (config.TargetLabel == "team" || config.TargetLabel == "owner") { + labelRemovalCount++ + Expect(config.Action).To(Equal("Replace")) + Expect(config.SourceLabels).To(ContainElement(osmv1.LabelName("alertname"))) + } + } + Expect(labelRemovalCount).To(Equal(2)) + }) + + It("should return error when trying to update non-platform rule", func() { + By("setting up a user-defined rule") + alertRuleId := "test-user-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "user-namespace", + Name: "user-rule", + }, nil + } + + updatedRule := monitoringv1.Rule{ + Alert: "UserAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot update non-platform alert rule")) + }) + + It("should return error when no label changes detected", func() { + By("setting up the existing platform rule") + existingRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-platform-alerts", + Namespace: "openshift-monitoring", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "platform-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "openshift-monitoring/openshift-platform-alerts": prometheusRule, + }) + + alertRuleId := "test-platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-alerts", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "PlatformAlert" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating with same labels") + updatedRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "warning", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no label changes detected")) + }) + + It("should return error when alert rule not found", func() { + By("setting up mapper to return rule ID") + alertRuleId := "non-existent-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return nil, errors.New("alert rule not found") + } + + updatedRule := monitoringv1.Rule{ + Alert: "PlatformAlert", + Expr: intstr.FromString("up == 0"), + Labels: map[string]string{ + "severity": "critical", + }, + } + + err := client.UpdatePlatformAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alert rule not found")) + }) + }) +}) diff --git a/pkg/management/update_user_defined_alert_rule.go b/pkg/management/update_user_defined_alert_rule.go new file mode 100644 index 00000000..ebfe1b7c --- /dev/null +++ b/pkg/management/update_user_defined_alert_rule.go @@ -0,0 +1,61 @@ +package management + +import ( + "context" + "fmt" + + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/openshift/monitoring-plugin/pkg/management/mapper" +) + +func (c *client) UpdateUserDefinedAlertRule(ctx context.Context, alertRuleId string, alertRule monitoringv1.Rule) error { + prId, err := c.mapper.FindAlertRuleById(mapper.PrometheusAlertRuleId(alertRuleId)) + if err != nil { + return err + } + + if IsPlatformAlertRule(types.NamespacedName(*prId)) { + return fmt.Errorf("cannot update alert rule in a platform-managed PrometheusRule") + } + + pr, found, err := c.k8sClient.PrometheusRules().Get(ctx, prId.Namespace, prId.Name) + if err != nil { + return err + } + + if !found { + return &NotFoundError{Resource: "PrometheusRule", Id: fmt.Sprintf("%s/%s", prId.Namespace, prId.Name)} + } + + updated := false + for groupIdx := range pr.Spec.Groups { + for ruleIdx := range pr.Spec.Groups[groupIdx].Rules { + rule := &pr.Spec.Groups[groupIdx].Rules[ruleIdx] + if c.shouldUpdateRule(*rule, alertRuleId) { + pr.Spec.Groups[groupIdx].Rules[ruleIdx] = alertRule + updated = true + break + } + } + if updated { + break + } + } + + if !updated { + return fmt.Errorf("alert rule with id %s not found in PrometheusRule %s/%s", alertRuleId, prId.Namespace, prId.Name) + } + + err = c.k8sClient.PrometheusRules().Update(ctx, *pr) + if err != nil { + return fmt.Errorf("failed to update PrometheusRule %s/%s: %w", pr.Namespace, pr.Name, err) + } + + return nil +} + +func (c *client) shouldUpdateRule(rule monitoringv1.Rule, alertRuleId string) bool { + return alertRuleId == string(c.mapper.GetAlertingRuleId(&rule)) +} diff --git a/pkg/management/update_user_defined_alert_rule_test.go b/pkg/management/update_user_defined_alert_rule_test.go new file mode 100644 index 00000000..1b246080 --- /dev/null +++ b/pkg/management/update_user_defined_alert_rule_test.go @@ -0,0 +1,250 @@ +package management_test + +import ( + "context" + + . "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/util/intstr" + + "github.com/openshift/monitoring-plugin/pkg/k8s" + "github.com/openshift/monitoring-plugin/pkg/management" + "github.com/openshift/monitoring-plugin/pkg/management/mapper" + "github.com/openshift/monitoring-plugin/pkg/management/testutils" +) + +var _ = Describe("UpdateUserDefinedAlertRule", func() { + var ( + ctx context.Context + mockK8s *testutils.MockClient + mockPR *testutils.MockPrometheusRuleInterface + mockMapper *testutils.MockMapperClient + client management.Client + ) + + BeforeEach(func() { + ctx = context.Background() + + mockPR = &testutils.MockPrometheusRuleInterface{} + mockK8s = &testutils.MockClient{ + PrometheusRulesFunc: func() k8s.PrometheusRuleInterface { + return mockPR + }, + } + mockMapper = &testutils.MockMapperClient{} + + client = management.NewWithCustomMapper(ctx, mockK8s, mockMapper) + }) + + Context("when updating a user-defined alert rule", func() { + It("should successfully update an existing alert rule", func() { + By("setting up the existing rule") + existingRule := monitoringv1.Rule{ + Alert: "OldAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user-rule", + Namespace: "user-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "test-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "user-namespace/user-rule": prometheusRule, + }) + + alertRuleId := "test-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "user-namespace", + Name: "user-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "OldAlert" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating with new values") + updatedRule := monitoringv1.Rule{ + Alert: "UpdatedAlert", + Expr: intstr.FromString("up == 1"), + Annotations: map[string]string{ + "summary": "Updated summary", + }, + } + + err := client.UpdateUserDefinedAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the update succeeded") + updatedPR, found, err := mockPR.Get(ctx, "user-namespace", "user-rule") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedPR.Spec.Groups).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[0].Rules).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[0].Rules[0].Alert).To(Equal("UpdatedAlert")) + Expect(updatedPR.Spec.Groups[0].Rules[0].Expr.String()).To(Equal("up == 1")) + Expect(updatedPR.Spec.Groups[0].Rules[0].Annotations["summary"]).To(Equal("Updated summary")) + }) + + It("should update the correct rule when multiple rules exist", func() { + By("setting up multiple rules across different groups") + rule1 := monitoringv1.Rule{ + Alert: "Alert1", + Expr: intstr.FromString("up == 0"), + } + + rule2 := monitoringv1.Rule{ + Alert: "Alert2", + Expr: intstr.FromString("cpu_usage > 80"), + } + + rule3 := monitoringv1.Rule{ + Alert: "Alert3", + Expr: intstr.FromString("memory_usage > 90"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-rule", + Namespace: "user-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "group1", + Rules: []monitoringv1.Rule{rule1, rule2}, + }, + { + Name: "group2", + Rules: []monitoringv1.Rule{rule3}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "user-namespace/multi-rule": prometheusRule, + }) + + alertRuleId := "alert2-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "user-namespace", + Name: "multi-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + if alertRule.Alert == "Alert2" { + return mapper.PrometheusAlertRuleId(alertRuleId) + } + return mapper.PrometheusAlertRuleId("other-id") + } + + By("updating only the second rule") + updatedRule := monitoringv1.Rule{ + Alert: "Alert2Updated", + Expr: intstr.FromString("cpu_usage > 90"), + } + + err := client.UpdateUserDefinedAlertRule(ctx, alertRuleId, updatedRule) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the targeted rule was updated") + updatedPR, found, err := mockPR.Get(ctx, "user-namespace", "multi-rule") + Expect(found).To(BeTrue()) + Expect(err).ToNot(HaveOccurred()) + Expect(updatedPR.Spec.Groups).To(HaveLen(2)) + + Expect(updatedPR.Spec.Groups[0].Rules).To(HaveLen(2)) + Expect(updatedPR.Spec.Groups[0].Rules[0].Alert).To(Equal("Alert1")) + Expect(updatedPR.Spec.Groups[0].Rules[1].Alert).To(Equal("Alert2Updated")) + Expect(updatedPR.Spec.Groups[0].Rules[1].Expr.String()).To(Equal("cpu_usage > 90")) + + Expect(updatedPR.Spec.Groups[1].Rules).To(HaveLen(1)) + Expect(updatedPR.Spec.Groups[1].Rules[0].Alert).To(Equal("Alert3")) + }) + + It("should return error when alert rule ID is not found", func() { + existingRule := monitoringv1.Rule{ + Alert: "ExistingAlert", + Expr: intstr.FromString("up == 0"), + } + + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user-rule", + Namespace: "user-namespace", + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{ + { + Name: "test-group", + Rules: []monitoringv1.Rule{existingRule}, + }, + }, + }, + } + + mockPR.SetPrometheusRules(map[string]*monitoringv1.PrometheusRule{ + "user-namespace/user-rule": prometheusRule, + }) + + alertRuleId := "non-existent-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "user-namespace", + Name: "user-rule", + }, nil + } + mockMapper.GetAlertingRuleIdFunc = func(alertRule *monitoringv1.Rule) mapper.PrometheusAlertRuleId { + return mapper.PrometheusAlertRuleId("different-id") + } + + updatedRule := monitoringv1.Rule{ + Alert: "UpdatedAlert", + Expr: intstr.FromString("up == 1"), + } + + err := client.UpdateUserDefinedAlertRule(ctx, alertRuleId, updatedRule) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + It("should return error when trying to update a platform-managed alert rule", func() { + alertRuleId := "platform-rule-id" + mockMapper.FindAlertRuleByIdFunc = func(id mapper.PrometheusAlertRuleId) (*mapper.PrometheusRuleId, error) { + return &mapper.PrometheusRuleId{ + Namespace: "openshift-monitoring", + Name: "openshift-platform-rules", + }, nil + } + + updatedRule := monitoringv1.Rule{ + Alert: "UpdatedAlert", + Expr: intstr.FromString("up == 1"), + } + + err := client.UpdateUserDefinedAlertRule(ctx, alertRuleId, updatedRule) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("platform-managed")) + }) + }) +}) diff --git a/pkg/server.go b/pkg/server.go index 653fca84..271ac400 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -12,7 +12,6 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" - "github.com/openshift/monitoring-plugin/pkg/proxy" "github.com/sirupsen/logrus" "gopkg.in/yaml.v2" v1 "k8s.io/api/core/v1" @@ -21,6 +20,12 @@ import ( "k8s.io/client-go/kubernetes/scheme" "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") @@ -60,6 +65,7 @@ const ( Incidents Feature = "incidents" DevConfig Feature = "dev-config" PersesDashboards Feature = "perses-dashboards" + ManagementAPI Feature = "management-api" ) func (pluginConfig *PluginConfig) MarshalJSON() ([]byte, error) { @@ -103,6 +109,8 @@ func (s *PluginServer) Shutdown(ctx context.Context) error { func createHTTPServer(ctx context.Context, cfg *Config) (*http.Server, error) { acmMode := cfg.Features[AcmAlerting] + managementMode := cfg.Features[ManagementAPI] + acmLocationsLength := len(cfg.AlertmanagerUrl) + len(cfg.ThanosQuerierUrl) if acmLocationsLength > 0 && !acmMode { @@ -116,15 +124,19 @@ func createHTTPServer(ctx context.Context, cfg *Config) (*http.Server, error) { return nil, fmt.Errorf("cannot set default port to reserved port %d", cfg.Port) } + var k8sconfig *rest.Config + var err error + // Uncomment the following line for local development: - // k8sconfig, err := clientcmd.BuildConfigFromFlags("", "$HOME/.kube/config") + // k8sconfig, err = clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) + // if err != nil { + // return nil, fmt.Errorf("cannot get kubeconfig from file: %w", err) + // } // Comment the following line for local development: var k8sclient *dynamic.DynamicClient - if acmMode { - - k8sconfig, err := rest.InClusterConfig() - + if acmMode || managementMode { + k8sconfig, err = rest.InClusterConfig() if err != nil { return nil, fmt.Errorf("cannot get in cluster config: %w", err) } @@ -137,7 +149,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 managementMode { + k8sClient, err := k8s.NewClient(ctx, k8sconfig) + if err != nil { + return nil, fmt.Errorf("failed to create k8s client for management API: %w", err) + } + + if err := k8sClient.TestConnection(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to kubernetes cluster for management API: %w", err) + } + + managementClient = management.New(ctx, k8sClient) + log.Info("Management API enabled") + } + + router, pluginConfig := setupRoutes(cfg, managementClient) router.Use(corsHeaderMiddleware()) tlsConfig := &tls.Config{} @@ -222,7 +250,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() @@ -233,6 +261,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