Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ ProvisioningNetworkCIDR where the 1st address represents
the start of the range and the 2nd address represents the
last usable address in the range.

- ProvisioningNetworkGateway is the IP address of the default gateway
for the provisioning network. This gateway is provided to baremetal
hosts via DHCP to enable routing to external networks during
inspection and provisioning. This field is optional and only
used when ProvisioningNetwork is set to Managed. The gateway IP
must be within the ProvisioningNetworkCIDR but outside of the
ProvisioningDHCPRange and must not be the same as ProvisioningIP.

- ProvisioningOSDownloadURL is the location from which the OS
Image used to boot baremetal host machines can be downloaded
by the metal3 cluster.
Expand Down
9 changes: 9 additions & 0 deletions api/v1alpha1/provisioning_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ type ProvisioningSpec struct {
// last usable address in the range.
ProvisioningDHCPRange string `json:"provisioningDHCPRange,omitempty"`

// ProvisioningNetworkGateway is the IP address of the default gateway
// for the provisioning network. This gateway is provided to baremetal
// hosts via DHCP to enable routing to external networks during
// inspection and provisioning. This field is optional and only
// used when ProvisioningNetwork is set to Managed. The gateway IP
// must be within the ProvisioningNetworkCIDR but outside of the
// ProvisioningDHCPRange and must not be the same as ProvisioningIP.
ProvisioningNetworkGateway string `json:"provisioningNetworkGateway,omitempty"`

// ProvisioningOSDownloadURL is the location from which the OS
// Image used to boot baremetal host machines can be downloaded
// by the metal3 cluster.
Expand Down
68 changes: 65 additions & 3 deletions api/v1alpha1/provisioning_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,20 @@ func (prov *Provisioning) ValidateBaremetalProvisioningConfig(enabledFeatures En
}
}

// Only force check of dhcpRange if in managed mode.
// Only force check of dhcpRange and gatewayIP if in managed mode.
dhcpRange := prov.Spec.ProvisioningDHCPRange
gatewayIP := prov.Spec.ProvisioningNetworkGateway

// Validate that gateway IP is only set for Managed provisioning network
if provisioningNetworkMode != ProvisioningNetworkManaged {
if prov.Spec.ProvisioningNetworkGateway != "" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if gatewayIP != ""

errs = append(errs, fmt.Errorf("provisioningNetworkGateway is only supported for Managed provisioning network, but provisioning network is set to %s", provisioningNetworkMode))
}
dhcpRange = ""
gatewayIP = ""
Comment thread
jadhaj marked this conversation as resolved.
}

