Repository: ironcore-dev/cluster-api-provider-ironcore-metal
Pull request #67: Add capi ipam support
Author: login: SzymonSAP
Labels: name: api-change
name: size/XL
name: area/metal-automation
description: Automation processes within the Metal project.
Created: 2025-03-03T12:40:54Z | Merged: 2025-03-17T14:58:10Z

# Proposed Changes

- There are `ipamConfig` and `metadata` fields added to `IroncoreMetalMachineSpec`
- For each `ipamConfig` there is created an `IPAddressClaim`
- There is a new file added to an ignition which contains metadata and IP addresses
- When machine is deleted the `IPAddressClaim` is deleted
- Tilt is updated to work with capi ipam
- Tests for creation of an ignition are added

Fixes #66

Linked issue #66: Add `IPAM` support
# Summary

Similar to [Gardener extension](https://github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pull/106), we need IPAM for `ironcore-metal` CAPI provider machines.

It could go together with `metadata` support similar to Gardener implementation.

For now, it's acceptable to support only https://github.com/kubernetes-sigs/cluster-api-ipam-provider-in-cluster


Diff:
diff --git a/Tiltfile b/Tiltfile
index 7c358fd..1cd6a44 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -19,6 +19,7 @@ settings = {
     "preload_images_for_kind": True,
     "kind_cluster_name": "capm",
     "capi_version": "v1.6.4",
+    "capi_ipam_version": "v1.0.1",
     "cert_manager_version": "v1.14.4",
     "kubernetes_version": "v1.29.4",
     "metal_image": "ghcr.io/ironcore-dev/metal-operator-controller-manager:latest",
@@ -43,7 +44,7 @@ settings = {
         "--insecure-diagnostics=false",
         "--feature-gates=MachinePool=false,KubeadmBootstrapFormatIgnition=true",
         "--bootstrap-token-ttl=15m"
-        ]
+        ],
     }
 }
 
@@ -91,6 +92,13 @@ def deploy_capi():
             if kb_new_args:
                 replace_args_with_new_args("capi-kubeadm-bootstrap-system", "capi-kubeadm-bootstrap-controller-manager", kb_new_args)
 
+# deploy CAPI IPAM
+def deploy_capi_ipam():
+    version = settings.get("capi_ipam_version")
+    capi_uri = "https://github.com/kubernetes-sigs/cluster-api-ipam-provider-in-cluster/releases/download/{}/ipam-components.yaml".format(version)
+    cmd = "curl -sSL {} | {} | {} apply -f -".format(capi_uri, envsubst_cmd, kubectl_cmd)
+    local(cmd, quiet=True)
+
 # deploy metal-operator
 def deploy_metal():
     version = settings.get("metal_version")
@@ -288,6 +296,8 @@ deploy_cert_manager()
 
 deploy_capi()
 
+deploy_capi_ipam()
+
 deploy_metal()
 
 capm()
@@ -298,30 +308,38 @@ k8s_yaml('./templates/test/cluster_v1beta1_cluster.yaml')
 k8s_resource(
     objects=['cluster-sample:cluster'],
     new_name='cluster-sample',
-    trigger_mode=TRIGGER_MODE_MANUAL,
-    auto_init=False
+    trigger_mode=TRIGGER_MODE_AUTO,
+    auto_init=True
 )
 
 k8s_yaml('./config/samples/infrastructure_v1alpha1_ironcoremetalcluster.yaml')
 k8s_resource(
     objects=['ironcoremetalcluster-sample:ironcoremetalcluster'],
     new_name='ironcoremetalcluster-sample',
-    trigger_mode=TRIGGER_MODE_MANUAL,
-    auto_init=False
+    trigger_mode=TRIGGER_MODE_AUTO,
+    auto_init=True
 )
 
 k8s_yaml('./config/samples/infrastructure_v1alpha1_ironcoremetalmachinetemplate.yaml')
 k8s_resource(
     objects=['ironcoremetalmachinetemplate-sample-control-plane:ironcoremetalmachinetemplate'],
     new_name='ironcoremetalmachinetemplate-sample-control-plane',
-    trigger_mode=TRIGGER_MODE_MANUAL,
-    auto_init=False
+    trigger_mode=TRIGGER_MODE_AUTO,
+    auto_init=True
 )
 
 k8s_yaml('./templates/test/cluster_v1beta1_kubeadmcontrolplane.yaml')
 k8s_resource(
     objects=['kubeadmcontrolplane-sample-cp:kubeadmcontrolplane'],
     new_name='kubeadmcontrolplane-sample-cp',
-    trigger_mode=TRIGGER_MODE_MANUAL,
-    auto_init=False
+    trigger_mode=TRIGGER_MODE_AUTO,
+    auto_init=True
+)
+
+k8s_yaml('./templates/test/ipam_cluster_v1alpha2_globalinclusterippool.yaml')
+k8s_resource(
+    objects=['globalinclusterippool-sample-cp:globalinclusterippool'],
+    new_name='globalinclusterippool-sample-cp',
+    trigger_mode=TRIGGER_MODE_AUTO,
+    auto_init=True
 )
