diff --git a/pkg/migrate/helpers.go b/pkg/migrate/helpers.go index 8e56f2f936..5506e9d113 100644 --- a/pkg/migrate/helpers.go +++ b/pkg/migrate/helpers.go @@ -16,6 +16,7 @@ package migrate import ( "encoding/base64" + "errors" "fmt" "log/slog" "slices" @@ -23,11 +24,13 @@ import ( monitoringv1 "github.com/GoogleCloudPlatform/prometheus-engine/pkg/operator/apis/monitoring/v1" pomonitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + prommodel "github.com/prometheus/common/model" "github.com/prometheus/prometheus/google/export" "github.com/prometheus/prometheus/model/relabel" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation" ) @@ -122,8 +125,8 @@ func CopyObjectMeta(src metav1.ObjectMeta, targetNamespace string, logger *slog. return dst } -// ParseAndCleanNamespaces trims whitespace, filters out empty strings, and deduplicates namespaces. -func ParseAndCleanNamespaces(namespaces []string) []string { +// parseAndCleanNamespaces trims whitespace, filters out empty strings, and deduplicates namespaces. +func parseAndCleanNamespaces(namespaces []string) []string { unique := make(map[string]bool) var cleaned []string for _, ns := range namespaces { @@ -136,13 +139,41 @@ func ParseAndCleanNamespaces(namespaces []string) []string { return cleaned } +// determineNamespaceScoping resolves the target namespaces from a NamespaceSelector. +func determineNamespaceScoping(nsSel pomonitoringv1.NamespaceSelector, defaultNS string) ([]string, bool, error) { + if nsSel.Any { + return nil, true, nil + } + if len(nsSel.MatchNames) > 0 { + targetNamespaces := parseAndCleanNamespaces(nsSel.MatchNames) + if len(targetNamespaces) == 0 { + return nil, false, errors.New("namespaceSelector.matchNames contains only empty or invalid values") + } + return targetNamespaces, false, nil + } + return []string{defaultNS}, false, nil +} + +// commonMonitorSpec holds common fields extracted from Prometheus Operator monitor specs for building GMP resources. +type commonMonitorSpec struct { + endpoints []monitoringv1.ScrapeEndpoint + mergedFromPod []monitoringv1.LabelMapping + mergedSelector metav1.LabelSelector + metadata *[]string + filterRunning *bool + limits *monitoringv1.ScrapeLimits + generatedSecrets []*unstructured.Unstructured +} + // conversionContext groups common parameters passed down to conversion helper functions. type conversionContext struct { logger *slog.Logger // cache provides access to dependent resources. cache *ResourceCache - // namespace is the source namespace of the primary resource. - namespace string + // sourceNamespace is the original namespace where inputs (Secrets/ConfigMaps) live in the cache. + sourceNamespace string + // targetNamespace is the destination namespace for generated resources. + targetNamespace string // generatedSecrets accumulates created Secrets when migrating ConfigMaps, keyed by Secret name. generatedSecrets map[string]*unstructured.Unstructured } @@ -362,7 +393,7 @@ func (c *conversionContext) extractResourceKey(kind, name, key string) (string, return "", fmt.Errorf("%s reference has an empty key for name %q", kindUpper, name) } - obj, ok := c.cache.Get(kind, c.namespace, name) + obj, ok := c.cache.Get(kind, c.sourceNamespace, name) if !ok { c.logger.Warn("Resource not found in cache. Cannot extract key. Hardcoding placeholder.", slog.String("referenced_kind", kind), @@ -451,7 +482,7 @@ func (c *conversionContext) convertConfigMapToSecretSelector(sel *corev1.ConfigM } if _, exists := c.generatedSecrets[secretName]; !exists { - obj, ok := c.cache.Get(KindConfigMap, c.namespace, sel.Name) + obj, ok := c.cache.Get(KindConfigMap, c.sourceNamespace, sel.Name) if !ok { c.logger.Warn("TLS ConfigMap reference was not found in the inputs. Updated reference to GMP Secret, but you must manually convert your ConfigMap to a Secret with this name in GMP.", slog.String("configmap", sel.Name), @@ -465,7 +496,7 @@ func (c *conversionContext) convertConfigMapToSecretSelector(sel *corev1.ConfigM newSecret.SetAPIVersion("v1") newSecret.SetKind(KindSecret) newSecret.SetName(secretName) - newSecret.SetNamespace(c.namespace) + newSecret.SetNamespace(c.targetNamespace) data, found, _ := unstructured.NestedMap(obj.Object, "data") if found { @@ -479,7 +510,7 @@ func (c *conversionContext) convertConfigMapToSecretSelector(sel *corev1.ConfigM } } - secretRef := &monitoringv1.SecretKeySelector{Name: secretName, Key: secretKey, Namespace: c.namespace} + secretRef := &monitoringv1.SecretKeySelector{Name: secretName, Key: secretKey, Namespace: c.targetNamespace} return &monitoringv1.SecretSelector{Secret: secretRef}, nil } @@ -513,7 +544,7 @@ func (c *conversionContext) convertSecretSelector(sel *corev1.SecretKeySelector) c.logger.Warn("Secret reference had 'optional: true'. GMP does not support optional secrets. The reference is now mandatory.", slog.String("secret", sel.Name)) } - secretRef := &monitoringv1.SecretKeySelector{Name: sel.Name, Key: sel.Key, Namespace: c.namespace} + secretRef := &monitoringv1.SecretKeySelector{Name: sel.Name, Key: sel.Key, Namespace: c.targetNamespace} return &monitoringv1.SecretSelector{Secret: secretRef}, nil } @@ -630,10 +661,68 @@ func (c *conversionContext) convertAuthorization(auth *pomonitoringv1.SafeAuthor }, nil } +// applyAuthAndTLS converts credentials and TLS settings for a generic endpoint. +func (c *conversionContext) applyAuthAndTLS( + gmpEp *monitoringv1.ScrapeEndpoint, + basicAuth *pomonitoringv1.BasicAuth, + oAuth2 *pomonitoringv1.OAuth2, + tlsConfig *pomonitoringv1.SafeTLSConfig, + authorization *pomonitoringv1.SafeAuthorization, + bearerTokenSecret corev1.SecretKeySelector, +) error { + if gmpEp == nil { + return errors.New("scrape endpoint cannot be nil") + } + + if basicAuth != nil { + ba, err := c.convertBasicAuth(basicAuth) + if err != nil { + return fmt.Errorf("basicAuth: %w", err) + } + gmpEp.BasicAuth = ba + } + if oAuth2 != nil { + oa, err := c.convertOAuth2(oAuth2) + if err != nil { + return fmt.Errorf("oAuth2: %w", err) + } + gmpEp.OAuth2 = oa + } + if tlsConfig != nil { + tls, err := c.convertSafeTLSConfig(tlsConfig) + if err != nil { + return fmt.Errorf("tlsConfig: %w", err) + } + gmpEp.TLS = tls + } + if authorization != nil { + auth, err := c.convertAuthorization(authorization) + if err != nil { + return fmt.Errorf("authorization: %w", err) + } + gmpEp.Authorization = auth + } + + // Handle deprecated BearerTokenSecret -> Authorization. + if bearerTokenSecret.Name != "" || bearerTokenSecret.Key != "" { // nolint:staticcheck // Map deprecated BearerTokenSecret for backwards compatibility. + if gmpEp.Authorization != nil { + c.logger.Warn("Endpoint has both 'bearerTokenSecret' and 'authorization' defined. Dropping 'bearerTokenSecret'.") + } else { + tokenSecret := bearerTokenSecret // nolint:staticcheck // Map deprecated BearerTokenSecret for backwards compatibility. + auth, err := c.convertAuthorization(&pomonitoringv1.SafeAuthorization{Credentials: &tokenSecret}) + if err != nil { + return fmt.Errorf("bearerTokenSecret: %w", err) + } + gmpEp.Authorization = auth + } + } + return nil +} + func convertMetricRelabelings( logger *slog.Logger, configs []pomonitoringv1.RelabelConfig, -) ([]monitoringv1.RelabelingRule, error) { +) []monitoringv1.RelabelingRule { var rules []monitoringv1.RelabelingRule for _, config := range configs { @@ -681,7 +770,7 @@ func convertMetricRelabelings( rules = append(rules, rule) } - return rules, nil + return rules } func convertTargetLabels(logger *slog.Logger, sourceLabels []string, jobLabel string, labelKind string) []monitoringv1.LabelMapping { @@ -963,3 +1052,245 @@ func convertLimits(sampleLimit, labelLimit, labelNameLengthLimit, labelValueLeng } return limits } + +// toStrictUnstructured converts a struct to an unstructured map and normalizes uint64 fields to int64. +// This prevents unstructured.DeepCopy from panicking without losing integer precision. +func toStrictUnstructured(obj any) (map[string]any, error) { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + return sanitizeUInt64(u).(map[string]any), nil +} + +// sanitizeUInt64 recursively converts uint64 primitives to int64 for unstructured compatibility. +func sanitizeUInt64(val any) any { + switch v := val.(type) { + case uint64: + return int64(v) + case map[string]any: + for k, child := range v { + v[k] = sanitizeUInt64(child) + } + case []any: + for i, child := range v { + v[i] = sanitizeUInt64(child) + } + } + return val +} + +// buildPodMonitoring constructs a GMP PodMonitoring resource from common spec. +func buildPodMonitoring( + srcMeta metav1.ObjectMeta, + targetNamespace string, + spec *commonMonitorSpec, + logger *slog.Logger, +) (*unstructured.Unstructured, error) { + if spec == nil { + return nil, errors.New("spec cannot be nil") + } + gmpPM := &monitoringv1.PodMonitoring{ + TypeMeta: BuildTypeMeta(KindPodMonitoring), + ObjectMeta: CopyObjectMeta(srcMeta, targetNamespace, logger), + Spec: monitoringv1.PodMonitoringSpec{ + Selector: spec.mergedSelector, + Endpoints: spec.endpoints, + TargetLabels: monitoringv1.TargetLabels{ + FromPod: spec.mergedFromPod, + Metadata: spec.metadata, + }, + Limits: spec.limits, + FilterRunning: spec.filterRunning, + }, + } + + unstructuredMap, err := toStrictUnstructured(gmpPM) + if err != nil { + return nil, fmt.Errorf("failed to marshal PodMonitoring: %w", err) + } + + u := &unstructured.Unstructured{Object: unstructuredMap} + u.SetAPIVersion(GMPAPIVersion) + u.SetKind(KindPodMonitoring) + + return u, nil +} + +// buildClusterPodMonitoring constructs a GMP ClusterPodMonitoring resource from common spec. +func buildClusterPodMonitoring( + srcMeta metav1.ObjectMeta, + spec *commonMonitorSpec, + logger *slog.Logger, +) (*unstructured.Unstructured, error) { + if spec == nil { + return nil, errors.New("spec cannot be nil") + } + + gmpCPM := &monitoringv1.ClusterPodMonitoring{ + TypeMeta: BuildTypeMeta(KindClusterPodMonitoring), + ObjectMeta: CopyObjectMeta(srcMeta, "", logger), + Spec: monitoringv1.ClusterPodMonitoringSpec{ + Selector: spec.mergedSelector, + Endpoints: spec.endpoints, + TargetLabels: monitoringv1.ClusterTargetLabels{ + FromPod: spec.mergedFromPod, + Metadata: spec.metadata, + }, + Limits: spec.limits, + FilterRunning: spec.filterRunning, + }, + } + + unstructuredMap, err := toStrictUnstructured(gmpCPM) + if err != nil { + return nil, fmt.Errorf("failed to marshal ClusterPodMonitoring: %w", err) + } + + u := &unstructured.Unstructured{Object: unstructuredMap} + u.SetAPIVersion(GMPAPIVersion) + u.SetKind(KindClusterPodMonitoring) + + return u, nil +} + +// resolveScrapeClass handles ScrapeClass resolution. +// Logs a warning that ScrapeClass settings will be lost. +// TODO(M2): Lookup ScrapeClass from Prometheus CR and merge its settings. +func resolveScrapeClass(name *string, logger *slog.Logger) { + if name != nil && *name != "" { + logger.Warn(fmt.Sprintf("ScrapeClass %q was not found in the inputs. The 'scrapeClassName' field has been dropped and inherited settings will be lost.", *name)) + } +} + +// validateScrapeProtocols logs a warning if any scrape protocol requires Protobuf. +func validateScrapeProtocols(protocols []pomonitoringv1.ScrapeProtocol, logger *slog.Logger) { + for _, sp := range protocols { + if sp == scrapeProtocolPrometheusProto || strings.Contains(strings.ToLower(string(sp)), "proto") { + logger.Warn("Scrape protocol settings (scrapeProtocols) requiring Protobuf are unsupported. Scrapes may fail if target lacks text fallback.") + break + } + } +} + +// resolveAttachMetadata appends "node" to metadata if attachMetadata.node is enabled. +func resolveAttachMetadata(attachMetadata *pomonitoringv1.AttachMetadata, baseMetadata *[]string, isCluster bool) *[]string { + if attachMetadata != nil && attachMetadata.Node != nil && *attachMetadata.Node { + if baseMetadata == nil { + defaults := namespacedMetadataDefaults + if isCluster { + defaults = clusterMetadataDefaults + } + union := unionMetadata([]string{labelNode}, defaults) + return &union + } + union := unionMetadata([]string{labelNode}, *baseMetadata) + return &union + } + return baseMetadata +} + +// resolveFilterRunning evaluates filterRunning settings across endpoints and resolves them to a single resource-level setting. +// TODO: Potential better optimization would be to split into separate PodMonitorings based on filterRunning value. +func resolveFilterRunning(filterRunnings []*bool, logger *slog.Logger, isCluster bool) *bool { + var hasFalse, hasTrue bool + for _, fr := range filterRunnings { + if fr != nil && !*fr { + hasFalse = true + } else { + hasTrue = true + } + } + if hasFalse { + falseVal := false + if hasTrue { + if isCluster { + logger.Warn("Endpoint-level configuration conflict detected: some endpoints are configured with 'filterRunning: false' and others with 'true' (or default), but GMP only supports 'filterRunning' at the resource level. Setting 'filterRunning: false' globally on the ClusterPodMonitoring resource.") + } else { + logger.Warn("Endpoint-level configuration conflict detected: some endpoints are configured with 'filterRunning: false' and others with 'true' (or default), but GMP only supports 'filterRunning' at the resource level. Setting 'filterRunning: false' globally.") + } + } + return &falseVal + } + return nil +} + +// resolveScrapeIntervalAndTimeout validates and caps timeout to interval if needed. +func resolveScrapeIntervalAndTimeout(logger *slog.Logger, interval, timeout string) (resolvedInterval, resolvedTimeout string, err error) { + // TODO(M2): Inherit global scrape interval from Prometheus CR if empty. + if interval == "" { + logger.Warn("Scrape interval is empty. Defaulting to '30s' as GMP requires this field.") + interval = "30s" + } + + intDur, err := prommodel.ParseDuration(interval) + if err != nil { + return "", "", fmt.Errorf("invalid interval %q: %w", interval, err) + } + + // TODO(M2): Inherit global scrape timeout from Prometheus CR if empty. + if timeout != "" { + toDur, err := prommodel.ParseDuration(timeout) + if err != nil { + return "", "", fmt.Errorf("invalid scrapeTimeout %q: %w", timeout, err) + } + if toDur > intDur { + logger.Warn("Scrape timeout is larger than scrape interval. Capping timeout to interval.", + slog.String("timeout", timeout), + slog.String("interval", interval)) + timeout = interval + } + } + return interval, timeout, nil +} + +// convertProxyURL verifies proxy URL credentials. +func convertProxyURL(proxyURL *string) (string, error) { + if proxyURL == nil { + return "", nil + } + if strings.Contains(*proxyURL, "@") { + return "", errors.New("proxyUrl contains credentials (matches '@'), which is blocked by GMP API validation") + } + return *proxyURL, nil +} + +// warnUnsupportedEndpointFields logs warnings for fields that GMP does not support. +func warnUnsupportedEndpointFields(logger *slog.Logger, followRedirects *bool, enableHTTP2 *bool, honorLabels bool, honorTimestamps *bool, trackTimestampsStaleness *bool, i int) { + if followRedirects != nil && !*followRedirects { + logger.Warn(fmt.Sprintf("endpoint [%d]: field 'followRedirects: false' is unsupported by GMP Managed Collection and has been dropped. The collector will always follow redirects.", i)) + } + if enableHTTP2 != nil && !*enableHTTP2 { + logger.Warn(fmt.Sprintf("endpoint [%d]: field 'enableHttp2: false' is unsupported by GMP Managed Collection and has been dropped. The collector will always negotiate HTTP/2 for TLS connections.", i)) + } + if honorLabels { + logger.Warn(fmt.Sprintf("endpoint [%d]: field 'honorLabels: true' is unsupported and dropped. GMP always overrides conflicting labels. Clashing metric labels will be renamed with the 'exported_' prefix.", i)) + } + if honorTimestamps != nil && *honorTimestamps { + logger.Warn(fmt.Sprintf("endpoint [%d]: field 'honorTimestamps: true' is unsupported and dropped. GMP always uses the scrape ingestion timestamp. Target metric timestamps will be ignored.", i)) + } + if trackTimestampsStaleness != nil { + logger.Warn(fmt.Sprintf("endpoint [%d]: field 'trackTimestampsStaleness' is unsupported in GMP and has been dropped.", i)) + } +} + +// combineAndConvertRelabelings combines promoted pre-scrape rules and converts metricRelabelings. +func combineAndConvertRelabelings(logger *slog.Logger, promoted []monitoringv1.RelabelingRule, configs []pomonitoringv1.RelabelConfig) []monitoringv1.RelabelingRule { + totalRules := len(promoted) + len(configs) + if totalRules == 0 { + return nil + } + + allRules := make([]monitoringv1.RelabelingRule, 0, totalRules) + allRules = append(allRules, promoted...) + + if len(configs) > 0 { + rules := convertMetricRelabelings(logger, configs) + allRules = append(allRules, rules...) + } + + if len(allRules) == 0 { + return nil + } + return allRules +} diff --git a/pkg/migrate/helpers_test.go b/pkg/migrate/helpers_test.go index 7a3f07a20c..efca7e1f48 100644 --- a/pkg/migrate/helpers_test.go +++ b/pkg/migrate/helpers_test.go @@ -22,6 +22,7 @@ import ( monitoringv1 "github.com/GoogleCloudPlatform/prometheus-engine/pkg/operator/apis/monitoring/v1" "github.com/google/go-cmp/cmp" pomonitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" + "github.com/prometheus/prometheus/google/export" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -30,9 +31,10 @@ import ( func newTestConversionContext() *conversionContext { return &conversionContext{ - logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), - cache: NewResourceCache(), - namespace: "default", + logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), + cache: NewResourceCache(), + sourceNamespace: "default", + targetNamespace: "default", } } @@ -597,3 +599,361 @@ func TestConvertLimits(t *testing.T) { }) } } + +func TestDetermineNamespaceScoping(t *testing.T) { + tests := []struct { + name string + nsSel pomonitoringv1.NamespaceSelector + defaultNS string + expectedNS []string + expectedIsCluster bool + expectErr bool + }{ + { + name: "any namespace true", + nsSel: pomonitoringv1.NamespaceSelector{Any: true}, + defaultNS: "default", + expectedNS: nil, + expectedIsCluster: true, + expectErr: false, + }, + { + name: "specific matchNames", + nsSel: pomonitoringv1.NamespaceSelector{MatchNames: []string{"ns1", "ns2"}}, + defaultNS: "default", + expectedNS: []string{"ns1", "ns2"}, + expectedIsCluster: false, + expectErr: false, + }, + { + name: "empty matchNames fallback to default", + nsSel: pomonitoringv1.NamespaceSelector{}, + defaultNS: "default", + expectedNS: []string{"default"}, + expectedIsCluster: false, + expectErr: false, + }, + { + name: "invalid matchNames", + nsSel: pomonitoringv1.NamespaceSelector{MatchNames: []string{""}}, + defaultNS: "default", + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ns, isCluster, err := determineNamespaceScoping(tc.nsSel, tc.defaultNS) + if (err != nil) != tc.expectErr { + t.Fatalf("determineNamespaceScoping() error = %v, expectErr = %v", err, tc.expectErr) + } + if tc.expectErr { + return + } + if isCluster != tc.expectedIsCluster { + t.Errorf("determineNamespaceScoping() isCluster = %v, want %v", isCluster, tc.expectedIsCluster) + } + if diff := cmp.Diff(tc.expectedNS, ns); diff != "" { + t.Errorf("determineNamespaceScoping() namespaces mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolveScrapeIntervalAndTimeout(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + tests := []struct { + name string + interval string + timeout string + expectedInt string + expectedTimeout string + expectErr bool + }{ + { + name: "empty defaults to 30s", + interval: "", + timeout: "", + expectedInt: "30s", + expectedTimeout: "", + expectErr: false, + }, + { + name: "valid interval and timeout", + interval: "15s", + timeout: "10s", + expectedInt: "15s", + expectedTimeout: "10s", + expectErr: false, + }, + { + name: "timeout larger than interval is capped", + interval: "10s", + timeout: "20s", + expectedInt: "10s", + expectedTimeout: "10s", + expectErr: false, + }, + { + name: "invalid interval duration", + interval: "invalid", + expectErr: true, + }, + { + name: "invalid timeout duration", + interval: "15s", + timeout: "invalid", + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + intVal, toVal, err := resolveScrapeIntervalAndTimeout(logger, tc.interval, tc.timeout) + if (err != nil) != tc.expectErr { + t.Fatalf("resolveScrapeIntervalAndTimeout() error = %v, expectErr = %v", err, tc.expectErr) + } + if tc.expectErr { + return + } + if intVal != tc.expectedInt { + t.Errorf("resolveScrapeIntervalAndTimeout() interval = %v, want %v", intVal, tc.expectedInt) + } + if toVal != tc.expectedTimeout { + t.Errorf("resolveScrapeIntervalAndTimeout() timeout = %v, want %v", toVal, tc.expectedTimeout) + } + }) + } +} + +func TestConvertProxyURL(t *testing.T) { + tests := []struct { + name string + proxyURL *string + expectedURL string + expectErr bool + }{ + { + name: "nil proxyURL", + proxyURL: nil, + expectedURL: "", + expectErr: false, + }, + { + name: "valid proxyURL without credentials", + proxyURL: ptrTo("http://proxy.example.com"), + expectedURL: "http://proxy.example.com", + expectErr: false, + }, + { + name: "proxyURL with credentials returns error", + proxyURL: ptrTo("http://user:pass@proxy.example.com"), + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + url, err := convertProxyURL(tc.proxyURL) + if (err != nil) != tc.expectErr { + t.Fatalf("convertProxyURL() error = %v, expectErr = %v", err, tc.expectErr) + } + if tc.expectErr { + return + } + if url != tc.expectedURL { + t.Errorf("convertProxyURL() = %v, want %v", url, tc.expectedURL) + } + }) + } +} + +func TestResolveFilterRunning(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + tests := []struct { + name string + filterRunnings []*bool + isCluster bool + expected *bool + }{ + { + name: "empty cluster monitor defaults to nil", + filterRunnings: nil, + isCluster: true, + expected: nil, + }, + { + name: "empty namespaced monitor defaults to nil", + filterRunnings: nil, + isCluster: false, + expected: nil, + }, + { + name: "all true resolves to nil (GMP default)", + filterRunnings: []*bool{ptrTo(true), ptrTo(true)}, + isCluster: false, + expected: nil, + }, + { + name: "all false resolves to false", + filterRunnings: []*bool{ptrTo(false), ptrTo(false)}, + isCluster: false, + expected: ptrTo(false), + }, + { + name: "mixed true and false resolves to false", + filterRunnings: []*bool{ptrTo(true), ptrTo(false)}, + isCluster: false, + expected: ptrTo(false), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveFilterRunning(tc.filterRunnings, logger, tc.isCluster) + if diff := cmp.Diff(tc.expected, got); diff != "" { + t.Errorf("resolveFilterRunning() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolveAttachMetadata(t *testing.T) { + tests := []struct { + name string + attachMetadata *pomonitoringv1.AttachMetadata + base *[]string + isCluster bool + expected *[]string + }{ + { + name: "nil attachMetadata returns base", + attachMetadata: nil, + base: &[]string{labelPod}, + isCluster: false, + expected: &[]string{labelPod}, + }, + { + name: "attachMetadata node false returns base", + attachMetadata: &pomonitoringv1.AttachMetadata{Node: ptrTo(false)}, + base: nil, + isCluster: false, + expected: nil, + }, + { + name: "namespaced monitor with nil base returns node", + attachMetadata: &pomonitoringv1.AttachMetadata{Node: ptrTo(true)}, + base: nil, + isCluster: false, + expected: &[]string{labelContainer, labelNode, labelPod, labelTopLevelControllerName, labelTopLevelControllerType}, + }, + { + name: "cluster monitor with nil base returns node plus cluster defaults", + attachMetadata: &pomonitoringv1.AttachMetadata{Node: ptrTo(true)}, + base: nil, + isCluster: true, + expected: &[]string{labelContainer, export.KeyNamespace, labelNode, labelPod, labelTopLevelControllerName, labelTopLevelControllerType}, + }, + { + name: "namespaced monitor with existing base appends node", + attachMetadata: &pomonitoringv1.AttachMetadata{Node: ptrTo(true)}, + base: &[]string{labelPod}, + isCluster: false, + expected: &[]string{labelNode, labelPod}, + }, + { + name: "namespaced monitor with node already present does not duplicate", + attachMetadata: &pomonitoringv1.AttachMetadata{Node: ptrTo(true)}, + base: &[]string{labelNode, labelPod}, + isCluster: false, + expected: &[]string{labelNode, labelPod}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveAttachMetadata(tc.attachMetadata, tc.base, tc.isCluster) + if diff := cmp.Diff(tc.expected, got); diff != "" { + t.Errorf("resolveAttachMetadata() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestToStrictUnstructured(t *testing.T) { + tests := []struct { + name string + obj any + }{ + { + name: "scrape limits with uint64 converts to int64", + obj: &monitoringv1.ScrapeLimits{ + Samples: 5000, + Labels: 100, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + u, err := toStrictUnstructured(tc.obj) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Verify uint64 fields are converted to int64. + if val, ok := u["samples"]; ok { + if _, isInt64 := val.(int64); !isInt64 { + t.Errorf("expected samples to be int64, got %T", val) + } + } + // Verify DeepCopy does not panic. + unstruct := &unstructured.Unstructured{Object: u} + _ = unstruct.DeepCopy() + }) + } +} + +func TestDecoupledNamespaces(t *testing.T) { + ctx := newTestConversionContext() + ctx.sourceNamespace = "source-ns" + ctx.targetNamespace = "target-ns" + + // Verify that secret extraction reads from sourceNamespace. + if err := addSecretToCache(ctx.cache, "source-ns", "my-secret", "user", "admin", true); err != nil { + t.Fatalf("failed to add secret to cache: %v", err) + } + sel := corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + Key: "user", + } + val, err := ctx.extractSecretKey(sel) + if err != nil { + t.Fatalf("extractSecretKey() unexpected error: %v", err) + } + if val != "admin" { + t.Errorf("extractSecretKey() = %q, want %q", val, "admin") + } + + // Verify that ConfigMap to Secret conversion reads from sourceNamespace and generates Secret in targetNamespace. + if err := addConfigMapToCache(ctx.cache, "source-ns", "tls-cm", "ca.crt", "cert-data"); err != nil { + t.Fatalf("failed to add configmap to cache: %v", err) + } + cmSel := &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "tls-cm"}, + Key: "ca.crt", + } + secretSel, err := ctx.convertConfigMapToSecretSelector(cmSel) + if err != nil { + t.Fatalf("convertConfigMapToSecretSelector() unexpected error: %v", err) + } + if secretSel.Secret.Namespace != "target-ns" { + t.Errorf("expected selector namespace %q, got %q", "target-ns", secretSel.Secret.Namespace) + } + genSecrets := ctx.getGeneratedSecrets() + if len(genSecrets) != 1 { + t.Fatalf("expected 1 generated secret, got %d", len(genSecrets)) + } + if genSecrets[0].GetNamespace() != "target-ns" { + t.Errorf("expected generated secret namespace %q, got %q", "target-ns", genSecrets[0].GetNamespace()) + } +} diff --git a/pkg/migrate/podmonitor.go b/pkg/migrate/podmonitor.go index e6ff6a6bbd..d6b36215ab 100644 --- a/pkg/migrate/podmonitor.go +++ b/pkg/migrate/podmonitor.go @@ -24,7 +24,6 @@ import ( monitoringv1 "github.com/GoogleCloudPlatform/prometheus-engine/pkg/operator/apis/monitoring/v1" pomonitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" - prommodel "github.com/prometheus/common/model" "github.com/prometheus/prometheus/google/export" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -62,12 +61,14 @@ func (c *PodMonitorConverter) Convert(_ context.Context, logger *slog.Logger, un // TODO(M2): Override local namespace scoping if Prometheus CR specifies ignoreNamespaceSelectors. // 2. Determine Scoping based on namespaceSelector. - nsSel := podMonitor.Spec.NamespaceSelector + targetNamespaces, isClusterScoped, err := determineNamespaceScoping(podMonitor.Spec.NamespaceSelector, podMonitor.Namespace) + if err != nil { + return nil, err + } - if nsSel.Any { - // Case A: namespaceSelector.any = true -> Single ClusterPodMonitoring. + if isClusterScoped { logger.Info("namespaceSelector selects 'any: true'. Translated to 'ClusterPodMonitoring'") - u, generatedSecrets, err := c.convertToClusterPodMonitoring(&podMonitor, logger, cache) + u, generatedSecrets, err := c.convertToClusterPodMonitoring(&podMonitor, podMonitor.Namespace, logger, cache) if err != nil { return nil, err } @@ -76,45 +77,21 @@ func (c *PodMonitorConverter) Convert(_ context.Context, logger *slog.Logger, un return outputs, nil } - if len(nsSel.MatchNames) > 0 { - // Case B: namespaceSelector.matchNames listed -> Multiple PodMonitoring resources (one per namespace). - targetNamespaces := ParseAndCleanNamespaces(nsSel.MatchNames) - - // 2.1 Fail if all provided names were empty/whitespace (broken config). - if len(targetNamespaces) == 0 { - return nil, errors.New("namespaceSelector.matchNames contains only empty or invalid values") - } - - if len(targetNamespaces) > 1 { - logger.Info("namespaceSelector targets multiple namespaces. Generating separate PodMonitoring resources for each namespace", - slog.Any("namespaces", targetNamespaces), - ) - } + if len(targetNamespaces) > 1 { + logger.Info("namespaceSelector targets multiple namespaces. Generating separate PodMonitoring resources for each namespace", + slog.Any("namespaces", targetNamespaces), + ) + } - // 2.2 Convert to a base namespaced PodMonitoring. - baseU, generatedSecrets, err := c.convertToPodMonitoring(&podMonitor, logger, cache) + var outputs []*unstructured.Unstructured + for _, ns := range targetNamespaces { + u, generatedSecrets, err := c.convertToPodMonitoring(&podMonitor, ns, logger, cache) if err != nil { return nil, err } - - // 2.3 Clone and apply target namespaces. - var outputs []*unstructured.Unstructured - for _, ns := range targetNamespaces { - uClone := baseU.DeepCopy() - uClone.SetNamespace(ns) - outputs = append(outputs, uClone) - } + outputs = append(outputs, u) outputs = append(outputs, generatedSecrets...) - return outputs, nil } - - // Case C: namespaceSelector is empty/omitted -> Single PodMonitoring in local namespace. - u, generatedSecrets, err := c.convertToPodMonitoring(&podMonitor, logger, cache) - if err != nil { - return nil, err - } - outputs := []*unstructured.Unstructured{u} - outputs = append(outputs, generatedSecrets...) return outputs, nil } @@ -147,123 +124,34 @@ func (c *PodMonitorConverter) convertEndpoints( gmpEp.Params = ep.Params // 3. Scrape Intervals & Timeouts. - gmpEp.Interval = string(ep.Interval) - gmpEp.Timeout = string(ep.ScrapeTimeout) - - // TODO(M2): Inherit global scrape interval from Prometheus CR if empty. - if gmpEp.Interval == "" { - convCtx.logger.Warn("Scrape interval is empty. Defaulting to '30s' as GMP requires this field.") - gmpEp.Interval = "30s" - } - - intDur, err := prommodel.ParseDuration(gmpEp.Interval) + interval, timeout, err := resolveScrapeIntervalAndTimeout(convCtx.logger, string(ep.Interval), string(ep.ScrapeTimeout)) if err != nil { - return nil, fmt.Errorf("endpoint [%d]: invalid interval %q: %w", i, gmpEp.Interval, err) - } - - if gmpEp.Timeout != "" { - toDur, err := prommodel.ParseDuration(gmpEp.Timeout) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: invalid scrapeTimeout %q: %w", i, gmpEp.Timeout, err) - } - if toDur > intDur { - convCtx.logger.Warn("Scrape timeout is larger than scrape interval. Capping timeout to interval.", - slog.String("timeout", gmpEp.Timeout), - slog.String("interval", gmpEp.Interval)) - gmpEp.Timeout = gmpEp.Interval - } + return nil, fmt.Errorf("endpoint [%d]: %w", i, err) } - // TODO(M2): Inherit global scrape timeout from Prometheus CR if empty. + gmpEp.Interval = interval + gmpEp.Timeout = timeout // 4. Relabeling Rules (Promoted Pre-Scrape + MetricRelabelings). - totalRules := len(epResults[i].PromotedRules) + len(ep.MetricRelabelConfigs) - var allRules []monitoringv1.RelabelingRule - if totalRules > 0 { - allRules = make([]monitoringv1.RelabelingRule, 0, totalRules) - allRules = append(allRules, epResults[i].PromotedRules...) - if len(ep.MetricRelabelConfigs) > 0 { - rules, err := convertMetricRelabelings(convCtx.logger, ep.MetricRelabelConfigs) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: %w", i, err) - } - allRules = append(allRules, rules...) - } - } - gmpEp.MetricRelabeling = allRules + gmpEp.MetricRelabeling = combineAndConvertRelabelings(convCtx.logger, epResults[i].PromotedRules, ep.MetricRelabelConfigs) // Proxy Settings. - if ep.ProxyURL != nil { - if strings.Contains(*ep.ProxyURL, "@") { - return nil, fmt.Errorf("endpoint [%d]: proxyUrl contains credentials (matches '@'), which is blocked by GMP API validation", i) - } - gmpEp.ProxyURL = *ep.ProxyURL + proxyURL, err := convertProxyURL(ep.ProxyURL) + if err != nil { + return nil, fmt.Errorf("endpoint [%d]: %w", i, err) } + gmpEp.ProxyURL = proxyURL // noProxy, proxyConnectHeader, and proxyFromEnvironment fields are silently dropped. // The pinned Prometheus Operator version lacks these fields, and GMP does not support them anyway. // Auth & TLS mappings. - if ep.BasicAuth != nil { - ba, err := convCtx.convertBasicAuth(ep.BasicAuth) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: basicAuth: %w", i, err) - } - gmpEp.BasicAuth = ba - } - if ep.OAuth2 != nil { - oa, err := convCtx.convertOAuth2(ep.OAuth2) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: oAuth2: %w", i, err) - } - gmpEp.OAuth2 = oa - } - if ep.TLSConfig != nil { - tls, err := convCtx.convertSafeTLSConfig(ep.TLSConfig) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: tlsConfig: %w", i, err) - } - gmpEp.TLS = tls - } - if ep.Authorization != nil { - auth, err := convCtx.convertAuthorization(ep.Authorization) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: authorization: %w", i, err) - } - gmpEp.Authorization = auth - } - - // Handle deprecated BearerTokenSecret -> Authorization. - if ep.BearerTokenSecret.Name != "" { // nolint:staticcheck // Map deprecated BearerTokenSecret for backwards compatibility. - if gmpEp.Authorization != nil { - convCtx.logger.Warn("Endpoint has both 'bearerTokenSecret' and 'authorization' defined. Dropping 'bearerTokenSecret'.", - slog.Int("endpoint_index", i)) - } else { - tokenSecret := ep.BearerTokenSecret // nolint:staticcheck // Map deprecated BearerTokenSecret for backwards compatibility. - auth, err := convCtx.convertAuthorization(&pomonitoringv1.SafeAuthorization{Credentials: &tokenSecret}) - if err != nil { - return nil, fmt.Errorf("endpoint [%d]: bearerTokenSecret: %w", i, err) - } - gmpEp.Authorization = auth - } + err = convCtx.applyAuthAndTLS(&gmpEp, ep.BasicAuth, ep.OAuth2, ep.TLSConfig, ep.Authorization, ep.BearerTokenSecret) // nolint:staticcheck // Map deprecated BearerTokenSecret for backwards compatibility. + if err != nil { + return nil, fmt.Errorf("endpoint [%d]: %w", i, err) } // 5. Warnings for Unsupported Fields in Endpoint. - if ep.FollowRedirects != nil && !*ep.FollowRedirects { - convCtx.logger.Warn(fmt.Sprintf("endpoint [%d]: field 'followRedirects: false' is unsupported by GMP Managed Collection and has been dropped. The collector will always follow redirects.", i)) - } - if ep.EnableHttp2 != nil && !*ep.EnableHttp2 { - convCtx.logger.Warn(fmt.Sprintf("endpoint [%d]: field 'enableHttp2: false' is unsupported by GMP Managed Collection and has been dropped. The collector will always negotiate HTTP/2 for TLS connections.", i)) - } - - if ep.HonorLabels { - convCtx.logger.Warn("Field 'honorLabels: true' is unsupported and dropped. GMP always overrides conflicting labels. Clashing metric labels will be renamed with the 'exported_' prefix.") - } - if ep.HonorTimestamps != nil && *ep.HonorTimestamps { - convCtx.logger.Warn("Field 'honorTimestamps: true' is unsupported and dropped. GMP always uses the scrape ingestion timestamp. Target metric timestamps will be ignored.") - } - if ep.TrackTimestampsStaleness != nil { - convCtx.logger.Warn("Field 'trackTimestampsStaleness' is unsupported in GMP and has been dropped.") - } + warnUnsupportedEndpointFields(convCtx.logger, ep.FollowRedirects, ep.EnableHttp2, ep.HonorLabels, ep.HonorTimestamps, ep.TrackTimestampsStaleness, i) gmpEndpoints = append(gmpEndpoints, gmpEp) } @@ -271,214 +159,115 @@ func (c *PodMonitorConverter) convertEndpoints( return gmpEndpoints, nil } -func (c *PodMonitorConverter) convertToPodMonitoring(pm *pomonitoringv1.PodMonitor, logger *slog.Logger, cache *ResourceCache) (*unstructured.Unstructured, []*unstructured.Unstructured, error) { +func (c *PodMonitorConverter) convertMonitorSpec(pm *pomonitoringv1.PodMonitor, targetNamespace string, logger *slog.Logger, cache *ResourceCache, isCluster bool) (*commonMonitorSpec, error) { convCtx := &conversionContext{ - logger: logger, - cache: cache, - namespace: pm.Namespace, + logger: logger, + cache: cache, + sourceNamespace: pm.Namespace, + targetNamespace: targetNamespace, } rules, err := extractPreScrapeRelabelings(logger, pm.Spec.PodMetricsEndpoints) if err != nil { - return nil, nil, err + return nil, err } endpoints, err := c.convertEndpoints(convCtx, pm.Spec.PodMetricsEndpoints, rules.PerEndpoint) if err != nil { - return nil, nil, err + return nil, err } mergedFromPod := mergeFromPod(logger, convertTargetLabels(logger, pm.Spec.PodTargetLabels, pm.Spec.JobLabel, "Pod"), rules.ResourceCombined.FromPod) mergedSelector, err := mergeLabelSelector(pm.Spec.Selector, rules.ResourceCombined.MatchLabels, rules.ResourceCombined.MatchExpressions) if err != nil { - return nil, nil, err + return nil, err } if len(mergedSelector.MatchLabels) == 0 && len(mergedSelector.MatchExpressions) == 0 { - logger.Warn("Resulting PodMonitoring selector is empty. It will select and scrape all pods in this namespace. Verify if this is intended.") + if isCluster { + logger.Warn("Resulting ClusterPodMonitoring selector is empty. It will select and scrape all pods across all namespaces. Verify if this is intended.") + } else { + logger.Warn("Resulting PodMonitoring selector is empty. It will select and scrape all pods in this namespace. Verify if this is intended.") + } } // Spec-level warnings for unsupported fields. warnUnsupportedSpecFields(logger, &pm.Spec) - // TODO(M2): Resolve and merge ScrapeClass configurations from Prometheus CR if scrapeClassName is specified. - if pm.Spec.ScrapeClassName != nil && *pm.Spec.ScrapeClassName != "" { - logger.Warn(fmt.Sprintf("ScrapeClass %q was not found in the inputs. The 'scrapeClassName' field has been dropped and inherited settings will be lost.", *pm.Spec.ScrapeClassName)) - } - // Check against the PrometheusProto enum value. - if slices.Contains(pm.Spec.ScrapeProtocols, scrapeProtocolPrometheusProto) { - logger.Warn("Scrape protocol settings (scrapeProtocols) requiring Protobuf are unsupported. Scrapes may fail if target lacks text fallback.") - } + resolveScrapeClass(pm.Spec.ScrapeClassName, logger) + validateScrapeProtocols(pm.Spec.ScrapeProtocols, logger) - var filteredMetadata *[]string - if rules.ResourceCombined.Metadata != nil { - union := unionMetadata(*rules.ResourceCombined.Metadata, namespacedMetadataDefaults) - var md []string - for _, m := range union { - if m != export.KeyNamespace { - md = append(md, m) - } else { - logger.Warn("Relabeling rule referencing namespace metadata is unsupported in namespaced PodMonitoring (it is only allowed in ClusterPodMonitoring). The metadata entry has been omitted .") - } - } - if len(md) > 0 { - filteredMetadata = &md - } - } - - // In GMP, Metadata: nil on a PodMonitoring defaults to emitting namespaced defaults (container, pod, etc.). - // When setting Metadata explicitly for AttachMetadata.Node, we must merge namespacedMetadataDefaults so that default metadata is not dropped. - if pm.Spec.AttachMetadata != nil && pm.Spec.AttachMetadata.Node != nil && *pm.Spec.AttachMetadata.Node { - if filteredMetadata == nil { - union := unionMetadata([]string{labelNode}, namespacedMetadataDefaults) - filteredMetadata = &union - } else { - union := unionMetadata([]string{labelNode}, *filteredMetadata) - filteredMetadata = &union - } - } + filteredMetadata := filterMetadata(rules.ResourceCombined.Metadata, isCluster, logger) + filteredMetadata = resolveAttachMetadata(pm.Spec.AttachMetadata, filteredMetadata, isCluster) - var hasFalse, hasTrue bool + var filterRunnings []*bool for _, ep := range pm.Spec.PodMetricsEndpoints { - if ep.FilterRunning != nil && !*ep.FilterRunning { - hasFalse = true - } else { - hasTrue = true - } - } - // A nil filterRunning defaults to true in the GMP operator. - var filterRunning *bool - if hasFalse { - falseVal := false - filterRunning = &falseVal - if hasTrue { - logger.Warn("Endpoint-level configuration conflict detected: some endpoints are configured with 'filterRunning: false' and others with 'true' (or default), but GMP only supports 'filterRunning' at the resource level. Setting 'filterRunning: false' globally on the PodMonitoring resource.") - } + filterRunnings = append(filterRunnings, ep.FilterRunning) } + filterRunning := resolveFilterRunning(filterRunnings, logger, isCluster) limits := convertLimits(pm.Spec.SampleLimit, pm.Spec.LabelLimit, pm.Spec.LabelNameLengthLimit, pm.Spec.LabelValueLengthLimit) - gmpPM := &monitoringv1.PodMonitoring{ - TypeMeta: BuildTypeMeta(KindPodMonitoring), - ObjectMeta: CopyObjectMeta(pm.ObjectMeta, pm.Namespace, logger), - Spec: monitoringv1.PodMonitoringSpec{ - Selector: mergedSelector, - Endpoints: endpoints, - TargetLabels: monitoringv1.TargetLabels{ - FromPod: mergedFromPod, - Metadata: filteredMetadata, - }, - Limits: limits, - FilterRunning: filterRunning, - }, - } - - unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(gmpPM) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal PodMonitoring: %w", err) - } - - u := &unstructured.Unstructured{Object: unstructuredMap} - u.SetAPIVersion(GMPAPIVersion) - u.SetKind(KindPodMonitoring) - - return u, convCtx.getGeneratedSecrets(), nil + return &commonMonitorSpec{ + endpoints: endpoints, + mergedFromPod: mergedFromPod, + mergedSelector: mergedSelector, + metadata: filteredMetadata, + filterRunning: filterRunning, + limits: limits, + generatedSecrets: convCtx.getGeneratedSecrets(), + }, nil } -func (c *PodMonitorConverter) convertToClusterPodMonitoring(pm *pomonitoringv1.PodMonitor, logger *slog.Logger, cache *ResourceCache) (*unstructured.Unstructured, []*unstructured.Unstructured, error) { - convCtx := &conversionContext{ - logger: logger, - cache: cache, - namespace: pm.Namespace, - } - rules, err := extractPreScrapeRelabelings(logger, pm.Spec.PodMetricsEndpoints) - if err != nil { - return nil, nil, err - } - endpoints, err := c.convertEndpoints(convCtx, pm.Spec.PodMetricsEndpoints, rules.PerEndpoint) - if err != nil { - return nil, nil, err - } - - mergedFromPod := mergeFromPod(logger, convertTargetLabels(logger, pm.Spec.PodTargetLabels, pm.Spec.JobLabel, "Pod"), rules.ResourceCombined.FromPod) - mergedSelector, err := mergeLabelSelector(pm.Spec.Selector, rules.ResourceCombined.MatchLabels, rules.ResourceCombined.MatchExpressions) - if err != nil { - return nil, nil, err - } - if len(mergedSelector.MatchLabels) == 0 && len(mergedSelector.MatchExpressions) == 0 { - logger.Warn("Resulting ClusterPodMonitoring selector is empty. It will select and scrape all pods across all namespaces. Verify if this is intended.") - } - - // Spec-level warnings for unsupported fields. - warnUnsupportedSpecFields(logger, &pm.Spec) - // TODO(M2): Resolve and merge ScrapeClass configurations from Prometheus CR if scrapeClassName is specified. - if pm.Spec.ScrapeClassName != nil && *pm.Spec.ScrapeClassName != "" { - logger.Warn(fmt.Sprintf("ScrapeClass %q was not found in the inputs. The 'scrapeClassName' field has been dropped and inherited settings will be lost.", *pm.Spec.ScrapeClassName)) +// filterMetadata applies namespaced or cluster metadata defaults and strips namespace metadata in namespaced resources. +func filterMetadata(metadata *[]string, isCluster bool, logger *slog.Logger) *[]string { + if metadata == nil { + return nil } - // Check against the PrometheusProto enum value. - if slices.Contains(pm.Spec.ScrapeProtocols, scrapeProtocolPrometheusProto) { - logger.Warn("Scrape protocol settings (scrapeProtocols) requiring Protobuf are unsupported. Scrapes may fail if target lacks text fallback.") + if isCluster { + union := unionMetadata(*metadata, clusterMetadataDefaults) + return &union } - - var filteredMetadata *[]string - if rules.ResourceCombined.Metadata != nil { - union := unionMetadata(*rules.ResourceCombined.Metadata, clusterMetadataDefaults) - filteredMetadata = &union - } - - // In GMP, Metadata: nil on a ClusterPodMonitoring defaults to emitting namespace and other cluster defaults. - // When setting Metadata explicitly for AttachMetadata.Node, we must merge clusterMetadataDefaults so that namespace is not dropped. - if pm.Spec.AttachMetadata != nil && pm.Spec.AttachMetadata.Node != nil && *pm.Spec.AttachMetadata.Node { - if filteredMetadata == nil { - union := unionMetadata([]string{labelNode}, clusterMetadataDefaults) - filteredMetadata = &union + union := unionMetadata(*metadata, namespacedMetadataDefaults) + var md []string + for _, m := range union { + if m != export.KeyNamespace { + md = append(md, m) } else { - union := unionMetadata([]string{labelNode}, *filteredMetadata) - filteredMetadata = &union + logger.Warn("Relabeling rule referencing namespace metadata is unsupported in namespaced PodMonitoring (it is only allowed in ClusterPodMonitoring). The metadata entry has been omitted.") } } + return &md +} - var hasFalse, hasTrue bool - for _, ep := range pm.Spec.PodMetricsEndpoints { - if ep.FilterRunning != nil && !*ep.FilterRunning { - hasFalse = true - } else { - hasTrue = true - } - } - // A nil filterRunning defaults to true in the GMP operator. - var filterRunning *bool - if hasFalse { - falseVal := false - filterRunning = &falseVal - if hasTrue { - logger.Warn("Endpoint-level configuration conflict detected: some endpoints are configured with 'filterRunning: false' and others with 'true' (or default), but GMP only supports 'filterRunning' at the resource level. Setting 'filterRunning: false' globally on the ClusterPodMonitoring resource.") - } +// convertToMonitoringResource is a parameterized helper that converts a PodMonitor to either a PodMonitoring or ClusterPodMonitoring resource. +func (c *PodMonitorConverter) convertToMonitoringResource( + pm *pomonitoringv1.PodMonitor, + targetNamespace string, + logger *slog.Logger, + cache *ResourceCache, + isCluster bool, +) (*unstructured.Unstructured, []*unstructured.Unstructured, error) { + res, err := c.convertMonitorSpec(pm, targetNamespace, logger, cache, isCluster) + if err != nil { + return nil, nil, err } - limits := convertLimits(pm.Spec.SampleLimit, pm.Spec.LabelLimit, pm.Spec.LabelNameLengthLimit, pm.Spec.LabelValueLengthLimit) - - gmpCPM := &monitoringv1.ClusterPodMonitoring{ - TypeMeta: BuildTypeMeta(KindClusterPodMonitoring), - ObjectMeta: CopyObjectMeta(pm.ObjectMeta, "", logger), - Spec: monitoringv1.ClusterPodMonitoringSpec{ - Selector: mergedSelector, - Endpoints: endpoints, - TargetLabels: monitoringv1.ClusterTargetLabels{ - FromPod: mergedFromPod, - Metadata: filteredMetadata, - }, - Limits: limits, - FilterRunning: filterRunning, - }, + var u *unstructured.Unstructured + if isCluster { + u, err = buildClusterPodMonitoring(pm.ObjectMeta, res, logger) + } else { + u, err = buildPodMonitoring(pm.ObjectMeta, targetNamespace, res, logger) } - - unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(gmpCPM) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal ClusterPodMonitoring: %w", err) + return nil, nil, err } - u := &unstructured.Unstructured{Object: unstructuredMap} - u.SetAPIVersion(GMPAPIVersion) - u.SetKind(KindClusterPodMonitoring) + return u, res.generatedSecrets, nil +} + +func (c *PodMonitorConverter) convertToPodMonitoring(pm *pomonitoringv1.PodMonitor, targetNamespace string, logger *slog.Logger, cache *ResourceCache) (*unstructured.Unstructured, []*unstructured.Unstructured, error) { + return c.convertToMonitoringResource(pm, targetNamespace, logger, cache, false) +} - return u, convCtx.getGeneratedSecrets(), nil +func (c *PodMonitorConverter) convertToClusterPodMonitoring(pm *pomonitoringv1.PodMonitor, targetNamespace string, logger *slog.Logger, cache *ResourceCache) (*unstructured.Unstructured, []*unstructured.Unstructured, error) { + return c.convertToMonitoringResource(pm, targetNamespace, logger, cache, true) } func unionMetadata(extracted []string, defaults []string) []string { diff --git a/pkg/migrate/podmonitor_test.go b/pkg/migrate/podmonitor_test.go index 44d78a4b28..42e038ed5d 100644 --- a/pkg/migrate/podmonitor_test.go +++ b/pkg/migrate/podmonitor_test.go @@ -42,7 +42,7 @@ func TestPodMonitorConversion(t *testing.T) { dontWantWarnings []string }{ { - name: "Case A: Cluster-Scoped (Any Namespace)", + name: "Cluster-Scoped (Any Namespace)", input: &pomonitoringv1.PodMonitor{ TypeMeta: metav1.TypeMeta{ APIVersion: "monitoring.coreos.com/v1", @@ -83,7 +83,7 @@ func TestPodMonitorConversion(t *testing.T) { }, }, { - name: "Case B: Multi-Namespace Split", + name: "Multi-Namespace Split", input: &pomonitoringv1.PodMonitor{ TypeMeta: metav1.TypeMeta{ APIVersion: "monitoring.coreos.com/v1", @@ -140,7 +140,7 @@ func TestPodMonitorConversion(t *testing.T) { }, }, { - name: "Case B.2: Namespace Deduplication & Trimming", + name: "Namespace Deduplication & Trimming", input: &pomonitoringv1.PodMonitor{ TypeMeta: metav1.TypeMeta{ APIVersion: "monitoring.coreos.com/v1", @@ -180,7 +180,93 @@ func TestPodMonitorConversion(t *testing.T) { }, }, { - name: "Case B.3: Broken Config", + name: "Multiple Target Namespaces sets SecretReferences to target namespace upon creation", + input: &pomonitoringv1.PodMonitor{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "monitoring.coreos.com/v1", + Kind: KindPodMonitor, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-monitor", + Namespace: "default", + }, + Spec: pomonitoringv1.PodMonitorSpec{ + NamespaceSelector: pomonitoringv1.NamespaceSelector{ + MatchNames: []string{"ns-1", "ns-2"}, + }, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "secret-app"}, + }, + PodMetricsEndpoints: []pomonitoringv1.PodMetricsEndpoint{ + { + Port: "metrics", + BasicAuth: &pomonitoringv1.BasicAuth{ + Username: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "auth"}, Key: "user"}, + Password: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "auth"}, Key: "pass"}, + }, + }, + }, + }, + }, + expected: []runtime.Object{ + &monitoringv1.PodMonitoring{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "monitoring.googleapis.com/v1", + Kind: KindPodMonitoring, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-monitor", + Namespace: "ns-1", + }, + Spec: monitoringv1.PodMonitoringSpec{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "secret-app"}, + }, + Endpoints: []monitoringv1.ScrapeEndpoint{ + { + Port: intstr.FromString("metrics"), + Interval: "30s", + HTTPClientConfig: monitoringv1.HTTPClientConfig{ + BasicAuth: &monitoringv1.BasicAuth{ + Username: "", + Password: &monitoringv1.SecretSelector{Secret: &monitoringv1.SecretKeySelector{Name: "auth", Key: "pass", Namespace: "ns-1"}}, + }, + }, + }, + }, + }, + }, + &monitoringv1.PodMonitoring{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "monitoring.googleapis.com/v1", + Kind: KindPodMonitoring, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-monitor", + Namespace: "ns-2", + }, + Spec: monitoringv1.PodMonitoringSpec{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "secret-app"}, + }, + Endpoints: []monitoringv1.ScrapeEndpoint{ + { + Port: intstr.FromString("metrics"), + Interval: "30s", + HTTPClientConfig: monitoringv1.HTTPClientConfig{ + BasicAuth: &monitoringv1.BasicAuth{ + Username: "", + Password: &monitoringv1.SecretSelector{Secret: &monitoringv1.SecretKeySelector{Name: "auth", Key: "pass", Namespace: "ns-2"}}, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Broken Config", input: &pomonitoringv1.PodMonitor{ TypeMeta: metav1.TypeMeta{ APIVersion: "monitoring.coreos.com/v1", @@ -202,7 +288,7 @@ func TestPodMonitorConversion(t *testing.T) { wantErr: "namespaceSelector.matchNames contains only empty or invalid values", }, { - name: "Case C: Local Scoping (Omitted Selector)", + name: "Local Scoping (Omitted Selector)", input: &pomonitoringv1.PodMonitor{ TypeMeta: metav1.TypeMeta{ APIVersion: "monitoring.coreos.com/v1", @@ -1547,6 +1633,31 @@ func TestPodMonitorConversion(t *testing.T) { }, wantErr: "conflicting keep rules for label \"env\": cannot require both \"production\" and \"staging\" simultaneously", }, + { + name: "BearerTokenSecret with empty Name returns validation error", + input: &pomonitoringv1.PodMonitor{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "monitoring.coreos.com/v1", + Kind: KindPodMonitor, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "bearer-token-secret-err", + Namespace: "default", + }, + Spec: pomonitoringv1.PodMonitorSpec{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "frontend"}, + }, + PodMetricsEndpoints: []pomonitoringv1.PodMetricsEndpoint{ + { + Port: "metrics", + BearerTokenSecret: corev1.SecretKeySelector{Key: "token"}, + }, + }, + }, + }, + wantErr: "bearerTokenSecret: secret reference has an empty name for key \"token\"", + }, { name: "Pre-Scrape Relabelings: drop action rule on pod label falls through to metricRelabelings", input: &pomonitoringv1.PodMonitor{