if err := validateProvisioningNetworkSettings(prov.Spec.ProvisioningIP, prov.Spec.ProvisioningNetworkCIDR, dhcpRange, prov.getProvisioningNetworkMode()); err != nil {
if err := validateProvisioningNetworkSettings(prov.Spec.ProvisioningIP, prov.Spec.ProvisioningNetworkCIDR, dhcpRange, gatewayIP, prov.getProvisioningNetworkMode()); err != nil {
errs = append(errs, err...)
}

Expand All @@ -97,6 +104,32 @@ func (prov *Provisioning) ValidateBaremetalProvisioningConfig(enabledFeatures En
return errors.NewAggregate(errs)
}

// isNetworkOrBroadcastAddress checks if the IP is a network or broadcast address
// For IPv4 with masks narrower than /31, network and broadcast addresses are not usable
func isNetworkOrBroadcastAddress(ip net.IP, cidr *net.IPNet) bool {
ones, bits := cidr.Mask.Size()
// Only apply network/broadcast checks to IPv4.
if bits != 32 {
return false
}
// For IPv4 /31 and /32, all addresses are usable.
if ones >= 31 {
return false
}
// Regular IPv4 → check network/broadcast
networkAddr := cidr.IP.Mask(cidr.Mask)
if ip.Equal(networkAddr) {
return true
}
// IPv4 broadcast address check (all host bits are 1).
broadcast := make(net.IP, len(networkAddr))
copy(broadcast, networkAddr)
for i := range broadcast {
broadcast[i] |= ^cidr.Mask[i]
}
return ip.Equal(broadcast)
}
Comment thread
jadhaj marked this conversation as resolved.

func (prov *Provisioning) getProvisioningNetworkMode() ProvisioningNetwork {
provisioningNetworkMode := prov.Spec.ProvisioningNetwork
if provisioningNetworkMode == "" {
Expand Down Expand Up @@ -147,7 +180,7 @@ func validateProvisioningOSDownloadURL(uri string) []error {
return errs
}

func validateProvisioningNetworkSettings(ip string, cidr string, dhcpRange string, provisioningNetworkMode ProvisioningNetwork) []error {
func validateProvisioningNetworkSettings(ip string, cidr string, dhcpRange string, gatewayIP string, provisioningNetworkMode ProvisioningNetwork) []error {
// provisioningIP and networkCIDR are always set. DHCP range is optional
// depending on mode.
var errs []error
Expand Down Expand Up @@ -181,6 +214,27 @@ func validateProvisioningNetworkSettings(ip string, cidr string, dhcpRange strin
errs = append(errs, fmt.Errorf("provisioningIP %q is not in the range defined by the provisioningNetworkCIDR %q", ip, cidr))
}

// Validate gateway IP if provided
if gatewayIP != "" {
gateway := net.ParseIP(gatewayIP)
if gateway == nil {
errs = append(errs, fmt.Errorf("could not parse provisioningNetworkGateway %q", gatewayIP))
return errs
}
// Ensure gateway IP is in the network CIDR
if !provisioningCIDR.Contains(gateway) {
errs = append(errs, fmt.Errorf("provisioningNetworkGateway %q is not in the range defined by the provisioningNetworkCIDR %q", gatewayIP, cidr))
}
// Ensure gateway IP is not the same as provisioning IP
if gateway.Equal(provisioningIP) {
errs = append(errs, fmt.Errorf("provisioningNetworkGateway %q cannot be the same as provisioningIP %q", gatewayIP, ip))
}
// Ensure gateway is a usable host address (not network or broadcast)
if isNetworkOrBroadcastAddress(gateway, provisioningCIDR) {
errs = append(errs, fmt.Errorf("provisioningNetworkGateway %q is not a usable host address (network or broadcast address)", gatewayIP))
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// DHCP Range might not be set in which case we're done here.
if dhcpRange == "" {
return errs
Expand Down Expand Up @@ -219,6 +273,14 @@ func validateProvisioningNetworkSettings(ip string, cidr string, dhcpRange strin
if bytes.Compare(provisioningIP, start) >= 0 && bytes.Compare(provisioningIP, end) <= 0 {
errs = append(errs, fmt.Errorf("invalid provisioningIP %q, value must be outside of the provisioningDHCPRange %q", provisioningIP, dhcpRange))
}

// Ensure gateway IP is not in the DHCP range if provided
if gatewayIP != "" {
gateway := net.ParseIP(gatewayIP)
if gateway != nil && bytes.Compare(gateway, start) >= 0 && bytes.Compare(gateway, end) <= 0 {
errs = append(errs, fmt.Errorf("invalid provisioningNetworkGateway %q, value must be outside of the provisioningDHCPRange %q", gatewayIP, dhcpRange))
}
}
}

return errs
Expand Down
106 changes: 106 additions & 0 deletions api/v1alpha1/provisioning_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,91 @@ func TestValidateManagedProvisioningConfig(t *testing.T) {
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "unsupported scheme",
},
{
// Valid gateway in CIDR, outside DHCP range
name: "ValidManagedWithGateway",
spec: managedProvisioning().ProvisioningNetworkGateway("172.30.20.1").build(),
expectedError: false,
expectedMode: ProvisioningNetworkManaged,
},
{
// Gateway with invalid IP format
name: "InvalidManagedGatewayBadIP",
spec: managedProvisioning().ProvisioningNetworkGateway("not-an-ip").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "could not parse provisioningNetworkGateway",
},
{
// Gateway outside CIDR
name: "InvalidManagedGatewayOutsideCIDR",
spec: managedProvisioning().ProvisioningNetworkGateway("192.168.1.1").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "is not in the range defined by the provisioningNetworkCIDR",
},
{
// Gateway same as provisioningIP
name: "InvalidManagedGatewaySameAsProvisioningIP",
spec: managedProvisioning().ProvisioningNetworkGateway("172.30.20.3").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "cannot be the same as provisioningIP",
},
{
// Gateway is network address
name: "InvalidManagedGatewayIsNetworkAddress",
spec: managedProvisioning().ProvisioningNetworkGateway("172.30.20.0").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "is not a usable host address",
},
{
// Gateway is broadcast address
name: "InvalidManagedGatewayIsBroadcastAddress",
spec: managedProvisioning().ProvisioningNetworkGateway("172.30.20.255").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "is not a usable host address",
},
{
// Gateway in DHCP range
name: "InvalidManagedGatewayInDHCPRange",
spec: managedProvisioning().ProvisioningNetworkGateway("172.30.20.50").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "value must be outside of the provisioningDHCPRange",
},
{
// Gateway on /31 network (RFC 3021 - all addresses usable)
name: "ValidManagedGatewayOn31Network",
spec: managedProvisioning().ProvisioningNetworkCIDR("172.30.20.0/31").ProvisioningDHCPRange("").ProvisioningNetworkGateway("172.30.20.0").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "provisioningDHCPRange is required in Managed mode",
},
{
// Gateway on /32 network (point-to-point - all addresses usable)
name: "ValidManagedGatewayOn32Network",
spec: managedProvisioning().ProvisioningNetworkCIDR("172.30.20.1/32").ProvisioningDHCPRange("").ProvisioningNetworkGateway("172.30.20.1").build(),
expectedError: true,
expectedMode: ProvisioningNetworkManaged,
expectedMsg: "provisioningDHCPRange is required in Managed mode",
},
{
// Empty provisioningNetwork with gateway (defaults to Managed)
name: "ValidImpliedManagedWithGateway",
spec: managedProvisioning().ProvisioningNetwork("").ProvisioningNetworkGateway("172.30.20.1").build(),
expectedError: false,
expectedMode: ProvisioningNetworkManaged,
},
{
// IPv6 gateway using subnet base address (should be allowed)
name: "ValidManagedIPv6GatewaySubnetBase",
spec: managedProvisioning().ProvisioningNetworkCIDR("fd00::/64").ProvisioningIP("fd00::2").ProvisioningDHCPRange("fd00::10,fd00::ff").ProvisioningNetworkGateway("fd00::").build(),
expectedError: false,
expectedMode: ProvisioningNetworkManaged,
},
}
for _, tc := range tCases {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -225,6 +310,14 @@ func TestValidateUnmanagedProvisioningConfig(t *testing.T) {
expectedMode: ProvisioningNetworkUnmanaged,
expectedMsg: "provisioningIP",
},
{
// Gateway set for Unmanaged provisioning network (should be rejected)
name: "InvalidUnmanagedWithGateway",
spec: unmanagedProvisioning().ProvisioningNetworkGateway("172.30.20.1").build(),
expectedError: true,
expectedMode: ProvisioningNetworkUnmanaged,
expectedMsg: "provisioningNetworkGateway is only supported for Managed provisioning network",
},
}
for _, tc := range tCases {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -300,6 +393,14 @@ func TestValidateDisabledProvisioningConfig(t *testing.T) {
expectedError: false,
expectedMode: ProvisioningNetworkDisabled,
},
{
// Gateway set for Disabled provisioning network (should be rejected)
name: "InvalidDisabledWithGateway",
spec: disabledProvisioning().ProvisioningNetworkGateway("172.30.20.1").build(),
expectedError: true,
expectedMode: ProvisioningNetworkDisabled,
expectedMsg: "provisioningNetworkGateway is only supported for Managed provisioning network",
},
}
for _, tc := range tCases {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -486,3 +587,8 @@ func (pb *provisioningBuilder) ProvisioningOSDownloadURL(value string) *provisio
pb.ProvisioningSpec.ProvisioningOSDownloadURL = value
return pb
}

func (pb *provisioningBuilder) ProvisioningNetworkGateway(value string) *provisioningBuilder {
pb.ProvisioningSpec.ProvisioningNetworkGateway = value
return pb
}
10 changes: 10 additions & 0 deletions config/crd/bases/metal3.io_provisionings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ spec:
and in a network managed by the Baremetal IPI solution this cannot be a
network larger than a /64.
type: string
provisioningNetworkGateway:
description: |-
ProvisioningNetworkGateway is the IP address of the default gateway
for the provisioning network. This gateway is provided to baremetal
hosts via DHCP to enable routing to external networks during
inspection and provisioning. This field is optional and only
used when ProvisioningNetwork is set to Managed. The gateway IP
must be within the ProvisioningNetworkCIDR but outside of the
ProvisioningDHCPRange and must not be the same as ProvisioningIP.
type: string
provisioningOSDownloadURL:
description: |-
ProvisioningOSDownloadURL is the location from which the OS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,16 @@ spec:
and in a network managed by the Baremetal IPI solution this cannot be a
network larger than a /64.
type: string
provisioningNetworkGateway:
description: |-
ProvisioningNetworkGateway is the IP address of the default gateway
for the provisioning network. This gateway is provided to baremetal
hosts via DHCP to enable routing to external networks during
inspection and provisioning. This field is optional and only
used when ProvisioningNetwork is set to Managed. The gateway IP
must be within the ProvisioningNetworkCIDR but outside of the
ProvisioningDHCPRange and must not be the same as ProvisioningIP.
type: string
provisioningOSDownloadURL:
description: |-
ProvisioningOSDownloadURL is the location from which the OS
Expand Down
12 changes: 12 additions & 0 deletions provisioning/baremetal_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
vmediaHttpsPort = "VMEDIA_TLS_PORT"
dnsIP = "DNS_IP"
dhcpRange = "DHCP_RANGE"
gatewayIP = "GATEWAY_IP"
machineImageUrl = "RHCOS_IMAGE_URL"
ipOptions = "IP_OPTIONS"
bootIsoSource = "IRONIC_BOOT_ISO_SOURCE"
Expand Down Expand Up @@ -126,6 +127,15 @@ func getBootIsoSource(config *metal3iov1alpha1.ProvisioningSpec) *string {
return nil
}

func getGatewayIP(config *metal3iov1alpha1.ProvisioningSpec) *string {
// Gateway is only supported for Managed provisioning network.
// Validation ensures ProvisioningNetwork is never empty at runtime.
if config.ProvisioningNetwork == metal3iov1alpha1.ProvisioningNetworkManaged && config.ProvisioningNetworkGateway != "" {
return &config.ProvisioningNetworkGateway
}
return nil
}
Comment thread
jadhaj marked this conversation as resolved.

func getMetal3DeploymentConfig(name string, baremetalConfig *metal3iov1alpha1.ProvisioningSpec) *string {
switch name {
case provisioningIP:
Expand All @@ -142,6 +152,8 @@ func getMetal3DeploymentConfig(name string, baremetalConfig *metal3iov1alpha1.Pr
return ptr.To(baremetalVmediaHttpsPort)
case dhcpRange:
return getDHCPRange(baremetalConfig)
case gatewayIP:
return getGatewayIP(baremetalConfig)
case machineImageUrl:
return getProvisioningOSDownloadURL(baremetalConfig)
case bootIsoSource:
Expand Down
5 changes: 5 additions & 0 deletions provisioning/baremetal_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ func (pb *provisioningBuilder) ProvisioningDHCPRange(value string) *provisioning
return pb
}

func (pb *provisioningBuilder) ProvisioningNetworkGateway(value string) *provisioningBuilder {
pb.ProvisioningSpec.ProvisioningNetworkGateway = value
return pb
}

func (pb *provisioningBuilder) VirtualMediaViaExternalNetwork(value bool) *provisioningBuilder {
pb.ProvisioningSpec.VirtualMediaViaExternalNetwork = value
return pb
Expand Down
7 changes: 7 additions & 0 deletions provisioning/baremetal_pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,13 @@ func createContainerMetal3Dnsmasq(images *Images, config *metal3iov1alpha1.Provi
buildEnvVar(dhcpRange, config),
buildEnvVar(provisioningMacAddresses, config),
}
// Only add GATEWAY_IP if it has a value
if gatewayValue := getGatewayIP(config); gatewayValue != nil {
envVars = append(envVars, corev1.EnvVar{
Name: gatewayIP,
Value: *gatewayValue,
})
}
if config.ProvisioningDNS {
envVars = append(envVars, corev1.EnvVar{
Name: dnsIP,
Expand Down
15 changes: 15 additions & 0 deletions provisioning/baremetal_pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,21 @@ func TestNewMetal3Containers(t *testing.T) {
},
sshkey: "sshkey",
},
{
name: "ManagedSpec with Gateway",
config: managedProvisioning().ProvisioningNetworkGateway("192.0.2.1").build(),
expectedContainers: []corev1.Container{
withTLSEnv(containers["metal3-httpd"], sshkey),
withTLSEnv(containers["metal3-ironic"], sshkey),
containers["metal3-ramdisk-logs"],
containers["metal3-static-ip-manager"],
withEnv(
containers["metal3-dnsmasq"],
envWithValue("GATEWAY_IP", "192.0.2.1"),
),
},
sshkey: "sshkey",
},
{
name: "ManagedSpec with virtualmedia",
config: managedProvisioning().VirtualMediaViaExternalNetwork(true).build(),
Expand Down