@@ -94,6 +94,18 @@ type VMClusterSpec struct {
9494 // See https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#automatic-vmstorage-discovery
9595 // +optional
9696 Discovery * VMClusterDiscovery `json:"discovery,omitempty"`
97+
98+ // Pools defines named groups of vmstorage (and optionally vminsert) components.
99+ // Each pool gets its own StatefulSet and headless Service named <component>-<cluster>-<pool>.
100+ // Top-level vmstorage and vminsert specs act as defaults; pool specs override them field-by-field.
101+ // vmselect queries all pools using the pool name as a storage group name (-storageNode=<pool>/<addr>).
102+ // When pools are defined the top-level vmstorage is not deployed; pools replace it entirely.
103+ // The top-level vminsert is deployed as a shared insert across all pools only when no pool
104+ // defines its own vminsert; as soon as any pool has a dedicated vminsert the top-level one is skipped.
105+ // +optional
106+ // +listType=map
107+ // +listMapKey=name
108+ Pools []VMClusterPool `json:"pools,omitempty"`
97109}
98110
99111// VMClusterDiscovery configures automatic vmstorage node discovery for vminsert and vmselect.
@@ -128,7 +140,16 @@ func (d *VMClusterDiscovery) enabled() bool {
128140 return d != nil && d .Enabled
129141}
130142
131- func (d * VMClusterDiscovery ) validate () error {
143+ func (d * VMClusterDiscovery ) validate (license * License ) error {
144+ if ! d .enabled () {
145+ return nil
146+ }
147+ if ! license .IsProvided () {
148+ return fmt .Errorf ("discovery requires a valid license key, see https://docs.victoriametrics.com/victoriametrics/enterprise/" )
149+ }
150+ if err := license .validate (); err != nil {
151+ return err
152+ }
132153 if len (d .Filter ) > 0 {
133154 if _ , err := regexp .Compile (d .Filter ); err != nil {
134155 return fmt .Errorf ("discovery.filter is not a valid regexp: %w" , err )
@@ -488,6 +509,11 @@ type VMStorage struct {
488509 // it can be overwritten with component specific image.tag value.
489510 // +optional
490511 ComponentVersion string `json:"componentVersion,omitempty"`
512+ // RetentionPeriod overrides the cluster-level retentionPeriod for this storage instance.
513+ // Useful when using Pools to implement multi-retention setups.
514+ // +optional
515+ // +kubebuilder:validation:Pattern:="^[0-9]+(h|d|w|y)?$"
516+ RetentionPeriod string `json:"retentionPeriod,omitempty"`
491517 // PodMetadata configures Labels and Annotations which are propagated to the VMStorage pods.
492518 PodMetadata * EmbeddedObjectMetadata `json:"podMetadata,omitempty"`
493519 // LogFormat for VMStorage to be configured with.
@@ -707,6 +733,54 @@ func (cr *VMCluster) GetRemoteWriteURL() string {
707733 return fmt .Sprintf ("%s%s" , insertURL , BuildPathWithPrefixFlag (cr .Spec .VMInsert .ExtraArgs , "/insert/multitenant/prometheus/api/v1/write" ))
708734}
709735
736+ func (vms * VMStorage ) validate (license * License , clusterRetentionPeriod string ) error {
737+ if vms .VMBackup != nil {
738+ if err := vms .VMBackup .validate (license ); err != nil {
739+ return err
740+ }
741+ }
742+ retention := clusterRetentionPeriod
743+ if vms .RetentionPeriod != "" {
744+ retention = vms .RetentionPeriod
745+ }
746+ if err := vms .RetentionFilters .validate (license , retention ); err != nil {
747+ return err
748+ }
749+ if vms .HPA != nil {
750+ if vms .HPA .Behaviour != nil && vms .HPA .Behaviour .ScaleDown != nil {
751+ return fmt .Errorf ("scaledown HPA behavior is not supported" )
752+ }
753+ if err := vms .HPA .Validate (); err != nil {
754+ return err
755+ }
756+ }
757+ if vms .VPA != nil {
758+ if err := vms .VPA .Validate (); err != nil {
759+ return err
760+ }
761+ }
762+ if vms .RollingUpdateStrategyBehavior != nil {
763+ if err := vms .RollingUpdateStrategyBehavior .Validate (); err != nil {
764+ return err
765+ }
766+ }
767+ return vms .Validate ()
768+ }
769+
770+ func (vmi * VMInsert ) validate () error {
771+ if vmi .HPA != nil {
772+ if err := vmi .HPA .Validate (); err != nil {
773+ return err
774+ }
775+ }
776+ if vmi .VPA != nil {
777+ if err := vmi .VPA .Validate (); err != nil {
778+ return err
779+ }
780+ }
781+ return vmi .Validate ()
782+ }
783+
710784func (cr * VMCluster ) Validate () error {
711785 if MustSkipCRValidation (cr ) {
712786 return nil
@@ -742,17 +816,7 @@ func (cr *VMCluster) Validate() error {
742816 if vmi .ServiceSpec != nil && vmi .ServiceSpec .Name == name {
743817 return fmt .Errorf (".serviceSpec.Name cannot be equal to prefixed name=%q" , name )
744818 }
745- if vmi .HPA != nil {
746- if err := vmi .HPA .Validate (); err != nil {
747- return err
748- }
749- }
750- if vmi .VPA != nil {
751- if err := vmi .VPA .Validate (); err != nil {
752- return err
753- }
754- }
755- if err := vmi .Validate (); err != nil {
819+ if err := vmi .validate (); err != nil {
756820 return fmt .Errorf ("vminsert: %w" , err )
757821 }
758822 }
@@ -765,28 +829,7 @@ func (cr *VMCluster) Validate() error {
765829 if vms .ServiceSpec != nil && vms .ServiceSpec .Name == name {
766830 return fmt .Errorf (".serviceSpec.Name cannot be equal to prefixed name=%q" , name )
767831 }
768- if cr .Spec .VMStorage .VMBackup != nil {
769- if err := cr .Spec .VMStorage .VMBackup .validate (cr .Spec .License ); err != nil {
770- return err
771- }
772- }
773- if err := vms .RetentionFilters .validate (cr .Spec .License , cr .Spec .RetentionPeriod ); err != nil {
774- return err
775- }
776- if vms .RollingUpdateStrategyBehavior != nil {
777- if err := vms .RollingUpdateStrategyBehavior .Validate (); err != nil {
778- return fmt .Errorf ("vmstorage: %w" , err )
779- }
780- }
781- if vms .HPA != nil && vms .HPA .Behaviour != nil && vms .HPA .Behaviour .ScaleDown != nil {
782- return fmt .Errorf ("vmstorage scaledown HPA behavior is not supported" )
783- }
784- if vms .VPA != nil {
785- if err := vms .VPA .Validate (); err != nil {
786- return err
787- }
788- }
789- if err := vms .Validate (); err != nil {
832+ if err := vms .validate (cr .Spec .License , cr .Spec .RetentionPeriod ); err != nil {
790833 return fmt .Errorf ("vmstorage: %w" , err )
791834 }
792835 }
@@ -807,34 +850,66 @@ func (cr *VMCluster) Validate() error {
807850 if cr .Spec .VMSelect != nil {
808851 vmselectDiscovery = cr .Spec .VMSelect .Discovery .OrDefault (cr .Spec .Discovery )
809852 }
810- if vminsertDiscovery .enabled () || vmselectDiscovery .enabled () {
811- if ! cr .Spec .License .IsProvided () {
812- return fmt .Errorf ("discovery requires a valid license key, see https://docs.victoriametrics.com/victoriametrics/enterprise/" )
813- }
814- if err := cr .Spec .License .validate (); err != nil {
815- return err
816- }
817- }
818853 if vminsertDiscovery .enabled () {
819854 if cr .Spec .VMStorage != nil && len (cr .Spec .VMStorage .MaintenanceInsertNodeIDs ) > 0 {
820855 return fmt .Errorf ("maintenanceInsertNodeIDs cannot be used when vminsert discovery is enabled" )
821856 }
822- if err := vminsertDiscovery . validate (); err != nil {
823- return fmt . Errorf ( "vminsert: %w" , err )
824- }
857+ }
858+ if err := vminsertDiscovery . validate ( cr . Spec . License ); err != nil {
859+ return fmt . Errorf ( "vminsert: %w" , err )
825860 }
826861 if vmselectDiscovery .enabled () {
827862 if cr .Spec .VMStorage != nil && len (cr .Spec .VMStorage .MaintenanceSelectNodeIDs ) > 0 {
828863 return fmt .Errorf ("maintenanceSelectNodeIDs cannot be used when vmselect discovery is enabled" )
829864 }
830- if err := vmselectDiscovery .validate (); err != nil {
831- return fmt .Errorf ("vmselect: %w" , err )
865+ }
866+ if err := vmselectDiscovery .validate (cr .Spec .License ); err != nil {
867+ return fmt .Errorf ("vmselect: %w" , err )
868+ }
869+
870+ poolNames := make (map [string ]struct {}, len (cr .Spec .Pools ))
871+ for i , pool := range cr .Spec .Pools {
872+ if ! poolNameRe .MatchString (pool .Name ) {
873+ return fmt .Errorf ("pools[%d].name %q is invalid: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" , i , pool .Name )
874+ }
875+ if _ , dup := poolNames [pool .Name ]; dup {
876+ return fmt .Errorf ("pools[%d].name %q is duplicated" , i , pool .Name )
877+ }
878+ poolNames [pool .Name ] = struct {}{}
879+ if pool .VMStorage != nil {
880+ vms := pool .VMStorage .DeepCopy ()
881+ if cr .Spec .VMStorage != nil {
882+ if err := MergeDeep (vms , cr .Spec .VMStorage , true ); err != nil {
883+ return fmt .Errorf ("pools[%d] vmstorage merge: %w" , i , err )
884+ }
885+ }
886+ if err := vms .validate (cr .Spec .License , cr .Spec .RetentionPeriod ); err != nil {
887+ return fmt .Errorf ("pools[%d] vmstorage: %w" , i , err )
888+ }
889+ }
890+ if pool .VMInsert != nil {
891+ vmi := pool .VMInsert .DeepCopy ()
892+ if cr .Spec .VMInsert != nil {
893+ if err := MergeDeep (vmi , cr .Spec .VMInsert , true ); err != nil {
894+ return fmt .Errorf ("pools[%d] vminsert merge: %w" , i , err )
895+ }
896+ }
897+ if err := vmi .validate (); err != nil {
898+ return fmt .Errorf ("pools[%d] vminsert: %w" , i , err )
899+ }
900+ poolDiscovery := vmi .Discovery .OrDefault (cr .Spec .Discovery )
901+ if err := poolDiscovery .validate (cr .Spec .License ); err != nil {
902+ return fmt .Errorf ("pools[%d] vminsert: %w" , i , err )
903+ }
832904 }
833905 }
834906
835907 return nil
836908}
837909
910+ // poolNameRe validates pool names: lowercase alphanumeric with interior hyphens.
911+ var poolNameRe = regexp .MustCompile (`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` )
912+
838913// AvailableStorageNodeIDs returns ids of the storage nodes for the provided component
839914func (cr * VMCluster ) AvailableStorageNodeIDs (kind ClusterComponent ) []int32 {
840915 var result []int32
@@ -1184,3 +1259,35 @@ func (cr *VMAuthLoadBalancerSpec) UseTLS() bool {
11841259func (cr * VMAuthLoadBalancerSpec ) GetMetricsPath () string {
11851260 return BuildPathWithPrefixFlag (cr .ExtraArgs , metricsPath )
11861261}
1262+
1263+ // VMClusterPool defines a named group of vmstorage (and optionally vminsert) components
1264+ // within a VMCluster. Each pool gets its own StatefulSet and headless Service.
1265+ // +k8s:openapi-gen=true
1266+ type VMClusterPool struct {
1267+ // Name is the unique identifier for this pool within the cluster.
1268+ // Used as a suffix for generated resource names and as a storage group name in vmselect.
1269+ // Must be a lowercase alphanumeric DNS label; hyphens allowed in the interior.
1270+ // +kubebuilder:validation:Pattern:="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
1271+ // +kubebuilder:validation:MaxLength=32
1272+ Name string `json:"name"`
1273+ // VMStorage defines pool-specific vmstorage configuration.
1274+ // Each field overrides the corresponding field in the top-level vmstorage spec.
1275+ // Fields absent here inherit from the top-level vmstorage.
1276+ // RetentionPeriod on VMStorage overrides the cluster-level retentionPeriod for this pool.
1277+ // +optional
1278+ // +kubebuilder:validation:Schemaless
1279+ // +kubebuilder:pruning:PreserveUnknownFields
1280+ VMStorage * VMStorage `json:"vmstorage,omitempty"`
1281+ // VMInsert defines a dedicated vminsert for this pool.
1282+ // Each field overrides the corresponding field in the top-level vminsert spec.
1283+ // When nil, the top-level shared vminsert writes to this pool's storage nodes as well.
1284+ // +optional
1285+ // +kubebuilder:validation:Schemaless
1286+ // +kubebuilder:pruning:PreserveUnknownFields
1287+ VMInsert * VMInsert `json:"vminsert,omitempty"`
1288+ }
1289+
1290+ // PoolPrefixedName returns the Kubernetes resource name for the given component in a pool.
1291+ func (cr * VMCluster ) PoolPrefixedName (kind ClusterComponent , poolName string ) string {
1292+ return ClusterPrefixedName (kind , cr .Name , "vm" , false ) + "-" + poolName
1293+ }
0 commit comments