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. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
networkCIDRstring + NetworkCIDR is the CIDR of the subnet. Required.
+
true
rangeBeginstring + RangeBegin is the first IP that can be given to hosts. Must be inside NetworkCIDR.
+
true
rangeEndstring + 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{