Skip to content

Commit 67e105c

Browse files
vmcluster: support storage node pools
1 parent 136ab76 commit 67e105c

18 files changed

Lines changed: 1199 additions & 392 deletions

File tree

api/operator/v1beta1/vmcluster_types.go

Lines changed: 154 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
710784
func (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
839914
func (cr *VMCluster) AvailableStorageNodeIDs(kind ClusterComponent) []int32 {
840915
var result []int32
@@ -1184,3 +1259,35 @@ func (cr *VMAuthLoadBalancerSpec) UseTLS() bool {
11841259
func (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+
}

api/operator/v1beta1/vmextra_types.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,3 +1763,59 @@ func (bs *BytesString) String() string {
17631763
}
17641764
return string(*bs)
17651765
}
1766+
1767+
func mergeMapsRecursive(baseMap, overrideMap map[string]any) {
1768+
if len(overrideMap) == 0 {
1769+
return
1770+
}
1771+
for key, overrideValue := range overrideMap {
1772+
if baseVal, ok := baseMap[key]; ok {
1773+
if baseMapNested, isBaseMap := baseVal.(map[string]any); isBaseMap {
1774+
if overrideMapNested, isOverrideMap := overrideValue.(map[string]any); isOverrideMap {
1775+
mergeMapsRecursive(baseMapNested, overrideMapNested)
1776+
continue
1777+
}
1778+
}
1779+
}
1780+
baseMap[key] = overrideValue
1781+
}
1782+
}
1783+
1784+
// MergeDeep merges an override object into a base one using JSON round-trip.
1785+
// Fields present in the override overwrite corresponding fields in the base.
1786+
// When reverse is true the roles are swapped: base fills absent fields in override
1787+
// (useful when the override should win and the base provides defaults).
1788+
func MergeDeep[T comparable](base, override T, reverse bool) error {
1789+
var zero T
1790+
if override == zero {
1791+
return nil
1792+
}
1793+
baseJSON, err := json.Marshal(base)
1794+
if err != nil {
1795+
return fmt.Errorf("failed to marshal base spec: %w", err)
1796+
}
1797+
overrideJSON, err := json.Marshal(override)
1798+
if err != nil {
1799+
return fmt.Errorf("failed to marshal override spec: %w", err)
1800+
}
1801+
var baseMap map[string]any
1802+
if err := json.Unmarshal(baseJSON, &baseMap); err != nil {
1803+
return fmt.Errorf("failed to unmarshal base spec to map: %w", err)
1804+
}
1805+
var overrideMap map[string]any
1806+
if err := json.Unmarshal(overrideJSON, &overrideMap); err != nil {
1807+
return fmt.Errorf("failed to unmarshal override spec to map: %w", err)
1808+
}
1809+
if reverse {
1810+
baseMap, overrideMap = overrideMap, baseMap
1811+
}
1812+
mergeMapsRecursive(baseMap, overrideMap)
1813+
mergedJSON, err := json.Marshal(baseMap)
1814+
if err != nil {
1815+
return fmt.Errorf("failed to marshal merged spec map: %w", err)
1816+
}
1817+
if err := json.Unmarshal(mergedJSON, base); err != nil {
1818+
return fmt.Errorf("failed to unmarshal merged spec JSON: %w", err)
1819+
}
1820+
return nil
1821+
}

0 commit comments

Comments
 (0)