From cc19d0e3fc10ba4e7f325d7e30bc80677a55b5fb Mon Sep 17 00:00:00 2001 From: Jayapriya Pai Date: Mon, 6 Oct 2025 15:03:21 +0530 Subject: [PATCH] feat: add support for UTF8 labels and rules (#7637) for admission webhook default is legacy validation Assissted-By: Cursor Signed-off-by: Jayapriya Pai Co-authored-by: Simon Pasquier --- cmd/admission-webhook/main.go | 26 +- cmd/operator/main.go | 3 +- go.mod | 15 +- go.sum | 30 +- pkg/admission/admission.go | 20 +- pkg/admission/admission_test.go | 103 ++++++- .../testdata/goodRulesWithUTF8.golden | 70 +++++ pkg/operator/rules.go | 22 +- pkg/operator/rules_test.go | 94 ++++++- pkg/operator/validations.go | 36 +++ pkg/prometheus/resource_selector.go | 42 ++- pkg/prometheus/resource_selector_test.go | 266 ++++++++++++++++-- test/e2e/main_test.go | 1 + test/e2e/prometheus_test.go | 225 +++++++++++++++ 14 files changed, 850 insertions(+), 103 deletions(-) create mode 100644 pkg/admission/testdata/goodRulesWithUTF8.golden create mode 100644 pkg/operator/validations.go diff --git a/cmd/admission-webhook/main.go b/cmd/admission-webhook/main.go index f500e593c..f9f46efc2 100644 --- a/cmd/admission-webhook/main.go +++ b/cmd/admission-webhook/main.go @@ -24,6 +24,7 @@ import ( "syscall" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/model" "golang.org/x/sync/errgroup" "github.com/prometheus-operator/prometheus-operator/internal/goruntime" @@ -35,13 +36,15 @@ import ( ) const defaultGOMemlimitRatio = 0.0 +const defaultValidationScheme = "legacy" func main() { var ( - serverConfig = server.DefaultConfig(":8443", true) - flagset = flag.CommandLine - logConfig logging.Config - memlimitRatio float64 + serverConfig = server.DefaultConfig(":8443", true) + flagset = flag.CommandLine + logConfig logging.Config + memlimitRatio float64 + nameValidationScheme string ) server.RegisterFlags(flagset, &serverConfig) @@ -49,6 +52,7 @@ func main() { logging.RegisterFlags(flagset, &logConfig) flagset.Float64Var(&memlimitRatio, "auto-gomemlimit-ratio", defaultGOMemlimitRatio, "The ratio of reserved GOMEMLIMIT memory to the detected maximum container or system memory. The value should be greater than 0.0 and less than 1.0. Default: 0.0 (disabled).") + flagset.StringVar(&nameValidationScheme, "name-validation-scheme", defaultValidationScheme, "The name validation scheme to use ('legacy' or 'utf8').") _ = flagset.Parse(os.Args[1:]) @@ -65,12 +69,24 @@ func main() { goruntime.SetMaxProcs(logger) goruntime.SetMemLimit(logger, memlimitRatio) + // Parse and validate the name validation scheme + var validationScheme model.ValidationScheme + switch nameValidationScheme { + case "utf8": + validationScheme = model.UTF8Validation + case "legacy": + validationScheme = model.LegacyValidation + default: + logger.Error("invalid name validation scheme", "scheme", nameValidationScheme, "supported", []string{"utf8", "legacy"}) + os.Exit(1) + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() wg, ctx := errgroup.WithContext(ctx) mux := http.NewServeMux() - admit := admission.New(logger.With("component", "admissionwebhook")) + admit := admission.New(logger.With("component", "admissionwebhook"), validationScheme) admit.Register(mux) r := metrics.NewRegistry("prometheus_operator_admission_webhook") diff --git a/cmd/operator/main.go b/cmd/operator/main.go index d2973970b..dac79dfc3 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -30,6 +30,7 @@ import ( "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/model" "github.com/prometheus/common/version" "golang.org/x/sync/errgroup" appsv1 "k8s.io/api/apps/v1" @@ -601,7 +602,7 @@ func run(fs *flag.FlagSet) int { // Setup the web server. mux := http.NewServeMux() - admit := admission.New(logger.With("component", "admissionwebhook")) + admit := admission.New(logger.With("component", "admissionwebhook"), model.LegacyValidation) admit.Register(mux) r.MustRegister(cfg.Gates) diff --git a/go.mod b/go.mod index a79fd9106..7716753b3 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,8 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.66.1 github.com/prometheus/exporter-toolkit v0.14.1 - github.com/prometheus/prometheus v0.306.0 + // Since we needed the change added in https://github.com/prometheus/prometheus/pull/16928 and it's not released yet. + github.com/prometheus/prometheus v0.305.1-0.20250818080900-0a40df33fb4e github.com/stretchr/testify v1.11.1 github.com/thanos-io/thanos v0.39.2 go.uber.org/automaxprocs v1.6.0 @@ -48,19 +49,19 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2 v1.37.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.0 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect - github.com/aws/smithy-go v1.22.3 // indirect + github.com/aws/smithy-go v1.22.5 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect diff --git a/go.sum b/go.sum index bf75a5b25..c38f59c32 100644 --- a/go.sum +++ b/go.sum @@ -28,32 +28,32 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2 v1.37.0 h1:YtCOESR/pN4j5oA7cVHSfOwIcuh/KwHC4DOSXFbv5F0= +github.com/aws/aws-sdk-go-v2 v1.37.0/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2/config v1.29.15 h1:I5XjesVMpDZXZEZonVfjI12VNMrYa38LtLnw4NtY5Ss= github.com/aws/aws-sdk-go-v2/config v1.29.15/go.mod h1:tNIp4JIPonlsgaO5hxO372a6gjhN63aSWl2GVl5QoBQ= github.com/aws/aws-sdk-go-v2/credentials v1.17.68 h1:cFb9yjI02/sWHBSYXAtkamjzCuRymvmeFmt0TC0MbYY= github.com/aws/aws-sdk-go-v2/credentials v1.17.68/go.mod h1:H6E+jBzyqUu8u0vGaU6POkK3P0NylYEeRZ6ynBpMqIk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.0 h1:H2iZoqW/v2Jnrh1FnU725Bq6KJ0k2uP63yH+DcY+HUI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.0/go.mod h1:L0FqLbwMXHvNC/7crWV1iIxUlOKYZUE8KuTIA+TozAI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.0 h1:EDped/rNzAhFPhVY0sDGbtD16OKqksfA8OjF/kLEgw8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.0/go.mod h1:uUI335jvzpZRPpjYx6ODc/wg1qH+NnoSTK/FwVeK0C0= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.0 h1:eRhU3Sh8dGbaniI6B+I48XJMrTPRkK4DKo+vqIxziOU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.0/go.mod h1:paNLV18DZ6FnWE/bd06RIKPDIFpjuvCkGKWTG/GDBeM= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 h1:oIaQ1e17CSKaWmUTu62MtraRWVIosn/iONMuZt0gbqc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.20/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -274,13 +274,15 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/exporter-toolkit v0.14.1 h1:uKPE4ewweVRWFainwvAcHs3uw15pjw2dk3I7b+aNo9o= github.com/prometheus/exporter-toolkit v0.14.1/go.mod h1:di7yaAJiaMkcjcz48f/u4yRPwtyuxTU5Jr4EnM2mhtQ= +github.com/prometheus/otlptranslator v0.0.2 h1:+1CdeLVrRQ6Psmhnobldo0kTp96Rj80DRXRd5OSnMEQ= +github.com/prometheus/otlptranslator v0.0.2/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/prometheus/prometheus v0.306.0 h1:Q0Pvz/ZKS6vVWCa1VSgNyNJlEe8hxdRlKklFg7SRhNw= -github.com/prometheus/prometheus v0.306.0/go.mod h1:7hMSGyZHt0dcmZ5r4kFPJ/vxPQU99N5/BGwSPDxeZrQ= +github.com/prometheus/prometheus v0.305.1-0.20250818080900-0a40df33fb4e h1:HcaG1Uuc0LCVkHHo+QMgJf8eXjCxEZDoYGOvmUjPU2g= +github.com/prometheus/prometheus v0.305.1-0.20250818080900-0a40df33fb4e/go.mod h1:uxFMhGI+u8QK+W7Zr/oZGvf2lkHgnjbBmAEEnoymLyg= github.com/prometheus/sigv4 v0.2.0 h1:qDFKnHYFswJxdzGeRP63c4HlH3Vbn1Yf/Ao2zabtVXk= github.com/prometheus/sigv4 v0.2.0/go.mod h1:D04rqmAaPPEUkjRQxGqjoxdyJuyCh6E0M18fZr0zBiE= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= diff --git a/pkg/admission/admission.go b/pkg/admission/admission.go index 3b1f98d60..d0d76a041 100644 --- a/pkg/admission/admission.go +++ b/pkg/admission/admission.go @@ -22,6 +22,7 @@ import ( "net/http" "strings" + "github.com/prometheus/common/model" v1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -74,18 +75,20 @@ var ( // 1. PrometheusRules (validation, mutation) - ensuring created resources can be loaded by Promethues // 2. monitoringv1alpha1.AlertmanagerConfig (validation) - ensuring. type Admission struct { - logger *slog.Logger - wh http.Handler + logger *slog.Logger + wh http.Handler + validationScheme model.ValidationScheme } -func New(logger *slog.Logger) *Admission { +func New(logger *slog.Logger, validationScheme model.ValidationScheme) *Admission { scheme := runtime.NewScheme() utilruntime.Must(monitoringv1alpha1.AddToScheme(scheme)) utilruntime.Must(monitoringv1beta1.AddToScheme(scheme)) return &Admission{ - logger: logger, - wh: conversion.NewWebhookHandler(scheme), + logger: logger, + wh: conversion.NewWebhookHandler(scheme), + validationScheme: validationScheme, } } @@ -166,7 +169,10 @@ func (a *Admission) serveAdmission(w http.ResponseWriter, r *http.Request, admit responseAdmissionReview.Response = admit(requestedAdmissionReview) } - responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID + // Adding nil check here since request can be nil if deserialization fails. + if requestedAdmissionReview.Request != nil { + responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID + } responseAdmissionReview.APIVersion = requestedAdmissionReview.APIVersion responseAdmissionReview.Kind = requestedAdmissionReview.Kind @@ -233,7 +239,7 @@ func (a *Admission) validatePrometheusRules(ar v1.AdmissionReview) *v1.Admission return toAdmissionResponseFailure(errUnmarshalRules, prometheusRuleResource, []error{err}) } - errors := promoperator.ValidateRule(promRule.Spec) + errors := promoperator.ValidateRule(promRule.Spec, a.validationScheme) if len(errors) != 0 { const m = "Invalid rule" a.logger.Debug(m, "content", promRule.Spec) diff --git a/pkg/admission/admission_test.go b/pkg/admission/admission_test.go index ebce8c2ea..6e11b54cd 100644 --- a/pkg/admission/admission_test.go +++ b/pkg/admission/admission_test.go @@ -26,6 +26,7 @@ import ( "testing" jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "gotest.tools/v3/golden" v1 "k8s.io/api/admission/v1" @@ -310,12 +311,22 @@ func TestAlertmanagerConfigConversion(t *testing.T) { } } +// api returns an Admission instance with legacy validation for backward compatibility. func api() *Admission { - a := New( - slog.New(slog.DiscardHandler), - ) + return apiWithValidationScheme(model.LegacyValidation) +} - return a +// apiWithUTF8Validation returns an Admission instance configured for UTF-8 validation. +func apiWithUTF8Validation() *Admission { + return apiWithValidationScheme(model.UTF8Validation) +} + +// apiWithValidationScheme returns an Admission instance with the specified validation scheme. +func apiWithValidationScheme(validationScheme model.ValidationScheme) *Admission { + return New( + slog.New(slog.DiscardHandler), + validationScheme, + ) } func server(h http.HandlerFunc) *httptest.Server { @@ -434,3 +445,87 @@ func buildConversionReviewFromAlertmanagerConfigSpec(t *testing.T, from, to, spe spec) return []byte(tmpl) } + +func TestAdmitGoodRuleWithUTF8Validation(t *testing.T) { + ts := server(apiWithUTF8Validation().servePrometheusRulesValidate) + defer ts.Close() + + resp := sendAdmissionReview(t, ts, golden.Get(t, "goodRulesWithUTF8.golden")) + if !resp.Response.Allowed { + t.Errorf("Expected admission to be allowed with UTF-8 validation") + } +} + +func TestAdmitUTF8RuleWithLegacyValidation(t *testing.T) { + // This test verifies that UTF-8 rules are rejected with legacy validation + ts := server(api().servePrometheusRulesValidate) // Uses legacy validation + defer ts.Close() + + resp := sendAdmissionReview(t, ts, golden.Get(t, "goodRulesWithUTF8.golden")) + if resp.Response.Allowed { + t.Errorf("Expected admission to be rejected with legacy validation for UTF-8 characters") + } + + // Verify that it's rejected for the right reason (validation error) + if resp.Response.Result == nil || resp.Response.Result.Message == "" { + t.Errorf("Expected validation error message, got empty result") + } +} + +func TestValidationSchemeComparison(t *testing.T) { + tests := []struct { + name string + admit *Admission + goldenFile string + shouldAllow bool + description string + }{ + { + name: "legacy_validation_with_ascii_rules", + admit: api(), + goldenFile: "goodRulesWithAnnotations.golden", + shouldAllow: true, + description: "ASCII rules should pass with legacy validation", + }, + { + name: "utf8_validation_with_ascii_rules", + admit: apiWithUTF8Validation(), + goldenFile: "goodRulesWithAnnotations.golden", + shouldAllow: true, + description: "ASCII rules should pass with UTF-8 validation", + }, + { + name: "legacy_validation_with_utf8_rules", + admit: api(), + goldenFile: "goodRulesWithUTF8.golden", + shouldAllow: false, + description: "UTF-8 rules should be rejected with legacy validation", + }, + { + name: "utf8_validation_with_utf8_rules", + admit: apiWithUTF8Validation(), + goldenFile: "goodRulesWithUTF8.golden", + shouldAllow: true, + description: "UTF-8 rules should pass with UTF-8 validation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := server(tt.admit.servePrometheusRulesValidate) + defer ts.Close() + + resp := sendAdmissionReview(t, ts, golden.Get(t, tt.goldenFile)) + + if tt.shouldAllow && !resp.Response.Allowed { + t.Errorf("%s: Expected admission to be allowed, but it was rejected. Message: %s", + tt.description, + resp.Response.Result.Message) + } + + if !tt.shouldAllow && resp.Response.Allowed { + t.Errorf("%s: Expected admission to be rejected, but it was allowed", tt.description) + } + }) + } +} diff --git a/pkg/admission/testdata/goodRulesWithUTF8.golden b/pkg/admission/testdata/goodRulesWithUTF8.golden new file mode 100644 index 000000000..a315a60d2 --- /dev/null +++ b/pkg/admission/testdata/goodRulesWithUTF8.golden @@ -0,0 +1,70 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "87c5df7f-5090-11e9-b9b4-02425473f309", + "kind": { + "group": "monitoring.coreos.com", + "version": "v1", + "kind": "PrometheusRule" + }, + "resource": { + "group": "monitoring.coreos.com", + "version": "v1", + "resource": "prometheusrules" + }, + "namespace": "monitoring", + "operation": "CREATE", + "userInfo": { + "username": "kubernetes-admin", + "groups": [ + "system:masters", + "system:authenticated" + ] + }, + "object": { + "apiVersion": "monitoring.coreos.com/v1", + "kind": "PrometheusRule", + "metadata": { + "creationTimestamp": "2019-03-27T13:02:09Z", + "generation": 1, + "name": "test-utf8", + "namespace": "monitoring", + "uid": "87c5d31d-5090-11e9-b9b4-02425473f309" + }, + "spec": { + "groups": [ + { + "name": "test.utf8.rules", + "rules": [ + { + "alert": "TestUTF8Alert", + "annotations": { + "message": "UTF-8 test rule with émojis 🚀 and ünicöde", + "description": "测试 UTF-8 characters in annotations" + }, + "expr": "up{app=\"myapp\",service=\"测试服务\"} > 0", + "for": "5m", + "labels": { + "severity": "critical", + "team": "platform", + "测试标签": "测试_label_with_中文" + } + }, + { + "record": "http.requests.rate_5m", + "expr": "sum by(service,job,\"http.request\") (rate({\"http.requests.total\",\"error.type\"!=\"\"}[5m]))", + "labels": { + "type": "recording", + "描述": "Recording rule with UTF-8 characters 🎯" + } + } + ] + } + ] + } + }, + "oldObject": null, + "dryRun": false + } +} diff --git a/pkg/operator/rules.go b/pkg/operator/rules.go index c7dc12f95..1ade818bc 100644 --- a/pkg/operator/rules.go +++ b/pkg/operator/rules.go @@ -35,15 +35,6 @@ import ( namespacelabeler "github.com/prometheus-operator/prometheus-operator/pkg/namespacelabeler" ) -func init() { - // For now, the operator only supports legacy label names. - // Eventually the operator should support UTF-8 label names too and the - // issue is tracked by - // https://github.com/prometheus-operator/prometheus-operator/issues/7362 - // nolint:staticcheck - model.NameValidationScheme = model.LegacyValidation -} - type RuleConfigurationFormat int const ( @@ -108,7 +99,14 @@ func (prs *PrometheusRuleSelector) generateRulesConfiguration(promRule *monitori return "", fmt.Errorf("failed to marshal content: %w", err) } - errs := ValidateRule(promRuleSpec) + var validationScheme model.ValidationScheme + if prs.ruleFormat == ThanosFormat { + validationScheme = ValidationSchemeForThanos(prs.version) + } else { + validationScheme = ValidationSchemeForPrometheus(prs.version) + } + + errs := ValidateRule(promRuleSpec, validationScheme) if len(errs) != 0 { const m = "invalid rule" logger.Debug(m, "content", content) @@ -170,7 +168,7 @@ func (prs *PrometheusRuleSelector) sanitizePrometheusRulesSpec(promRuleSpec moni } // ValidateRule takes PrometheusRuleSpec and validates it using the upstream prometheus rule validator. -func ValidateRule(promRuleSpec monitoringv1.PrometheusRuleSpec) []error { +func ValidateRule(promRuleSpec monitoringv1.PrometheusRuleSpec, validationScheme model.ValidationScheme) []error { for i := range promRuleSpec.Groups { // The upstream Prometheus rule validator doesn't support the // partial_response_strategy field. @@ -200,7 +198,7 @@ func ValidateRule(promRuleSpec monitoringv1.PrometheusRuleSpec) []error { return []error{fmt.Errorf("the length of rendered Prometheus Rule is %d bytes which is above the maximum limit of %d bytes", promRuleSize, MaxConfigMapDataSize)} } - _, errs := rulefmt.Parse(content, false) + _, errs := rulefmt.Parse(content, false, validationScheme) return errs } diff --git a/pkg/operator/rules_test.go b/pkg/operator/rules_test.go index 3ab0bfd63..597fbb64b 100644 --- a/pkg/operator/rules_test.go +++ b/pkg/operator/rules_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/blang/semver/v4" + "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/intstr" @@ -28,25 +29,33 @@ import ( ) func TestMakeRulesConfigMaps(t *testing.T) { - t.Run("shouldAcceptRuleWithValidPartialResponseStrategyValue", shouldAcceptRuleWithValidPartialResponseStrategyValue) + // Basic validation t.Run("shouldAcceptValidRule", shouldAcceptValidRule) - t.Run("shouldAcceptRulesWithEmptyDurations", shouldAcceptRulesWithEmptyDurations) t.Run("shouldRejectRuleWithInvalidLabels", shouldRejectRuleWithInvalidLabels) t.Run("shouldRejectRuleWithInvalidExpression", shouldRejectRuleWithInvalidExpression) - t.Run("shouldResetRuleWithPartialResponseStrategySet", shouldResetRuleWithPartialResponseStrategySet) + t.Run("shouldAcceptRulesWithEmptyDurations", shouldAcceptRulesWithEmptyDurations) + t.Run("shouldErrorOnTooLargePrometheusRule", shouldErrorOnTooLargePrometheusRule) + + // Prometheus features t.Run("shouldAcceptRuleWithLimitPrometheus", shouldAcceptRuleWithLimitPrometheus) - t.Run("shouldAcceptRuleWithLimitThanos", shouldAcceptRuleWithLimitThanos) - t.Run("shouldAcceptRuleWithQueryOffsetPrometheus", shouldAcceptRuleWithQueryOffsetPrometheus) t.Run("shouldDropLimitFieldForUnsupportedPrometheusVersion", shouldDropLimitFieldForUnsupportedPrometheusVersion) - t.Run("shouldDropLimitFieldForUnsupportedThanosVersion", shouldDropLimitFieldForUnsupportedThanosVersion) + t.Run("shouldAcceptRuleWithQueryOffsetPrometheus", shouldAcceptRuleWithQueryOffsetPrometheus) t.Run("shouldDropQueryOffsetFieldForUnsupportedPrometheusVersion", shouldDropQueryOffsetFieldForUnsupportedPrometheusVersion) t.Run("shouldAcceptRuleWithKeepFiringForPrometheus", shouldAcceptRuleWithKeepFiringForPrometheus) - t.Run("shouldDropRuleFiringForThanos", shouldDropRuleFiringForThanos) - t.Run("shouldAcceptRuleFiringForThanos", shouldAcceptRuleFiringForThanos) t.Run("shouldDropKeepFiringForFieldForUnsupportedPrometheusVersion", shouldDropKeepFiringForFieldForUnsupportedPrometheusVersion) - t.Run("shouldErrorOnTooLargePrometheusRule", shouldErrorOnTooLargePrometheusRule) t.Run("shouldDropGroupLabelsForUnsupportedPrometheusVersion", shouldDropGroupLabelsForUnsupportedPrometheusVersion) t.Run("shouldAcceptRuleWithGroupLabels", shouldAcceptRuleWithGroupLabels) + + // Thanos features + t.Run("shouldAcceptRuleWithValidPartialResponseStrategyValue", shouldAcceptRuleWithValidPartialResponseStrategyValue) + t.Run("shouldResetRuleWithPartialResponseStrategySet", shouldResetRuleWithPartialResponseStrategySet) + t.Run("shouldAcceptRuleWithLimitThanos", shouldAcceptRuleWithLimitThanos) + t.Run("shouldDropLimitFieldForUnsupportedThanosVersion", shouldDropLimitFieldForUnsupportedThanosVersion) + t.Run("shouldDropRuleFiringForThanos", shouldDropRuleFiringForThanos) + t.Run("shouldAcceptRuleFiringForThanos", shouldAcceptRuleFiringForThanos) + + // UTF-8 validation + t.Run("UTF8Validation", TestUTF8Validation) } func newRuleSelectorForConfigGeneration(ruleFormat RuleConfigurationFormat, version semver.Version) PrometheusRuleSelector { @@ -150,9 +159,10 @@ func shouldRejectRuleWithInvalidLabels(t *testing.T) { }, }}, } - promVersion, _ := semver.ParseTolerant(DefaultPrometheusVersion) + promVersion, err := semver.ParseTolerant("2.55.0") + require.NoError(t, err) pr := newRuleSelectorForConfigGeneration(PrometheusFormat, promVersion) - _, err := pr.generateRulesConfiguration(rules) + _, err = pr.generateRulesConfiguration(rules) require.Error(t, err) } @@ -440,7 +450,7 @@ func shouldErrorOnTooLargePrometheusRule(t *testing.T) { ruleLbel := map[string]string{} ruleLbel["label"] = strings.Repeat("a", MaxConfigMapDataSize+1) - err := ValidateRule(monitoringv1.PrometheusRuleSpec{ + ruleSpec := monitoringv1.PrometheusRuleSpec{ Groups: []monitoringv1.RuleGroup{ { Name: "group", @@ -454,8 +464,13 @@ func shouldErrorOnTooLargePrometheusRule(t *testing.T) { }, }, }, - }) - require.NotEmpty(t, err, "expected ValidateRule to return error of size limit") + } + + err := ValidateRule(ruleSpec, model.UTF8Validation) + require.NotEmpty(t, err, "expected ValidateRule to return error of size limit with UTF8Validation") + + err = ValidateRule(ruleSpec, model.LegacyValidation) + require.NotEmpty(t, err, "expected ValidateRule to return error of size limit with LegacyValidation") } func shouldDropGroupLabelsForUnsupportedPrometheusVersion(t *testing.T) { @@ -509,3 +524,54 @@ func shouldAcceptRuleWithGroupLabels(t *testing.T) { _, err := pr.generateRulesConfiguration(rules) require.NoError(t, err) } + +func TestUTF8Validation(t *testing.T) { + rule := createUTF8Rule() + + tests := []struct { + name string + format RuleConfigurationFormat + version string + shouldSucceed bool + }{ + {"Prometheus 3.0.0 accepts UTF-8", PrometheusFormat, "3.0.0", true}, + {"Prometheus 2.55.0 rejects UTF-8", PrometheusFormat, "2.55.0", false}, + {"Thanos 0.38.0 accepts UTF-8", ThanosFormat, "0.38.0", true}, + {"Thanos 0.37.0 rejects UTF-8", ThanosFormat, "0.37.0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := semver.ParseTolerant(tt.version) + require.NoError(t, err) + + pr := newRuleSelectorForConfigGeneration(tt.format, version) + _, err = pr.generateRulesConfiguration(rule) + + if tt.shouldSucceed { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func createUTF8Rule() *monitoringv1.PrometheusRule { + return &monitoringv1.PrometheusRule{ + Spec: monitoringv1.PrometheusRuleSpec{Groups: []monitoringv1.RuleGroup{ + { + Name: "group", + Rules: []monitoringv1.Rule{ + { + Alert: "alert", + Expr: intstr.FromString("vector(1)"), + Labels: map[string]string{ + "unicode_测试": "utf8_value", + }, + }, + }, + }, + }}, + } +} diff --git a/pkg/operator/validations.go b/pkg/operator/validations.go new file mode 100644 index 000000000..0ea67e426 --- /dev/null +++ b/pkg/operator/validations.go @@ -0,0 +1,36 @@ +// Copyright 2025 The prometheus-operator Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package operator + +import ( + "github.com/blang/semver/v4" + "github.com/prometheus/common/model" +) + +// ValidationSchemeForPrometheus returns the appropriate validation scheme based on Prometheus version. +func ValidationSchemeForPrometheus(version semver.Version) model.ValidationScheme { + if version.GTE(semver.MustParse("3.0.0")) { + return model.UTF8Validation + } + return model.LegacyValidation +} + +// ValidationSchemeForThanos returns the appropriate validation scheme based on Thanos version. +func ValidationSchemeForThanos(version semver.Version) model.ValidationScheme { + if version.GTE(semver.MustParse("0.38.0")) { + return model.UTF8Validation + } + return model.LegacyValidation +} diff --git a/pkg/prometheus/resource_selector.go b/pkg/prometheus/resource_selector.go index 07023c105..bbfb28183 100644 --- a/pkg/prometheus/resource_selector.go +++ b/pkg/prometheus/resource_selector.go @@ -20,13 +20,11 @@ import ( "fmt" "log/slog" "net/url" - "regexp" "slices" "strings" "github.com/asaskevich/govalidator" "github.com/blang/semver/v4" - "github.com/prometheus/common/model" "github.com/prometheus/prometheus/model/relabel" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -49,9 +47,11 @@ const ( selectingConfigurationResourcesAction = "SelectingConfigurationResources" ) -// validationScheme defines how to validate label names. -// For now, the operator only supports the legacy scheme (e.g. not UTF-8). -var validationScheme model.ValidationScheme = model.LegacyValidation +// isValidLabelName validates a label name using version-aware validation scheme. +func isValidLabelName(labelName string, version semver.Version) bool { + scheme := operator.ValidationSchemeForPrometheus(version) + return scheme.IsValidLabelName(labelName) +} // ConfigurationResource is a type constraint that permits only the specific pointer types for configuration resources // selectable by Prometheus or PrometheusAgent. @@ -388,9 +388,17 @@ func (lcv *LabelConfigValidator) Validate(rcs []monitoringv1.RelabelConfig) erro return nil } -func (lcv *LabelConfigValidator) validate(rc monitoringv1.RelabelConfig) error { - relabelTarget := regexp.MustCompile(`^(?:(?:[a-zA-Z_]|\$(?:\{\w+\}|\w+))+\w*)+$`) +// From https://github.com/prometheus/prometheus/blob/747c5ee2b19a9e6a51acfafae9fa2c77e224803d/model/relabel/relabel.go#L378-L380 +func varInRegexTemplate(template string) bool { + return strings.Contains(template, "$") +} +func (lcv *LabelConfigValidator) isValidLabelName(labelName string) bool { + validationScheme := operator.ValidationSchemeForPrometheus(lcv.v) + return validationScheme.IsValidLabelName(labelName) +} + +func (lcv *LabelConfigValidator) validate(rc monitoringv1.RelabelConfig) error { minimumVersionCaseActions := lcv.v.GTE(semver.MustParse("2.36.0")) minimumVersionEqualActions := lcv.v.GTE(semver.MustParse("2.41.0")) if rc.Action == "" { @@ -418,7 +426,15 @@ func (lcv *LabelConfigValidator) validate(rc monitoringv1.RelabelConfig) error { return fmt.Errorf("relabel configuration for %s action needs targetLabel value", rc.Action) } - if (action == string(relabel.Replace) || action == string(relabel.Lowercase) || action == string(relabel.Uppercase) || action == string(relabel.KeepEqual) || action == string(relabel.DropEqual)) && !relabelTarget.MatchString(rc.TargetLabel) { + if (action == string(relabel.Replace)) && !varInRegexTemplate(rc.TargetLabel) && !lcv.isValidLabelName(rc.TargetLabel) { + return fmt.Errorf("%q is invalid 'target_label' for %s action", rc.TargetLabel, rc.Action) + } + + if (action == string(relabel.Replace)) && varInRegexTemplate(rc.TargetLabel) && !lcv.isValidLabelName(rc.TargetLabel) { + return fmt.Errorf("%q is invalid 'target_label' for %s action", rc.TargetLabel, rc.Action) + } + + if (action == string(relabel.Lowercase) || action == string(relabel.Uppercase) || action == string(relabel.KeepEqual) || action == string(relabel.DropEqual)) && !lcv.isValidLabelName(rc.TargetLabel) { return fmt.Errorf("%q is invalid 'target_label' for %s action", rc.TargetLabel, rc.Action) } @@ -426,13 +442,11 @@ func (lcv *LabelConfigValidator) validate(rc monitoringv1.RelabelConfig) error { return fmt.Errorf("'replacement' can not be set for %s action", rc.Action) } - if action == string(relabel.LabelMap) { - if rc.Replacement != nil && !relabelTarget.MatchString(*rc.Replacement) { - return fmt.Errorf("%q is invalid 'replacement' for %s action", *rc.Replacement, rc.Action) - } + if action == string(relabel.LabelMap) && (rc.Replacement != nil) && !lcv.isValidLabelName(*rc.Replacement) { + return fmt.Errorf("%q is invalid 'replacement' for %s action", *rc.Replacement, rc.Action) } - if action == string(relabel.HashMod) && !validationScheme.IsValidLabelName(rc.TargetLabel) { + if action == string(relabel.HashMod) && !lcv.isValidLabelName(rc.TargetLabel) { return fmt.Errorf("%q is invalid 'target_label' for %s action", rc.TargetLabel, rc.Action) } @@ -1432,7 +1446,7 @@ func (rs *ResourceSelector) validateScalewaySDConfigs(ctx context.Context, sc *m func (rs *ResourceSelector) validateStaticConfig(sc *monitoringv1alpha1.ScrapeConfig) error { for i, config := range sc.Spec.StaticConfigs { for labelName := range config.Labels { - if !validationScheme.IsValidLabelName(labelName) { + if !isValidLabelName(labelName, rs.version) { return fmt.Errorf("[%d]: invalid label in map %s", i, labelName) } } diff --git a/pkg/prometheus/resource_selector_test.go b/pkg/prometheus/resource_selector_test.go index 3e2eff183..a9639c9a3 100644 --- a/pkg/prometheus/resource_selector_test.go +++ b/pkg/prometheus/resource_selector_test.go @@ -176,11 +176,11 @@ func TestValidateRelabelConfig(t *testing.T) { }, // Test valid labelmap relabel config with replacement specified { - scenario: "valid labelmap config", + scenario: "valid labelmap config with replacement", relabelConfig: monitoringv1.RelabelConfig{ Action: "labelmap", Regex: "__meta_kubernetes_service_label_(.+)", - Replacement: ptr.To("${2}"), + Replacement: ptr.To("abc"), }, prometheus: defaultPrometheusSpec, }, @@ -438,6 +438,7 @@ func TestSelectProbes(t *testing.T) { for _, tc := range []struct { scenario string updateSpec func(*monitoringv1.ProbeSpec) + promVersion string valid bool scrapeClass *string }{ @@ -639,7 +640,7 @@ func TestSelectProbes(t *testing.T) { valid: true, }, { - scenario: "invalid metric relabeling config", + scenario: "utf-8 metric relabeling config with prom2", updateSpec: func(ps *monitoringv1.ProbeSpec) { ps.MetricRelabelConfigs = []monitoringv1.RelabelConfig{ { @@ -649,6 +650,22 @@ func TestSelectProbes(t *testing.T) { }, } }, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 metric relabeling config with prom3", + updateSpec: func(ps *monitoringv1.ProbeSpec) { + ps.MetricRelabelConfigs = []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + } + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid static relabeling config", @@ -664,7 +681,7 @@ func TestSelectProbes(t *testing.T) { valid: true, }, { - scenario: "invalid static relabeling config", + scenario: "utf-8 static relabeling config with prom2", updateSpec: func(ps *monitoringv1.ProbeSpec) { ps.Targets.StaticConfig.RelabelConfigs = []monitoringv1.RelabelConfig{ { @@ -674,7 +691,22 @@ func TestSelectProbes(t *testing.T) { }, } }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 static relabeling config with prom3", + updateSpec: func(ps *monitoringv1.ProbeSpec) { + ps.Targets.StaticConfig.RelabelConfigs = []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + } + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid ingress relabeling config", @@ -693,7 +725,7 @@ func TestSelectProbes(t *testing.T) { valid: true, }, { - scenario: "invalid ingress relabeling config", + scenario: "utf-8 ingress relabeling config with prom2", updateSpec: func(ps *monitoringv1.ProbeSpec) { ps.Targets.Ingress = &monitoringv1.ProbeTargetIngress{ RelabelConfigs: []monitoringv1.RelabelConfig{ @@ -705,7 +737,24 @@ func TestSelectProbes(t *testing.T) { }, } }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 ingress relabeling config with prom3", + updateSpec: func(ps *monitoringv1.ProbeSpec) { + ps.Targets.Ingress = &monitoringv1.ProbeTargetIngress{ + RelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + } + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "inexistent scrape class", @@ -758,6 +807,7 @@ func TestSelectProbes(t *testing.T) { p := &monitoringv1.Prometheus{ Spec: monitoringv1.PrometheusSpec{ CommonPrometheusFields: monitoringv1.CommonPrometheusFields{ + Version: tc.promVersion, ScrapeClasses: []monitoringv1.ScrapeClass{ { Name: "existent", @@ -915,6 +965,7 @@ func TestSelectServiceMonitors(t *testing.T) { for _, tc := range []struct { scenario string updateSpec func(*monitoringv1.ServiceMonitorSpec) + promVersion string valid bool scrapeClass *string }{ @@ -934,7 +985,7 @@ func TestSelectServiceMonitors(t *testing.T) { valid: true, }, { - scenario: "invalid metric relabeling config", + scenario: "utf-8 metric relabeling config with prom2", updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ MetricRelabelConfigs: []monitoringv1.RelabelConfig{ @@ -946,7 +997,24 @@ func TestSelectServiceMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 metric relabeling config with prom3", + updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { + sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid relabeling config", @@ -964,7 +1032,7 @@ func TestSelectServiceMonitors(t *testing.T) { valid: true, }, { - scenario: "invalid relabeling config", + scenario: "utf-8 relabeling config with prom2", updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ RelabelConfigs: []monitoringv1.RelabelConfig{ @@ -976,7 +1044,24 @@ func TestSelectServiceMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 relabeling config with prom3", + updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { + sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ + RelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid TLS config with CA, cert and key", @@ -1291,7 +1376,7 @@ func TestSelectServiceMonitors(t *testing.T) { valid: true, }, { - scenario: "Mixed Endpoints", + scenario: "utf-8 mixed endpoints with prom2", updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ MetricRelabelConfigs: []monitoringv1.RelabelConfig{ @@ -1312,7 +1397,33 @@ func TestSelectServiceMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 mixed endpoints with prom3", + updateSpec: func(sm *monitoringv1.ServiceMonitorSpec) { + sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + sm.Endpoints = append(sm.Endpoints, monitoringv1.Endpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "valid", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, } { t.Run(tc.scenario, func(t *testing.T) { @@ -1345,6 +1456,7 @@ func TestSelectServiceMonitors(t *testing.T) { p := &monitoringv1.Prometheus{ Spec: monitoringv1.PrometheusSpec{ CommonPrometheusFields: monitoringv1.CommonPrometheusFields{ + Version: tc.promVersion, ScrapeClasses: []monitoringv1.ScrapeClass{ { Name: "existent", @@ -1399,6 +1511,7 @@ func TestSelectPodMonitors(t *testing.T) { for _, tc := range []struct { scenario string updateSpec func(*monitoringv1.PodMonitorSpec) + promVersion string valid bool scrapeClass *string }{ @@ -1418,7 +1531,7 @@ func TestSelectPodMonitors(t *testing.T) { valid: true, }, { - scenario: "invalid metric relabeling config", + scenario: "utf-8 metric relabeling config with prom2", updateSpec: func(pm *monitoringv1.PodMonitorSpec) { pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ MetricRelabelConfigs: []monitoringv1.RelabelConfig{ @@ -1430,7 +1543,24 @@ func TestSelectPodMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 metric relabeling config with prom3", + updateSpec: func(pm *monitoringv1.PodMonitorSpec) { + pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid relabeling config", @@ -1448,7 +1578,7 @@ func TestSelectPodMonitors(t *testing.T) { valid: true, }, { - scenario: "invalid relabeling config", + scenario: "utf-8 relabeling config with prom2", updateSpec: func(pm *monitoringv1.PodMonitorSpec) { pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ RelabelConfigs: []monitoringv1.RelabelConfig{ @@ -1460,7 +1590,24 @@ func TestSelectPodMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 relabeling config with prom3", + updateSpec: func(pm *monitoringv1.PodMonitorSpec) { + pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ + RelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid proxy config", @@ -1610,7 +1757,7 @@ func TestSelectPodMonitors(t *testing.T) { valid: true, }, { - scenario: "Mixed Endpoints", + scenario: "utf-8 mixed Endpoints with prom2", updateSpec: func(pm *monitoringv1.PodMonitorSpec) { pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ MetricRelabelConfigs: []monitoringv1.RelabelConfig{ @@ -1631,7 +1778,33 @@ func TestSelectPodMonitors(t *testing.T) { }, }) }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 mixed Endpoints with prom3", + updateSpec: func(pm *monitoringv1.PodMonitorSpec) { + pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + pm.PodMetricsEndpoints = append(pm.PodMetricsEndpoints, monitoringv1.PodMetricsEndpoint{ + MetricRelabelConfigs: []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: "valid", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + }, + }) + }, + promVersion: "3.5.0", + valid: true, }, } { t.Run(tc.scenario, func(t *testing.T) { @@ -1650,6 +1823,7 @@ func TestSelectPodMonitors(t *testing.T) { p := &monitoringv1.Prometheus{ Spec: monitoringv1.PrometheusSpec{ CommonPrometheusFields: monitoringv1.CommonPrometheusFields{ + Version: tc.promVersion, ScrapeClasses: []monitoringv1.ScrapeClass{ { Name: "existent", @@ -1729,7 +1903,7 @@ func TestSelectScrapeConfigs(t *testing.T) { valid: true, }, { - scenario: "invalid relabeling config", + scenario: "utf-8 relabeling config with prom2", updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { sc.RelabelConfigs = []monitoringv1.RelabelConfig{ { @@ -1739,7 +1913,22 @@ func TestSelectScrapeConfigs(t *testing.T) { }, } }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 relabeling config with prom3", + updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { + sc.RelabelConfigs = []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + } + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid metric relabeling config", @@ -1755,7 +1944,7 @@ func TestSelectScrapeConfigs(t *testing.T) { valid: true, }, { - scenario: "invalid metric relabeling config", + scenario: "utf-8 metric relabeling config with prom2", updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { sc.MetricRelabelConfigs = []monitoringv1.RelabelConfig{ { @@ -1765,7 +1954,22 @@ func TestSelectScrapeConfigs(t *testing.T) { }, } }, - valid: false, + promVersion: "2.55.0", + valid: false, + }, + { + scenario: "utf-8 metric relabeling config with prom3", + updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { + sc.MetricRelabelConfigs = []monitoringv1.RelabelConfig{ + { + Action: "Replace", + TargetLabel: " invalid label name", + SourceLabels: []monitoringv1.LabelName{"foo", "bar"}, + }, + } + }, + promVersion: "3.5.0", + valid: true, }, { scenario: "valid proxy config", @@ -1948,11 +2152,23 @@ func TestSelectScrapeConfigs(t *testing.T) { valid: true, }, { - scenario: "staticConfig with invalid Labels", + scenario: "staticConfig with utf-8 label", + promVersion: "3.0.0", updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { sc.StaticConfigs = []monitoringv1alpha1.StaticConfig{ { - Labels: map[string]string{"1owner": "prometheus"}, + Labels: map[string]string{"测试服务": "prometheus"}, + }, + } + }, + valid: true, + }, + { + scenario: "staticConfig with invalid utf-8 label", + updateSpec: func(sc *monitoringv1alpha1.ScrapeConfigSpec) { + sc.StaticConfigs = []monitoringv1alpha1.StaticConfig{ + { + Labels: map[string]string{"\xff": "prometheus"}, }, } }, diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 54588471a..a134c11cb 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -320,6 +320,7 @@ func testAllNSPrometheus(t *testing.T) { "PrometheusServiceName": testPrometheusServiceName, "PrometheusAgentSSetServiceName": testPrometheusAgentSSetServiceName, "PrometheusReconciliationOnSecretChanges": testPrometheusReconciliationOnSecretChanges, + "PrometheusUTF8MetricsSupport": testPrometheusUTF8MetricsSupport, } for name, f := range testFuncs { diff --git a/test/e2e/prometheus_test.go b/test/e2e/prometheus_test.go index 5a55e02dc..c79f844d4 100644 --- a/test/e2e/prometheus_test.go +++ b/test/e2e/prometheus_test.go @@ -5495,6 +5495,231 @@ func testPrometheusReconciliationOnSecretChanges(t *testing.T) { require.NoError(t, err) } +func testPrometheusUTF8MetricsSupport(t *testing.T) { + t.Parallel() + + testCtx := framework.NewTestCtx(t) + defer testCtx.Cleanup(t) + ns := framework.CreateNamespace(context.Background(), t, testCtx) + // Disable admission webhook for rule since utf8 is not enabled by default and rule contain metric name with utf8 characters. + ruleNamespaceSelector := map[string]string{"excludeFromWebhook": "true"} + err := framework.AddLabelsToNamespace(context.Background(), ns, ruleNamespaceSelector) + require.NoError(t, err) + + framework.SetupPrometheusRBAC(context.Background(), t, testCtx, ns) + + name := "prometheus-utf8-test" + + // Create deployment for instrumented sample app + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "instrumented-sample-app", + Namespace: ns, + Labels: map[string]string{ + "app": "instrumented-sample-app", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "instrumented-sample-app"}, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "instrumented-sample-app", + }, + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{{ + Name: "instrumented-sample-app", + Image: "quay.io/prometheus-operator/instrumented-sample-app:latest", + Ports: []v1.ContainerPort{{ + Name: "web", + ContainerPort: 8080, + Protocol: v1.ProtocolTCP, + }}, + }}, + }, + }, + }, + } + _, err = framework.KubeClient.AppsV1().Deployments(ns).Create(context.Background(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + service := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "utf8-test-service", + Namespace: ns, + Labels: map[string]string{ + "app": "instrumented-sample-app", + "group": "test-app", + }, + }, + Spec: v1.ServiceSpec{ + Ports: []v1.ServicePort{{Name: "web", Port: 8080, TargetPort: intstr.FromInt(8080)}}, + Selector: map[string]string{"app": "instrumented-sample-app"}, + }, + } + _, err = framework.KubeClient.CoreV1().Services(ns).Create(context.Background(), service, metav1.CreateOptions{}) + require.NoError(t, err) + + sm := &monitoringv1.ServiceMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "utf8-servicemonitor", + Namespace: ns, + Labels: map[string]string{"group": "test-app"}, + }, + Spec: monitoringv1.ServiceMonitorSpec{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "instrumented-sample-app"}, + }, + Endpoints: []monitoringv1.Endpoint{{ + Port: "web", + Interval: "30s", + BasicAuth: &monitoringv1.BasicAuth{ + Username: v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "basic-auth"}, + Key: "username", + }, + Password: v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "basic-auth"}, + Key: "password", + }, + }, + }}, + }, + } + + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "basic-auth", + Namespace: ns, + }, + Type: v1.SecretTypeOpaque, + StringData: map[string]string{ + "username": "user", + "password": "pass", + }, + } + _, err = framework.KubeClient.CoreV1().Secrets(ns).Create(context.Background(), secret, metav1.CreateOptions{}) + require.NoError(t, err) + + _, err = framework.MonClientV1.ServiceMonitors(ns).Create(context.Background(), sm, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for deployment to be ready + err = framework.WaitForDeploymentReady(context.Background(), ns, "instrumented-sample-app", 1) + require.NoError(t, err) + + // Create PrometheusRule with UTF-8 metrics. + prometheusRule := &monitoringv1.PrometheusRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: "utf8-prometheus-rule", + Namespace: ns, + Labels: map[string]string{ + "app": "test-app", + "role": "rulefile", + }, + Annotations: map[string]string{ + "description": "Test rule", + }, + }, + Spec: monitoringv1.PrometheusRuleSpec{ + Groups: []monitoringv1.RuleGroup{{ + Name: "utf8.test.rules", + Rules: []monitoringv1.Rule{ + { + Alert: "UTF8TestAlert", + Expr: intstr.FromString(`count by("app.version") ({"app.info"})`), + Labels: map[string]string{ + "severity": "warning", + "service.name": "web", + }, + Annotations: map[string]string{ + "summary": "Service is down", + "description.cluster": "The cluster service is not responding", + "runbook": "https://runbook.example.com/cluster", + }, + }, + { + Record: "cluster.app_info:5m", + Expr: intstr.FromString(`avg_over_time({"app.info"}[5m])`), + Labels: map[string]string{ + "service.cluster": "availability", + }, + }, + }, + }}, + }, + } + _, err = framework.MonClientV1.PrometheusRules(ns).Create(context.Background(), prometheusRule, metav1.CreateOptions{}) + require.NoError(t, err) + + prom := framework.MakeBasicPrometheus(ns, name, "test-app", 1) + _, err = framework.CreatePrometheusAndWaitUntilReady(context.Background(), ns, prom) + require.NoError(t, err) + + // Default Prometheus service name is "prometheus-operated". + promSvcName := "prometheus-operated" + + // Wait for the instrumented-sample-app target to be discovered + err = framework.WaitForHealthyTargets(context.Background(), ns, promSvcName, 1) + require.NoError(t, err) + + // Verify UTF8 metrics work in queries. + err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 2*time.Minute, false, func(ctx context.Context) (bool, error) { + // Query for UTF8 metric + results, err := framework.PrometheusQuery(ns, promSvcName, "http", `{"app.info"}`) + if err != nil { + t.Logf("UTF8 query failed: %v", err) + return false, nil + } + + if len(results) == 0 { + t.Logf("UTF8 query returned no results") + return false, nil + } + + return true, nil + }) + require.NoError(t, err, "UTF-8 metrics should work in Prometheus 3.0+ queries") + + // Check UTF8 recording rule from PrometheusRule + err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 2*time.Minute, false, func(ctx context.Context) (bool, error) { + results, err := framework.PrometheusQuery(ns, promSvcName, "http", `{"cluster.app_info:5m"}`) + if err != nil { + t.Logf("UTF8 PrometheusRule recording query failed: %v", err) + return false, nil + } + + if len(results) == 0 { + t.Logf("UTF8 recording query returned no results") + return false, nil + } + + return true, nil + }) + require.NoError(t, err, "UTF-8 PrometheusRule recording rule should work") + + // Verify the alert rule exists in Prometheus + err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 2*time.Minute, false, func(ctx context.Context) (bool, error) { + results, err := framework.PrometheusQuery(ns, promSvcName, "http", `ALERTS{alertname="UTF8TestAlert"}`) + if err != nil { + t.Logf("UTF8 alert rule query failed: %v", err) + return false, nil + } + if len(results) == 0 { + t.Logf("UTF8TestAlert rule not found - may not be loaded yet") + return false, nil + } + + t.Logf("UTF8TestAlert rule found and loaded") + return true, nil + }) + require.NoError(t, err, "UTF-8 alert rule should be queryable") +} + func isAlertmanagerDiscoveryWorking(ns, promSVCName, alertmanagerName string) func(ctx context.Context) (bool, error) { return func(ctx context.Context) (bool, error) { pods, err := framework.KubeClient.CoreV1().Pods(ns).List(