Skip to content

Commit 7f25c10

Browse files
committed
Add support for bonding multiple network interfaces
This commit introduces support for bonding multiple physical network interfaces for the control plane network as required by some deployments. Changes: - Add BondConfig struct to encapsulate bonding configuration including interfaces list, bond mode, and additional options - Add CtlplaneBond field (pointer to BondConfig) to OpenStackBaremetalSetTemplateSpec for template-level bonding configuration that applies to all hosts - Update the networkdata cloud-init template to generate bond configuration when bond interfaces are specified - Modify baremetalhost.go to pass bonding parameters from BondConfig to the template Supported bonding modes include active-backup, 802.3ad (LACP), balance-rr, balance-xor, and others supported by cloud-init. Example usage: ctlplaneInterface: bond0 ctlplaneBond: bondInterfaces: - eno1 - eno2 bondMode: "802.3ad" bondOptions: bond-miimon: "100" bond-xmit-hash-policy: "layer3+4" Assisted-by: Claude (Anthropic AI Assistant) Signed-off-by: rabi <[email protected]>
1 parent 76f901e commit 7f25c10

File tree

8 files changed

+291
-2
lines changed

8 files changed

+291
-2
lines changed

api/bases/baremetal.openstack.org_openstackbaremetalsets.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ spec:
146146
default: cloud-admin
147147
description: CloudUser to be configured for remote access
148148
type: string
149+
ctlplaneBond:
150+
description: CtlplaneBond - Bonding configuration for ctlplane network
151+
properties:
152+
bondInterfaces:
153+
description: BondInterfaces - List of physical interfaces to bond
154+
items:
155+
type: string
156+
minItems: 2
157+
type: array
158+
bondMode:
159+
default: active-backup
160+
description: BondMode - Bonding mode (e.g., active-backup, 802.3ad)
161+
type: string
162+
bondOptions:
163+
additionalProperties:
164+
type: string
165+
description: BondOptions - Additional bonding options as key-value
166+
pairs
167+
type: object
168+
required:
169+
- bondInterfaces
170+
type: object
149171
ctlplaneGateway:
150172
description: 'CtlplaneGateway - IP of gateway for ctrlplane network
151173
(TODO: acquire this is another manner?)'

