diff --git a/api/v1alpha1/ironic_types.go b/api/v1alpha1/ironic_types.go
index fe8b3f07..0adb9246 100644
--- a/api/v1alpha1/ironic_types.go
+++ b/api/v1alpha1/ironic_types.go
@@ -53,6 +53,18 @@ type Inspection struct {
VLANInterfaces []string `json:"vlanInterfaces,omitempty"`
}
+// DHCPRange defines a DHCP range for a subnet served via DHCP relay agents.
+type DHCPRange struct {
+ // NetworkCIDR is the CIDR of the subnet. Required.
+ NetworkCIDR string `json:"networkCIDR"`
+
+ // RangeBegin is the first IP that can be given to hosts. Must be inside NetworkCIDR.
+ RangeBegin string `json:"rangeBegin"`
+
+ // RangeEnd is the last IP that can be given to hosts. Must be inside NetworkCIDR.
+ RangeEnd string `json:"rangeEnd"`
+}
+
type DHCP struct {
// DNSAddress is the IP address of the DNS server to pass to hosts via DHCP.
// Must not be set together with ServeDNS.
@@ -84,6 +96,11 @@ type DHCP struct {
// RangeEnd is the last IP that can be given to hosts. Must be inside NetworkCIDR.
RangeEnd string `json:"rangeEnd,omitempty"`
+ // NetworkRanges is a list of additional DHCP ranges for subnets served via DHCP relay agents.
+ // Each range defines a separate subnet. The provisioning IP does not need to be within these subnets.
+ // +optional
+ NetworkRanges []DHCPRange `json:"networkRanges,omitempty"`
+
// ServeDNS is set to true to pass the provisioning host as the DNS server on the provisioning network.
// Must not be set together with DNSAddress.
// +optional
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index d23ec762..128fdbfc 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -39,6 +39,11 @@ func (in *DHCP) DeepCopyInto(out *DHCP) {
*out = make([]string, len(*in))
copy(*out, *in)
}
+ if in.NetworkRanges != nil {
+ in, out := &in.NetworkRanges, &out.NetworkRanges
+ *out = make([]DHCPRange, len(*in))
+ copy(*out, *in)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DHCP.
@@ -51,6 +56,21 @@ func (in *DHCP) DeepCopy() *DHCP {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DHCPRange) DeepCopyInto(out *DHCPRange) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DHCPRange.
+func (in *DHCPRange) DeepCopy() *DHCPRange {
+ if in == nil {
+ return nil
+ }
+ out := new(DHCPRange)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Database) DeepCopyInto(out *Database) {
*out = *in
diff --git a/config/crd/bases/ironic.metal3.io_ironics.yaml b/config/crd/bases/ironic.metal3.io_ironics.yaml
index b057dd88..d846fbab 100644
--- a/config/crd/bases/ironic.metal3.io_ironics.yaml
+++ b/config/crd/bases/ironic.metal3.io_ironics.yaml
@@ -223,6 +223,32 @@ spec:
description: NetworkCIDR is a CIDR of the provisioning network.
Required.
type: string
+ networkRanges:
+ description: |-
+ NetworkRanges is a list of additional DHCP ranges for subnets served via DHCP relay agents.
+ Each range defines a separate subnet. The provisioning IP does not need to be within these subnets.
+ items:
+ description: DHCPRange defines a DHCP range for a subnet
+ served via DHCP relay agents.
+ properties:
+ networkCIDR:
+ description: NetworkCIDR is the CIDR of the subnet.
+ Required.
+ type: string
+ rangeBegin:
+ description: RangeBegin is the first IP that can be
+ given to hosts. Must be inside NetworkCIDR.
+ type: string
+ rangeEnd:
+ description: RangeEnd is the last IP that can be given
+ to hosts. Must be inside NetworkCIDR.
+ type: string
+ required:
+ - networkCIDR
+ - rangeBegin
+ - rangeEnd
+ type: object
+ type: array
rangeBegin:
description: RangeBegin is the first IP that can be given
to hosts. Must be inside NetworkCIDR.
diff --git a/docs/api.md b/docs/api.md
index bbbf6216..f362b776 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -615,6 +615,14 @@ There is no API-side validation. Most users will leave this unset.
NetworkCIDR is a CIDR of the provisioning network. Required.
false |
+
+ | networkRanges |
+ []object |
+
+ NetworkRanges is a list of additional DHCP ranges for subnets served via DHCP relay agents.
+Each range defines a separate subnet. The provisioning IP does not need to be within these subnets.
+ |
+ false |
| rangeBegin |
string |
@@ -641,6 +649,47 @@ Must not be set together with DNSAddress.
+### Ironic.spec.networking.dhcp.networkRanges[index]
+[↩ Parent](#ironicspecnetworkingdhcp)
+
+
+
+DHCPRange defines a DHCP range for a subnet served via DHCP relay agents.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | networkCIDR |
+ string |
+
+ NetworkCIDR is the CIDR of the subnet. Required.
+ |
+ true |
+
+ | rangeBegin |
+ string |
+
+ RangeBegin is the first IP that can be given to hosts. Must be inside NetworkCIDR.
+ |
+ true |
+
+ | rangeEnd |
+ string |
+
+ RangeEnd is the last IP that can be given to hosts. Must be inside NetworkCIDR.
+ |
+ true |
+
+
+
+
### Ironic.spec.overrides
[↩ Parent](#ironicspec)
diff --git a/pkg/ironic/containers.go b/pkg/ironic/containers.go
index 92bf6d55..1681472e 100644
--- a/pkg/ironic/containers.go
+++ b/pkg/ironic/containers.go
@@ -506,12 +506,26 @@ func buildIronicHttpdPorts(ironic *metal3api.Ironic) (ironicPorts []corev1.Conta
}
func buildDHCPRange(dhcp *metal3api.DHCP) string {
- prefix, err := netip.ParsePrefix(dhcp.NetworkCIDR)
- if err != nil {
- return "" // don't disable your webhooks people
+ var parts []string
+
+ // Primary range (from flat fields)
+ if dhcp.NetworkCIDR != "" && dhcp.RangeBegin != "" && dhcp.RangeEnd != "" {
+ prefix, err := netip.ParsePrefix(dhcp.NetworkCIDR)
+ if err == nil {
+ parts = append(parts, fmt.Sprintf("%s,%s,%d", dhcp.RangeBegin, dhcp.RangeEnd, prefix.Bits()))
+ }
+ }
+
+ // Additional network ranges
+ for _, r := range dhcp.NetworkRanges {
+ prefix, err := netip.ParsePrefix(r.NetworkCIDR)
+ if err != nil {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s,%s,%d", r.RangeBegin, r.RangeEnd, prefix.Bits()))
}
- return fmt.Sprintf("%s,%s,%d", dhcp.RangeBegin, dhcp.RangeEnd, prefix.Bits())
+ return strings.Join(parts, ";")
}
func buildDNSIP(dhcp *metal3api.DHCP) string {
diff --git a/pkg/ironic/containers_test.go b/pkg/ironic/containers_test.go
index d43a2dea..d6865fef 100644
--- a/pkg/ironic/containers_test.go
+++ b/pkg/ironic/containers_test.go
@@ -503,6 +503,88 @@ func TestIronicPortEnvVars(t *testing.T) {
}
}
+func TestBuildDHCPRange(t *testing.T) {
+ testCases := []struct {
+ name string
+ dhcp *metal3api.DHCP
+ expected string
+ }{
+ {
+ name: "primary range only",
+ dhcp: &metal3api.DHCP{
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.200",
+ },
+ expected: "192.168.1.10,192.168.1.200,24",
+ },
+ {
+ name: "networkRanges only",
+ dhcp: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.200",
+ },
+ {
+ NetworkCIDR: "192.168.2.0/24",
+ RangeBegin: "192.168.2.10",
+ RangeEnd: "192.168.2.200",
+ },
+ },
+ },
+ expected: "192.168.1.10,192.168.1.200,24;192.168.2.10,192.168.2.200,24",
+ },
+ {
+ name: "primary range and networkRanges combined",
+ dhcp: &metal3api.DHCP{
+ NetworkCIDR: "10.0.0.0/16",
+ RangeBegin: "10.0.1.1",
+ RangeEnd: "10.0.1.254",
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.200",
+ },
+ },
+ },
+ expected: "10.0.1.1,10.0.1.254,16;192.168.1.10,192.168.1.200,24",
+ },
+ {
+ name: "IPv6 networkRanges",
+ dhcp: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "fd69:158d:692a:1::/64",
+ RangeBegin: "fd69:158d:692a:1::3000",
+ RangeEnd: "fd69:158d:692a:1::3fff",
+ },
+ {
+ NetworkCIDR: "fd69:158d:692a:2::/64",
+ RangeBegin: "fd69:158d:692a:2::3000",
+ RangeEnd: "fd69:158d:692a:2::3fff",
+ },
+ },
+ },
+ expected: "fd69:158d:692a:1::3000,fd69:158d:692a:1::3fff,64;fd69:158d:692a:2::3000,fd69:158d:692a:2::3fff,64",
+ },
+ {
+ name: "empty DHCP",
+ dhcp: &metal3api.DHCP{},
+ expected: "",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := buildDHCPRange(tc.dhcp)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
func TestPrometheusExporterEnvVars(t *testing.T) {
testCases := []struct {
name string
diff --git a/pkg/ironic/validation.go b/pkg/ironic/validation.go
index 918b20dc..450b6237 100644
--- a/pkg/ironic/validation.go
+++ b/pkg/ironic/validation.go
@@ -38,33 +38,89 @@ func validateIPinPrefix(ip string, prefix netip.Prefix) error {
return nil
}
+func validateDHCPRange(r *metal3api.DHCPRange, fieldPath string) error {
+ if r.NetworkCIDR == "" {
+ return fmt.Errorf("%s.networkCIDR is required", fieldPath)
+ }
+
+ prefix, err := netip.ParsePrefix(r.NetworkCIDR)
+ if err != nil {
+ return fmt.Errorf("%s.networkCIDR is invalid: %w", fieldPath, err)
+ }
+
+ if r.RangeBegin == "" {
+ return fmt.Errorf("%s.rangeBegin is required", fieldPath)
+ }
+
+ if r.RangeEnd == "" {
+ return fmt.Errorf("%s.rangeEnd is required", fieldPath)
+ }
+
+ if err := validateIPinPrefix(r.RangeBegin, prefix); err != nil {
+ return fmt.Errorf("%s: %w", fieldPath, err)
+ }
+
+ if err := validateIPinPrefix(r.RangeEnd, prefix); err != nil {
+ return fmt.Errorf("%s: %w", fieldPath, err)
+ }
+
+ return nil
+}
+
func ValidateDHCP(ironic *metal3api.IronicSpec) error {
dhcp := ironic.Networking.DHCP
hasNetworking := ironic.Networking.IPAddress != "" || ironic.Networking.Interface != "" || len(ironic.Networking.MACAddresses) > 0
if !hasNetworking {
return errors.New("networking: at least one of ipAddress, interface or macAddresses is required when DHCP is used")
}
- if dhcp.NetworkCIDR == "" {
- return errors.New("networking.dhcp.networkCIDR is required when DHCP is used")
- }
if dhcp.ServeDNS && dhcp.DNSAddress != "" {
return errors.New("networking.dhcp.dnsAddress cannot set together with serveDNS")
}
- if dhcp.RangeBegin == "" || dhcp.RangeEnd == "" {
- return errors.New("networking.dhcp: rangeBegin and rangeEnd are required")
- }
- provCIDR, err := netip.ParsePrefix(dhcp.NetworkCIDR)
- if err != nil {
- return fmt.Errorf("networking.dhcp.networkCIDR is invalid: %w", err)
+ hasPrimaryRange := dhcp.RangeBegin != "" && dhcp.RangeEnd != ""
+ hasNetworkRanges := len(dhcp.NetworkRanges) > 0
+
+ if !hasPrimaryRange && !hasNetworkRanges {
+ return errors.New("networking.dhcp: at least one of rangeBegin/rangeEnd or networkRanges is required")
}
- if err := validateIPinPrefix(dhcp.RangeBegin, provCIDR); err != nil {
- return err
+ // Validate primary range if present
+ if dhcp.RangeBegin != "" || dhcp.RangeEnd != "" {
+ if dhcp.NetworkCIDR == "" {
+ return errors.New("networking.dhcp.networkCIDR is required when rangeBegin/rangeEnd are set")
+ }
+ if dhcp.RangeBegin == "" || dhcp.RangeEnd == "" {
+ return errors.New("networking.dhcp: both rangeBegin and rangeEnd must be set together")
+ }
+
+ provCIDR, err := netip.ParsePrefix(dhcp.NetworkCIDR)
+ if err != nil {
+ return fmt.Errorf("networking.dhcp.networkCIDR is invalid: %w", err)
+ }
+
+ if err := validateIPinPrefix(dhcp.RangeBegin, provCIDR); err != nil {
+ return err
+ }
+
+ if err := validateIPinPrefix(dhcp.RangeEnd, provCIDR); err != nil {
+ return err
+ }
+
+ // Check that the provisioning IP is in the CIDR
+ if ironic.Networking.IPAddress != "" {
+ provIP, _ := netip.ParseAddr(ironic.Networking.IPAddress)
+ if !provCIDR.Contains(provIP) {
+ return errors.New("networking.dhcp.networkCIDR must contain networking.ipAddress")
+ }
+ }
}
- if err := validateIPinPrefix(dhcp.RangeEnd, provCIDR); err != nil {
- return err
+ // Validate additional network ranges
+ for i := range dhcp.NetworkRanges {
+ fieldPath := fmt.Sprintf("networking.dhcp.networkRanges[%d]", i)
+ if err := validateDHCPRange(&dhcp.NetworkRanges[i], fieldPath); err != nil {
+ return err
+ }
}
if err := validateIP(dhcp.DNSAddress); err != nil {
@@ -75,19 +131,6 @@ func ValidateDHCP(ironic *metal3api.IronicSpec) error {
return err
}
- // These are supposed to be populated by the webhook
- if dhcp.RangeBegin == "" || dhcp.RangeEnd == "" {
- return errors.New("firstIP and lastIP are not set and could not be automatically populated")
- }
-
- // Check that the provisioning IP is in the CIDR
- if ironic.Networking.IPAddress != "" {
- provIP, _ := netip.ParseAddr(ironic.Networking.IPAddress)
- if !provCIDR.Contains(provIP) {
- return errors.New("networking.dhcp.networkCIDR must contain networking.ipAddress")
- }
- }
-
return nil
}
diff --git a/pkg/ironic/validation_test.go b/pkg/ironic/validation_test.go
index 73352865..b3892134 100644
--- a/pkg/ironic/validation_test.go
+++ b/pkg/ironic/validation_test.go
@@ -299,6 +299,111 @@ func TestValidateIronic(t *testing.T) {
},
ExpectedError: "10.0.0.10 is not in networking.dhcp.networkCIDR",
},
+ {
+ Scenario: "DHCP with networkRanges only",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ DHCP: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.100",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ Scenario: "DHCP with primary range and networkRanges",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ IPAddress: "192.168.1.1",
+ DHCP: &metal3api.DHCP{
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.100",
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "192.168.2.0/24",
+ RangeBegin: "192.168.2.10",
+ RangeEnd: "192.168.2.100",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ Scenario: "DHCP with IPv6 networkRanges",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ DHCP: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "fd69:158d:692a:1::/64",
+ RangeBegin: "fd69:158d:692a:1::3000",
+ RangeEnd: "fd69:158d:692a:1::3fff",
+ },
+ {
+ NetworkCIDR: "fd69:158d:692a:2::/64",
+ RangeBegin: "fd69:158d:692a:2::3000",
+ RangeEnd: "fd69:158d:692a:2::3fff",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ Scenario: "DHCP with neither primary range nor networkRanges",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ DHCP: &metal3api.DHCP{},
+ },
+ },
+ ExpectedError: "at least one of rangeBegin/rangeEnd or networkRanges is required",
+ },
+ {
+ Scenario: "networkRange with missing networkCIDR",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ DHCP: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ RangeBegin: "192.168.1.10",
+ RangeEnd: "192.168.1.100",
+ },
+ },
+ },
+ },
+ },
+ ExpectedError: "networking.dhcp.networkRanges[0].networkCIDR is required",
+ },
+ {
+ Scenario: "networkRange with IP outside CIDR",
+ Ironic: metal3api.IronicSpec{
+ Networking: metal3api.Networking{
+ Interface: "eth0",
+ DHCP: &metal3api.DHCP{
+ NetworkRanges: []metal3api.DHCPRange{
+ {
+ NetworkCIDR: "192.168.1.0/24",
+ RangeBegin: "10.0.0.10",
+ RangeEnd: "192.168.1.100",
+ },
+ },
+ },
+ },
+ },
+ ExpectedError: "networking.dhcp.networkRanges[0]",
+ },
{
Scenario: "Provisioning IP address not in the CIDR",
Ironic: metal3api.IronicSpec{