Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,46 @@ spec:
type: array
insecure:
type: boolean
ipPool:
description: |-
IPPool defines the IP pool configuration for static IP assignment to cluster nodes.
When specified, nodes will be assigned static IPs from this pool instead of using DHCP.
The CLI creates an InClusterIPPool resource from this configuration.
properties:
addresses:
description: |-
Addresses defines the IP addresses to include in the pool.
Comment thread
parlakisik marked this conversation as resolved.
Supports ranges (e.g., "192.168.1.100-192.168.1.120"),
CIDR blocks (e.g., "192.168.1.0/24"), or individual IPs.
items:
type: string
minItems: 1
type: array
gateway:
description: Gateway is the default gateway IP address for the
subnet.
type: string
name:
description: Name is the name for the generated InClusterIPPool
resource.
type: string
nameservers:
description: Nameservers is a list of DNS server IP addresses.
items:
type: string
type: array
prefix:
description: Prefix is the subnet prefix length (e.g., 24 for
/24 subnet).
maximum: 32
minimum: 1
type: integer
required:
- addresses
- gateway
- name
- prefix
type: object
network:
type: string
server:
Expand Down
21 changes: 21 additions & 0 deletions internal/pkg/api/vsphere.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,24 @@ func RemoveEtcdVsphereMachineConfig() VSphereFiller {
}
}
}

// WithVSphereIPPool configures the IP pool for static IP assignment on the VSphereDatacenterConfig.
// This enables CAPI IPAM-based static IP allocation for vSphere clusters.
func WithVSphereIPPool(name string, addresses []string, prefix int, gateway string, nameservers []string) VSphereFiller {
return func(config VSphereConfig) {
config.datacenterConfig.Spec.IPPool = &anywherev1.IPPoolConfiguration{
Name: name,
Addresses: addresses,
Prefix: prefix,
Gateway: gateway,
Nameservers: nameservers,
}
}
}

// RemoveVSphereIPPool removes the IP pool configuration from the VSphereDatacenterConfig.
func RemoveVSphereIPPool() VSphereFiller {
return func(config VSphereConfig) {
config.datacenterConfig.Spec.IPPool = nil
}
}
132 changes: 132 additions & 0 deletions pkg/api/v1alpha1/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -1089,3 +1089,135 @@ func validateEksaVersion(clusterConfig *Cluster) error {

return nil
}

// validateIPPoolConfig validates the IP pool configuration fields.
func validateIPPoolConfig(ipPool *IPPoolConfiguration) error {
if ipPool.Name == "" {
return errors.New("ipPool.name cannot be empty")
}

if len(ipPool.Addresses) == 0 {
return errors.New("ipPool.addresses cannot be empty")
}

for _, addr := range ipPool.Addresses {
if err := validateIPPoolAddress(addr); err != nil {
return fmt.Errorf("invalid ipPool.addresses entry %q: %v", addr, err)
}
}

if ipPool.Prefix < 1 || ipPool.Prefix > 32 {
return fmt.Errorf("ipPool.prefix must be between 1 and 32, got %d", ipPool.Prefix)
}

if err := validateIPPoolGatewayAndNameservers(ipPool); err != nil {
return err
}

return nil
}

// validateIPPoolGatewayAndNameservers validates gateway and nameserver IPs.
func validateIPPoolGatewayAndNameservers(ipPool *IPPoolConfiguration) error {
if ipPool.Gateway == "" {
return errors.New("ipPool.gateway cannot be empty")
}
if net.ParseIP(ipPool.Gateway) == nil {
return fmt.Errorf("ipPool.gateway %q is not a valid IP address", ipPool.Gateway)
}

for _, ns := range ipPool.Nameservers {
if net.ParseIP(ns) == nil {
return fmt.Errorf("ipPool.nameservers entry %q is not a valid IP address", ns)
}
}

return nil
}

// validateIPPoolAddress validates a single address entry which can be:
// - A range: "192.168.1.100-192.168.1.120"
// - A CIDR: "192.168.1.0/24"
// - A single IP: "192.168.1.100".
func validateIPPoolAddress(addr string) error {
// Check if it's a range
if strings.Contains(addr, "-") {
parts := strings.Split(addr, "-")
if len(parts) != 2 {
return fmt.Errorf("invalid range format, expected 'start-end'")
}
startIP := net.ParseIP(strings.TrimSpace(parts[0]))
endIP := net.ParseIP(strings.TrimSpace(parts[1]))
if startIP == nil {
return fmt.Errorf("invalid start IP in range")
}
if endIP == nil {
return fmt.Errorf("invalid end IP in range")
}
// Ensure start <= end (simple byte comparison for IPv4)
if bytes.Compare(startIP.To4(), endIP.To4()) > 0 {
return fmt.Errorf("start IP must be less than or equal to end IP")
}
return nil
}

// Check if it's a CIDR
if strings.Contains(addr, "/") {
_, _, err := net.ParseCIDR(addr)
if err != nil {
return fmt.Errorf("invalid CIDR: %v", err)
}
return nil
}

// Must be a single IP
if net.ParseIP(addr) == nil {
return fmt.Errorf("invalid IP address")
}

return nil
}