diff --git a/api/v1alpha1/ironcoremetalmachine_types.go b/api/v1alpha1/ironcoremetalmachine_types.go
index a3d4e02..12c5d28 100644
--- a/api/v1alpha1/ironcoremetalmachine_types.go
+++ b/api/v1alpha1/ironcoremetalmachine_types.go
@@ -6,6 +6,7 @@ package v1alpha1
 import (
 	"time"
 
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -31,6 +32,13 @@ type IroncoreMetalMachineSpec struct {
 	// This is used to claim specific Server types for a IroncoreMetalMachine.
 	// +optional
 	ServerSelector *metav1.LabelSelector `json:"serverSelector,omitempty"`
+
+	// IPAMConfig is a list of references to Network resources that should be used to assign IP addresses to the worker nodes.
+	// +optional
+	IPAMConfig []IPAMConfig `json:"ipamConfig,omitempty"`
+	// Metadata is a key-value map of additional data which should be passed to the Machine.
+	// +optional
+	Metadata *apiextensionsv1.JSON `json:"metadata,omitempty"`
 }
 
 // IroncoreMetalMachineStatus defines the observed state of IroncoreMetalMachine
@@ -102,3 +110,21 @@ type IroncoreMetalMachineList struct {
 func init() {
 	SchemeBuilder.Register(&IroncoreMetalMachine{}, &IroncoreMetalMachineList{})
 }
+
+// IPAMObjectReference is a reference to the IPAM object, which will be used for IP allocation.
+type IPAMObjectReference struct {
+	// Name is the name of resource being referenced.
+	Name string `json:"name"`
+	// APIGroup is the group for the resource being referenced.
+	APIGroup string `json:"apiGroup"`
+	// Kind is the type of resource being referenced.
+	Kind string `json:"kind"`
+}
+
+// IPAMConfig is a reference to an IPAM resource.
+type IPAMConfig struct {
+	// MetadataKey is the name of metadata key for the network.
+	MetadataKey string `json:"metadataKey"`
+	// IPAMRef is a reference to the IPAM object, which will be used for IP allocation.
+	IPAMRef *IPAMObjectReference `json:"ipamRef"`
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index cc100dc..ec31445 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -8,11 +8,47 @@
 package v1alpha1
 
 import (
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1"
 	runtime "k8s.io/apimachinery/pkg/runtime"
 	"sigs.k8s.io/cluster-api/api/v1beta1"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *IPAMConfig) DeepCopyInto(out *IPAMConfig) {
+	*out = *in
+	if in.IPAMRef != nil {
+		in, out := &in.IPAMRef, &out.IPAMRef
+		*out = new(IPAMObjectReference)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPAMConfig.
+func (in *IPAMConfig) DeepCopy() *IPAMConfig {
+	if in == nil {
+		return nil
+	}
+	out := new(IPAMConfig)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *IPAMObjectReference) DeepCopyInto(out *IPAMObjectReference) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IPAMObjectReference.
+func (in *IPAMObjectReference) DeepCopy() *IPAMObjectReference {
+	if in == nil {
+		return nil
+	}
+	out := new(IPAMObjectReference)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *IroncoreMetalCluster) DeepCopyInto(out *IroncoreMetalCluster) {
 	*out = *in
@@ -182,6 +218,18 @@ func (in *IroncoreMetalMachineSpec) DeepCopyInto(out *IroncoreMetalMachineSpec)
 		*out = new(v1.LabelSelector)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.IPAMConfig != nil {
+		in, out := &in.IPAMConfig, &out.IPAMConfig
+		*out = make([]IPAMConfig, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.Metadata != nil {
+		in, out := &in.Metadata, &out.Metadata
+		*out = new(apiextensionsv1.JSON)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IroncoreMetalMachineSpec.
diff --git a/cmd/main.go b/cmd/main.go
index 382b904..0230c68 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -28,6 +28,7 @@ import (
 	infrastructurev1alpha1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
 	"github.com/ironcore-dev/cluster-api-provider-ironcore-metal/internal/controller"
 	metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
+	capiv1beta1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
 	// +kubebuilder:scaffold:imports
 )
 
@@ -37,6 +38,7 @@ var (
 )
 
 func init() {
+	utilruntime.Must(capiv1beta1.AddToScheme(scheme))
 	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
 	utilruntime.Must(clusterv1.AddToScheme(scheme))
 	utilruntime.Must(infrastructurev1alpha1.AddToScheme(scheme))
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachines.yaml
index ca05e15..95684dc 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachines.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachines.yaml
@@ -43,6 +43,44 @@ spec:
               image:
                 description: Image specifies the boot image to be used for the server.
                 type: string
+              ipamConfig:
+                description: IPAMConfig is a list of references to Network resources
+                  that should be used to assign IP addresses to the worker nodes.
+                items:
+                  description: IPAMConfig is a reference to an IPAM resource.
+                  properties:
+                    ipamRef:
+                      description: IPAMRef is a reference to the IPAM object, which
+                        will be used for IP allocation.
+                      properties:
+                        apiGroup:
+                          description: APIGroup is the group for the resource being
+                            referenced.
+                          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:
+                      - apiGroup
+                      - kind
+                      - name
+                      type: object
+                    metadataKey:
+                      description: MetadataKey is the name of metadata key for the
+                        network.
+                      type: string
+                  required:
+                  - ipamRef
+                  - metadataKey
+                  type: object
+                type: array
+              metadata:
+                description: Metadata is a key-value map of additional data which
+                  should be passed to the Machine.
+                x-kubernetes-preserve-unknown-fields: true
               providerID:
                 description: ProviderID is the unique identifier as specified by the
                   cloud provider.
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachinetemplates.yaml
index 54ef188..5a81e47 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ironcoremetalmachinetemplates.yaml
@@ -77,6 +77,47 @@ spec:
                         description: Image specifies the boot image to be used for
                           the server.
                         type: string
+                      ipamConfig:
+                        description: IPAMConfig is a list of references to Network
+                          resources that should be used to assign IP addresses to
+                          the worker nodes.
+                        items:
+                          description: IPAMConfig is a reference to an IPAM resource.
+                          properties:
+                            ipamRef:
+                              description: IPAMRef is a reference to the IPAM object,
+                                which will be used for IP allocation.
+                              properties:
+                                apiGroup:
+                                  description: APIGroup is the group for the resource
+                                    being referenced.
+                                  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:
+                              - apiGroup
+                              - kind
+                              - name
+                              type: object
+                            metadataKey:
+                              description: MetadataKey is the name of metadata key
+                                for the network.
+                              type: string
+                          required:
+                          - ipamRef
+                          - metadataKey
+                          type: object
+                        type: array
+                      metadata:
+                        description: Metadata is a key-value map of additional data
+                          which should be passed to the Machine.
+                        x-kubernetes-preserve-unknown-fields: true
                       providerID:
                         description: ProviderID is the unique identifier as specified
                           by the cloud provider.
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index a0166e7..578e141 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -81,6 +81,26 @@ 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:
+  - ipaddresses
+  verbs:
+  - get
+  - list
+  - watch
 - apiGroups:
   - metal.ironcore.dev
   resources:
diff --git a/config/samples/infrastructure_v1alpha1_ironcoremetalmachinetemplate.yaml b/config/samples/infrastructure_v1alpha1_ironcoremetalmachinetemplate.yaml
index 3199d51..e1be451 100644
--- a/config/samples/infrastructure_v1alpha1_ironcoremetalmachinetemplate.yaml
+++ b/config/samples/infrastructure_v1alpha1_ironcoremetalmachinetemplate.yaml
@@ -11,4 +11,12 @@ spec:
       serverSelector:
         matchLabels:
           server: metal
-      image: ghcr.io/ironcore-dev/os-images/gardenlinux:1443.3
\ No newline at end of file
+      image: ghcr.io/ironcore-dev/os-images/gardenlinux:1443.3
+      metadata:
+        foo: bar
+      ipamConfig:
+      - metadataKey: bond
+        ipamRef:
+          apiGroup: ipam.cluster.x-k8s.io
+          kind: GlobalInClusterIPPool
+          name: globalinclusterippool-sample-cp
diff --git a/go.mod b/go.mod
index 0171470..2dfde3d 100644
--- a/go.mod
+++ b/go.mod
@@ -4,12 +4,14 @@ go 1.23.0
 
 require (
 	github.com/go-logr/logr v1.4.2
+	github.com/imdario/mergo v0.3.16
 	github.com/ironcore-dev/controller-utils v0.9.7
 	github.com/ironcore-dev/metal-operator v0.0.0-20241009145147-7ccca8caf3b1
 	github.com/onsi/ginkgo/v2 v2.22.2
 	github.com/onsi/gomega v1.36.2
 	github.com/pkg/errors v0.9.1
 	k8s.io/api v0.31.4
+	k8s.io/apiextensions-apiserver v0.31.4
 	k8s.io/apimachinery v0.31.4
 	k8s.io/client-go v0.31.4
 	k8s.io/klog/v2 v2.130.1
@@ -48,7 +50,6 @@ require (
 	github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
-	github.com/imdario/mergo v0.3.16 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
@@ -91,7 +92,6 @@ require (
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/apiextensions-apiserver v0.31.4 // indirect
 	k8s.io/apiserver v0.31.4 // indirect
 	k8s.io/component-base v0.31.4 // indirect
 	k8s.io/kube-openapi v0.0.0-20240709000822-3c01b740850f // indirect
diff --git a/internal/controller/ironcoremetalmachine_controller.go b/internal/controller/ironcoremetalmachine_controller.go
index e5d7b78..b96aa62 100644
--- a/internal/controller/ironcoremetalmachine_controller.go
+++ b/internal/controller/ironcoremetalmachine_controller.go
@@ -5,33 +5,38 @@ package controller
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/go-logr/logr"
+	"github.com/imdario/mergo"
+	infrav1alpha1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
 	"github.com/ironcore-dev/cluster-api-provider-ironcore-metal/internal/scope"
 	"github.com/ironcore-dev/controller-utils/clientutils"
+	metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
 	"github.com/pkg/errors"
 	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/validation"
+	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/klog/v2"
-
 	clusterapiv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	capiv1beta1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
 	"sigs.k8s.io/cluster-api/util"
 	"sigs.k8s.io/cluster-api/util/annotations"
-	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
-	"sigs.k8s.io/controller-runtime/pkg/handler"
-	"sigs.k8s.io/controller-runtime/pkg/reconcile"
-
-	"k8s.io/apimachinery/pkg/runtime"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
 	"sigs.k8s.io/controller-runtime/pkg/log"
-
-	infrav1alpha1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
-	metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 )
 
 // IroncoreMetalMachineReconciler reconciles a IroncoreMetalMachine object
@@ -43,6 +48,10 @@ type IroncoreMetalMachineReconciler struct {
 const (
 	IroncoreMetalMachineFinalizer = "infrastructure.cluster.x-k8s.io/ironcoremetalmachine"
 	DefaultIgnitionSecretKeyName  = "ignition"
+	metaDataFile                  = "/var/lib/metal-cloud-config/metadata"
+	fileMode                      = 0644
+	bootstrapDataKey              = "value"
+	metalHostnamePlaceholder      = "%24%24%7BMETAL_HOSTNAME%7D"
 )
 
 // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=ironcoremetalmachines,verbs=get;list;watch;create;update;patch;delete
@@ -52,6 +61,8 @@ const (
 // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinedeployments,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinesets,verbs=get;list;watch
 // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=kubeadmcontrolplanes,verbs=get;list;watch;create;update;patch;delete
+// +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=ipaddresses,verbs=get;list;watch
 // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=serverclaims,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch
 // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
@@ -174,6 +185,24 @@ func (r *IroncoreMetalMachineReconciler) reconcileDelete(ctx context.Context, ma
 
 	// insert ServerClaim deletion logic here
 
+	ipList := &capiv1beta1.IPAddressClaimList{}
+	if err := r.Client.List(ctx, ipList, client.InNamespace(machineScope.IroncoreMetalMachine.Namespace)); meta.IsNoMatchError(err) {
+		machineScope.Logger.Info("Kind not found, assuming IP objects for that kind is absent", "kind", ipList.GetObjectKind().GroupVersionKind().Kind)
+		ipList = &capiv1beta1.IPAddressClaimList{}
+	} else if err != nil {
+		return ctrl.Result{}, fmt.Errorf("error listing ip resources: %s", err.Error())
+	}
+	for _, ip := range ipList.Items {
+		if strings.HasPrefix(ip.Name, machineScope.IroncoreMetalMachine.Name) {
+			if err := r.Client.Delete(ctx, &ip); meta.IsNoMatchError(err) {
+				machineScope.Logger.Info("Kind not found, assuming IP objects for that kind is absent", "kind", ipList.GetObjectKind().GroupVersionKind().Kind, "object", ip.Name)
+			} else if client.IgnoreNotFound(err) != nil {
+				// Unknown leads to short retry in machine controller
+				return ctrl.Result{}, fmt.Errorf("error deleting ip resource: %s", err.Error())
+			}
+		}
+	}
+
 	if modified, err := clientutils.PatchEnsureNoFinalizer(ctx, r.Client, machineScope.IroncoreMetalMachine, IroncoreMetalMachineFinalizer); !apierrors.IsNotFound(err) || modified {
 		return ctrl.Result{}, err
 	}
@@ -220,8 +249,15 @@ func (r *IroncoreMetalMachineReconciler) reconcileNormal(ctx context.Context, ma
 		return ctrl.Result{}, err
 	}
 
+	machineScope.Info("Creating an ignition", "Machine", machineScope.IroncoreMetalMachine.Name)
+	ignition, err := r.createIgnition(ctx, machineScope.Logger, machineScope.IroncoreMetalMachine, bootstrapSecret.Data[bootstrapDataKey])
+	if err != nil {
+		machineScope.Error(err, "failed to create an ignition")
+		return ctrl.Result{}, err
+	}
+
 	machineScope.Info("Creating IgnitionSecret", "Secret", machineScope.IroncoreMetalMachine.Name)
-	ignitionSecret, err := r.applyIgnitionSecret(ctx, machineScope.Logger, machineScope.IroncoreMetalMachine, bootstrapSecret)
+	ignitionSecret, err := r.applyIgnitionSecret(ctx, machineScope.Logger, bootstrapSecret, ignition)
 	if err != nil {
 		machineScope.Error(err, "failed to create or patch ignition secret")
 		return ctrl.Result{}, err
@@ -254,10 +290,121 @@ func (r *IroncoreMetalMachineReconciler) reconcileNormal(ctx context.Context, ma
 	return reconcile.Result{}, nil
 }
 
-func (r *IroncoreMetalMachineReconciler) applyIgnitionSecret(ctx context.Context, log *logr.Logger, ironcoremetalmachine *infrav1alpha1.IroncoreMetalMachine, capidatasecret *corev1.Secret) (*corev1.Secret, error) {
-	dataSecret := capidatasecret
-	findAndReplaceIgnition(ironcoremetalmachine, dataSecret)
+func (r *IroncoreMetalMachineReconciler) createIgnition(ctx context.Context, log *logr.Logger, ironcoremetalmachine *infrav1alpha1.IroncoreMetalMachine, ignition []byte) ([]byte, error) {
+	ignition = findAndReplaceIgnition(ironcoremetalmachine, ignition)
+
+	ignitionMap := make(map[string]any)
+	if err := json.Unmarshal(ignition, &ignitionMap); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal secret data: %w", err)
+	}
+
+	metaData, err := r.createMetaData(ctx, log, ironcoremetalmachine, ironcoremetalmachine.Spec.Metadata)
+	if err != nil {
+		return nil, fmt.Errorf("failed to apply IPAddresses: %w", err)
+	}
+
+	if len(metaData) > 2 { // check if metaData is not an empty json object e.g. {}
+		metaDataConf := map[string]any{
+			"storage": map[string]any{
+				"files": []any{map[string]any{
+					"path": metaDataFile,
+					"mode": fileMode,
+					"contents": map[string]any{
+						"inline": string(metaData),
+					},
+				}},
+			},
+		}
+		// merge metaData configuration with ignition content
+		if err := mergo.Merge(&ignitionMap, metaDataConf, mergo.WithAppendSlice); err != nil {
+			return nil, fmt.Errorf("failed to merge metaData configuration with ignition content: %w", err)
+		}
+	}
+
+	return json.Marshal(ignitionMap)
+}
+
+func (r *IroncoreMetalMachineReconciler) createMetaData(ctx context.Context, log *logr.Logger, ironcoremetalmachine *infrav1alpha1.IroncoreMetalMachine, metaData *apiextensionsv1.JSON) ([]byte, error) {
+	metaDataMap := make(map[string]any)
+	if metaData != nil {
+		if err := json.Unmarshal(metaData.Raw, &metaDataMap); err != nil {
+			return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
+		}
+	}
+
+	for _, networkRef := range ironcoremetalmachine.Spec.IPAMConfig {
+		ipAddrClaimName := fmt.Sprintf("%s-%s", ironcoremetalmachine.Name, networkRef.MetadataKey)
+		if len(ipAddrClaimName) > validation.DNS1123SubdomainMaxLength {
+			log.Info("IP address claim name is too long, it will be shortened which can cause name collisions", "name", ipAddrClaimName)
+			ipAddrClaimName = ipAddrClaimName[:validation.DNS1123SubdomainMaxLength]
+		}
+
+		ipAddrClaimKey := client.ObjectKey{Namespace: ironcoremetalmachine.Namespace, Name: ipAddrClaimName}
+		ipClaim := &capiv1beta1.IPAddressClaim{}
+		if err := r.Client.Get(ctx, ipAddrClaimKey, ipClaim); err != nil && !apierrors.IsNotFound(err) {
+			return nil, err
+
+		} else if err == nil {
+			log.V(3).Info("IP address claim found", "IP", ipAddrClaimKey.String())
+			if ipClaim.Status.AddressRef.Name == "" {
+				return nil, errors.New("IP address claim isn't ready")
+			}
+
+		} else if apierrors.IsNotFound(err) {
+			if networkRef.IPAMRef == nil {
+				return nil, errors.New("ipamRef of an ipamConfig is not set")
+			}
+			log.V(3).Info("creating IP address claim", "name", ipAddrClaimKey.String())
+			apiGroup := networkRef.IPAMRef.APIGroup
+			ipClaim = &capiv1beta1.IPAddressClaim{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      ipAddrClaimKey.Name,
+					Namespace: ipAddrClaimKey.Namespace,
+				},
+				Spec: capiv1beta1.IPAddressClaimSpec{
+					PoolRef: corev1.TypedLocalObjectReference{
+						APIGroup: &apiGroup,
+						Kind:     networkRef.IPAMRef.Kind,
+						Name:     networkRef.IPAMRef.Name,
+					},
+				},
+			}
+			if err = r.Client.Create(ctx, ipClaim); err != nil {
+				return nil, fmt.Errorf("error creating IP: %w", err)
+			}
+
+			// Wait for the IP address claim to reach the ready state
+			err = wait.PollUntilContextTimeout(
+				ctx,
+				time.Millisecond*50,
+				time.Millisecond*340,
+				true,
+				func(ctx context.Context) (bool, error) {
+					if err = r.Client.Get(ctx, ipAddrClaimKey, ipClaim); err != nil && !apierrors.IsNotFound(err) {
+						return false, err
+					}
+					return ipClaim.Status.AddressRef.Name != "", nil
+				})
+			if err != nil {
+				return nil, err
+			}
+		}
 
+		ipAddrKey := client.ObjectKey{Namespace: ipClaim.Namespace, Name: ipClaim.Status.AddressRef.Name}
+		ipAddr := &capiv1beta1.IPAddress{}
+		if err := r.Client.Get(ctx, ipAddrKey, ipAddr); err != nil {
+			return nil, err
+		}
+		metaDataMap[networkRef.MetadataKey] = map[string]any{
+			"ip":      ipAddr.Spec.Address,
+			"prefix":  ipAddr.Spec.Prefix,
+			"gateway": ipAddr.Spec.Gateway,
+		}
+	}
+	return json.Marshal(metaDataMap)
+}
+
+func (r *IroncoreMetalMachineReconciler) applyIgnitionSecret(ctx context.Context, log *logr.Logger, capidatasecret *corev1.Secret, ignition []byte) (*corev1.Secret, error) {
 	secretObj := &corev1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      fmt.Sprintf("ignition-%s", capidatasecret.Name),
@@ -268,7 +415,7 @@ func (r *IroncoreMetalMachineReconciler) applyIgnitionSecret(ctx context.Context
 			APIVersion: corev1.SchemeGroupVersion.String(),
 		},
 		Data: map[string][]byte{
-			DefaultIgnitionSecretKeyName: dataSecret.Data["value"],
+			DefaultIgnitionSecretKeyName: ignition,
 		},
 	}
 
@@ -345,12 +492,9 @@ func (r *IroncoreMetalMachineReconciler) ensureServerClaimBound(ctx context.Cont
 	return true, nil
 }
 
-func findAndReplaceIgnition(ironcoremetalmachine *infrav1alpha1.IroncoreMetalMachine, capidatasecret *corev1.Secret) {
-	data := capidatasecret.Data["value"]
-
+func findAndReplaceIgnition(ironcoremetalmachine *infrav1alpha1.IroncoreMetalMachine, data []byte) []byte {
 	// replace $${METAL_HOSTNAME} with machine name
-	hostname := "%24%24%7BMETAL_HOSTNAME%7D"
-	modifiedData := strings.ReplaceAll(string(data), hostname, ironcoremetalmachine.Name)
+	modifiedData := strings.ReplaceAll(string(data), metalHostnamePlaceholder, ironcoremetalmachine.Name)
 
-	capidatasecret.Data["value"] = []byte(modifiedData)
+	return []byte(modifiedData)
 }
diff --git a/internal/controller/ironcoremetalmachine_controller_test.go b/internal/controller/ironcoremetalmachine_controller_test.go
index ee2365d..c5ce89a 100644
--- a/internal/controller/ironcoremetalmachine_controller_test.go
+++ b/internal/controller/ironcoremetalmachine_controller_test.go
@@ -5,67 +5,295 @@ package controller
 
 import (
 	"context"
+	"fmt"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	"k8s.io/apimachinery/pkg/api/errors"
+	. "sigs.k8s.io/controller-runtime/pkg/envtest/komega"
+
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clusterapiv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	capiv1beta1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
 
-	infrav1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
+	infrav1alpha1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
+	"github.com/ironcore-dev/controller-utils/clientutils"
+	metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
 )
 
 var _ = Describe("IroncoreMetalMachine Controller", func() {
-	Context("When reconciling a resource", func() {
-		const resourceName = "test-resource"
+	When("all resources are present to reconcile", func() {
+		const namespace = "default"
 
-		ctx := context.Background()
+		var (
+			ctx                  = context.Background()
+			secret               *corev1.Secret
+			metalCluster         *infrav1alpha1.IroncoreMetalCluster
+			cluster              *clusterapiv1beta1.Cluster
+			machine              *clusterapiv1beta1.Machine
+			metalMachine         *infrav1alpha1.IroncoreMetalMachine
+			controllerReconciler *IroncoreMetalMachineReconciler
+			metalSecretNN        = types.NamespacedName{}
 
-		typeNamespacedName := types.NamespacedName{
-			Name:      resourceName,
-			Namespace: "default", // TODO(user):Modify as needed
-		}
-		ironcoremetalmachine := &infrav1.IroncoreMetalMachine{}
+			get = func(obj client.Object) error {
+				return k8sClient.Get(ctx, client.ObjectKeyFromObject(obj), obj)
+			}
+
+			expectIgnition = func(expected string) {
+				metalSecret := &corev1.Secret{}
+				Expect(k8sClient.Get(ctx, metalSecretNN, metalSecret)).To(Succeed())
+				Expect(metalSecret.Data).To(HaveKey(DefaultIgnitionSecretKeyName))
+				Expect(metalSecret.Data[DefaultIgnitionSecretKeyName]).To(Equal([]byte(expected)))
+			}
+		)
 
 		BeforeEach(func() {
-			By("creating the custom resource for the Kind IroncoreMetalMachine")
-			err := k8sClient.Get(ctx, typeNamespacedName, ironcoremetalmachine)
-			if err != nil && errors.IsNotFound(err) {
-				resource := &infrav1.IroncoreMetalMachine{
-					ObjectMeta: metav1.ObjectMeta{
-						Name:      resourceName,
-						Namespace: "default",
+			secret = &corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "secret",
+					Namespace: namespace,
+				},
+				Data: map[string][]byte{
+					bootstrapDataKey: []byte(fmt.Sprintf(`{"name": "%s"}`, metalHostnamePlaceholder)),
+				},
+			}
+
+			metalCluster = &infrav1alpha1.IroncoreMetalCluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "metal-cluster",
+					Namespace: namespace,
+				},
+			}
+
+			cluster = &clusterapiv1beta1.Cluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "cluster",
+					Namespace: namespace,
+				},
+				Spec: clusterapiv1beta1.ClusterSpec{
+					InfrastructureRef: &corev1.ObjectReference{
+						APIVersion: infrav1alpha1.GroupVersion.String(),
+						Kind:       "IroncoreMetalCluster",
+						Name:       metalCluster.Name,
 					},
-					// TODO(user): Specify other spec details if needed.
-				}
-				Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+				},
 			}
-		})
 
-		AfterEach(func() {
-			// TODO(user): Cleanup logic after each test, like removing the resource instance.
-			resource := &infrav1.IroncoreMetalMachine{}
-			err := k8sClient.Get(ctx, typeNamespacedName, resource)
-			Expect(err).NotTo(HaveOccurred())
+			machine = &clusterapiv1beta1.Machine{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "machine",
+					Namespace: namespace,
+					Labels:    map[string]string{clusterapiv1beta1.ClusterNameLabel: cluster.Name},
+				},
+				Spec: clusterapiv1beta1.MachineSpec{
+					ClusterName: cluster.Name,
+					Bootstrap: clusterapiv1beta1.Bootstrap{
+						DataSecretName: &secret.Name,
+					},
+				},
+			}
 
-			By("Cleanup the specific resource instance IroncoreMetalMachine")
-			Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
-		})
-		It("should successfully reconcile the resource", func() {
-			By("Reconciling the created resource")
-			controllerReconciler := &IroncoreMetalMachineReconciler{
+			metalMachine = &infrav1alpha1.IroncoreMetalMachine{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "metal-machine",
+					Namespace: namespace,
+				},
+			}
+
+			metalSecretNN = types.NamespacedName{Name: fmt.Sprintf("ignition-%s", secret.Name), Namespace: namespace}
+
+			controllerReconciler = &IroncoreMetalMachineReconciler{
 				Client: k8sClient,
 				Scheme: k8sClient.Scheme(),
 			}
+		})
+
+		JustBeforeEach(func() {
+			Expect(k8sClient.Create(ctx, secret)).To(Succeed())
+			Expect(k8sClient.Create(ctx, metalCluster)).To(Succeed())
+			Eventually(func() error {
+				if err := get(metalCluster); err != nil {
+					return err
+				}
+				metalCluster.Status.Ready = true
+				return k8sClient.Status().Update(ctx, metalCluster)
+			}).Should(Succeed())
+			Expect(k8sClient.Create(ctx, cluster)).To(Succeed())
+			Eventually(func() error {
+				if err := get(cluster); err != nil {
+					return err
+				}
+				cluster.Status.InfrastructureReady = true
+				return k8sClient.Status().Update(ctx, cluster)
+			}).Should(Succeed())
+			Expect(k8sClient.Create(ctx, machine)).To(Succeed())
+			Expect(controllerutil.SetOwnerReference(machine, metalMachine, k8sClient.Scheme())).To(Succeed())
+			Expect(k8sClient.Create(ctx, metalMachine)).To(Succeed())
+			Eventually(func() error {
+				if err := get(metalMachine); err != nil {
+					return err
+				}
+				return clientutils.PatchAddFinalizer(ctx, k8sClient, metalMachine, IroncoreMetalMachineFinalizer)
+			}).Should(Succeed())
+		})
+
+		AfterEach(func() {
+			Expect(k8sClient.Delete(ctx, secret)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, metalCluster)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, cluster)).To(Succeed())
+			Expect(k8sClient.Delete(ctx, machine)).To(Succeed())
+			if err := get(metalMachine); err == nil {
+				Expect(clientutils.PatchRemoveFinalizer(ctx, k8sClient, metalMachine, IroncoreMetalMachineFinalizer)).To(Succeed())
+				Expect(k8sClient.Delete(ctx, metalMachine)).To(Succeed())
+
+				serverClaim := &metalv1alpha1.ServerClaim{}
+				Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(metalMachine), serverClaim)).To(Succeed())
+				Expect(k8sClient.Delete(ctx, serverClaim)).To(Succeed())
 
+				metalSecret := &corev1.Secret{}
+				Expect(k8sClient.Get(ctx, metalSecretNN, metalSecret)).To(Succeed())
+				Expect(k8sClient.Delete(ctx, metalSecret)).To(Succeed())
+			}
+		})
+
+		It("should create the ignition secret", func() {
 			_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
-				NamespacedName: typeNamespacedName,
+				NamespacedName: client.ObjectKeyFromObject(metalMachine),
 			})
 			Expect(err).NotTo(HaveOccurred())
-			// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
-			// Example: If you expect a certain status condition after reconciliation, verify it here.
+
+			expectIgnition(`{"name":"metal-machine"}`)
+		})
+
+		When("the metadata is present in the metal machine", func() {
+			BeforeEach(func() {
+				metalMachine.Spec.Metadata = &apiextensionsv1.JSON{
+					Raw: []byte(`{"foo": "bar"}`),
+				}
+			})
+
+			It("should create the ignition secret with the metadata", func() {
+				_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+					NamespacedName: client.ObjectKeyFromObject(metalMachine),
+				})
+				Expect(err).NotTo(HaveOccurred())
+
+				expectIgnition(`{"name":"metal-machine","storage":{"files":[{"contents":{"inline":"{\"foo\":\"bar\"}"},"mode":420,"path":"/var/lib/metal-cloud-config/metadata"}]}}`)
+			})
+		})
+
+		When("the ipam config is present in the metal machine", func() {
+			const metadataKey = "meta-key"
+
+			var (
+				ipAddressClaim *capiv1beta1.IPAddressClaim
+				ipAddress      *capiv1beta1.IPAddress
+			)
+
+			BeforeEach(func() {
+				ipAddressClaim = &capiv1beta1.IPAddressClaim{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      fmt.Sprintf("%s-%s", metalMachine.Name, metadataKey),
+						Namespace: namespace,
+					},
+				}
+
+				ipAddress = &capiv1beta1.IPAddress{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "ip-address",
+						Namespace: namespace,
+					},
+					Spec: capiv1beta1.IPAddressSpec{
+						Address: "10.11.12.13",
+						Prefix:  24,
+						Gateway: "10.11.12.1",
+					},
+				}
+
+				metalMachine.Spec.IPAMConfig = []infrav1alpha1.IPAMConfig{{
+					MetadataKey: metadataKey,
+					IPAMRef: &infrav1alpha1.IPAMObjectReference{
+						Name:     "pool",
+						APIGroup: "ipam.cluster.x-k8s.io/v1alpha2",
+						Kind:     "GlobalInClusterIPPool",
+					}}}
+
+				Expect(k8sClient.Create(ctx, ipAddressClaim)).To(Succeed())
+				Eventually(func() error {
+					if err := get(ipAddressClaim); err != nil {
+						return err
+					}
+					ipAddressClaim.Status.AddressRef.Name = ipAddress.Name
+					return k8sClient.Status().Update(ctx, ipAddressClaim)
+				}).Should(Succeed())
+				Expect(k8sClient.Create(ctx, ipAddress)).To(Succeed())
+			})
+
+			AfterEach(func() {
+				Expect(k8sClient.Delete(ctx, ipAddress)).To(Succeed())
+				Expect(k8sClient.Delete(ctx, ipAddressClaim)).To(Succeed())
+			})
+
+			It("should create the ignition secret with the ip address", func() {
+				_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+					NamespacedName: client.ObjectKeyFromObject(metalMachine),
+				})
+				Expect(err).NotTo(HaveOccurred())
+
+				expectIgnition(`{"name":"metal-machine","storage":{"files":[{"contents":{"inline":"{\"meta-key\":{\"gateway\":\"10.11.12.1\",\"ip\":\"10.11.12.13\",\"prefix\":24}}"},"mode":420,"path":"/var/lib/metal-cloud-config/metadata"}]}}`)
+			})
+
+			When("the metadata is present in the metal machine", func() {
+				BeforeEach(func() {
+					metalMachine.Spec.Metadata = &apiextensionsv1.JSON{
+						Raw: []byte(`{"foo": "bar"}`),
+					}
+				})
+
+				It("should create the ignition secret with the ip address and the metadata", func() {
+					_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+						NamespacedName: client.ObjectKeyFromObject(metalMachine),
+					})
+					Expect(err).NotTo(HaveOccurred())
+
+					expectIgnition(`{"name":"metal-machine","storage":{"files":[{"contents":{"inline":"{\"foo\":\"bar\",\"meta-key\":{\"gateway\":\"10.11.12.1\",\"ip\":\"10.11.12.13\",\"prefix\":24}}"},"mode":420,"path":"/var/lib/metal-cloud-config/metadata"}]}}`)
+				})
+			})
+		})
+
+		When("deletion timestamp is set", func() {
+			It("should delete the ip address claims", func() {
+				Expect(k8sClient.Delete(ctx, metalMachine)).To(Succeed())
+				ipAddressClaim1 := &capiv1beta1.IPAddressClaim{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      fmt.Sprintf("%s-1", metalMachine.Name),
+						Namespace: namespace,
+					},
+				}
+				Expect(k8sClient.Create(ctx, ipAddressClaim1)).To(Succeed())
+				ipAddressClaim2 := &capiv1beta1.IPAddressClaim{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      fmt.Sprintf("%s-2", metalMachine.Name),
+						Namespace: namespace,
+					},
+				}
+				Expect(k8sClient.Create(ctx, ipAddressClaim2)).To(Succeed())
+
+				_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+					NamespacedName: client.ObjectKeyFromObject(metalMachine),
+				})
+				Expect(err).NotTo(HaveOccurred())
+
+				Eventually(Get(ipAddressClaim1)).Should(Satisfy(apierrors.IsNotFound))
+				Eventually(Get(ipAddressClaim2)).Should(Satisfy(apierrors.IsNotFound))
+			})
 		})
 	})
 })
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index 8cb5d1b..40ff4cc 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -8,40 +8,59 @@ import (
 	"path/filepath"
 	"runtime"
 	"testing"
+	"time"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	. "sigs.k8s.io/controller-runtime/pkg/envtest/komega"
 
+	infrav1alpha1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
+	"github.com/ironcore-dev/controller-utils/modutils"
+	metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/client-go/rest"
+	clusterapiv1beta1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	capiv1beta1 "sigs.k8s.io/cluster-api/exp/ipam/api/v1beta1"
+	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/envtest"
-	logf "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
-
-	infrav1 "github.com/ironcore-dev/cluster-api-provider-ironcore-metal/api/v1alpha1"
 	// +kubebuilder:scaffold:imports
 )
 
