diff --git a/api/v1beta1/tinkerbellmachine_types.go b/api/v1beta1/tinkerbellmachine_types.go index 587ca62f..765bd60d 100644 --- a/api/v1beta1/tinkerbellmachine_types.go +++ b/api/v1beta1/tinkerbellmachine_types.go @@ -82,6 +82,13 @@ type TinkerbellMachineSpec struct { // +optional BootOptions BootOptions `json:"bootOptions,omitempty"` + // IPAMPoolRef is a reference to an IPAM pool resource to allocate an IP address from. + // When specified, an IPAddressClaim will be created to request an IP address allocation. + // The allocated IP will be set on the Hardware's first interface DHCP configuration. + // This enables integration with Cluster API IPAM providers for dynamic IP allocation. + // +optional + IPAMPoolRef *corev1.TypedLocalObjectReference `json:"ipamPoolRef,omitempty"` + // Those fields are set programmatically, but they cannot be re-constructed from "state of the world", so // we put them in spec instead of status. HardwareName string `json:"hardwareName,omitempty"` diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 171ab132..140e1e07 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -243,6 +243,11 @@ func (in *TinkerbellMachineSpec) DeepCopyInto(out *TinkerbellMachineSpec) { (*in).DeepCopyInto(*out) } out.BootOptions = in.BootOptions + if in.IPAMPoolRef != nil { + in, out := &in.IPAMPoolRef, &out.IPAMPoolRef + *out = new(v1.TypedLocalObjectReference) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TinkerbellMachineSpec. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachines.yaml index 74f25d7c..5f95880a 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachines.yaml @@ -265,6 +265,30 @@ spec: ImageLookupOSVersion is the version of the OS distribution to use when fetching machine images. If not set it will default based on ImageLookupOSDistro. type: string + ipamPoolRef: + description: |- + IPAMPoolRef is a reference to an IPAM pool resource to allocate an IP address from. + When specified, an IPAddressClaim will be created to request an IP address allocation. + The allocated IP will be set on the Hardware's first interface DHCP configuration. + This enables integration with Cluster API IPAM providers for dynamic IP allocation. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic providerID: type: string templateOverride: diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachinetemplates.yaml index a09b73ee..d620ab39 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_tinkerbellmachinetemplates.yaml @@ -258,6 +258,30 @@ spec: ImageLookupOSVersion is the version of the OS distribution to use when fetching machine images. If not set it will default based on ImageLookupOSDistro. type: string + ipamPoolRef: + description: |- + IPAMPoolRef is a reference to an IPAM pool resource to allocate an IP address from. + When specified, an IPAddressClaim will be created to request an IP address allocation. + The allocated IP will be set on the Hardware's first interface DHCP configuration. + This enables integration with Cluster API IPAM providers for dynamic IP allocation. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic providerID: type: string templateOverride: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 19354bda..38d8948d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -54,6 +54,34 @@ rules: - get - patch - update +- apiGroups: + - ipam.cluster.x-k8s.io + resources: + - ipaddressclaims + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ipam.cluster.x-k8s.io + resources: + - ipaddressclaims/status + verbs: + - get + - patch + - update +- apiGroups: + - ipam.cluster.x-k8s.io + resources: + - ipaddresses + verbs: + - get + - list + - watch - apiGroups: - tinkerbell.org resources: diff --git a/controller/machine/hardware.go b/controller/machine/hardware.go index 645e818d..a9abe3a2 100644 --- a/controller/machine/hardware.go +++ b/controller/machine/hardware.go @@ -2,6 +2,7 @@ package machine import ( "fmt" + "maps" "sort" "strings" @@ -84,9 +85,7 @@ func (scope *machineReconcileScope) patchHardwareAnnotations(hw *tinkv1.Hardware hw.Annotations = map[string]string{} } - for k, v := range annotations { - hw.Annotations[k] = v - } + maps.Copy(hw.Annotations, annotations) if err := patchHelper.Patch(scope.ctx, hw); err != nil { return fmt.Errorf("patching Hardware object: %w", err) @@ -152,6 +151,14 @@ func (scope *machineReconcileScope) ensureHardware() (*tinkv1.Hardware, error) { return nil, fmt.Errorf("ensuring Hardware user data: %w", err) } + // Check if IPAM pool is configured and handle IP allocation + poolRef := scope.getIPAMPoolRef() + if poolRef != nil { + if err := scope.reconcileIPAM(hw, poolRef); err != nil { + return hw, fmt.Errorf("failed to reconcile IPAM: %w", err) + } + } + return hw, scope.setStatus(hw) } diff --git a/controller/machine/ipam.go b/controller/machine/ipam.go new file mode 100644 index 00000000..00bd7793 --- /dev/null +++ b/controller/machine/ipam.go @@ -0,0 +1,299 @@ +/* +Copyright 2022 The Tinkerbell Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package machine + +import ( + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/cluster-api/api/v1beta1" + ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + tinkv1 "github.com/tinkerbell/tinkerbell/api/v1alpha1/tinkerbell" +) + +const ( + // IPAMClaimFinalizer is added to IPAddressClaim to ensure proper cleanup. + IPAMClaimFinalizer = "tinkerbellmachine.infrastructure.cluster.x-k8s.io/ipam-claim" + + // IPAMClaimNameFormat is the format for generating IPAddressClaim names. + // Format: -. + IPAMClaimNameFormat = "%s-%d" +) + +var ( + // ErrHardwareNoInterfaces indicates that hardware has no network interfaces configured. + ErrHardwareNoInterfaces = errors.New("hardware has no interfaces") + // ErrHardwareNoDHCPConfig indicates that hardware's first interface has no DHCP configuration. + ErrHardwareNoDHCPConfig = errors.New("hardware's first interface has no DHCP configuration") + // ErrIPAMClaimNoAddressRef indicates that an IPAddressClaim has no address reference. + ErrIPAMClaimNoAddressRef = errors.New("IPAddressClaim has no address reference") +) + +// hardwareHasIPConfigured checks if the Hardware already has an IP address configured +// on its first interface. +func hardwareHasIPConfigured(hw *tinkv1.Hardware) bool { + return len(hw.Spec.Interfaces) > 0 && + hw.Spec.Interfaces[0].DHCP != nil && + hw.Spec.Interfaces[0].DHCP.IP != nil && + hw.Spec.Interfaces[0].DHCP.IP.Address != "" +} + +// ensureIPAddressClaim creates or retrieves an IPAddressClaim for the machine. +// It returns the claim and a boolean indicating if the IP has been allocated. +// If the Hardware already has an IP address configured on its first interface, +// IPAM is skipped to avoid conflicts with pre-existing manual configuration. +func (scope *machineReconcileScope) ensureIPAddressClaim(hw *tinkv1.Hardware, poolRef *corev1.TypedLocalObjectReference) (*ipamv1.IPAddressClaim, bool, error) { + if poolRef == nil { + // No IPAM pool configured, skip IPAM + return nil, false, nil + } + + // Check if Hardware already has an IP configured on the first interface + // If it does, skip IPAM to avoid conflicts with manual configuration + if hardwareHasIPConfigured(hw) { + scope.log.Info("Hardware already has IP configured, skipping IPAM", + "hardware", hw.Name, + "ip", hw.Spec.Interfaces[0].DHCP.IP.Address) + // Return nil claim but indicate "allocated" to skip further IPAM processing + return nil, true, nil + } + + claimName := fmt.Sprintf(IPAMClaimNameFormat, scope.tinkerbellMachine.Name, 0) + + claim := &ipamv1.IPAddressClaim{} + claimKey := client.ObjectKey{ + Name: claimName, + Namespace: scope.tinkerbellMachine.Namespace, + } + + err := scope.client.Get(scope.ctx, claimKey, claim) + if err != nil && !apierrors.IsNotFound(err) { + return nil, false, fmt.Errorf("failed to get IPAddressClaim: %w", err) + } + + // Create the claim if it doesn't exist + if apierrors.IsNotFound(err) { + claim, err = scope.createIPAddressClaim(claimName, poolRef) + if err != nil { + return nil, false, fmt.Errorf("failed to create IPAddressClaim: %w", err) + } + scope.log.Info("Created IPAddressClaim", "claim", claimName) + return claim, false, nil + } + + // Check if the claim has been fulfilled + if claim.Status.AddressRef.Name == "" { + scope.log.Info("Waiting for IPAddressClaim to be fulfilled", "claim", claimName) + return claim, false, nil + } + + return claim, true, nil +} + +// createIPAddressClaim creates a new IPAddressClaim for the machine. +func (scope *machineReconcileScope) createIPAddressClaim(name string, poolRef *corev1.TypedLocalObjectReference) (*ipamv1.IPAddressClaim, error) { + claim := &ipamv1.IPAddressClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: scope.tinkerbellMachine.Namespace, + Labels: map[string]string{ + v1beta1.ClusterNameLabel: scope.machine.Spec.ClusterName, + }, + Finalizers: []string{IPAMClaimFinalizer}, + }, + Spec: ipamv1.IPAddressClaimSpec{ + PoolRef: *poolRef, + }, + } + + // Set owner reference to TinkerbellMachine with controller=true for clusterctl move support + if err := controllerutil.SetControllerReference(scope.tinkerbellMachine, claim, scope.client.Scheme()); err != nil { + return nil, fmt.Errorf("failed to set owner reference: %w", err) + } + + if err := scope.client.Create(scope.ctx, claim); err != nil { + return nil, fmt.Errorf("failed to create IPAddressClaim: %w", err) + } + + return claim, nil +} + +// getIPAddressFromClaim fetches the IPAddress resource referenced by the claim. +func (scope *machineReconcileScope) getIPAddressFromClaim(claim *ipamv1.IPAddressClaim) (*ipamv1.IPAddress, error) { + if claim.Status.AddressRef.Name == "" { + return nil, ErrIPAMClaimNoAddressRef + } + + ipAddress := &ipamv1.IPAddress{} + ipAddressKey := client.ObjectKey{ + Name: claim.Status.AddressRef.Name, + Namespace: scope.tinkerbellMachine.Namespace, + } + + if err := scope.client.Get(scope.ctx, ipAddressKey, ipAddress); err != nil { + return nil, fmt.Errorf("failed to get IPAddress: %w", err) + } + + return ipAddress, nil +} + +// patchHardwareWithIPAMAddress updates the Hardware's first interface with the allocated IP address. +func (scope *machineReconcileScope) patchHardwareWithIPAMAddress(hw *tinkv1.Hardware, ipAddr *ipamv1.IPAddress) error { + if len(hw.Spec.Interfaces) == 0 { + return ErrHardwareNoInterfaces + } + if hw.Spec.Interfaces[0].DHCP == nil { + return ErrHardwareNoDHCPConfig + } + + // Parse the IP address and related information + address := ipAddr.Spec.Address + prefix := ipAddr.Spec.Prefix + gateway := ipAddr.Spec.Gateway + + // Update the DHCP IP configuration + if hw.Spec.Interfaces[0].DHCP.IP == nil { + hw.Spec.Interfaces[0].DHCP.IP = &tinkv1.IP{} + } + + hw.Spec.Interfaces[0].DHCP.IP.Address = address + + // Set netmask if prefix is provided + if prefix > 0 { + netmask := prefixToNetmask(prefix) + hw.Spec.Interfaces[0].DHCP.IP.Netmask = netmask + } + + // Set gateway if provided + if gateway != "" { + hw.Spec.Interfaces[0].DHCP.IP.Gateway = gateway + } + + // Update the Hardware resource + if err := scope.client.Update(scope.ctx, hw); err != nil { + return fmt.Errorf("failed to update Hardware with IPAM address: %w", err) + } + + scope.log.Info("Updated Hardware with IPAM allocated IP", + "hardware", hw.Name, + "address", address, + "prefix", prefix, + "gateway", gateway) + + return nil +} + +// deleteIPAddressClaim removes the IPAddressClaim when the machine is being deleted. +func (scope *machineReconcileScope) deleteIPAddressClaim() error { + claimName := fmt.Sprintf(IPAMClaimNameFormat, scope.tinkerbellMachine.Name, 0) + claim := &ipamv1.IPAddressClaim{} + claimKey := client.ObjectKey{ + Name: claimName, + Namespace: scope.tinkerbellMachine.Namespace, + } + + err := scope.client.Get(scope.ctx, claimKey, claim) + if err != nil { + if apierrors.IsNotFound(err) { + // Claim already deleted + return nil + } + return fmt.Errorf("failed to get IPAddressClaim for deletion: %w", err) + } + + // Remove finalizer to allow deletion + controllerutil.RemoveFinalizer(claim, IPAMClaimFinalizer) + if err := scope.client.Update(scope.ctx, claim); err != nil { + return fmt.Errorf("failed to remove finalizer from IPAddressClaim: %w", err) + } + + // Delete the claim + if err := scope.client.Delete(scope.ctx, claim); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete IPAddressClaim: %w", err) + } + + scope.log.Info("Deleted IPAddressClaim", "claim", claimName) + return nil +} + +// prefixToNetmask converts a CIDR prefix length to a netmask string. +// For example, 24 -> "255.255.255.0". +func prefixToNetmask(prefix int) string { + if prefix < 0 || prefix > 32 { + return "" + } + + var mask uint32 = 0xFFFFFFFF << (32 - prefix) + return fmt.Sprintf("%d.%d.%d.%d", + byte(mask>>24), + byte(mask>>16), + byte(mask>>8), + byte(mask)) +} + +// getIPAMPoolRef extracts the IPAM pool reference from the TinkerbellMachine spec. +// Returns nil if no pool is configured. +func (scope *machineReconcileScope) getIPAMPoolRef() *corev1.TypedLocalObjectReference { + return scope.tinkerbellMachine.Spec.IPAMPoolRef +} + +// reconcileIPAM handles the IPAM reconciliation for the machine. +// It creates an IPAddressClaim, waits for allocation, and updates the Hardware. +func (scope *machineReconcileScope) reconcileIPAM(hw *tinkv1.Hardware, poolRef *corev1.TypedLocalObjectReference) error { + // Ensure IPAddressClaim exists + claim, allocated, err := scope.ensureIPAddressClaim(hw, poolRef) + if err != nil { + return fmt.Errorf("failed to ensure IPAddressClaim: %w", err) + } + + if !allocated { + // IP not yet allocated, requeue + scope.log.Info("Waiting for IPAM to allocate IP address") + return nil + } + + // Get the allocated IPAddress + ipAddress, err := scope.getIPAddressFromClaim(claim) + if err != nil { + return fmt.Errorf("failed to get IPAddress from claim: %w", err) + } + + // Check if Hardware already has this IP configured + if len(hw.Spec.Interfaces) > 0 && + hw.Spec.Interfaces[0].DHCP != nil && + hw.Spec.Interfaces[0].DHCP.IP != nil && + hw.Spec.Interfaces[0].DHCP.IP.Address == ipAddress.Spec.Address { + // IP already configured, nothing to do + return nil + } + + // Update Hardware with the allocated IP + if err := scope.patchHardwareWithIPAMAddress(hw, ipAddress); err != nil { + return fmt.Errorf("failed to patch Hardware with IPAM address: %w", err) + } + + scope.log.Info("Successfully configured Hardware with IPAM allocated IP", + "address", ipAddress.Spec.Address) + + return nil +} diff --git a/controller/machine/ipam_test.go b/controller/machine/ipam_test.go new file mode 100644 index 00000000..6f16fe32 --- /dev/null +++ b/controller/machine/ipam_test.go @@ -0,0 +1,298 @@ +/* +Copyright 2022 The Tinkerbell Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package machine + +import ( + "testing" + + . "github.com/onsi/gomega" //nolint:revive // one day we will remove gomega + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + tinkv1 "github.com/tinkerbell/tinkerbell/api/v1alpha1/tinkerbell" + + infrastructurev1 "github.com/tinkerbell/cluster-api-provider-tinkerbell/api/v1beta1" + "github.com/tinkerbell/cluster-api-provider-tinkerbell/controller" +) + +func Test_prefixToNetmask(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix int + want string + }{ + { + name: "CIDR /24", + prefix: 24, + want: "255.255.255.0", + }, + { + name: "CIDR /16", + prefix: 16, + want: "255.255.0.0", + }, + { + name: "CIDR /8", + prefix: 8, + want: "255.0.0.0", + }, + { + name: "CIDR /32", + prefix: 32, + want: "255.255.255.255", + }, + { + name: "CIDR /0", + prefix: 0, + want: "0.0.0.0", + }, + { + name: "Invalid negative", + prefix: -1, + want: "", + }, + { + name: "Invalid too large", + prefix: 33, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + got := prefixToNetmask(tt.prefix) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +//nolint:funlen // test table is intentionally comprehensive +func Test_patchHardwareWithIPAMAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hardware *tinkv1.Hardware + ipAddress *ipamv1.IPAddress + wantErr bool + wantAddress string + wantNetmask string + wantGateway string + }{ + { + name: "successful update with full IP information", + hardware: &tinkv1.Hardware{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hw", + Namespace: "default", + }, + Spec: tinkv1.HardwareSpec{ + Interfaces: []tinkv1.Interface{ + { + DHCP: &tinkv1.DHCP{ + MAC: "00:00:00:00:00:01", + }, + }, + }, + }, + }, + ipAddress: &ipamv1.IPAddress{ + Spec: ipamv1.IPAddressSpec{ + Address: "192.168.1.100", + Prefix: 24, + Gateway: "192.168.1.1", + }, + }, + wantErr: false, + wantAddress: "192.168.1.100", + wantNetmask: "255.255.255.0", + wantGateway: "192.168.1.1", + }, + { + name: "update without prefix", + hardware: &tinkv1.Hardware{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hw", + Namespace: "default", + }, + Spec: tinkv1.HardwareSpec{ + Interfaces: []tinkv1.Interface{ + { + DHCP: &tinkv1.DHCP{ + MAC: "00:00:00:00:00:01", + IP: &tinkv1.IP{}, + }, + }, + }, + }, + }, + ipAddress: &ipamv1.IPAddress{ + Spec: ipamv1.IPAddressSpec{ + Address: "10.0.0.5", + Gateway: "10.0.0.1", + }, + }, + wantErr: false, + wantAddress: "10.0.0.5", + wantNetmask: "", + wantGateway: "10.0.0.1", + }, + { + name: "hardware with no interfaces", + hardware: &tinkv1.Hardware{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hw", + Namespace: "default", + }, + Spec: tinkv1.HardwareSpec{ + Interfaces: []tinkv1.Interface{}, + }, + }, + ipAddress: &ipamv1.IPAddress{ + Spec: ipamv1.IPAddressSpec{ + Address: "192.168.1.100", + }, + }, + wantErr: true, + }, + { + name: "hardware with no DHCP config", + hardware: &tinkv1.Hardware{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hw", + Namespace: "default", + }, + Spec: tinkv1.HardwareSpec{ + Interfaces: []tinkv1.Interface{ + { + DHCP: nil, + }, + }, + }, + }, + ipAddress: &ipamv1.IPAddress{ + Spec: ipamv1.IPAddressSpec{ + Address: "192.168.1.100", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + scheme := runtime.NewScheme() + _ = controller.AddToSchemeTinkerbell(scheme) + _ = infrastructurev1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.hardware). + Build() + + scope := &machineReconcileScope{ + client: fakeClient, + } + + err := scope.patchHardwareWithIPAMAddress(tt.hardware, tt.ipAddress) + + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(tt.hardware.Spec.Interfaces[0].DHCP.IP).ToNot(BeNil()) + g.Expect(tt.hardware.Spec.Interfaces[0].DHCP.IP.Address).To(Equal(tt.wantAddress)) + + if tt.wantNetmask != "" { + g.Expect(tt.hardware.Spec.Interfaces[0].DHCP.IP.Netmask).To(Equal(tt.wantNetmask)) + } + + if tt.wantGateway != "" { + g.Expect(tt.hardware.Spec.Interfaces[0].DHCP.IP.Gateway).To(Equal(tt.wantGateway)) + } + }) + } +} + +func Test_getIPAMPoolRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tinkerbellMachine *infrastructurev1.TinkerbellMachine + want *corev1.TypedLocalObjectReference + }{ + { + name: "pool ref is set", + tinkerbellMachine: &infrastructurev1.TinkerbellMachine{ + Spec: infrastructurev1.TinkerbellMachineSpec{ + IPAMPoolRef: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("ipam.cluster.x-k8s.io"), + Kind: "InClusterIPPool", + Name: "test-pool", + }, + }, + }, + want: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("ipam.cluster.x-k8s.io"), + Kind: "InClusterIPPool", + Name: "test-pool", + }, + }, + { + name: "pool ref is nil", + tinkerbellMachine: &infrastructurev1.TinkerbellMachine{ + Spec: infrastructurev1.TinkerbellMachineSpec{ + IPAMPoolRef: nil, + }, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + scope := &machineReconcileScope{ + tinkerbellMachine: tt.tinkerbellMachine, + } + + got := scope.getIPAMPoolRef() + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func stringPtr(s string) *string { + return &s +} diff --git a/controller/machine/scope.go b/controller/machine/scope.go index 055139bc..365f625f 100644 --- a/controller/machine/scope.go +++ b/controller/machine/scope.go @@ -122,6 +122,14 @@ func (scope *machineReconcileScope) Reconcile() error { return fmt.Errorf("failed to ensure hardware: %w", err) } + // Check if IPAM pool is configured and handle IP allocation + poolRef := scope.getIPAMPoolRef() + if poolRef != nil { + if err := scope.reconcileIPAM(hw, poolRef); err != nil { + return fmt.Errorf("failed to reconcile IPAM: %w", err) + } + } + return scope.reconcile(hw) } @@ -257,7 +265,7 @@ func (scope *machineReconcileScope) DeleteMachineWithDependencies() error { //no return nil } -// removeDependencies removes the Template and Workflow linked to the Machine/Hardware. +// removeDependencies removes the Template, Workflow, and IPAddressClaim linked to the Machine/Hardware. func (scope *machineReconcileScope) removeDependencies() error { if err := scope.removeTemplate(); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("removing Template: %w", err) @@ -267,6 +275,13 @@ func (scope *machineReconcileScope) removeDependencies() error { return fmt.Errorf("removing Workflow: %w", err) } + // Remove IPAddressClaim if IPAM was configured + if scope.getIPAMPoolRef() != nil { + if err := scope.deleteIPAddressClaim(); err != nil { + return fmt.Errorf("removing IPAddressClaim: %w", err) + } + } + return nil } diff --git a/controller/machine/tinkerbellmachine.go b/controller/machine/tinkerbellmachine.go index fa5c51e4..1faf3737 100644 --- a/controller/machine/tinkerbellmachine.go +++ b/controller/machine/tinkerbellmachine.go @@ -56,6 +56,9 @@ type TinkerbellMachineReconciler struct { // +kubebuilder:rbac:groups=tinkerbell.org,resources=templates;templates/status,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=tinkerbell.org,resources=workflows;workflows/status,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=bmc.tinkerbell.org,resources=jobs,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddressclaims,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddressclaims/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddresses,verbs=get;list;watch // Reconcile ensures that all Tinkerbell machines are aligned with a given spec. // diff --git a/docs/IPAM.md b/docs/IPAM.md new file mode 100644 index 00000000..b7a25da2 --- /dev/null +++ b/docs/IPAM.md @@ -0,0 +1,298 @@ +# IPAM Integration for Cluster API Provider Tinkerbell + +## Overview + +Cluster API Provider Tinkerbell (CAPT) now supports integration with Cluster API IPAM providers for dynamic IP address allocation. This allows you to automatically allocate IP addresses from an IPAM pool and configure them on Hardware resources. + +## How It Works + +When a `TinkerbellMachine` is created with an IPAM pool reference: + +1. CAPT creates an `IPAddressClaim` resource requesting an IP address from the specified pool +2. An IPAM provider (e.g., in-cluster IPAM) fulfills the claim by creating an `IPAddress` resource +3. CAPT reads the allocated IP address from the `IPAddress` resource +4. The IP address is automatically set on the Hardware's first network interface DHCP configuration + +This seamlessly integrates with the Tinkerbell workflow, ensuring that machines are provisioned with their allocated IP addresses. + +## Prerequisites + +1. A working Cluster API management cluster +2. CAPT installed and configured +3. An IPAM provider installed (e.g., cluster-api-ipam-provider-in-cluster) +4. An IP pool resource created by your IPAM provider + +## Usage Example + +### Step 1: Create an IP Pool + +First, create an IPAM pool resource. The exact format depends on your IPAM provider. Here's an example using the in-cluster IPAM provider: + +```yaml +apiVersion: ipam.cluster.x-k8s.io/v1beta1 +kind: InClusterIPPool +metadata: + name: my-ip-pool + namespace: default +spec: + addresses: + - 192.168.1.100-192.168.1.200 + prefix: 24 + gateway: 192.168.1.1 +``` + +### Step 2: Reference the Pool in TinkerbellMachineTemplate + +Add the `ipamPoolRef` field to your `TinkerbellMachineTemplate`: + +```yaml +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 +kind: TinkerbellMachineTemplate +metadata: + name: my-machine-template + namespace: default +spec: + template: + spec: + hardwareAffinity: + required: + - labelSelector: + matchLabels: + type: worker + ipamPoolRef: + apiGroup: ipam.cluster.x-k8s.io + kind: InClusterIPPool + name: my-ip-pool +``` + +### Step 3: Create the Cluster + +When you create a cluster using this template, CAPT will automatically: + +- Create an `IPAddressClaim` for each machine +- Wait for the IPAM provider to allocate an IP +- Configure the Hardware with the allocated IP address + +```bash +kubectl apply -f my-cluster.yaml +``` + +### Step 4: Verify IP Allocation + +Check that IP addresses have been allocated: + +```bash +# List IP address claims +kubectl get ipaddressclaims + +# List allocated IP addresses +kubectl get ipaddresses + +# Check Hardware configuration +kubectl get hardware my-hardware-name -o jsonpath='{.spec.interfaces[0].dhcp.ip}' +``` + +## Hardware Configuration + +When an IP is allocated via IPAM, CAPT automatically updates the Hardware resource: + +```yaml +apiVersion: tinkerbell.org/v1alpha1 +kind: Hardware +metadata: + name: my-hardware +spec: + interfaces: + - dhcp: + mac: "00:00:00:00:00:01" + ip: + address: "192.168.1.100" # Allocated by IPAM + netmask: "255.255.255.0" # Derived from pool prefix + gateway: "192.168.1.1" # From pool configuration + hostname: my-machine + netboot: + allowPXE: true + allowWorkflow: true +``` + +## Cleanup + +When a `TinkerbellMachine` is deleted: + +1. CAPT removes the finalizer from the `IPAddressClaim` +2. The `IPAddressClaim` is deleted +3. The IPAM provider releases the IP address back to the pool +4. The `IPAddress` resource is cleaned up + +This ensures proper IP address lifecycle management. + +## Supported IPAM Providers + +CAPT supports any IPAM provider that implements the Cluster API IPAM contract, including: + +- [In-Cluster IPAM Provider](https://github.com/kubernetes-sigs/cluster-api-ipam-provider-in-cluster) +- [Nutanix IPAM Provider](https://github.com/nutanix-cloud-native/cluster-api-ipam-provider-nutanix) +- Custom IPAM providers + +## Without IPAM + +If you don't specify an `ipamPoolRef`, CAPT works as before - you must manually configure IP addresses in your Hardware resources: + +```yaml +apiVersion: tinkerbell.org/v1alpha1 +kind: Hardware +spec: + interfaces: + - dhcp: + mac: "00:00:00:00:00:01" + ip: + address: "192.168.1.100" # Manually configured + netmask: "255.255.255.0" + gateway: "192.168.1.1" +``` + +## Troubleshooting + +### IP Not Allocated + +If an IP address is not being allocated: + +1. Check that the IPAM provider is running: + ```bash + kubectl get pods -n ipam-system + ``` + +2. Check the IPAddressClaim status: + ```bash + kubectl describe ipaddressclaim + ``` + +3. Check IPAM provider logs: + ```bash + kubectl logs -n ipam-system -l control-plane=controller-manager + ``` + +### IP Pool Exhausted + +If the IP pool is exhausted: + +1. Check available addresses in the pool: + ```bash + kubectl describe inclusterippool my-ip-pool + ``` + +2. Either expand the pool or remove unused machines to free up addresses + +### Hardware Not Updated + +If the Hardware is not being updated with the allocated IP: + +1. Check TinkerbellMachine controller logs: + ```bash + kubectl logs -n capt-system -l control-plane=controller-manager + ``` + +2. Verify the IPAddress resource was created: + ```bash + kubectl get ipaddress + ``` + +## API Reference + +### TinkerbellMachineSpec.IPAMPoolRef + +```go +type TinkerbellMachineSpec struct { + // ... other fields ... + + // IPAMPoolRef is a reference to an IPAM pool resource to allocate an IP address from. + // When specified, an IPAddressClaim will be created to request an IP address allocation. + // The allocated IP will be set on the Hardware's first interface DHCP configuration. + // This enables integration with Cluster API IPAM providers for dynamic IP allocation. + // +optional + IPAMPoolRef *corev1.TypedLocalObjectReference `json:"ipamPoolRef,omitempty"` +} +``` + +The `IPAMPoolRef` field accepts a standard Kubernetes `TypedLocalObjectReference`: + +- `apiGroup`: The API group of the IP pool resource (e.g., `ipam.cluster.x-k8s.io`) +- `kind`: The kind of the IP pool resource (e.g., `InClusterIPPool`) +- `name`: The name of the IP pool resource + +## Example: Complete Cluster with IPAM + +```yaml +--- +apiVersion: ipam.cluster.x-k8s.io/v1beta1 +kind: InClusterIPPool +metadata: + name: production-pool + namespace: default +spec: + addresses: + - 10.0.0.100-10.0.0.200 + prefix: 24 + gateway: 10.0.0.1 + +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 +kind: TinkerbellCluster +metadata: + name: my-cluster + namespace: default +spec: + controlPlaneEndpoint: + host: 10.0.0.50 + port: 6443 + +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 +kind: TinkerbellMachineTemplate +metadata: + name: my-cluster-control-plane + namespace: default +spec: + template: + spec: + hardwareAffinity: + required: + - labelSelector: + matchLabels: + type: controlplane + ipamPoolRef: + apiGroup: ipam.cluster.x-k8s.io + kind: InClusterIPPool + name: production-pool + +--- +apiVersion: cluster.x-k8s.io/v1beta1 +kind: Cluster +metadata: + name: my-cluster + namespace: default +spec: + clusterNetwork: + pods: + cidrBlocks: + - 172.25.0.0/16 + services: + cidrBlocks: + - 172.26.0.0/16 + controlPlaneRef: + apiVersion: controlplane.cluster.x-k8s.io/v1beta1 + kind: KubeadmControlPlane + name: my-cluster-control-plane + infrastructureRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 + kind: TinkerbellCluster + name: my-cluster +``` + +## Benefits + +- **Automated IP Management**: No need to manually track and assign IP addresses +- **Conflict Prevention**: IPAM ensures no IP address conflicts +- **Scalability**: Easy to provision many machines without manual IP assignment +- **Integration**: Works seamlessly with existing Cluster API IPAM providers +- **Flexibility**: Optional feature - use it when needed, fallback to manual configuration otherwise diff --git a/main.go b/main.go index 9c1a03bb..1c034ace 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ import ( cgrecord "k8s.io/client-go/tools/record" "k8s.io/klog/v2" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + ipamv1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1" "sigs.k8s.io/cluster-api/util/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -59,6 +60,7 @@ func init() { _ = clientgoscheme.AddToScheme(scheme) _ = infrastructurev1.AddToScheme(scheme) _ = clusterv1.AddToScheme(scheme) + _ = ipamv1.AddToScheme(scheme) _ = captctrl.AddToSchemeTinkerbell(scheme) _ = captctrl.AddToSchemeBMC(scheme)