// CalculateIPPoolSize calculates the total number of IPs in the pool.
func CalculateIPPoolSize(addresses []string) (int, error) {
total := 0
for _, addr := range addresses {
// Range
if strings.Contains(addr, "-") {
parts := strings.Split(addr, "-")
startIP := net.ParseIP(strings.TrimSpace(parts[0])).To4()
endIP := net.ParseIP(strings.TrimSpace(parts[1])).To4()
if startIP == nil || endIP == nil {
return 0, fmt.Errorf("invalid IP range: %s", addr)
}
// Calculate range size
start := ipToUint32(startIP)
end := ipToUint32(endIP)
total += int(end - start + 1)
continue
}

// CIDR
if strings.Contains(addr, "/") {
_, ipNet, err := net.ParseCIDR(addr)
if err != nil {
return 0, fmt.Errorf("invalid CIDR: %s", addr)
}
ones, bits := ipNet.Mask.Size()
// Number of hosts = 2^(bits-ones) - 2 (excluding network and broadcast)
// But for IP pools, we typically include all addresses
total += 1 << (bits - ones)
continue
}

// Single IP
total++
}
return total, nil
}

// ipToUint32 converts an IPv4 address to uint32.
func ipToUint32(ip net.IP) uint32 {
ip = ip.To4()
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
}
179 changes: 179 additions & 0 deletions pkg/api/v1alpha1/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4269,3 +4269,182 @@ rules:
})
}
}

func TestIPPoolConfigurationEqual(t *testing.T) {
tests := []struct {
name string
pool1 *IPPoolConfiguration
pool2 *IPPoolConfiguration
expect bool
}{
{
name: "both nil",
pool1: nil,
pool2: nil,
expect: true,
},
{
name: "first nil",
pool1: nil,
pool2: &IPPoolConfiguration{
Name: "test",
},
expect: false,
},
{
name: "second nil",
pool1: &IPPoolConfiguration{
Name: "test",
},
pool2: nil,
expect: false,
},
{
name: "equal configs",
pool1: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
Nameservers: []string{"8.8.8.8", "8.8.4.4"},
},
pool2: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
Nameservers: []string{"8.8.8.8", "8.8.4.4"},
},
expect: true,
},
{
name: "different name",
pool1: &IPPoolConfiguration{
Name: "test-pool-1",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
},
pool2: &IPPoolConfiguration{
Name: "test-pool-2",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
},
expect: false,
},
{
name: "different addresses",
pool1: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
},
pool2: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.110"},
Prefix: 24,
Gateway: "192.168.1.1",
},
expect: false,
},
{
name: "different prefix",
pool1: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
},
pool2: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 16,
Gateway: "192.168.1.1",
},
expect: false,
},
{
name: "different gateway",
pool1: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.1",
},
pool2: &IPPoolConfiguration{
Name: "test-pool",
Addresses: []string{"192.168.1.100-192.168.1.120"},
Prefix: 24,
Gateway: "192.168.1.254",
},
expect: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.pool1.Equal(tt.pool2)
assert.Equal(t, tt.expect, result)
})
}
}

func TestCalculateIPPoolSize(t *testing.T) {
tests := []struct {
name string
addresses []string
expected int
wantErr bool
}{
{
name: "single IP",
addresses: []string{"192.168.1.100"},
expected: 1,
wantErr: false,
},
{
name: "multiple single IPs",
addresses: []string{"192.168.1.100", "192.168.1.101", "192.168.1.102"},
expected: 3,
wantErr: false,
},
{
name: "IP range",
addresses: []string{"192.168.1.100-192.168.1.110"},
expected: 11,
wantErr: false,
},
{
name: "CIDR /24",
addresses: []string{"192.168.1.0/24"},
expected: 256,
wantErr: false,
},
{
name: "CIDR /28",
addresses: []string{"192.168.1.0/28"},
expected: 16,
wantErr: false,
},
{
name: "mixed addresses",
addresses: []string{"192.168.1.100", "192.168.1.110-192.168.1.120", "192.168.2.0/28"},
expected: 1 + 11 + 16,
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := CalculateIPPoolSize(tt.addresses)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
Loading
Loading