diff --git a/cmd/helm-converter/main.go b/cmd/helm-converter/main.go index d0223d1de..9e61353b1 100644 --- a/cmd/helm-converter/main.go +++ b/cmd/helm-converter/main.go @@ -59,21 +59,42 @@ func main() { os.Exit(1) } - cr, err := converter.Convert(*name, *namespace, values) - if err != nil { - fmt.Printf("cannot convert values: %v\n", err) - os.Exit(1) - } - - objects := []any{cr} - if authValues, ok := values.(*converter.VMAuthHelmValues); ok { - users, err := converter.ConvertVMAuthUsers(*name, *namespace, authValues) + var objects []any + if alertValues, ok := values.(*converter.VMAlertHelmValues); ok { + // ConvertVMAlert also returns the auth Secrets, so both come from one conversion pass. + alert, secrets, err := converter.ConvertVMAlert(*name, *namespace, alertValues) + if err != nil { + fmt.Printf("cannot convert values: %v\n", err) + os.Exit(1) + } + objects = append(objects, alert) + for _, s := range secrets { + objects = append(objects, s) + } + rule, err := converter.ConvertVMAlertRules(*name, *namespace, alertValues) + if err != nil { + fmt.Printf("cannot convert config.alerts.groups: %v\n", err) + os.Exit(1) + } + if rule != nil { + objects = append(objects, rule) + } + } else { + cr, err := converter.Convert(*name, *namespace, values) if err != nil { - fmt.Printf("cannot convert config.users: %v\n", err) + fmt.Printf("cannot convert values: %v\n", err) os.Exit(1) } - for _, u := range users { - objects = append(objects, u) + objects = append(objects, cr) + if authValues, ok := values.(*converter.VMAuthHelmValues); ok { + users, err := converter.ConvertVMAuthUsers(*name, *namespace, authValues) + if err != nil { + fmt.Printf("cannot convert config.users: %v\n", err) + os.Exit(1) + } + for _, u := range users { + objects = append(objects, u) + } } } diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 62fc8246c..d1b52cd16 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -1,6 +1,7 @@ package converter import ( + "crypto/sha256" "fmt" "io" "net/http" @@ -151,26 +152,129 @@ type VMAnomalyWriterValues struct { } type VMAlertServerValues struct { - Image ImageValues `yaml:"image" json:"image"` - ImagePullSecrets []corev1.LocalObjectReference `yaml:"imagePullSecrets,omitempty" json:"imagePullSecrets,omitempty"` - ReplicaCount *int32 `yaml:"replicaCount,omitempty" json:"replicaCount,omitempty"` - ExtraArgs map[string]interface{} `yaml:"extraArgs,omitempty" json:"extraArgs,omitempty"` - ExtraEnvs []corev1.EnvVar `yaml:"env,omitempty" json:"env,omitempty"` - ExtraVolumes []corev1.Volume `yaml:"extraVolumes,omitempty" json:"extraVolumes,omitempty"` - ExtraVolumeMounts []corev1.VolumeMount `yaml:"extraVolumeMounts,omitempty" json:"extraVolumeMounts,omitempty"` - Resources *corev1.ResourceRequirements `yaml:"resources,omitempty" json:"resources,omitempty"` - NodeSelector map[string]string `yaml:"nodeSelector,omitempty" json:"nodeSelector,omitempty"` - Tolerations []corev1.Toleration `yaml:"tolerations,omitempty" json:"tolerations,omitempty"` - Affinity *corev1.Affinity `yaml:"affinity,omitempty" json:"affinity,omitempty"` - PodAnnotations map[string]string `yaml:"podAnnotations,omitempty" json:"podAnnotations,omitempty"` - Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` - PodSecurityContext *corev1.PodSecurityContext `yaml:"podSecurityContext,omitempty" json:"podSecurityContext,omitempty"` - SecurityContext *corev1.SecurityContext `yaml:"securityContext,omitempty" json:"securityContext,omitempty"` - Notifier *vmv1beta1.VMAlertNotifierSpec `yaml:"notifier,omitempty" json:"notifier,omitempty"` - Notifiers []vmv1beta1.VMAlertNotifierSpec `yaml:"notifiers,omitempty" json:"notifiers,omitempty"` - RemoteWrite *VMAlertRemoteWriteValues `yaml:"remoteWrite,omitempty" json:"remoteWrite,omitempty"` - RemoteRead *vmv1beta1.VMAlertRemoteReadSpec `yaml:"remoteRead,omitempty" json:"remoteRead,omitempty"` - Datasource vmv1beta1.VMAlertDatasourceSpec `yaml:"datasource,omitempty" json:"datasource,omitempty"` + Image ImageValues `yaml:"image" json:"image"` + ImagePullSecrets []corev1.LocalObjectReference `yaml:"imagePullSecrets,omitempty" json:"imagePullSecrets,omitempty"` + ReplicaCount *int32 `yaml:"replicaCount,omitempty" json:"replicaCount,omitempty"` + ExtraArgs map[string]interface{} `yaml:"extraArgs,omitempty" json:"extraArgs,omitempty"` + ExtraEnvs []corev1.EnvVar `yaml:"env,omitempty" json:"env,omitempty"` + ExtraVolumes []corev1.Volume `yaml:"extraVolumes,omitempty" json:"extraVolumes,omitempty"` + ExtraVolumeMounts []corev1.VolumeMount `yaml:"extraVolumeMounts,omitempty" json:"extraVolumeMounts,omitempty"` + Resources *corev1.ResourceRequirements `yaml:"resources,omitempty" json:"resources,omitempty"` + NodeSelector map[string]string `yaml:"nodeSelector,omitempty" json:"nodeSelector,omitempty"` + Tolerations []corev1.Toleration `yaml:"tolerations,omitempty" json:"tolerations,omitempty"` + Affinity *corev1.Affinity `yaml:"affinity,omitempty" json:"affinity,omitempty"` + PodAnnotations map[string]string `yaml:"podAnnotations,omitempty" json:"podAnnotations,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + PodSecurityContext *chartPodSecurityContext `yaml:"podSecurityContext,omitempty" json:"podSecurityContext,omitempty"` + SecurityContext *chartSecurityContext `yaml:"securityContext,omitempty" json:"securityContext,omitempty"` + Notifier *VMAlertNotifierValues `yaml:"notifier,omitempty" json:"notifier,omitempty"` + Notifiers []VMAlertNotifierValues `yaml:"notifiers,omitempty" json:"notifiers,omitempty"` + RemoteWrite *VMAlertRemoteWriteValues `yaml:"remoteWrite,omitempty" json:"remoteWrite,omitempty"` + RemoteRead *VMAlertHTTPAuthValues `yaml:"remoteRead,omitempty" json:"remoteRead,omitempty"` + Datasource VMAlertHTTPAuthValues `yaml:"datasource,omitempty" json:"datasource,omitempty"` + Config *VMAlertConfigValues `yaml:"config,omitempty" json:"config,omitempty"` +} + +// VMAlertConfigValues represents vmalert's own native rule config (the chart's +// `server.config` value). Alerts.Groups reuses RuleGroup directly since its field names +// already match vmalert's native rule group format. +type VMAlertConfigValues struct { + Alerts struct { + Groups []vmv1beta1.RuleGroup `yaml:"groups,omitempty" json:"groups,omitempty"` + } `yaml:"alerts,omitempty" json:"alerts,omitempty"` +} + +// chartHTTPAuth mirrors the flat, dotted-key HTTP auth convention victoria-metrics-alert's +// values.yaml uses for datasource/remoteRead/notifier (e.g. `basicAuth.username`, +// `bearerToken`), unlike the operator's own nested, Secret-reference-only HTTPAuth. +type chartHTTPAuth struct { + BasicAuthUsername string `yaml:"basicAuth.username,omitempty" json:"basicAuth.username,omitempty"` + BasicAuthPassword string `yaml:"basicAuth.password,omitempty" json:"basicAuth.password,omitempty"` + BearerToken string `yaml:"bearerToken,omitempty" json:"bearerToken,omitempty"` + BearerTokenFile string `yaml:"bearerTokenFile,omitempty" json:"bearerTokenFile,omitempty"` + Headers []string `yaml:"headers,omitempty" json:"headers,omitempty"` +} + +// VMAlertHTTPAuthValues is the chart shape for a single HTTP-auth-bearing URL block +// (datasource, remoteRead). +type VMAlertHTTPAuthValues struct { + URL string `yaml:"url,omitempty" json:"url,omitempty"` + chartHTTPAuth `yaml:",inline" json:",inline"` +} + +// VMAlertNotifierValues is the chart shape for a notifier entry. +type VMAlertNotifierValues struct { + URL string `yaml:"url,omitempty" json:"url,omitempty"` + chartHTTPAuth `yaml:",inline" json:",inline"` +} + +// chartPodSecurityContext mirrors the chart's `{enabled: bool, ...}` toggle shape for +// podSecurityContext, distinct from the operator's plain corev1.PodSecurityContext. +type chartPodSecurityContext struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + corev1.PodSecurityContext `yaml:",inline" json:",inline"` +} + +func (c *chartPodSecurityContext) toCoreV1() *corev1.PodSecurityContext { + if c == nil || !c.Enabled { + return nil + } + psc := c.PodSecurityContext + return &psc +} + +// chartSecurityContext mirrors the chart's `{enabled: bool, ...}` toggle shape for +// securityContext, distinct from the operator's plain corev1.SecurityContext. +type chartSecurityContext struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + corev1.SecurityContext `yaml:",inline" json:",inline"` +} + +func (c *chartSecurityContext) toCoreV1() *corev1.SecurityContext { + if c == nil || !c.Enabled { + return nil + } + sc := c.SecurityContext + return &sc +} + +// convertHTTPAuth converts a chart-shaped HTTP auth block into the operator's HTTPAuth, +// returning a Secret for any plaintext credential found (nil if none). +func convertHTTPAuth(secretName string, auth chartHTTPAuth) (vmv1beta1.HTTPAuth, *corev1.Secret) { + var result vmv1beta1.HTTPAuth + result.Headers = auth.Headers + data := map[string][]byte{} + if auth.BasicAuthUsername != "" || auth.BasicAuthPassword != "" { + result.BasicAuth = &vmv1beta1.BasicAuth{} + if auth.BasicAuthUsername != "" { + data["username"] = []byte(auth.BasicAuthUsername) + result.BasicAuth.Username = corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "username"} + } + if auth.BasicAuthPassword != "" { + data["password"] = []byte(auth.BasicAuthPassword) + result.BasicAuth.Password = corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "password"} + } + } + if auth.BearerToken != "" { + data["token"] = []byte(auth.BearerToken) + result.BearerAuth = &vmv1beta1.BearerAuth{TokenSecret: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: "token"}} + } else if auth.BearerTokenFile != "" { + result.BearerAuth = &vmv1beta1.BearerAuth{TokenFilePath: auth.BearerTokenFile} + } + if len(data) == 0 { + return result, nil + } + return result, &corev1.Secret{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{Name: secretName}, + Data: data, + } +} + +// dropRuleExtraArg removes "rule" from extraArgs, if present: rule files are handled via a +// generated VMRule CR and spec.ruleSelector instead of a stringified -rule extraArg. +func dropRuleExtraArg(extraArgs map[string]interface{}) { + delete(extraArgs, "rule") } type GlobalValues struct { @@ -699,21 +803,10 @@ func Convert(name, namespace string, values any) (any, error) { cr = agent case *VMAlertHelmValues: - alert := &vmv1beta1.VMAlert{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "operator.victoriametrics.com/v1beta1", - Kind: "VMAlert", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - } - spec, err := convertVMAlertSpec(v) + alert, _, err := ConvertVMAlert(name, namespace, v) if err != nil { return nil, err } - alert.Spec = *spec cr = alert case *VMAnomalyHelmValues: @@ -1164,8 +1257,41 @@ func convertVMAnomalySpec(values *VMAnomalyHelmValues) (*vmv1.VMAnomalySpec, err return spec, nil } -func convertVMAlertSpec(values *VMAlertHelmValues) (*vmv1beta1.VMAlertSpec, error) { +// vmAlertSecretName returns the deterministic name for a generated auth Secret, so +// ConvertVMAlertSecrets and convertVMAlertSpec always agree on it. Truncated with a content +// hash suffix when it would otherwise exceed Kubernetes' name length limit. +func vmAlertSecretName(crName, field string) string { + name := fmt.Sprintf("%s-%s-auth", crName, field) + if len(name) <= validation.DNS1123SubdomainMaxLength { + return name + } + suffix := fmt.Sprintf("-%x", sha256.Sum256([]byte(name)))[:9] + return name[:validation.DNS1123SubdomainMaxLength-len(suffix)] + suffix +} + +func convertVMAlertNotifiers(crName string, items []VMAlertNotifierValues) ([]vmv1beta1.VMAlertNotifierSpec, []*corev1.Secret) { + if items == nil { + return nil, nil + } + result := make([]vmv1beta1.VMAlertNotifierSpec, 0, len(items)) + var secrets []*corev1.Secret + for i, item := range items { + auth, secret := convertHTTPAuth(vmAlertSecretName(crName, fmt.Sprintf("notifiers-%d", i)), item.chartHTTPAuth) + result = append(result, vmv1beta1.VMAlertNotifierSpec{URL: item.URL, HTTPAuth: auth}) + if secret != nil { + secrets = append(secrets, secret) + } + } + return result, secrets +} + +// convertVMAlertSpec converts Helm values into a VMAlertSpec, returning any Secrets generated +// for plaintext auth credentials found in datasource/remoteRead/notifier alongside it. +func convertVMAlertSpec(values *VMAlertHelmValues, name string) (*vmv1beta1.VMAlertSpec, []*corev1.Secret, error) { spec := &vmv1beta1.VMAlertSpec{} + var secrets []*corev1.Secret + + dropRuleExtraArg(values.Server.ExtraArgs) cfg, err := convertCommonConfig(ServerValues{ Image: values.Server.Image, @@ -1181,11 +1307,11 @@ func convertVMAlertSpec(values *VMAlertHelmValues) (*vmv1beta1.VMAlertSpec, erro Affinity: values.Server.Affinity, PodAnnotations: values.Server.PodAnnotations, Labels: values.Server.Labels, - PodSecurityContext: values.Server.PodSecurityContext, - SecurityContext: values.Server.SecurityContext, + PodSecurityContext: values.Server.PodSecurityContext.toCoreV1(), + SecurityContext: values.Server.SecurityContext.toCoreV1(), }, values.Global) if err != nil { - return nil, err + return nil, nil, err } spec.ReplicaCount = cfg.ReplicaCount @@ -1202,17 +1328,41 @@ func convertVMAlertSpec(values *VMAlertHelmValues) (*vmv1beta1.VMAlertSpec, erro spec.ImagePullSecrets = cfg.ImagePullSecrets spec.PodMetadata = cfg.PodMetadata spec.ServiceSpec = cfg.ServiceSpec - spec.Notifier = values.Server.Notifier - spec.Notifiers = values.Server.Notifiers - spec.RemoteWrite = convertVMAlertRemoteWrite(values.Server.RemoteWrite) - spec.RemoteRead = values.Server.RemoteRead - spec.Datasource = values.Server.Datasource + + if values.Server.Notifier != nil { + auth, secret := convertHTTPAuth(vmAlertSecretName(name, "notifier"), values.Server.Notifier.chartHTTPAuth) + spec.Notifier = &vmv1beta1.VMAlertNotifierSpec{URL: values.Server.Notifier.URL, HTTPAuth: auth} + if secret != nil { + secrets = append(secrets, secret) + } + } + notifiers, notifierSecrets := convertVMAlertNotifiers(name, values.Server.Notifiers) + spec.Notifiers = notifiers + secrets = append(secrets, notifierSecrets...) + + if rw := convertVMAlertRemoteWrite(values.Server.RemoteWrite); rw != nil && rw.URL != "" { + spec.RemoteWrite = rw + } + + if values.Server.RemoteRead != nil && values.Server.RemoteRead.URL != "" { + auth, secret := convertHTTPAuth(vmAlertSecretName(name, "remote-read"), values.Server.RemoteRead.chartHTTPAuth) + spec.RemoteRead = &vmv1beta1.VMAlertRemoteReadSpec{URL: values.Server.RemoteRead.URL, HTTPAuth: auth} + if secret != nil { + secrets = append(secrets, secret) + } + } + + datasourceAuth, datasourceSecret := convertHTTPAuth(vmAlertSecretName(name, "datasource"), values.Server.Datasource.chartHTTPAuth) + spec.Datasource = vmv1beta1.VMAlertDatasourceSpec{URL: values.Server.Datasource.URL, HTTPAuth: datasourceAuth} + if datasourceSecret != nil { + secrets = append(secrets, datasourceSecret) + } if values.ServiceAccount != nil && values.ServiceAccount.Name != "" { spec.ServiceAccountName = values.ServiceAccount.Name } - return spec, nil + return spec, secrets, nil } func convertVMAgentSpec(values *VMAgentHelmValues) (*vmv1beta1.VMAgentSpec, error) { @@ -1657,6 +1807,67 @@ func ConvertVMAuthUsers(vmauthName, namespace string, values *VMAuthHelmValues) return users, nil } +// ConvertVMAlert converts Helm values into a VMAlert CR and the Secrets generated for +// plaintext auth credentials found in it, in a single conversion pass. Convert's own +// VMAlertHelmValues case wraps this too, so callers that also need the Secrets should call +// this directly rather than Convert, to avoid running the conversion twice. +func ConvertVMAlert(name, namespace string, values *VMAlertHelmValues) (*vmv1beta1.VMAlert, []*corev1.Secret, error) { + alert := &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + spec, secrets, err := convertVMAlertSpec(values, name) + if err != nil { + return nil, nil, err + } + alert.Spec = *spec + if values.Server.Config != nil && len(values.Server.Config.Alerts.Groups) > 0 { + // Default ruleSelector/ruleNamespaceSelector (both nil) select nothing, so the + // generated VMRule would never actually get loaded without this. + alert.Spec.RuleSelector = &metav1.LabelSelector{MatchLabels: vmAlertRuleSelectorLabels(name)} + } + for _, s := range secrets { + s.Namespace = namespace + } + return alert, secrets, nil +} + +func vmAlertRuleSelectorLabels(vmalertName string) map[string]string { + return map[string]string{ + "app.kubernetes.io/managed-by": "helm-converter", + "app.kubernetes.io/instance": vmalertName, + } +} + +// ConvertVMAlertRules converts a victoria-metrics-alert chart's server.config.alerts.groups +// into a standalone VMRule CR, selected by the generated VMAlert via spec.ruleSelector rather +// than mounting a rule file directly. +func ConvertVMAlertRules(vmalertName, namespace string, values *VMAlertHelmValues) (*vmv1beta1.VMRule, error) { + if values.Server.Config == nil || len(values.Server.Config.Alerts.Groups) == 0 { + return nil, nil + } + return &vmv1beta1.VMRule{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMRule", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmalertName, + Namespace: namespace, + Labels: vmAlertRuleSelectorLabels(vmalertName), + }, + Spec: vmv1beta1.VMRuleSpec{ + Groups: values.Server.Config.Alerts.Groups, + }, + }, nil +} + // convertVMAuthConfigUserTargetRefs builds TargetRefs from a config user's url_prefix/ // url_map, which the operator only exposes via TargetRefs rather than as direct spec fields. func convertVMAuthConfigUserTargetRefs(u VMAuthConfigUser) ([]vmv1beta1.TargetRef, error) { diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go index e8975d3f5..c5ba667c0 100644 --- a/internal/converter/converter_test.go +++ b/internal/converter/converter_test.go @@ -3,6 +3,7 @@ package converter import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/utils/ptr" k8syaml "sigs.k8s.io/yaml" @@ -574,7 +576,7 @@ func TestConvertVMAlert(t *testing.T) { Repository: "victoriametrics/vmalert", Tag: "v1.93.0", }, - Notifier: &vmv1beta1.VMAlertNotifierSpec{ + Notifier: &VMAlertNotifierValues{ URL: "http://vmalertmanager:9093", }, }, @@ -632,6 +634,270 @@ func TestConvertVMAlert(t *testing.T) { return cr }, ) + + // extraArgs.rule (the chart's default -rule flag, pointing at a file the chart itself + // mounts) is dropped rather than passed through as a broken stringified extraArg: rule + // content instead comes from config.alerts.groups via a generated VMRule. See issue #2398. + f( + &VMAlertHelmValues{ + Server: VMAlertServerValues{ + ExtraArgs: map[string]interface{}{ + "rule": []interface{}{"/config/alert-rules.yaml"}, + "envflag.enable": true, + }, + }, + }, + func() *vmv1beta1.VMAlert { + cr := &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + } + cr.Spec.ExtraArgs = map[string]string{"envflag.enable": "true"} + return cr + }, + ) + + // remoteRead/remoteWrite with an empty url (the chart's default `{}` stub) must not + // produce a dangling empty-URL spec. See issue #2398. + f( + &VMAlertHelmValues{ + Server: VMAlertServerValues{ + RemoteRead: &VMAlertHTTPAuthValues{}, + RemoteWrite: &VMAlertRemoteWriteValues{}, + }, + }, + func() *vmv1beta1.VMAlert { + return &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + } + }, + ) + + // podSecurityContext/securityContext use the chart's {enabled, ...} toggle shape; disabled + // must not produce an empty-but-non-nil context. See issue #2398. + f( + &VMAlertHelmValues{ + Server: VMAlertServerValues{ + PodSecurityContext: &chartPodSecurityContext{Enabled: false}, + SecurityContext: &chartSecurityContext{Enabled: false}, + }, + }, + func() *vmv1beta1.VMAlert { + return &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + } + }, + ) + + // enabled: true carries the rest of the chart's securityContext fields through. + f( + &VMAlertHelmValues{ + Server: VMAlertServerValues{ + SecurityContext: &chartSecurityContext{ + Enabled: true, + SecurityContext: corev1.SecurityContext{ReadOnlyRootFilesystem: ptr.To(true)}, + }, + }, + }, + func() *vmv1beta1.VMAlert { + cr := &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + } + cr.Spec.SecurityContext = &vmv1beta1.SecurityContext{ + ContainerSecurityContext: &vmv1beta1.ContainerSecurityContext{ + ReadOnlyRootFilesystem: ptr.To(true), + }, + } + return cr + }, + ) + + // datasource bearer token is a plaintext value in the chart, but the operator only accepts + // a Secret reference; converting must reference a Secret rather than dropping it. See + // issue #2398. + f( + &VMAlertHelmValues{ + Server: VMAlertServerValues{ + Datasource: VMAlertHTTPAuthValues{ + URL: "http://vmselect:8481", + chartHTTPAuth: chartHTTPAuth{ + BearerToken: "s3cr3t", + }, + }, + }, + }, + func() *vmv1beta1.VMAlert { + cr := &vmv1beta1.VMAlert{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMAlert", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + } + cr.Spec.Datasource = vmv1beta1.VMAlertDatasourceSpec{ + URL: "http://vmselect:8481", + HTTPAuth: vmv1beta1.HTTPAuth{ + BearerAuth: &vmv1beta1.BearerAuth{ + TokenSecret: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "test-alert-datasource-auth"}, + Key: "token", + }, + }, + }, + } + return cr + }, + ) +} + +func TestConvertVMAlertSecrets(t *testing.T) { + values := &VMAlertHelmValues{ + Server: VMAlertServerValues{ + Datasource: VMAlertHTTPAuthValues{ + URL: "http://vmselect:8481", + chartHTTPAuth: chartHTTPAuth{BearerToken: "datasource-token"}, + }, + RemoteRead: &VMAlertHTTPAuthValues{ + URL: "http://vmselect:8481/prometheus", + chartHTTPAuth: chartHTTPAuth{BasicAuthUsername: "ro-user", BasicAuthPassword: "ro-pass"}, + }, + // singular Notifier and the first Notifiers entry both carry credentials, and must + // not collide on the same generated Secret name. + Notifier: &VMAlertNotifierValues{ + URL: "http://vmalertmanager:9093", + chartHTTPAuth: chartHTTPAuth{BearerToken: "notifier-token"}, + }, + Notifiers: []VMAlertNotifierValues{ + { + URL: "http://vmalertmanager-2:9093", + chartHTTPAuth: chartHTTPAuth{BearerToken: "notifiers-0-token"}, + }, + }, + }, + } + + _, secrets, err := ConvertVMAlert("test-alert", "test-ns", values) + assert.NoError(t, err) + assert.Len(t, secrets, 4) + + byName := make(map[string]*corev1.Secret, len(secrets)) + for _, s := range secrets { + assert.Equal(t, "test-ns", s.Namespace) + byName[s.Name] = s + } + + assert.Equal(t, []byte("datasource-token"), byName["test-alert-datasource-auth"].Data["token"]) + assert.Equal(t, []byte("ro-user"), byName["test-alert-remote-read-auth"].Data["username"]) + assert.Equal(t, []byte("ro-pass"), byName["test-alert-remote-read-auth"].Data["password"]) + assert.Equal(t, []byte("notifier-token"), byName["test-alert-notifier-auth"].Data["token"]) + assert.Equal(t, []byte("notifiers-0-token"), byName["test-alert-notifiers-0-auth"].Data["token"]) +} + +func TestVMAlertSecretName_LongNameTruncated(t *testing.T) { + longName := strings.Repeat("a", 300) + got := vmAlertSecretName(longName, "datasource") + assert.LessOrEqual(t, len(got), validation.DNS1123SubdomainMaxLength) + // must stay unique across different fields even after truncation. + assert.NotEqual(t, got, vmAlertSecretName(longName, "remote-read")) +} + +func TestConvertVMAlertRules(t *testing.T) { + // no config.alerts.groups: no VMRule generated, no ruleSelector set. + values := &VMAlertHelmValues{} + rule, err := ConvertVMAlertRules("test-alert", "test-ns", values) + assert.NoError(t, err) + assert.Nil(t, rule) + + cr, err := Convert("test-alert", "test-ns", values) + assert.NoError(t, err) + assert.Nil(t, cr.(*vmv1beta1.VMAlert).Spec.RuleSelector) + + // config.alerts.groups converts to a standalone VMRule, selected via spec.ruleSelector + // rather than mounting a rule file directly. See issue #2398. + values = &VMAlertHelmValues{ + Server: VMAlertServerValues{ + Config: &VMAlertConfigValues{ + Alerts: struct { + Groups []vmv1beta1.RuleGroup `yaml:"groups,omitempty" json:"groups,omitempty"` + }{ + Groups: []vmv1beta1.RuleGroup{ + { + Name: "example", + Rules: []vmv1beta1.Rule{ + {Alert: "ExampleAlert", Expr: "up == 0"}, + }, + }, + }, + }, + }, + }, + } + + rule, err = ConvertVMAlertRules("test-alert", "test-ns", values) + assert.NoError(t, err) + assert.Equal(t, &vmv1beta1.VMRule{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.victoriametrics.com/v1beta1", + Kind: "VMRule", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "helm-converter", + "app.kubernetes.io/instance": "test-alert", + }, + }, + Spec: vmv1beta1.VMRuleSpec{ + Groups: []vmv1beta1.RuleGroup{ + { + Name: "example", + Rules: []vmv1beta1.Rule{ + {Alert: "ExampleAlert", Expr: "up == 0"}, + }, + }, + }, + }, + }, rule) + + cr, err = Convert("test-alert", "test-ns", values) + assert.NoError(t, err) + assert.Equal(t, &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/managed-by": "helm-converter", + "app.kubernetes.io/instance": "test-alert", + }, + }, cr.(*vmv1beta1.VMAlert).Spec.RuleSelector) } func TestConvertVMAnomaly(t *testing.T) {