api/v1beta1/openstackbaremetalset_types.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ import (
2626
// +kubebuilder:validation:Enum=metadata;disabled
2727
type AutomatedCleaningMode string
2828

29+
// BondConfig defines the bonding configuration for network interfaces
30+
type BondConfig struct {
31+
// BondInterfaces - List of physical interfaces to bond
32+
// +kubebuilder:validation:MinItems=2
33+
BondInterfaces []string `json:"bondInterfaces"`
34+
// +kubebuilder:default=active-backup
35+
// BondMode - Bonding mode (e.g., active-backup, 802.3ad)
36+
BondMode string `json:"bondMode,omitempty"`
37+
// +kubebuilder:validation:Optional
38+
// BondOptions - Additional bonding options as key-value pairs
39+
BondOptions map[string]string `json:"bondOptions,omitempty"`
40+
}
41+
2942
// InstanceSpec Instance specific attributes
3043
type InstanceSpec struct {
3144
// +kubebuilder:validation:Optional
@@ -90,6 +103,9 @@ type OpenStackBaremetalSetTemplateSpec struct {
90103
DeploymentSSHSecret string `json:"deploymentSSHSecret"`
91104
// CtlplaneInterface - Interface on the provisioned nodes to use for ctlplane network
92105
CtlplaneInterface string `json:"ctlplaneInterface"`
106+
// +kubebuilder:validation:Optional
107+
// CtlplaneBond - Bonding configuration for ctlplane network
108+
CtlplaneBond *BondConfig `json:"ctlplaneBond,omitempty"`
93109
// +kubebuilder:default=openshift-machine-api
94110
// +kubebuilder:validation:Optional
95111
// BmhNamespace Namespace to look for BaremetalHosts(default: openshift-machine-api)

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/baremetal.openstack.org_openstackbaremetalsets.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ spec:
146146
default: cloud-admin
147147
description: CloudUser to be configured for remote access
148148
type: string
149+
ctlplaneBond:
150+
description: CtlplaneBond - Bonding configuration for ctlplane network
151+
properties:
152+
bondInterfaces:
153+
description: BondInterfaces - List of physical interfaces to bond
154+
items:
155+
type: string
156+
minItems: 2
157+
type: array
158+
bondMode:
159+
default: active-backup
160+
description: BondMode - Bonding mode (e.g., active-backup, 802.3ad)
161+
type: string
162+
bondOptions:
163+
additionalProperties:
164+
type: string
165+
description: BondOptions - Additional bonding options as key-value
166+
pairs
167+
type: object
168+
required:
169+
- bondInterfaces
170+
type: object
149171
ctlplaneGateway:
150172
description: 'CtlplaneGateway - IP of gateway for ctrlplane network
151173
(TODO: acquire this is another manner?)'

pkg/openstackbaremetalset/baremetalhost.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ func BaremetalHostProvision(
126126
} else {
127127
templateParameters["CtlplaneGateway"] = instance.Spec.CtlplaneGateway
128128
}
129+
// Handle bonding configuration from template spec
130+
if instance.Spec.CtlplaneBond != nil {
131+
templateParameters["CtlplaneBondInterfaces"] = instance.Spec.CtlplaneBond.BondInterfaces
132+
if instance.Spec.CtlplaneBond.BondMode != "" {
133+
templateParameters["CtlplaneBondMode"] = instance.Spec.CtlplaneBond.BondMode
134+
}
135+
if len(instance.Spec.CtlplaneBond.BondOptions) > 0 {
136+
templateParameters["CtlplaneBondOptions"] = instance.Spec.CtlplaneBond.BondOptions
137+
}
138+
}
129139
templateParameters["CtlplaneNetmask"] = net.IP(ipNet.Mask)
130140
if len(instance.Spec.BootstrapDNS) > 0 {
131141
templateParameters["CtlplaneDns"] = instance.Spec.BootstrapDNS

templates/openstackbaremetalset/cloudinit/networkdata

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
11
links:
2+
{{- if (index . "CtlplaneBondInterfaces") }}
3+
{{- range $iface := .CtlplaneBondInterfaces }}
4+
- name: {{ $iface }}
5+
id: {{ $iface }}
6+
type: vif
7+
{{- end }}
8+
- name: {{ .CtlplaneInterface }}
9+
id: {{ .CtlplaneInterface }}
10+
type: bond
11+
bond_interfaces:
12+
{{- range $iface := .CtlplaneBondInterfaces }}
13+
- {{ $iface }}
14+
{{- end }}
15+
bond_mode: {{ if .CtlplaneBondMode }}{{ .CtlplaneBondMode }}{{ else }}active-backup{{ end }}
16+
{{- if (index . "CtlplaneBondOptions") }}
17+
params:
18+
{{- range $key, $value := .CtlplaneBondOptions }}
19+
{{ $key }}: {{ $value }}
20+
{{- end }}
21+
{{- else }}
22+
params:
23+
bond-miimon: 100
24+
{{- end }}
25+
{{- else }}
226
- name: {{ .CtlplaneInterface }}
327
id: {{ .CtlplaneInterface }}
428
type: vif
29+
{{- end }}
530
{{- if (index . "CtlplaneVlan") }}
631
- name: {{ .CtlplaneInterface }}.{{ .CtlplaneVlan }}
732
id: {{ .CtlplaneInterface }}.{{ .CtlplaneVlan }}

tests/functional/openstackbaremetalset_controller_test.go

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import (
2222
. "github.com/onsi/ginkgo/v2" //revive:disable:dot-imports
2323
. "github.com/onsi/gomega" //revive:disable:dot-imports
2424
baremetalv1 "github.com/openstack-k8s-operators/openstack-baremetal-operator/api/v1beta1"
25+
corev1 "k8s.io/api/core/v1"
2526
k8s_errors "k8s.io/apimachinery/pkg/api/errors"
2627

2728
//revive:disable-next-line:dot-imports
2829
"github.com/openstack-k8s-operators/lib-common/modules/common/condition"
2930
. "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers"
30-
corev1 "k8s.io/api/core/v1"
31+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3132
"k8s.io/apimachinery/pkg/types"
33+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3234
)
3335

3436
var _ = Describe("BaremetalSet Test", func() {
@@ -267,8 +269,8 @@ var _ = Describe("BaremetalSet Test", func() {
267269
When("BMH provisioned with generated userdata and networkdata", func() {
268270
BeforeEach(func() {
269271
DeferCleanup(th.DeleteInstance, CreateBaremetalHost(bmhName))
270-
bmh := GetBaremetalHost(bmhName)
271272
Eventually(func(g Gomega) {
273+
bmh := GetBaremetalHost(bmhName)
272274
bmh.Status.Provisioning.State = metal3v1.StateAvailable
273275
g.Expect(th.K8sClient.Status().Update(th.Ctx, bmh)).To(Succeed())
274276
}, th.Timeout, th.Interval).Should(Succeed())
@@ -327,6 +329,11 @@ var _ = Describe("BaremetalSet Test", func() {
327329
})
328330
Expect(networkDataSecret.Data).To(HaveKey("networkData"))
329331
networkData := string(networkDataSecret.Data["networkData"])
332+
// Verify proper YAML formatting - links: should be on its own line
333+
Expect(networkData).To(MatchRegexp("(?m)^links:\n"))
334+
Expect(networkData).To(MatchRegexp("(?m)^networks:\n"))
335+
Expect(networkData).NotTo(ContainSubstring("links:- "))
336+
Expect(networkData).NotTo(ContainSubstring("networks:- "))
330337
Expect(networkData).To(ContainSubstring("links:"))
331338
Expect(networkData).To(ContainSubstring("name: eth0"))
332339
Expect(networkData).To(ContainSubstring("ip_address: 10.0.0.1"))
@@ -454,6 +461,9 @@ var _ = Describe("BaremetalSet Test", func() {
454461
Namespace: bmhName.Namespace,
455462
})
456463
networkData := string(networkDataSecret.Data["networkData"])
464+
// Verify proper YAML formatting for routes
465+
Expect(networkData).To(MatchRegexp("(?m)^ routes:\n"))
466+
Expect(networkData).NotTo(ContainSubstring("routes: - network:"))
457467
Expect(networkData).To(ContainSubstring("gateway: 10.0.0.254"))
458468
Expect(networkData).To(ContainSubstring("routes:"))
459469
})
@@ -472,6 +482,11 @@ var _ = Describe("BaremetalSet Test", func() {
472482
Namespace: bmhName.Namespace,
473483
})
474484
networkData := string(networkDataSecret.Data["networkData"])
485+
// Verify proper YAML formatting for DNS sections
486+
Expect(networkData).To(MatchRegexp("(?m)^ dns_nameservers:\n"))
487+
Expect(networkData).NotTo(ContainSubstring("dns_nameservers: - "))
488+
Expect(networkData).To(MatchRegexp("(?m)^ dns_search:\n"))
489+
Expect(networkData).NotTo(ContainSubstring("dns_search: - "))
475490
Expect(networkData).To(ContainSubstring("dns_nameservers:"))
476491
Expect(networkData).To(ContainSubstring("8.8.8.8"))
477492
Expect(networkData).To(ContainSubstring("8.8.4.4"))
@@ -947,4 +962,150 @@ var _ = Describe("BaremetalSet Test", func() {
947962
)
948963
})
949964
})
965+
966+
When("A BaremetalSet with bonding configuration generates network data", func() {
967+
BeforeEach(func() {
968+
DeferCleanup(th.DeleteInstance, CreateBaremetalHost(bmhName))
969+
bmh := GetBaremetalHost(bmhName)
970+
Eventually(func(g Gomega) {
971+
bmh.Status.Provisioning.State = metal3v1.StateAvailable
972+
g.Expect(th.K8sClient.Status().Update(th.Ctx, bmh)).To(Succeed())
973+
}, th.Timeout, th.Interval).Should(Succeed())
974+
975+
DeferCleanup(th.DeleteInstance, CreateSSHSecret(deploymentSecretName))
976+
})
977+
978+
// Helper to patch provision server to be ready
979+
patchProvisionServerReady := func() {
980+
provServerName := types.NamespacedName{
981+
Name: strings.Join([]string{baremetalSetName.Name, "provisionserver"}, "-"),
982+
Namespace: namespace,
983+
}
984+
985+
// Wait for provision server to be created by the controller
986+
// and patch it to be ready
987+
Eventually(func(g Gomega) {
988+
provServer := &baremetalv1.OpenStackProvisionServer{}
989+
g.Expect(th.K8sClient.Get(th.Ctx, provServerName, provServer)).To(Succeed())
990+
provServer.Status.ProvisionIP = "192.168.122.100"
991+
provServer.Status.LocalImageURL = "http://192.168.122.100:6190/images/edpm-hardened-uefi.qcow2"
992+
provServer.Status.LocalImageChecksumURL = "http://192.168.122.100:6190/images/edpm-hardened-uefi.qcow2.sha256sum"
993+
provServer.Status.ReadyCount = 1
994+
995+
g.Expect(th.K8sClient.Status().Update(th.Ctx, provServer)).To(Succeed())
996+
}, th.Timeout, th.Interval).Should(Succeed())
997+
}
998+
999+
It("Should generate networkdata secret with bonding configuration", func() {
1000+
bondSpec := DefaultBaremetalSetSpec(bmhName, true) // Need provision server
1001+
bondSpec["ctlplaneInterface"] = "bond0"
1002+
bondSpec["ctlplaneBond"] = map[string]any{
1003+
"bondInterfaces": []string{"eno1", "eno2"},
1004+
"bondMode": "active-backup",
1005+
}
1006+
DeferCleanup(th.DeleteInstance, CreateBaremetalSet(baremetalSetName, bondSpec))
1007+
1008+
// Verify default was applied
1009+
baremetalSetInstance := GetBaremetalSet(baremetalSetName)
1010+
Expect(baremetalSetInstance.Spec.CtlplaneBond.BondMode).Should(Equal("active-backup"))
1011+
1012+
//patch provision server to be ready
1013+
patchProvisionServerReady()
1014+
1015+
// Wait for network data secret with bonding config
1016+
Eventually(func(g Gomega) {
1017+
secretName := types.NamespacedName{
1018+
Name: strings.Join([]string{baremetalSetName.Name, "cloudinit-networkdata", "compute-0"}, "-"),
1019+
Namespace: namespace,
1020+
}
1021+
secret := &corev1.Secret{}
1022+
g.Expect(th.K8sClient.Get(th.Ctx, secretName, secret)).To(Succeed())
1023+
1024+
networkData := string(secret.Data["networkData"])
1025+
// Verify proper YAML formatting
1026+
g.Expect(networkData).To(MatchRegexp("(?m)^links:\n"))
1027+
g.Expect(networkData).NotTo(ContainSubstring("links:- "))
1028+
g.Expect(networkData).To(MatchRegexp("(?m)^ bond_interfaces:\n"))
1029+
g.Expect(networkData).NotTo(ContainSubstring("bond_interfaces: - "))
1030+
g.Expect(networkData).To(MatchRegexp("bond_mode: [^\n]+\n "))
1031+
g.Expect(networkData).NotTo(MatchRegexp("bond_mode: [^\n]+ params:"))
1032+
g.Expect(networkData).Should(ContainSubstring("type: bond"))
1033+
g.Expect(networkData).Should(ContainSubstring("bond_mode: active-backup"))
1034+
g.Expect(networkData).Should(ContainSubstring("eno1"))
1035+
g.Expect(networkData).Should(ContainSubstring("eno2"))
1036+
}, th.Timeout, th.Interval).Should(Succeed())
1037+
})
1038+
1039+
It("Should reject bonding with less than 2 interfaces", func() {
1040+
bondSpec := DefaultBaremetalSetSpec(bmhName, true)
1041+
bondSpec["ctlplaneInterface"] = "bond0"
1042+
bondSpec["ctlplaneBond"] = map[string]any{
1043+
"bondInterfaces": []string{"eno1"}, // Only one interface - should fail
1044+
"bondMode": "active-backup",
1045+
}
1046+
1047+
object := DefaultBaremetalSetTemplate(baremetalSetName, bondSpec)
1048+
unstructuredObj := &unstructured.Unstructured{Object: object}
1049+
_, err := controllerutil.CreateOrPatch(
1050+
th.Ctx, th.K8sClient, unstructuredObj, func() error { return nil })
1051+
Expect(err).Should(HaveOccurred())
1052+
Expect(err.Error()).Should(ContainSubstring("bondInterfaces"))
1053+
})
1054+
1055+
It("Should generate networkdata without bonding (backward compatibility)", func() {
1056+
noBondSpec := DefaultBaremetalSetSpec(bmhName, true)
1057+
noBondSpec["ctlplaneInterface"] = "eth0"
1058+
1059+
DeferCleanup(th.DeleteInstance, CreateBaremetalSet(baremetalSetName, noBondSpec))
1060+
1061+
// Verify provision server to be ready
1062+
patchProvisionServerReady()
1063+
1064+
// Wait for network data secret without bonding
1065+
Eventually(func(g Gomega) {
1066+
secretName := types.NamespacedName{
1067+
Name: strings.Join([]string{baremetalSetName.Name, "cloudinit-networkdata", "compute-0"}, "-"),
1068+
Namespace: namespace,
1069+
}
1070+
secret := &corev1.Secret{}
1071+
g.Expect(th.K8sClient.Get(th.Ctx, secretName, secret)).To(Succeed())
1072+
1073+
networkData := string(secret.Data["networkData"])
1074+
g.Expect(networkData).ShouldNot(ContainSubstring("type: bond"))
1075+
g.Expect(networkData).Should(ContainSubstring("type: vif"))
1076+
}, th.Timeout, th.Interval).Should(Succeed())
1077+
})
1078+
1079+
It("Should generate networkdata with bonding and VLAN", func() {
1080+
bondVlanSpec := DefaultBaremetalSetSpec(bmhName, true)
1081+
bondVlanSpec["ctlplaneInterface"] = "bond0"
1082+
bondVlanSpec["ctlplaneVlan"] = 100
1083+
bondVlanSpec["ctlplaneBond"] = map[string]any{
1084+
"bondInterfaces": []string{"eno1", "eno2"},
1085+
"bondMode": "802.3ad",
1086+
}
1087+
1088+
DeferCleanup(th.DeleteInstance, CreateBaremetalSet(baremetalSetName, bondVlanSpec))
1089+
1090+
// Verify provision server to be ready
1091+
patchProvisionServerReady()
1092+
1093+
// Wait for network data secret with bonding + VLAN
1094+
Eventually(func(g Gomega) {
1095+
secretName := types.NamespacedName{
1096+
Name: strings.Join([]string{baremetalSetName.Name, "cloudinit-networkdata", "compute-0"}, "-"),
1097+
Namespace: namespace,
1098+
}
1099+
secret := &corev1.Secret{}
1100+
g.Expect(th.K8sClient.Get(th.Ctx, secretName, secret)).To(Succeed())
1101+
1102+
networkData := string(secret.Data["networkData"])
1103+
g.Expect(networkData).Should(ContainSubstring("type: bond"))
1104+
g.Expect(networkData).Should(ContainSubstring("bond_mode: 802.3ad"))
1105+
g.Expect(networkData).Should(ContainSubstring("type: vlan"))
1106+
g.Expect(networkData).Should(ContainSubstring("vlan_id: 100"))
1107+
g.Expect(networkData).Should(ContainSubstring("bond0.100"))
1108+
}, th.Timeout, th.Interval).Should(Succeed())
1109+
})
1110+
})
9501111
})

0 commit comments

Comments
 (0)