-// These tests use Ginkgo (BDD-style Go testing framework). Refer to
-// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
+const (
+	eventuallyTimeout    = 20 * time.Second
+	pollingInterval      = 250 * time.Millisecond
+	consistentlyDuration = 1 * time.Second
+	apiServiceTimeout    = 5 * time.Minute
+)
 
-var cfg *rest.Config
-var k8sClient client.Client
-var testEnv *envtest.Environment
+var (
+	testEnv   *envtest.Environment
+	cfg       *rest.Config
+	k8sClient client.Client
+)
 
 func TestControllers(t *testing.T) {
+	SetDefaultConsistentlyPollingInterval(pollingInterval)
+	SetDefaultEventuallyPollingInterval(pollingInterval)
+	SetDefaultEventuallyTimeout(eventuallyTimeout)
+	SetDefaultConsistentlyDuration(consistentlyDuration)
 	RegisterFailHandler(Fail)
 
 	RunSpecs(t, "Controller Suite")
 }
 
 var _ = BeforeSuite(func() {
-	logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
+	ctrl.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
 
 	By("bootstrapping test environment")
 	testEnv = &envtest.Environment{
-		CRDDirectoryPaths:     []string{filepath.Join("..", "..", "config", "crd", "bases")},
+		CRDDirectoryPaths: []string{
+			filepath.Join("..", "..", "config", "crd", "bases"),
+			modutils.Dir("sigs.k8s.io/cluster-api", "config", "crd", "bases"),
+			modutils.Dir("github.com/ironcore-dev/metal-operator", "config", "crd", "bases"),
+		},
 		ErrorIfCRDPathMissing: true,
 
 		// The BinaryAssetsDirectory is only required if you want to run the tests directly
@@ -59,15 +78,21 @@ var _ = BeforeSuite(func() {
 	Expect(err).NotTo(HaveOccurred())
 	Expect(cfg).NotTo(BeNil())
 
-	err = infrav1.AddToScheme(scheme.Scheme)
+	err = infrav1alpha1.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
 	// +kubebuilder:scaffold:scheme
+	Expect(clusterapiv1beta1.AddToScheme(scheme.Scheme)).To(Succeed())
+	Expect(infrav1alpha1.AddToScheme(scheme.Scheme)).To(Succeed())
+	Expect(metalv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed())
+	Expect(capiv1beta1.AddToScheme(scheme.Scheme)).To(Succeed())
 
 	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
 	Expect(err).NotTo(HaveOccurred())
 	Expect(k8sClient).NotTo(BeNil())
 
+	// set komega client
+	SetClient(k8sClient)
 })
 
 var _ = AfterSuite(func() {
diff --git a/templates/test/cluster_v1beta1_kubeadmcontrolplane.yaml b/templates/test/cluster_v1beta1_kubeadmcontrolplane.yaml
index 9ad50f9..baa3e43 100644
--- a/templates/test/cluster_v1beta1_kubeadmcontrolplane.yaml
+++ b/templates/test/cluster_v1beta1_kubeadmcontrolplane.yaml
@@ -31,14 +31,15 @@ spec:
     ignition:
       containerLinuxConfig:
         additionalConfig: |-
-          files:
-          - path: /opt/testdir/testfile
-            filesystem: root
-            mode: 0644
-            contents:
-              inline: |
-                [TEST]
-                test=1
+          storage:
+            files:
+            - path: /opt/testdir/testfile
+              filesystem: root
+              mode: 0644
+              contents:
+                inline: |
+                  [TEST]
+                  test=1
     preKubeadmCommands:
     - hostnamectl set-hostname $${METAL_HOSTNAME}
   version: 1.29.4
diff --git a/templates/test/ipam_cluster_v1alpha2_globalinclusterippool.yaml b/templates/test/ipam_cluster_v1alpha2_globalinclusterippool.yaml
new file mode 100644
index 0000000..a98db7c
--- /dev/null
+++ b/templates/test/ipam_cluster_v1alpha2_globalinclusterippool.yaml
@@ -0,0 +1,11 @@
+apiVersion: ipam.cluster.x-k8s.io/v1alpha2
+kind: GlobalInClusterIPPool
+metadata:
+  name: globalinclusterippool-sample-cp
+spec:
+  addresses:
+  - 10.10.10.0/24
+  excludedAddresses:
+  - 10.10.10.0/29
+  gateway: 10.10.10.1
+  prefix: 24


Reviews:
user: login: defo89
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T15:59:09Z
user: login: SzymonSAP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T08:28:55Z
user: login: defo89
body: looks good, thanks Szymon!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T14:57:02Z

Review comments:
diff_hunk: @@ -174,6 +184,14 @@ func (r *IroncoreMetalMachineReconciler) reconcileDelete(ctx context.Context, ma
 
 	// insert ServerClaim deletion logic here
 
+	ip := &capiv1beta1.IPAddressClaim{ObjectMeta: metav1.ObjectMeta{
+		Name:      machineScope.IroncoreMetalMachine.Name,
+		Namespace: machineScope.IroncoreMetalMachine.Namespace,
+	}}
+	if err := r.Client.Delete(ctx, ip); client.IgnoreNotFound(err) != nil {
+		return ctrl.Result{}, fmt.Errorf("error deleting ip resource: %s", err.Error())
+	}
path: internal/controller/ironcoremetalmachine_controller.go
user: login: defo89
body: Since we now support multiple IPs, `delete` call needs to be adjusted. Otherwise old `ipaddressclaims` are not cleaned up.
created_at: 2025-03-11T15:59:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,6 +184,14 @@ func (r *IroncoreMetalMachineReconciler) reconcileDelete(ctx context.Context, ma
 
 	// insert ServerClaim deletion logic here
 
+	ip := &capiv1beta1.IPAddressClaim{ObjectMeta: metav1.ObjectMeta{
+		Name:      machineScope.IroncoreMetalMachine.Name,
+		Namespace: machineScope.IroncoreMetalMachine.Namespace,
+	}}
+	if err := r.Client.Delete(ctx, ip); client.IgnoreNotFound(err) != nil {
+		return ctrl.Result{}, fmt.Errorf("error deleting ip resource: %s", err.Error())
+	}
path: internal/controller/ironcoremetalmachine_controller.go
user: login: SzymonSAP
body: done
created_at: 2025-03-12T08:28:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: SzymonSAP
created_at: 2025-03-06T12:01:10Z
body: Now there is a section:
```
{
    "storage": {
        "files": [
            ...,
            {
                "contents": {
                    "inline": "{\"bond\":{\"gateway\":\"10.10.10.1\",\"ip\":\"10.10.10.8\",\"prefix\":24},\"foo\":\"bar\"}"
                },
                "mode": 420,
                "path": "/var/lib/metal-cloud-config/metadata"
            }
        ]
    },
    ...
}
```
present in `ignition-...` secret when run tilt up.
author_association: CONTRIBUTOR
user: login: defo89
created_at: 2025-03-11T15:54:29Z
body: it seems we are creating an empty file in case no metadata or no IP is claimed. Should we just don't create anything in this case?
```
      {
        "contents": {
          "inline": "{}"
        },
        "mode": 420,
        "path": "/var/lib/metal-cloud-config/metadata"
      }
```
author_association: CONTRIBUTOR