Skip to content

Commit bf944f2

Browse files
committed
feat: Add IPAM integration
Signed-off-by: appkins <[email protected]>
1 parent 35364da commit bf944f2

File tree

6 files changed

+888
-1
lines changed

6 files changed

+888
-1
lines changed

api/v1beta1/tinkerbellmachine_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ type TinkerbellMachineSpec struct {
8282
// +optional
8383
BootOptions BootOptions `json:"bootOptions,omitempty"`
8484

85+
// IPAMPoolRef is a reference to an IPAM pool resource to allocate an IP address from.
86+
// When specified, an IPAddressClaim will be created to request an IP address allocation.
87+
// The allocated IP will be set on the Hardware's first interface DHCP configuration.
88+
// This enables integration with Cluster API IPAM providers for dynamic IP allocation.
89+
// +optional
90+
IPAMPoolRef *corev1.TypedLocalObjectReference `json:"ipamPoolRef,omitempty"`
91+
8592
// Those fields are set programmatically, but they cannot be re-constructed from "state of the world", so
8693
// we put them in spec instead of status.
8794
HardwareName string `json:"hardwareName,omitempty"`

controller/machine/ipam.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
Copyright 2022 The Tinkerbell Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package machine
18+
19+
import (
20+
"fmt"
21+
22+
corev1 "k8s.io/api/core/v1"
23+
apierrors "k8s.io/apimachinery/pkg/api/errors"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"sigs.k8s.io/cluster-api/api/v1beta1"
26+
ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
27+
"sigs.k8s.io/controller-runtime/pkg/client"
28+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
29+
30+
tinkv1 "github.com/tinkerbell/tinkerbell/api/v1alpha1/tinkerbell"
31+
)
32+
33+
const (
34+
// IPAMClaimFinalizer is added to IPAddressClaim to ensure proper cleanup.
35+
IPAMClaimFinalizer = "tinkerbellmachine.infrastructure.cluster.x-k8s.io/ipam-claim"
36+
37+
// IPAMClaimNameFormat is the format for generating IPAddressClaim names.
38+
// Format: <machine-name>-<interface-index>
39+
IPAMClaimNameFormat = "%s-%d"
40+
)
41+
42+
// ensureIPAddressClaim creates or retrieves an IPAddressClaim for the machine.
43+
// It returns the claim and a boolean indicating if the IP has been allocated.
44+
func (scope *machineReconcileScope) ensureIPAddressClaim(hw *tinkv1.Hardware, poolRef *corev1.TypedLocalObjectReference) (*ipamv1.IPAddressClaim, bool, error) {
45+
if poolRef == nil {
46+
// No IPAM pool configured, skip IPAM
47+
return nil, false, nil
48+
}
49+
50+
claimName := fmt.Sprintf(IPAMClaimNameFormat, scope.tinkerbellMachine.Name, 0)
51+
52+
claim := &ipamv1.IPAddressClaim{}
53+
claimKey := client.ObjectKey{
54+
Name: claimName,
55+
Namespace: scope.tinkerbellMachine.Namespace,
56+
}
57+
58+
err := scope.client.Get(scope.ctx, claimKey, claim)
59+
if err != nil && !apierrors.IsNotFound(err) {
60+
return nil, false, fmt.Errorf("failed to get IPAddressClaim: %w", err)
61+
}
62+
63+
// Create the claim if it doesn't exist
64+
if apierrors.IsNotFound(err) {
65+
claim, err = scope.createIPAddressClaim(claimName, poolRef)
66+
if err != nil {
67+
return nil, false, fmt.Errorf("failed to create IPAddressClaim: %w", err)
68+
}
69+
scope.log.Info("Created IPAddressClaim", "claim", claimName)
70+
return claim, false, nil
71+
}
72+
73+
// Check if the claim has been fulfilled
74+
if claim.Status.AddressRef.Name == "" {
75+
scope.log.Info("Waiting for IPAddressClaim to be fulfilled", "claim", claimName)
76+
return claim, false, nil
77+
}
78+
79+
return claim, true, nil
80+
}
81+
82+
// createIPAddressClaim creates a new IPAddressClaim for the machine.
83+
func (scope *machineReconcileScope) createIPAddressClaim(name string, poolRef *corev1.TypedLocalObjectReference) (*ipamv1.IPAddressClaim, error) {
84+
claim := &ipamv1.IPAddressClaim{
85+
ObjectMeta: metav1.ObjectMeta{
86+
Name: name,
87+
Namespace: scope.tinkerbellMachine.Namespace,
88+
Labels: map[string]string{
89+
v1beta1.ClusterNameLabel: scope.machine.Spec.ClusterName,
90+
},
91+
Finalizers: []string{IPAMClaimFinalizer},
92+
},
93+
Spec: ipamv1.IPAddressClaimSpec{
94+
PoolRef: *poolRef,
95+
},
96+
}
97+
98+
// Set owner reference to TinkerbellMachine with controller=true for clusterctl move support
99+
if err := controllerutil.SetControllerReference(scope.tinkerbellMachine, claim, scope.client.Scheme()); err != nil {
100+
return nil, fmt.Errorf("failed to set owner reference: %w", err)
101+
}
102+
103+
if err := scope.client.Create(scope.ctx, claim); err != nil {
104+
return nil, fmt.Errorf("failed to create IPAddressClaim: %w", err)
105+
}
106+
107+
return claim, nil
108+
}
109+
110+
// getIPAddressFromClaim fetches the IPAddress resource referenced by the claim.
111+
func (scope *machineReconcileScope) getIPAddressFromClaim(claim *ipamv1.IPAddressClaim) (*ipamv1.IPAddress, error) {
112+
if claim.Status.AddressRef.Name == "" {
113+
return nil, fmt.Errorf("IPAddressClaim has no address reference")
114+
}
115+
116+
ipAddress := &ipamv1.IPAddress{}
117+
ipAddressKey := client.ObjectKey{
118+
Name: claim.Status.AddressRef.Name,
119+
Namespace: scope.tinkerbellMachine.Namespace,
120+
}
121+
122+
if err := scope.client.Get(scope.ctx, ipAddressKey, ipAddress); err != nil {
123+
return nil, fmt.Errorf("failed to get IPAddress: %w", err)
124+
}
125+
126+
return ipAddress, nil
127+
}
128+
129+
// patchHardwareWithIPAMAddress updates the Hardware's first interface with the allocated IP address.
130+
func (scope *machineReconcileScope) patchHardwareWithIPAMAddress(hw *tinkv1.Hardware, ipAddress *ipamv1.IPAddress) error {
131+
if len(hw.Spec.Interfaces) == 0 {
132+
return fmt.Errorf("hardware has no interfaces")
133+
}
134+
135+
if hw.Spec.Interfaces[0].DHCP == nil {
136+
return fmt.Errorf("hardware's first interface has no DHCP configuration")
137+
}
138+
139+
// Parse the IP address and related information
140+
address := ipAddress.Spec.Address
141+
prefix := ipAddress.Spec.Prefix
142+
gateway := ipAddress.Spec.Gateway
143+
144+
// Update the DHCP IP configuration
145+
if hw.Spec.Interfaces[0].DHCP.IP == nil {
146+
hw.Spec.Interfaces[0].DHCP.IP = &tinkv1.IP{}
147+
}
148+
149+
hw.Spec.Interfaces[0].DHCP.IP.Address = address
150+
151+
// Set netmask if prefix is provided
152+
if prefix > 0 {
153+
netmask := prefixToNetmask(prefix)
154+
hw.Spec.Interfaces[0].DHCP.IP.Netmask = netmask
155+
}
156+
157+
// Set gateway if provided
158+
if gateway != "" {
159+
hw.Spec.Interfaces[0].DHCP.IP.Gateway = gateway
160+
}
161+
162+
// Update the Hardware resource
163+
if err := scope.client.Update(scope.ctx, hw); err != nil {
164+
return fmt.Errorf("failed to update Hardware with IPAM address: %w", err)
165+
}
166+
167+
scope.log.Info("Updated Hardware with IPAM allocated IP",
168+
"hardware", hw.Name,
169+
"address", address,
170+
"prefix", prefix,
171+
"gateway", gateway)
172+
173+
return nil
174+
}
175+
176+
// deleteIPAddressClaim removes the IPAddressClaim when the machine is being deleted.
177+
func (scope *machineReconcileScope) deleteIPAddressClaim() error {
178+
claimName := fmt.Sprintf(IPAMClaimNameFormat, scope.tinkerbellMachine.Name, 0)
179+
claim := &ipamv1.IPAddressClaim{}
180+
claimKey := client.ObjectKey{
181+
Name: claimName,
182+
Namespace: scope.tinkerbellMachine.Namespace,
183+
}
184+
185+
err := scope.client.Get(scope.ctx, claimKey, claim)
186+
if err != nil {
187+
if apierrors.IsNotFound(err) {
188+
// Claim already deleted
189+
return nil
190+
}
191+
return fmt.Errorf("failed to get IPAddressClaim for deletion: %w", err)
192+
}
193+
194+
// Remove finalizer to allow deletion
195+
controllerutil.RemoveFinalizer(claim, IPAMClaimFinalizer)
196+
if err := scope.client.Update(scope.ctx, claim); err != nil {
197+
return fmt.Errorf("failed to remove finalizer from IPAddressClaim: %w", err)
198+
}
199+
200+
// Delete the claim
201+
if err := scope.client.Delete(scope.ctx, claim); err != nil && !apierrors.IsNotFound(err) {
202+
return fmt.Errorf("failed to delete IPAddressClaim: %w", err)
203+
}
204+
205+
scope.log.Info("Deleted IPAddressClaim", "claim", claimName)
206+
return nil
207+
}
208+
209+
// prefixToNetmask converts a CIDR prefix length to a netmask string.
210+
// For example, 24 -> "255.255.255.0"
211+
func prefixToNetmask(prefix int) string {
212+
if prefix < 0 || prefix > 32 {
213+
return ""
214+
}
215+
216+
var mask uint32 = 0xFFFFFFFF << (32 - prefix)
217+
return fmt.Sprintf("%d.%d.%d.%d",
218+
byte(mask>>24),
219+
byte(mask>>16),
220+
byte(mask>>8),
221+
byte(mask))
222+
}
223+
224+
// getIPAMPoolRef extracts the IPAM pool reference from the TinkerbellMachine spec.
225+
// Returns nil if no pool is configured.
226+
func (scope *machineReconcileScope) getIPAMPoolRef() *corev1.TypedLocalObjectReference {
227+
return scope.tinkerbellMachine.Spec.IPAMPoolRef
228+
}
229+
230+
// reconcileIPAM handles the IPAM reconciliation for the machine.
231+
// It creates an IPAddressClaim, waits for allocation, and updates the Hardware.
232+
func (scope *machineReconcileScope) reconcileIPAM(hw *tinkv1.Hardware, poolRef *corev1.TypedLocalObjectReference) error {
233+
// Ensure IPAddressClaim exists
234+
claim, allocated, err := scope.ensureIPAddressClaim(hw, poolRef)
235+
if err != nil {
236+
return fmt.Errorf("failed to ensure IPAddressClaim: %w", err)
237+
}
238+
239+
if !allocated {
240+
// IP not yet allocated, requeue
241+
scope.log.Info("Waiting for IPAM to allocate IP address")
242+
return nil
243+
}
244+
245+
// Get the allocated IPAddress
246+
ipAddress, err := scope.getIPAddressFromClaim(claim)
247+
if err != nil {
248+
return fmt.Errorf("failed to get IPAddress from claim: %w", err)
249+
}
250+
251+
// Check if Hardware already has this IP configured
252+
if len(hw.Spec.Interfaces) > 0 &&
253+
hw.Spec.Interfaces[0].DHCP != nil &&
254+
hw.Spec.Interfaces[0].DHCP.IP != nil &&
255+
hw.Spec.Interfaces[0].DHCP.IP.Address == ipAddress.Spec.Address {
256+
// IP already configured, nothing to do
257+
return nil
258+
}
259+
260+
// Update Hardware with the allocated IP
261+
if err := scope.patchHardwareWithIPAMAddress(hw, ipAddress); err != nil {
262+
return fmt.Errorf("failed to patch Hardware with IPAM address: %w", err)
263+
}
264+
265+
scope.log.Info("Successfully configured Hardware with IPAM allocated IP",
266+
"address", ipAddress.Spec.Address)
267+
268+
return nil
269+
}

0 commit comments

Comments
 (0)