Repository: rancher-sandbox/cluster-api-provider-rke2
Pull request #124 (2023-03-30 17:55:28)

Support ignition format for generating user data
<!--
Label the PR with the kind of change this for:

kind/feature
kind/bug
kind/documentation
kind/regression
kind/*
-->

**What this PR does / why we need it**:

Add support for ignition based user data. It similar to what is implemented for kubeadm, I also added a cluster template and instructions for bootstrapping.

<!-- Enter a description of the change and why this change is needed -->

**Which issue(s) this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*:
Fixes https://github.com/rancher-sandbox/cluster-api-provider-rke2/issues/66

**Special notes for your reviewer**:

**Checklist**:
<!-- Put an "X" character inside the brackets of each completed task. Some may be optional depending on the PR in which case these can be deleted -->

- [x] squashed commits into logical changes
- [x] includes documentation
- [x] adds unit tests
- [ ] adds or updates e2e tests



Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 89bfe2f..2c093a2 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -4,6 +4,7 @@ run:
   skip-files:
     - "zz_generated.*\\.go$"
     - "vendored_openapi\\.go$"
+    - ".*_test\\.go$"
   allow-parallel-runners: true
 issues:
   include:
diff --git a/bootstrap/internal/controllers/rke2config_controller.go b/bootstrap/internal/controllers/rke2config_controller.go
index 6496ea6..7c33af1 100644
--- a/bootstrap/internal/controllers/rke2config_controller.go
+++ b/bootstrap/internal/controllers/rke2config_controller.go
@@ -43,6 +43,7 @@ import (
 
 	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1alpha1"
 	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/cloudinit"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/ignition"
 	controlplanev1 "github.com/rancher-sandbox/cluster-api-provider-rke2/controlplane/api/v1alpha1"
 	"github.com/rancher-sandbox/cluster-api-provider-rke2/pkg/consts"
 	"github.com/rancher-sandbox/cluster-api-provider-rke2/pkg/locking"
@@ -413,12 +414,23 @@ func (r *RKE2ConfigReconciler) handleClusterNotInitialized(ctx context.Context,
 		Certificates: certificates,
 	}
 
-	cloudInitData, err := cloudinit.NewInitControlPlane(cpinput)
+	var userData []byte
+
+	switch scope.Config.Spec.AgentConfig.Format {
+	case bootstrapv1.Ignition:
+		userData, err = ignition.NewInitControlPlane(&ignition.ControlPlaneInput{
+			ControlPlaneInput:  cpinput,
+			AdditionalIgnition: &scope.Config.Spec.AgentConfig.AdditionalUserData,
+		})
+	default:
+		userData, err = cloudinit.NewInitControlPlane(cpinput)
+	}
+
 	if err != nil {
 		return ctrl.Result{}, err
 	}
 
-	if err := r.storeBootstrapData(ctx, scope, cloudInitData); err != nil {
+	if err := r.storeBootstrapData(ctx, scope, userData); err != nil {
 		return ctrl.Result{}, err
 	}
 
@@ -577,12 +589,27 @@ func (r *RKE2ConfigReconciler) joinControlplane(ctx context.Context, scope *Scop
 		},
 	}
 
-	cloudInitData, err := cloudinit.NewJoinControlPlane(cpinput)
 	if err != nil {
 		return ctrl.Result{}, err
 	}
 
-	if err := r.storeBootstrapData(ctx, scope, cloudInitData); err != nil {
+	var userData []byte
+
+	switch scope.Config.Spec.AgentConfig.Format {
+	case bootstrapv1.Ignition:
+		userData, err = ignition.NewJoinControlPlane(&ignition.ControlPlaneInput{
+			ControlPlaneInput:  cpinput,
+			AdditionalIgnition: &scope.Config.Spec.AgentConfig.AdditionalUserData,
+		})
+	default:
+		userData, err = cloudinit.NewJoinControlPlane(cpinput)
+	}
+
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	if err := r.storeBootstrapData(ctx, scope, userData); err != nil {
 		return ctrl.Result{}, err
 	}
 
@@ -668,12 +695,23 @@ func (r *RKE2ConfigReconciler) joinWorker(ctx context.Context, scope *Scope) (re
 		AdditionalCloudInit: scope.Config.Spec.AgentConfig.AdditionalUserData.Config,
 	}
 
-	cloudInitData, err := cloudinit.NewJoinWorker(wkInput)
+	var userData []byte
+
+	switch scope.Config.Spec.AgentConfig.Format {
+	case bootstrapv1.Ignition:
+		userData, err = ignition.NewJoinWorker(&ignition.JoinWorkerInput{
+			BaseUserData:       wkInput,
+			AdditionalIgnition: &scope.Config.Spec.AgentConfig.AdditionalUserData,
+		})
+	default:
+		userData, err = cloudinit.NewJoinWorker(wkInput)
+	}
+
 	if err != nil {
 		return ctrl.Result{}, err
 	}
 
-	if err := r.storeBootstrapData(ctx, scope, cloudInitData); err != nil {
+	if err := r.storeBootstrapData(ctx, scope, userData); err != nil {
 		return ctrl.Result{}, err
 	}
 
@@ -740,7 +778,8 @@ func (r *RKE2ConfigReconciler) storeBootstrapData(ctx context.Context, scope *Sc
 			},
 		},
 		Data: map[string][]byte{
-			"value": data,
+			"value":  data,
+			"format": []byte(scope.Config.Spec.AgentConfig.Format),
 		},
 		Type: clusterv1.ClusterSecretType,
 	}
diff --git a/bootstrap/internal/ignition/clc/clc.go b/bootstrap/internal/ignition/clc/clc.go
new file mode 100644
index 0000000..5d8bc95
--- /dev/null
+++ b/bootstrap/internal/ignition/clc/clc.go
@@ -0,0 +1,268 @@
+/*
+Copyright 2023 SUSE.
+
+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 clc
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"text/template"
+
+	clct "github.com/flatcar/container-linux-config-transpiler/config"
+	ignition "github.com/flatcar/ignition/config/v2_3"
+	ignitionTypes "github.com/flatcar/ignition/config/v2_3/types"
+	"github.com/pkg/errors"
+
+	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1alpha1"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/cloudinit"
+)
+
+// The template contains configurations for two main sections: systemd units and storage files.
+// The first section defines two systemd units: rke2-install.service and ntpd.service.
+// The rke2-install.service unit is enabled and is executed only once during the boot process to run the /etc/rke2-install.sh script.
+// This script installs and deploys RKE2, and performs pre and post-installation commands.
+// The ntpd.service unit is enabled only if NTP servers are specified.
+// The second section defines storage files for the system. It creates a file at /etc/rke2-install.sh. If CISEnabled is set to true,
+// it runs an additional CIS script to enforce system security standards. If NTP servers are specified,
+// it creates an NTP configuration file at /etc/ntp.conf.
+const (
+	clcTemplate = `---
+systemd:
+  units:
+    - name: rke2-install.service
+      enabled: true
+      contents: |
+        [Unit]
+        Description=rke2-install
+        [Service]
+        # To not restart the unit when it exits, as it is expected.
+        Type=oneshot
+        ExecStart=/etc/rke2-install.sh
+        [Install]
+        WantedBy=multi-user.target
+    {{- if .NTPServers }}
+    - name: ntpd.service
+      enabled: true
+    {{- end }}
+storage:
+  files:
+    - path: /etc/ssh/sshd_config
+      mode: 0600
+      contents:
+        inline: |
+          # Use most defaults for sshd configuration.
+          Subsystem sftp internal-sftp
+          ClientAliveInterval 180
+          UseDNS no
+          UsePAM yes
+          PrintLastLog no # handled by PAM
+          PrintMotd no # handled by PAM
+    {{- range .WriteFiles }}
+    - path: {{ .Path }}
+      {{- $owner := ParseOwner .Owner }}
+      {{ if $owner.User -}}
+      user:
+        name: {{ $owner.User }}
+      {{- end }}
+      {{ if $owner.Group -}}
+      group:
+        name: {{ $owner.Group }}
+      {{- end }}
+      # Owner
+      {{ if ne .Permissions "" -}}
+      mode: {{ .Permissions }}
+      {{ end -}}
+      contents:
+        {{ if eq .Encoding "base64" -}}
+        inline: !!binary |
+        {{- else -}}
+        inline: |
+        {{- end }}
+          {{ .Content | Indent 10 }}
+    {{- end }}
+    - path: /etc/rke2-install.sh
+      mode: 0700
+      contents:
+        inline: |
+          #!/bin/bash
+          set -e
+          {{ range .PreRKE2Commands }}
+          {{ . | Indent 10 }}
+          {{- end }}
+
+		  {{- if .CISEnabled }}
+  		  /opt/rke2-cis-script.sh
+		  {{ end }}
+
+          {{ range .DeployRKE2Commands }}
+          {{ . | Indent 10 }}
+          {{- end }}
+
+          mkdir -p /run/cluster-api && echo success > /run/cluster-api/bootstrap-success.complete
+          {{range .PostRKE2Commands }}
+          {{ . | Indent 10 }}
+          {{- end }}
+    {{- if .NTPServers }}
+    - path: /etc/ntp.conf
+      mode: 0644
+      contents:
+        inline: |
+          # Common pool
+          {{- range  .NTPServers }}
+          server {{ . }}
+          {{- end }}
+
+          # Warning: Using default NTP settings will leave your NTP
+          # server accessible to all hosts on the Internet.
+
+          # If you want to deny all machines (including your own)
+          # from accessing the NTP server, uncomment:
+          #restrict default ignore
+
+          # Default configuration:
+          # - Allow only time queries, at a limited rate, sending KoD when in excess.
+          # - Allow all local queries (IPv4, IPv6)
+          restrict default nomodify nopeer noquery notrap limited kod
+          restrict 127.0.0.1
+          restrict [::1]
+    {{- end }}
+`
+)
+
+func defaultTemplateFuncMap() template.FuncMap {
+	return template.FuncMap{
+		"Indent":     templateYAMLIndent,
+		"ParseOwner": parseOwner,
+	}
+}
+
+func templateYAMLIndent(i int, input string) string {
+	split := strings.Split(input, "\n")
+	ident := "\n" + strings.Repeat(" ", i)
+
+	return strings.Join(split, ident)
+}
+
+type owner struct {
+	User  *string
+	Group *string
+}
+
+func parseOwner(ownerRaw string) owner {
+	if ownerRaw == "" {
+		return owner{}
+	}
+
+	ownerSlice := strings.Split(ownerRaw, ":")
+
+	parseEntity := func(entity string) *string {
+		if entity == "" {
+			return nil
+		}
+
+		entityTrimmed := strings.TrimSpace(entity)
+
+		return &entityTrimmed
+	}
+
+	if len(ownerSlice) == 1 {
+		return owner{
+			User: parseEntity(ownerSlice[0]),
+		}
+	}
+
+	return owner{
+		User:  parseEntity(ownerSlice[0]),
+		Group: parseEntity(ownerSlice[1]),
+	}
+}
+
+func renderCLC(input *cloudinit.BaseUserData) ([]byte, error) {
+	t := template.Must(template.New("template").Funcs(defaultTemplateFuncMap()).Parse(clcTemplate))
+
+	var out bytes.Buffer
+	if err := t.Execute(&out, input); err != nil {
+		return nil, errors.Wrapf(err, "failed to render template")
+	}
+
+	return out.Bytes(), nil
+}
+
+// Render renders the provided user data and CLC snippets into Ignition config.
+func Render(input *cloudinit.BaseUserData, additionalConfig *bootstrapv1.AdditionalUserData) ([]byte, error) {
+	if input == nil {
+		return nil, errors.New("empty base user data")
+	}
+
+	clcBytes, err := renderCLC(input)
+	if err != nil {
+		return nil, errors.Wrapf(err, "rendering CLC configuration")
+	}
+
+	userData, _, err := buildIgnitionConfig(clcBytes, additionalConfig)
+	if err != nil {
+		return nil, errors.Wrapf(err, "building Ignition config")
+	}
+
+	return userData, nil
+}
+
+func buildIgnitionConfig(baseCLC []byte, additionalConfig *bootstrapv1.AdditionalUserData) ([]byte, string, error) {
+	// We control baseCLC config, so treat it as strict.
+	ign, _, err := clcToIgnition(baseCLC, true)
+	if err != nil {
+		return nil, "", errors.Wrapf(err, "converting generated CLC to Ignition")
+	}
+
+	var clcWarnings string
+
+	if additionalConfig != nil && additionalConfig.Config != "" {
+		additionalIgn, warnings, err := clcToIgnition([]byte(additionalConfig.Config), additionalConfig.Strict)
+		if err != nil {
+			return nil, "", errors.Wrapf(err, "converting additional CLC to Ignition")
+		}
+
+		clcWarnings = warnings
+
+		ign = ignition.Append(ign, additionalIgn)
+	}
+
+	userData, err := json.Marshal(&ign)
+	if err != nil {
+		return nil, "", errors.Wrapf(err, "marshaling generated Ignition config into JSON")
+	}
+
+	return userData, clcWarnings, nil
+}
+
+func clcToIgnition(data []byte, strict bool) (ignitionTypes.Config, string, error) {
+	clc, ast, reports := clct.Parse(data)
+
+	if (len(reports.Entries) > 0 && strict) || reports.IsFatal() {
+		return ignitionTypes.Config{}, "", fmt.Errorf("error parsing Container Linux Config: %v", reports.String())
+	}
+
+	ign, report := clct.Convert(clc, "", ast)
+	if (len(report.Entries) > 0 && strict) || report.IsFatal() {
+		return ignitionTypes.Config{}, "", fmt.Errorf("error converting to Ignition: %v", report.String())
+	}
+
+	reports.Merge(report)
+
+	return ign, reports.String(), nil
+}
diff --git a/bootstrap/internal/ignition/ignition.go b/bootstrap/internal/ignition/ignition.go
new file mode 100644
index 0000000..f63fdbd
--- /dev/null
+++ b/bootstrap/internal/ignition/ignition.go
@@ -0,0 +1,158 @@
+/*
+Copyright 2023 SUSE.
+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 ignition aggregates all Ignition flavors into a single package to be consumed
+// by the bootstrap provider by exposing an API similar to 'bootstrap/internal/ignition' package.
+package ignition
+
+import (
+	"fmt"
+
+	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1alpha1"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/cloudinit"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/ignition/clc"
+)
+
+const (
+	airGappedControlPlaneCommand = "INSTALL_RKE2_ARTIFACT_PATH=/opt/rke2-artifacts sh /opt/install.sh"
+	controlPlaneCommand          = "curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=%[1]s sh -s - server"
+	airGappedWorkerCommand       = "INSTALL_RKE2_ARTIFACT_PATH=/opt/rke2-artifacts INSTALL_RKE2_TYPE=\"agent\" sh /opt/install.sh"
+	workerCommand                = "curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=%[1]s INSTALL_RKE2_TYPE=\"agent\" sh -s -"
+)
+
+var (
+	serverSystemdServices = []string{
+		"systemctl enable rke2-server.service",
+		"systemctl start rke2-server.service",
+	}
+
+	workerSystemdServices = []string{
+		"systemctl enable rke2-agent.service",
+		"systemctl start rke2-agent.service",
+	}
+)
+
+// JoinWorkerInput defines the context to generate a node user data.
+type JoinWorkerInput struct {
+	*cloudinit.BaseUserData
+
+	AdditionalIgnition *bootstrapv1.AdditionalUserData
+}
+
+// ControlPlaneInput defines the context to generate a controlplane instance user data.
+type ControlPlaneInput struct {
+	*cloudinit.ControlPlaneInput
+
+	AdditionalIgnition *bootstrapv1.AdditionalUserData
+}
+
+// NewJoinWorker returns Ignition configuration for new worker node joining the cluster.
+func NewJoinWorker(input *JoinWorkerInput) ([]byte, error) {
+	if input == nil {
+		return nil, fmt.Errorf("input can't be nil")
+	}
+
+	if input.BaseUserData == nil {
+		return nil, fmt.Errorf("base userdata can't be nil")
+	}
+
+	deployRKE2Command, err := getWorkerRKE2Commands(input.BaseUserData)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get rke2 command: %w", err)
+	}
+
+	input.DeployRKE2Commands = deployRKE2Command
+	input.WriteFiles = append(input.WriteFiles, input.ConfigFile)
+
+	return render(input.BaseUserData, input.AdditionalIgnition)
+}
+
+// NewJoinControlPlane returns Ignition configuration for new controlplane node joining the cluster.
+func NewJoinControlPlane(input *ControlPlaneInput) ([]byte, error) {
+	processedInput, err := controlPlaneConfigInput(input)
+	if err != nil {
+		return nil, fmt.Errorf("failed to process controlplane input: %w", err)
+	}
+
+	return render(&processedInput.BaseUserData, processedInput.AdditionalIgnition)
+}
+
+// NewInitControlPlane returns Ignition configuration for bootstrapping new cluster.
+func NewInitControlPlane(input *ControlPlaneInput) ([]byte, error) {
+	processedInput, err := controlPlaneConfigInput(input)
+	if err != nil {
+		return nil, fmt.Errorf("failed to process controlplane input: %w", err)
+	}
+
+	return render(&processedInput.BaseUserData, processedInput.AdditionalIgnition)
+}
+
+func controlPlaneConfigInput(input *ControlPlaneInput) (*ControlPlaneInput, error) {
+	if input == nil {
+		return nil, fmt.Errorf("input can't be nil")
+	}
+
+	if input.ControlPlaneInput == nil {
+		return nil, fmt.Errorf("controlplane input can't be nil")
+	}
+
+	deployRKE2Command, err := getControlPlaneRKE2Commands(&input.BaseUserData)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get rke2 command: %w", err)
+	}
+
+	input.DeployRKE2Commands = deployRKE2Command
+	input.WriteFiles = append(input.WriteFiles, input.Certificates.AsFiles()...)
+	input.WriteFiles = append(input.WriteFiles, input.ConfigFile)
+
+	return input, nil
+}
+
+func render(input *cloudinit.BaseUserData, ignitionConfig *bootstrapv1.AdditionalUserData) ([]byte, error) {
+	additionalCLCConfig := &bootstrapv1.AdditionalUserData{}
+	if ignitionConfig != nil && ignitionConfig.Config != "" {
+		additionalCLCConfig = ignitionConfig
+	}
+
+	return clc.Render(input, additionalCLCConfig)
+}
+
+func getControlPlaneRKE2Commands(baseUserData *cloudinit.BaseUserData) ([]string, error) {
+	return getRKE2Commands(baseUserData, controlPlaneCommand, airGappedControlPlaneCommand, serverSystemdServices)
+}
+
+func getWorkerRKE2Commands(baseUserData *cloudinit.BaseUserData) ([]string, error) {
+	return getRKE2Commands(baseUserData, workerCommand, airGappedWorkerCommand, workerSystemdServices)
+}
+
+func getRKE2Commands(baseUserData *cloudinit.BaseUserData, command, airgappedCommand string, systemdServices []string) ([]string, error) {
+	if baseUserData == nil {
+		return nil, fmt.Errorf("base user data can't be nil")
+	}
+
+	if baseUserData.RKE2Version == "" {
+		return nil, fmt.Errorf("rke2 version can't be empty")
+	}
+
+	rke2Commands := []string{}
+
+	if baseUserData.AirGapped {
+		rke2Commands = append(rke2Commands, airgappedCommand)
+	} else {
+		rke2Commands = append(rke2Commands, fmt.Sprintf(command, baseUserData.RKE2Version))
+	}
+
+	rke2Commands = append(rke2Commands, systemdServices...)
+
+	return rke2Commands, nil
+}
diff --git a/controlplane/api/v1alpha1/rke2controlplane_types.go b/controlplane/api/v1alpha1/rke2controlplane_types.go
index 9dd5ca4..20e3615 100644
--- a/controlplane/api/v1alpha1/rke2controlplane_types.go
+++ b/controlplane/api/v1alpha1/rke2controlplane_types.go
@@ -293,14 +293,15 @@ const (
 // DisableComponents describes components of RKE2 (Kubernetes components and plugin components) that should be disabled.
 type DisableComponents struct {
 	// KubernetesComponents is a list of Kubernetes components to disable.
+	// +kubebuilder:validation:Enum=scheduler;kubeProxy;cloudController
 	KubernetesComponents []DisabledKubernetesComponent `json:"kubernetesComponents,omitempty"`
 
 	// PluginComponents is a list of PluginComponents to disable.
+	// +kubebuilder:validation:Enum=rke2-coredns;rke2-ingress-nginx;rke2-metrics-server
 	PluginComponents []DisabledPluginComponent `json:"pluginComponents,omitempty"`
 }
 
 // DisabledKubernetesComponent is an enum field that can take one of the following values: scheduler, kubeProxy or cloudController.
-// +kubebuilder:validation:Enum=scheduler;kubeProxy;cloudController
 type DisabledKubernetesComponent string
 
 const (
@@ -315,7 +316,6 @@ const (
 )
 
 // DisabledPluginComponent selects a plugin Components to be disabled.
-// +kubebuilder:validation:Enum=rke2-coredns;rke2-ingress-nginx;rke2-metrics-server
 type DisabledPluginComponent string
 
 const (
diff --git a/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanes.yaml b/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanes.yaml
index 0483316..9f114b9 100644
--- a/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanes.yaml
+++ b/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanes.yaml
@@ -714,26 +714,26 @@ spec:
                       kubernetesComponents:
                         description: KubernetesComponents is a list of Kubernetes
                           components to disable.
+                        enum:
+                        - scheduler
+                        - kubeProxy
+                        - cloudController
                         items:
                           description: 'DisabledKubernetesComponent is an enum field
                             that can take one of the following values: scheduler,
                             kubeProxy or cloudController.'
-                          enum:
-                          - scheduler
-                          - kubeProxy
-                          - cloudController
                           type: string
                         type: array
                       pluginComponents:
                         description: PluginComponents is a list of PluginComponents
                           to disable.
+                        enum:
+                        - rke2-coredns
+                        - rke2-ingress-nginx
+                        - rke2-metrics-server
                         items:
                           description: DisabledPluginComponent selects a plugin Components
                             to be disabled.
-                          enum:
-                          - rke2-coredns
-                          - rke2-ingress-nginx
-                          - rke2-metrics-server
                           type: string
                         type: array
                     type: object
diff --git a/controlplane/config/manager/manager.yaml b/controlplane/config/manager/manager.yaml
index 6aeec4f..6a2409f 100644
--- a/controlplane/config/manager/manager.yaml
+++ b/controlplane/config/manager/manager.yaml
@@ -55,7 +55,7 @@ spec:
         resources:
           limits:
             cpu: 500m
-            memory: 256Mi
+            memory: 128Mi
           requests:
             cpu: 10m
             memory: 64Mi
diff --git a/controlplane/internal/controllers/rke2controlplane_controller.go b/controlplane/internal/controllers/rke2controlplane_controller.go
index b6e16c3..f86a1d0 100644
--- a/controlplane/internal/controllers/rke2controlplane_controller.go
+++ b/controlplane/internal/controllers/rke2controlplane_controller.go
@@ -649,23 +649,8 @@ func (r *RKE2ControlPlaneReconciler) reconcileKubeconfig(
 func (r *RKE2ControlPlaneReconciler) reconcileControlPlaneConditions(ctx context.Context, controlPlane *rke2.ControlPlane) (ctrl.Result, error) {
 	logger := log.FromContext(ctx)
 
-	readyCPMachines := controlPlane.Machines.Filter(collections.IsReady())
-
-	if readyCPMachines.Len() == 0 {
+	if controlPlane.Machines.Len() == 0 {
 		controlPlane.RCP.Status.Initialized = false
-		controlPlane.RCP.Status.Ready = false
-		controlPlane.RCP.Status.ReadyReplicas = 0
-		controlPlane.RCP.Status.AvailableServerIPs = nil
-		conditions.MarkFalse(
-			controlPlane.RCP,
-			controlplanev1.AvailableCondition,
-			controlplanev1.WaitingForRKE2ServerReason,
-			clusterv1.ConditionSeverityInfo, "")
-		conditions.MarkFalse(
-			controlPlane.RCP,
-			controlplanev1.MachinesReadyCondition,
-			controlplanev1.WaitingForRKE2ServerReason,
-			clusterv1.ConditionSeverityInfo, "")
 	}
 
 	// If the cluster is not yet initialized, there is no way to connect to the workload cluster and fetch information
diff --git a/go.mod b/go.mod
index 1ae5a06..b104a93 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.19
 
 require (
 	github.com/flatcar/container-linux-config-transpiler v0.9.4
+	github.com/flatcar/ignition v0.36.2
 	github.com/go-logr/logr v1.2.3
 	github.com/onsi/ginkgo v1.16.5
 	github.com/onsi/ginkgo/v2 v2.9.1
@@ -59,7 +60,6 @@ require (
 	github.com/emicklei/go-restful/v3 v3.9.0 // indirect
 	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
 	github.com/evanphx/json-patch/v5 v5.6.0 // indirect
-	github.com/flatcar/ignition v0.36.2 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
 	github.com/go-logr/zapr v1.2.3 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
diff --git a/image-builder/Makefile b/image-builder/Makefile
deleted file mode 100644
index 43805f8..0000000
--- a/image-builder/Makefile
+++ /dev/null
@@ -1,77 +0,0 @@
-# /*
-# Copyright 2022 SUSE.
-
-# 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.
-# */
-
-.DEFAULT_GOAL := help
-
-CONTAINERD_VERSION?=1.5.9
-RKE2_VERSION?=1.26.0+rke2r1
-
-## --------------------------------------
-## Help
-## --------------------------------------
-
-##@ Helpers
-help: ## Display this help
-	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf "  \033[36m%-35s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
-
-## --------------------------------------
-## Packer flags
-## --------------------------------------
-
-# Set Packer color to true if not already set in env variables
-# Only valid for builds
-ifneq (,$(findstring build-, $(MAKECMDGOALS)))
-	# A build target
-	PACKER_COLOR ?= true
-	PACKER_FLAGS += -color=$(PACKER_COLOR)
-endif
-
-# If ON_ERROR_ASK=1 then Packer will set -on-error to ask, causing the Packer
-# build to pause when any error happens, instead of simply exiting. This is
-# useful when debugging unknown issues logging into the remote machine via ssh.
-ifeq (1,$(strip $(ON_ERROR_ASK)))
-	PACKER_FLAGS += -on-error=ask
-endif
-
-# If DEBUG=1 then Packer will set -debug, enabling debug mode for builds, providing
-# more verbose logging
-ifeq (1,$(strip $(DEBUG)))
-	PACKER_FLAGS += -debug
-endif
-
-## --------------------------------------
-## Platform and version combinations
-## --------------------------------------
-
-AWS_BUILD_NAMES ?= aws-opensuse-1504 aws-ubuntu-2204
-
-## --------------------------------------
-## Dynamic build targets
-## --------------------------------------
-
-AWS_BUILD_TARGETS := $(addprefix build-,$(AWS_BUILD_NAMES))
-
-.PHONY: $(AWS_BUILD_TARGETS)
-$(AWS_BUILD_TARGETS):
-	packer build $(PACKER_FLAGS) -var-file="$(abspath aws/$(subst build-aws-,,$@).json)" -var "containerd_version=$(CONTAINERD_VERSION)" -var "rke2_semver=$(RKE2_VERSION)" aws/packer.json
-
-## --------------------------------------
-## Document dynamic build targets
-## --------------------------------------
-##@ Builds
-build-aws-opensuse-1504: ## Builds an AMI for OpenSUSE 15.04
-build-aws-ubuntu-2204: ## Builds an AMI for Ubuntu 22.04
-build-aws-all: $(AWS_BUILD_TARGETS) ## Builds all AMIs
diff --git a/image-builder/README.md b/image-builder/README.md
deleted file mode 100644
index 8e46a0c..0000000
--- a/image-builder/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# RKE2 CAPI image builder
-
-## Description
-
-This directory contains the scripts and configuration files to build the images used by the RKE2 CAPI provider. It relies on the [packer](https://www.packer.io/) tool to build the images.
-
-We are using bash scripts to provision images with required dependencies, scripts for each platform are located in the `scripts` directory.
-
-## AWS
-
-### Requirements
-
-- Your AWS account must have the following permissions: https://developer.hashicorp.com/packer/plugins/builders/amazon#iam-task-or-instance-role
-
-- You must a default VPC in your AWS account. If you don't have one, you can create one using the following command:
-
-```bash
-aws ec2 create-default-vpc
-```
-
-### Steps
-
-For building the AWS AMIs, you can run the following command:
-
-```bash
-make build-aws-all
-```
-or 
-
-```bash
-make help
-```
-and it will show you the available options.
diff --git a/image-builder/aws/opensuse-1504.json b/image-builder/aws/opensuse-1504.json
deleted file mode 100644
index e2a7693..0000000
--- a/image-builder/aws/opensuse-1504.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "ami_filter_name": "openSUSE-Leap*",
-    "ami_filter_owners": "431331376479",
-    "build_name": "openSUSE-15.4-rke2",
-    "distribution": "openSUSE",
-    "distribution_release": "Leap",
-    "distribution_version": "15.4",
-    "root_device_name": "/dev/sda1",
-    "source_ami": "",
-    "ssh_username": "ec2-user",
-    "bootstrap_script_path": "./scripts/opensuse/bootstrap.sh"
-}
diff --git a/image-builder/aws/packer.json b/image-builder/aws/packer.json
deleted file mode 100644
index 31069ae..0000000
--- a/image-builder/aws/packer.json
+++ /dev/null
@@ -1,120 +0,0 @@
-{
-    "builders": [
-        {
-            "access_key": "{{user `aws_access_key`}}",
-            "ami_description": "{{user `ami_description`}}",
-            "ami_groups": "{{user `ami_groups`}}",
-            "ami_name": "capa-ami-{{user `build_name`}}-{{user `rke2_semver` | clean_resource_name}}-{{user `build_timestamp`}}",
-            "ami_product_codes": "",
-            "ami_regions": "{{user `ami_regions`}}",
-            "ami_users": "{{user `ami_users`}}",
-            "encrypt_boot": "{{user `encrypted`}}",
-            "iam_instance_profile": "{{user `iam_instance_profile`}}",
-            "instance_type": "{{user `builder_instance_type`}}",
-            "kms_key_id": "{{user `kms_key_id`}}",
-            "launch_block_device_mappings": [
-                {
-                    "delete_on_termination": true,
-                    "device_name": "{{ user `root_device_name` }}",
-                    "iops": "{{ user `iops`}}",
-                    "throughput": "{{ user `throughput` }}",
-                    "volume_size": "{{ user `volume_size` }}",
-                    "volume_type": "{{ user `volume_type` }}"
-                }
-            ],
-            "name": "{{user `build_name`}}",
-            "profile": "{{ user `aws_profile`}}",
-            "region": "{{ user `aws_region` }}",
-            "secret_key": "{{user `aws_secret_key`}}",
-            "security_group_ids": "{{user `aws_security_group_ids`}}",
-            "skip_create_ami": "{{ user `skip_create_ami`}}",
-            "skip_profile_validation": "{{user `skip_profile_validation`}}",
-            "snapshot_groups": "{{user `snapshot_groups`}}",
-            "snapshot_users": "{{user `snapshot_users`}}",
-            "source_ami": "{{user `source_ami`}}",
-            "source_ami_filter": {
-                "filters": {
-                    "architecture": "x86_64",
-                    "name": "{{user `ami_filter_name`}}",
-                    "root-device-type": "ebs",
-                    "virtualization-type": "hvm"
-                },
-                "most_recent": true,
-                "owners": "{{user `ami_filter_owners`}}"
-            },
-            "ssh_keypair_name": "{{user `ssh_keypair_name`}}",
-            "ssh_username": "{{user `ssh_username`}}",
-            "subnet_id": "{{ user `subnet_id` }}",
-            "tags": {
-                "build_date": "{{isotime}}",
-                "build_timestamp": "{{user `build_timestamp`}}",
-                "containerd_version": "{{user `containerd_version`}}",
-                "distribution": "{{user `distribution`}}",
-                "distribution_release": "{{user `distribution_release`}}",
-                "distribution_version": "{{user `distribution_version`}}",
-                "kubernetes_cni_version": "{{user `kubernetes_cni_semver`}}",
-                "kubernetes_version": "{{user `rke2_semver`}}",
-                "source_ami": "{{user `source_ami`}}"
-            },
-            "temporary_security_group_source_cidrs": "{{ user `temporary_security_group_source_cidrs` }}",
-            "token": "{{ user `aws_session_token` }}",
-            "type": "amazon-ebs",
-            "user_data": "{{ user `user_data` }}",
-            "vpc_id": "{{ user `vpc_id` }}"
-        }
-    ],
-    "provisioners": [
-        {
-            "type": "file",
-            "source": "{{ user `bootstrap_script_path` }}",
-            "destination": "~/bootstrap.sh"
-        },
-        {
-            "environment_vars": [
-                "BUILD_NAME={{user `build_name`}}",
-                "CONTAINERD_VERSION={{user `containerd_version`}}",
-                "ARCH=amd64",
-                "RKE2_VERSION={{user `rke2_semver`}}"
-            ],
-            "type": "shell",
-            "remote_folder": "~",
-            "inline": [
-                "sudo bash ~/bootstrap.sh $CONTAINERD_VERSION $ARCH $RKE2_VERSION",
-                "rm ~/bootstrap.sh"
-            ]
-        }
-    ],
-    "variables": {
-        "ami_description": "Cluster API base image designed for RKE2 {{user `rke2_semver`}}",
-        "ami_groups": "all",
-        "ami_regions": "ap-south-1,eu-west-3,eu-west-2,eu-west-1,ap-northeast-2,ap-northeast-1,sa-east-1,ca-central-1,ap-southeast-1,ap-southeast-2,eu-central-1,us-east-1,us-east-2,us-west-1,us-west-2",
-        "ami_users": "",
-        "aws_access_key": "",
-        "aws_profile": "",
-        "aws_region": "us-east-1",
-        "aws_secret_key": "",
-        "aws_security_group_ids": "",
-        "aws_session_token": "",
-        "build_timestamp": "{{timestamp}}",
-        "builder_instance_type": "t3.small",
-        "containerd_version": null,
-        "encrypted": "false",
-        "iam_instance_profile": "",
-        "iops": "3000",
-        "kms_key_id": "",
-        "rke2_semver": null,
-        "skip_create_ami": "false",
-        "skip_profile_validation": "false",
-        "snapshot_groups": "all",
-        "snapshot_users": "",
-        "ssh_keypair_name": "",
-        "subnet_id": "",
-        "temporary_security_group_source_cidrs": "",
-        "throughput": "125",
-        "user_data": "#cloud-config\nrepo_upgrade: none",
-        "volume_size": "10",
-        "volume_type": "gp3",
-        "vpc_id": "",
-        "bootstrap_script_path": ""
-    }
-}
diff --git a/image-builder/aws/ubuntu-2204.json b/image-builder/aws/ubuntu-2204.json
deleted file mode 100644
index 93a47a7..0000000
--- a/image-builder/aws/ubuntu-2204.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "ami_filter_name": "ubuntu/images/*ubuntu-jammy-22.04-amd64-server-*",
-    "ami_filter_owners": "099720109477",
-    "build_name": "ubuntu-22.04-rke2",
-    "distribution": "Ubuntu",
-    "distribution_release": "jammy",
-    "distribution_version": "22.04",
-    "root_device_name": "/dev/sda1",
-    "source_ami": "",
-    "ssh_username": "ubuntu",
-    "bootstrap_script_path": "./scripts/ubuntu/bootstrap.sh"
-}
diff --git a/image-builder/scripts/opensuse/bootstrap.sh b/image-builder/scripts/opensuse/bootstrap.sh
deleted file mode 100644
index ab74074..0000000
--- a/image-builder/scripts/opensuse/bootstrap.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env bash
-set -o errexit
-set -o nounset
-set -o pipefail
-
-echo "Install required packages"
-zypper --gpg-auto-import-keys ref && \
-zypper --gpg-auto-import-keys --non-interactive install \
-        curl \
-        openssh-server \
-        cloud-init \
-        systemd \
-
-echo "Install RKE2 components"
-mkdir -p /opt/rke2-artifacts
-curl -sfL -o /opt/rke2-artifacts/rke2-images.linux-amd64.tar.zst https://github.com/rancher/rke2/releases/download/v${3}/rke2-images.linux-amd64.tar.zst
-curl -sfL -o /opt/rke2-artifacts/rke2.linux-amd64.tar.gz https://github.com/rancher/rke2/releases/download/v${3}/rke2.linux-amd64.tar.gz
-curl -sfL -o /opt/rke2-artifacts/sha256sum-amd64.txt https://github.com/rancher/rke2/releases/download/v${3}/sha256sum-amd64.txt
-curl -sfL -o /opt/install.sh https://get.rke2.io
-
-echo "Done"
diff --git a/image-builder/scripts/ubuntu/bootstrap.sh b/image-builder/scripts/ubuntu/bootstrap.sh
deleted file mode 100644
index 5359882..0000000
--- a/image-builder/scripts/ubuntu/bootstrap.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env bash
-set -o errexit
-set -o nounset
-set -o pipefail
-
-echo "Install required packages"
-apt-get update && \
-apt-get install -y \
-        curl \
-        openssh-server \
-        cloud-init \
-        systemd \
-
-echo "Install RKE2 components"
-mkdir -p /opt/rke2-artifacts
-curl -sfL -o /opt/rke2-artifacts/rke2-images.linux-amd64.tar.zst https://github.com/rancher/rke2/releases/download/v${3}/rke2-images.linux-amd64.tar.zst
-curl -sfL -o /opt/rke2-artifacts/rke2.linux-amd64.tar.gz https://github.com/rancher/rke2/releases/download/v${3}/rke2.linux-amd64.tar.gz
-curl -sfL -o /opt/rke2-artifacts/sha256sum-amd64.txt https://github.com/rancher/rke2/releases/download/v${3}/sha256sum-amd64.txt
-curl -sfL -o /opt/install.sh https://get.rke2.io
-
-echo "Done"
diff --git a/samples/aws/README.md b/samples/aws/README.md
index 83876b6..bb0214b 100644
--- a/samples/aws/README.md
+++ b/samples/aws/README.md
@@ -140,4 +140,35 @@ Cluster/rke2-aws                                              True
 └─Workers
   └─MachineDeployment/rke2-aws-md-0                           True                     18m
     └─2 Machines...                                           True                     19m    See rke2-aws-md-0-6d47bf584d-g2ljz, rke2-aws-md-0-6d47bf584d-m9z8h
-```
\ No newline at end of file
+```
+
+## Ignition based bootstrap
+
+Make sure that `BootstrapFormatIgnition` feature gate is enable for CAPA manager, you can do it
+by changing flag in the CAPA manager deployment:
+
+```yaml
+containers:
+- args:
+  - --feature-gates=EKS=true,EKSEnableIAM=false,EKSAllowAddRoles=false,EKSFargate=false,MachinePool=false,EventBridgeInstanceState=false,AutoControllerIdentityCreator=true,BootstrapFormatIgnition=true,ExternalResourceGC=false
+  ...
+  name: manager
+```
+or by setting the following environment variable before installing CAPA with `clusterctl`:
+
+```bash
+export BOOTSTRAP_FORMAT_IGNITION=true
+```
+
+For the Ignition based bootstrap, you will also need to set the following environment variables:
+
+```bash
+export AWS_S3_BUCKET_NAME=<YOUR_AWS_S3_BUCKET_NAME>
+```
+
+Now you can generate manifests from the cluster template:
+
+```bash
+clusterctl generate cluster --from https://github.com/rancher-sandbox/cluster-api-provider-rke2/blob/main/samples/aws/ignition-external/cluster-template-aws-ignition-external-cloud-provider.yaml -n example-aws rke2-aws > aws-rke2-clusterctl.yaml
+```
+
diff --git a/samples/aws/external/cluster-template-external-cloud-provider.yaml b/samples/aws/external/cluster-template-external-cloud-provider.yaml
index 61362c4..cedb9f7 100644
--- a/samples/aws/external/cluster-template-external-cloud-provider.yaml
+++ b/samples/aws/external/cluster-template-external-cloud-provider.yaml
@@ -72,7 +72,7 @@ spec:
       instanceType: ${AWS_CONTROL_PLANE_MACHINE_TYPE}
       sshKeyName: ${AWS_SSH_KEY_NAME}
       rootVolume:
-        size: 51
+        size: 50
 ---
 apiVersion: cluster.x-k8s.io/v1beta1
 kind: MachineDeployment
@@ -120,8 +120,8 @@ metadata:
 spec: 
   template:
     spec:
-      #preRKE2Commands:
-      #- sudo hostnamectl set-hostname $(curl -s http://169.254.169.254/1.0/meta-data/hostname)
+      preRKE2Commands:
+      - sudo hostnamectl set-hostname $(curl -s http://169.254.169.254/1.0/meta-data/hostname)
       agentConfig:
         version: ${KUBERNETES_VERSION}+rke2r1
 ---
diff --git a/samples/aws/ignition-external/cluster-template-aws-ignition-external-cloud-provider.yaml b/samples/aws/ignition-external/cluster-template-aws-ignition-external-cloud-provider.yaml
new file mode 100644
index 0000000..4e2c572
--- /dev/null
+++ b/samples/aws/ignition-external/cluster-template-aws-ignition-external-cloud-provider.yaml
@@ -0,0 +1,1000 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: ${CABPR_NAMESPACE}
+---
+apiVersion: cluster.x-k8s.io/v1beta1
+kind: Cluster
+metadata:
+  labels:
+    ccm: external
+    cni: ${CLUSTER_NAME}-crs-0
+    csi: external
+  name: ${CLUSTER_NAME}
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  clusterNetwork:
+    pods:
+      cidrBlocks:
+      - 192.168.0.0/16
+  controlPlaneRef:
+    apiVersion: controlplane.cluster.x-k8s.io/v1beta1
+    kind: RKE2ControlPlane
+    name: ${CLUSTER_NAME}-control-plane
+  infrastructureRef:
+    apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+    kind: AWSCluster
+    name: ${CLUSTER_NAME}
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+kind: AWSCluster
+metadata:
+  name: ${CLUSTER_NAME}
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  network:
+    vpc:
+      availabilityZoneUsageLimit: 1
+  bastion:
+    enabled: true
+  region: ${AWS_REGION}
+  sshKeyName: ${AWS_SSH_KEY_NAME}
+  s3Bucket:
+    controlPlaneIAMInstanceProfile: control-plane.cluster-api-provider-aws.sigs.k8s.io
+    name: "${AWS_S3_BUCKET_NAME}"
+    nodesIAMInstanceProfiles:
+    - nodes.cluster-api-provider-aws.sigs.k8s.io
+---
+apiVersion: controlplane.cluster.x-k8s.io/v1alpha1
+kind: RKE2ControlPlane
+metadata:
+  name: ${CLUSTER_NAME}-control-plane
+  namespace: ${CABPR_NAMESPACE}
+spec: 
+  preRKE2Commands:
+  - sudo hostnamectl set-hostname $(curl -s http://169.254.169.254/1.0/meta-data/hostname)
+  replicas: ${CABPR_CP_REPLICAS}
+  agentConfig:
+    format: ignition
+    version: ${KUBERNETES_VERSION}+rke2r1
+  serverConfig:
+    cni: calico
+    cloudProviderName: external
+  infrastructureRef:
+    apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+    kind: AWSMachineTemplate
+    name: ${CLUSTER_NAME}-control-plane
+  nodeDrainTimeout: 2m
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+kind: AWSMachineTemplate
+metadata:
+  name: ${CLUSTER_NAME}-control-plane
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  template:
+    spec:
+      iamInstanceProfile: control-plane.cluster-api-provider-aws.sigs.k8s.io
+      instanceType: ${AWS_CONTROL_PLANE_MACHINE_TYPE}
+      sshKeyName: ${AWS_SSH_KEY_NAME}
+      rootVolume:
+        size: 50
+      imageLookupBaseOS: flatcar-stable
+---
+apiVersion: cluster.x-k8s.io/v1beta1
+kind: MachineDeployment
+metadata:
+  name: ${CLUSTER_NAME}-md-0
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  clusterName: ${CLUSTER_NAME}
+  replicas: ${CABPR_WK_REPLICAS}
+  selector:
+    matchLabels: null
+  template:
+    spec:
+      bootstrap:
+        configRef:
+          apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
+          kind: RKE2ConfigTemplate
+          name: ${CLUSTER_NAME}-md-0
+      clusterName: ${CLUSTER_NAME}
+      infrastructureRef:
+        apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+        kind: AWSMachineTemplate
+        name: ${CLUSTER_NAME}-md-0
+      version: ${KUBERNETES_VERSION}
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+kind: AWSMachineTemplate
+metadata:
+  name: ${CLUSTER_NAME}-md-0
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  template:
+    spec:
+      iamInstanceProfile: nodes.cluster-api-provider-aws.sigs.k8s.io
+      instanceType: ${AWS_NODE_MACHINE_TYPE}
+      sshKeyName: ${AWS_SSH_KEY_NAME}
+      rootVolume: 
+        size: 50
+      imageLookupBaseOS: flatcar-stable
+---
+apiVersion: bootstrap.cluster.x-k8s.io/v1alpha1
+kind: RKE2ConfigTemplate
+metadata:
+  namespace: ${CABPR_NAMESPACE}
+  name: ${CLUSTER_NAME}-md-0
+spec: 
+  template:
+    spec:
+      preRKE2Commands:
+      - sudo hostnamectl set-hostname $(curl -s http://169.254.169.254/1.0/meta-data/hostname)
+      agentConfig:
+        format: ignition
+        version: ${KUBERNETES_VERSION}+rke2r1
+---
+apiVersion: addons.cluster.x-k8s.io/v1beta1
+kind: ClusterResourceSet
+metadata:
+  name: crs-ccm
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  clusterSelector:
+    matchLabels:
+      ccm: external
+  resources:
+  - kind: ConfigMap
+    name: cloud-controller-manager-addon
+  strategy: ApplyOnce
+---
+apiVersion: addons.cluster.x-k8s.io/v1beta1
+kind: ClusterResourceSet
+metadata:
+  name: crs-csi
+  namespace: ${CABPR_NAMESPACE}
+spec:
+  clusterSelector:
+    matchLabels:
+      csi: external
+  resources:
+  - kind: ConfigMap
+    name: aws-ebs-csi-driver-addon
+  strategy: ApplyOnce
+---
+apiVersion: v1
+data:
+  aws-ccm-external.yaml: |
+    ---
+    apiVersion: apps/v1
+    kind: DaemonSet
+    metadata:
+      name: aws-cloud-controller-manager
+      namespace: kube-system
+      labels:
+        k8s-app: aws-cloud-controller-manager
+    spec:
+      selector:
+        matchLabels:
+          k8s-app: aws-cloud-controller-manager
+      updateStrategy:
+        type: RollingUpdate
+      template:
+        metadata:
+          labels:
+            k8s-app: aws-cloud-controller-manager
+        spec:
+          nodeSelector:
+            node-role.kubernetes.io/master: "true"
+          tolerations:
+            - key: node.cloudprovider.kubernetes.io/uninitialized
+              value: "true"
+              effect: NoSchedule
+            - key: node-role.kubernetes.io/master
+              effect: NoSchedule
+            - effect: NoSchedule
+              key: node-role.kubernetes.io/control-plane
+          affinity:
+            nodeAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                nodeSelectorTerms:
+                  - matchExpressions:
+                      - key: node-role.kubernetes.io/control-plane
+                        operator: Exists
+                  - matchExpressions:
+                      - key: node-role.kubernetes.io/master
+                        operator: Exists
+          serviceAccountName: cloud-controller-manager
+          containers:
+            - name: aws-cloud-controller-manager
+              image: gcr.io/k8s-staging-provider-aws/cloud-controller-manager:v1.20.0-alpha.0
+              args:
+                - --v=2
+              resources:
+                requests:
+                  cpu: 200m
+          hostNetwork: true
+    ---
+    apiVersion: v1
+    kind: ServiceAccount
+    metadata:
+      name: cloud-controller-manager
+      namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: RoleBinding
+    metadata:
+      name: cloud-controller-manager:apiserver-authentication-reader
+      namespace: kube-system
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: Role
+      name: extension-apiserver-authentication-reader
+    subjects:
+      - apiGroup: ""
+        kind: ServiceAccount
+        name: cloud-controller-manager
+        namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRole
+    metadata:
+      name: system:cloud-controller-manager
+    rules:
+      - apiGroups:
+          - ""
+        resources:
+          - events
+        verbs:
+          - create
+          - patch
+          - update
+      - apiGroups:
+          - ""
+        resources:
+          - nodes
+        verbs:
+          - '*'
+      - apiGroups:
+          - ""
+        resources:
+          - nodes/status
+        verbs:
+          - patch
+      - apiGroups:
+          - ""
+        resources:
+          - services
+        verbs:
+          - list
+          - patch
+          - update
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - services/status
+        verbs:
+          - list
+          - patch
+          - update
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - serviceaccounts
+        verbs:
+          - create
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumes
+        verbs:
+          - get
+          - list
+          - update
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - configmaps
+        verbs:
+          - list
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - endpoints
+        verbs:
+          - create
+          - get
+          - list
+          - watch
+          - update
+      - apiGroups:
+          - coordination.k8s.io
+        resources:
+          - leases
+        verbs:
+          - create
+          - get
+          - list
+          - watch
+          - update
+    ---
+    kind: ClusterRoleBinding
+    apiVersion: rbac.authorization.k8s.io/v1
+    metadata:
+      name: system:cloud-controller-manager
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: ClusterRole
+      name: system:cloud-controller-manager
+    subjects:
+      - apiGroup: ""
+        kind: ServiceAccount
+        name: cloud-controller-manager
+        namespace: kube-system
+kind: ConfigMap
+metadata:
+  annotations:
+    note: generated
+  labels:
+    type: generated
+  name: cloud-controller-manager-addon
+  namespace: ${CABPR_NAMESPACE}
+---
+apiVersion: v1
+data:
+  aws-ebs-csi-external.yaml: |-
+    apiVersion: v1
+    kind: Secret
+    metadata:
+      name: aws-secret
+      namespace: kube-system
+    stringData:
+      key_id: ""
+      access_key: ""
+    ---
+    apiVersion: v1
+    kind: ServiceAccount
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-controller-sa
+      namespace: kube-system
+    ---
+    apiVersion: v1
+    kind: ServiceAccount
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-node-sa
+      namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRole
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-external-attacher-role
+    rules:
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumes
+        verbs:
+          - get
+          - list
+          - watch
+          - update
+          - patch
+      - apiGroups:
+          - ""
+        resources:
+          - nodes
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - csi.storage.k8s.io
+        resources:
+          - csinodeinfos
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - volumeattachments
+        verbs:
+          - get
+          - list
+          - watch
+          - update
+          - patch
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - volumeattachments/status
+        verbs:
+          - patch
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRole
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-external-provisioner-role
+    rules:
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumes
+        verbs:
+          - get
+          - list
+          - watch
+          - create
+          - delete
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumeclaims
+        verbs:
+          - get
+          - list
+          - watch
+          - update
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - storageclasses
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - events
+        verbs:
+          - list
+          - watch
+          - create
+          - update
+          - patch
+      - apiGroups:
+          - snapshot.storage.k8s.io
+        resources:
+          - volumesnapshots
+        verbs:
+          - get
+          - list
+      - apiGroups:
+          - snapshot.storage.k8s.io
+        resources:
+          - volumesnapshotcontents
+        verbs:
+          - get
+          - list
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - csinodes
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - nodes
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - coordination.k8s.io
+        resources:
+          - leases
+        verbs:
+          - get
+          - watch
+          - list
+          - delete
+          - update
+          - create
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - volumeattachments
+        verbs:
+          - get
+          - list
+          - watch
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRole
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-external-resizer-role
+    rules:
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumes
+        verbs:
+          - get
+          - list
+          - watch
+          - update
+          - patch
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumeclaims
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - persistentvolumeclaims/status
+        verbs:
+          - update
+          - patch
+      - apiGroups:
+          - storage.k8s.io
+        resources:
+          - storageclasses
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - ""
+        resources:
+          - events
+        verbs:
+          - list
+          - watch
+          - create
+          - update
+          - patch
+      - apiGroups:
+          - ""
+        resources:
+          - pods
+        verbs:
+          - get
+          - list
+          - watch
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRole
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-external-snapshotter-role
+    rules:
+      - apiGroups:
+          - ""
+        resources:
+          - events
+        verbs:
+          - list
+          - watch
+          - create
+          - update
+          - patch
+      - apiGroups:
+          - ""
+        resources:
+          - secrets
+        verbs:
+          - get
+          - list
+      - apiGroups:
+          - snapshot.storage.k8s.io
+        resources:
+          - volumesnapshotclasses
+        verbs:
+          - get
+          - list
+          - watch
+      - apiGroups:
+          - snapshot.storage.k8s.io
+        resources:
+          - volumesnapshotcontents
+        verbs:
+          - create
+          - get
+          - list
+          - watch
+          - update
+          - delete
+      - apiGroups:
+          - snapshot.storage.k8s.io
+        resources:
+          - volumesnapshotcontents/status
+        verbs:
+          - update
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRoleBinding
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-attacher-binding
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: ClusterRole
+      name: ebs-external-attacher-role
+    subjects:
+      - kind: ServiceAccount
+        name: ebs-csi-controller-sa
+        namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRoleBinding
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-provisioner-binding
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: ClusterRole
+      name: ebs-external-provisioner-role
+    subjects:
+      - kind: ServiceAccount
+        name: ebs-csi-controller-sa
+        namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRoleBinding
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-resizer-binding
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: ClusterRole
+      name: ebs-external-resizer-role
+    subjects:
+      - kind: ServiceAccount
+        name: ebs-csi-controller-sa
+        namespace: kube-system
+    ---
+    apiVersion: rbac.authorization.k8s.io/v1
+    kind: ClusterRoleBinding
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-snapshotter-binding
+    roleRef:
+      apiGroup: rbac.authorization.k8s.io
+      kind: ClusterRole
+      name: ebs-external-snapshotter-role
+    subjects:
+      - kind: ServiceAccount
+        name: ebs-csi-controller-sa
+        namespace: kube-system
+    ---
+    apiVersion: apps/v1
+    kind: Deployment
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-controller
+      namespace: kube-system
+    spec:
+      replicas: 2
+      selector:
+        matchLabels:
+          app: ebs-csi-controller
+          app.kubernetes.io/name: aws-ebs-csi-driver
+      template:
+        metadata:
+          labels:
+            app: ebs-csi-controller
+            app.kubernetes.io/name: aws-ebs-csi-driver
+        spec:
+          containers:
+            - args:
+                - --endpoint=$(CSI_ENDPOINT)
+                - --logtostderr
+                - --v=2
+              env:
+                - name: CSI_ENDPOINT
+                  value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock
+                - name: CSI_NODE_NAME
+                  valueFrom:
+                    fieldRef:
+                      fieldPath: spec.nodeName
+                - name: AWS_ACCESS_KEY_ID
+                  valueFrom:
+                    secretKeyRef:
+                      key: key_id
+                      name: aws-secret
+                      optional: true
+                - name: AWS_SECRET_ACCESS_KEY
+                  valueFrom:
+                    secretKeyRef:
+                      key: access_key
+                      name: aws-secret
+                      optional: true
+              image: registry.k8s.io/provider-aws/aws-ebs-csi-driver:v1.2.0
+              imagePullPolicy: IfNotPresent
+              livenessProbe:
+                failureThreshold: 5
+                httpGet:
+                  path: /healthz
+                  port: healthz
+                initialDelaySeconds: 10
+                periodSeconds: 10
+                timeoutSeconds: 3
+              name: ebs-plugin
+              ports:
+                - containerPort: 9808
+                  name: healthz
+                  protocol: TCP
+              readinessProbe:
+                failureThreshold: 5
+                httpGet:
+                  path: /healthz
+                  port: healthz
+                initialDelaySeconds: 10
+                periodSeconds: 10
+                timeoutSeconds: 3
+              volumeMounts:
+                - mountPath: /var/lib/csi/sockets/pluginproxy/
+                  name: socket-dir
+            - args:
+                - --csi-address=$(ADDRESS)
+                - --v=2
+                - --feature-gates=Topology=true
+                - --extra-create-metadata
+                - --leader-election=true
+                - --default-fstype=ext4
+              env:
+                - name: ADDRESS
+                  value: /var/lib/csi/sockets/pluginproxy/csi.sock
+              image: registry.k8s.io/sig-storage/csi-provisioner:v2.1.1
+              name: csi-provisioner
+              volumeMounts:
+                - mountPath: /var/lib/csi/sockets/pluginproxy/
+                  name: socket-dir
+            - args:
+                - --csi-address=$(ADDRESS)
+                - --v=2
+                - --leader-election=true
+              env:
+                - name: ADDRESS
+                  value: /var/lib/csi/sockets/pluginproxy/csi.sock
+              image: registry.k8s.io/sig-storage/csi-attacher:v3.1.0
+              name: csi-attacher
+              volumeMounts:
+                - mountPath: /var/lib/csi/sockets/pluginproxy/
+                  name: socket-dir
+            - args:
+                - --csi-address=$(ADDRESS)
+                - --leader-election=true
+              env:
+                - name: ADDRESS
+                  value: /var/lib/csi/sockets/pluginproxy/csi.sock
+              image: registry.k8s.io/sig-storage/csi-snapshotter:v3.0.3
+              name: csi-snapshotter
+              volumeMounts:
+                - mountPath: /var/lib/csi/sockets/pluginproxy/
+                  name: socket-dir
+            - args:
+                - --csi-address=$(ADDRESS)
+                - --v=2
+              env:
+                - name: ADDRESS
+                  value: /var/lib/csi/sockets/pluginproxy/csi.sock
+              image: registry.k8s.io/sig-storage/csi-resizer:v1.0.0
+              imagePullPolicy: Always
+              name: csi-resizer
+              volumeMounts:
+                - mountPath: /var/lib/csi/sockets/pluginproxy/
+                  name: socket-dir
+            - args:
+                - --csi-address=/csi/csi.sock
+              image: registry.k8s.io/sig-storage/livenessprobe:v2.2.0
+              name: liveness-probe
+              volumeMounts:
+                - mountPath: /csi
+                  name: socket-dir
+          nodeSelector:
+            kubernetes.io/os: linux
+          priorityClassName: system-cluster-critical
+          serviceAccountName: ebs-csi-controller-sa
+          tolerations:
+            - key: CriticalAddonsOnly
+              operator: Exists
+            - effect: NoExecute
+              operator: Exists
+              tolerationSeconds: 300
+            - key: node-role.kubernetes.io/master
+              effect: NoSchedule
+            - effect: NoSchedule
+              key: node-role.kubernetes.io/control-plane
+          affinity:
+            nodeAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                nodeSelectorTerms:
+                  - matchExpressions:
+                      - key: node-role.kubernetes.io/control-plane
+                        operator: Exists
+                  - matchExpressions:
+                      - key: node-role.kubernetes.io/master
+                        operator: Exists
+          volumes:
+            - emptyDir: {}
+              name: socket-dir
+    ---
+    apiVersion: policy/v1beta1
+    kind: PodDisruptionBudget
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-controller
+      namespace: kube-system
+    spec:
+      maxUnavailable: 1
+      selector:
+        matchLabels:
+          app: ebs-csi-controller
+          app.kubernetes.io/name: aws-ebs-csi-driver
+    ---
+    apiVersion: apps/v1
+    kind: DaemonSet
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs-csi-node
+      namespace: kube-system
+    spec:
+      selector:
+        matchLabels:
+          app: ebs-csi-node
+          app.kubernetes.io/name: aws-ebs-csi-driver
+      template:
+        metadata:
+          labels:
+            app: ebs-csi-node
+            app.kubernetes.io/name: aws-ebs-csi-driver
+        spec:
+          affinity:
+            nodeAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                nodeSelectorTerms:
+                  - matchExpressions:
+                      - key: eks.amazonaws.com/compute-type
+                        operator: NotIn
+                        values:
+                          - fargate
+          containers:
+            - args:
+                - node
+                - --endpoint=$(CSI_ENDPOINT)
+                - --logtostderr
+                - --v=2
+              env:
+                - name: CSI_ENDPOINT
+                  value: unix:/csi/csi.sock
+                - name: CSI_NODE_NAME
+                  valueFrom:
+                    fieldRef:
+                      fieldPath: spec.nodeName
+              image: registry.k8s.io/provider-aws/aws-ebs-csi-driver:v1.2.0
+              livenessProbe:
+                failureThreshold: 5
+                httpGet:
+                  path: /healthz
+                  port: healthz
+                initialDelaySeconds: 10
+                periodSeconds: 10
+                timeoutSeconds: 3
+              name: ebs-plugin
+              ports:
+                - containerPort: 9808
+                  name: healthz
+                  protocol: TCP
+              securityContext:
+                privileged: true
+              volumeMounts:
+                - mountPath: /var/lib/kubelet
+                  mountPropagation: Bidirectional
+                  name: kubelet-dir
+                - mountPath: /csi
+                  name: plugin-dir
+                - mountPath: /dev
+                  name: device-dir
+            - args:
+                - --csi-address=$(ADDRESS)
+                - --kubelet-registration-path=$(DRIVER_REG_SOCK_PATH)
+                - --v=2
+              env:
+                - name: ADDRESS
+                  value: /csi/csi.sock
+                - name: DRIVER_REG_SOCK_PATH
+                  value: /var/lib/kubelet/plugins/ebs.csi.aws.com/csi.sock
+              image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.1.0
+              name: node-driver-registrar
+              volumeMounts:
+                - mountPath: /csi
+                  name: plugin-dir
+                - mountPath: /registration
+                  name: registration-dir
+            - args:
+                - --csi-address=/csi/csi.sock
+              image: registry.k8s.io/sig-storage/livenessprobe:v2.2.0
+              name: liveness-probe
+              volumeMounts:
+                - mountPath: /csi
+                  name: plugin-dir
+          nodeSelector:
+            kubernetes.io/os: linux
+          priorityClassName: system-node-critical
+          serviceAccountName: ebs-csi-node-sa
+          tolerations:
+            - key: CriticalAddonsOnly
+              operator: Exists
+            - effect: NoExecute
+              operator: Exists
+              tolerationSeconds: 300
+          volumes:
+            - hostPath:
+                path: /var/lib/kubelet
+                type: Directory
+              name: kubelet-dir
+            - hostPath:
+                path: /var/lib/kubelet/plugins/ebs.csi.aws.com/
+                type: DirectoryOrCreate
+              name: plugin-dir
+            - hostPath:
+                path: /var/lib/kubelet/plugins_registry/
+                type: Directory
+              name: registration-dir
+            - hostPath:
+                path: /dev
+                type: Directory
+              name: device-dir
+      updateStrategy:
+        rollingUpdate:
+          maxUnavailable: 10%
+        type: RollingUpdate
+    ---
+    apiVersion: storage.k8s.io/v1
+    kind: CSIDriver
+    metadata:
+      labels:
+        app.kubernetes.io/name: aws-ebs-csi-driver
+      name: ebs.csi.aws.com
+    spec:
+      attachRequired: true
+      podInfoOnMount: false
+kind: ConfigMap
+metadata:
+  annotations:
+    note: generated
+  labels:
+    type: generated
+  name: aws-ebs-csi-driver-addon
+  namespace: ${CABPR_NAMESPACE}
+--- 
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
+kind: AWSClusterControllerIdentity
+metadata:
+  name: default
+spec:
+  allowedNamespaces:
+    list:
+      - ${CABPR_NAMESPACE}
diff --git a/samples/docker/disable-components/rke2-sample.yaml b/samples/docker/disable-components/rke2-sample.yaml
deleted file mode 100644
index 687cd5c..0000000
--- a/samples/docker/disable-components/rke2-sample.yaml
+++ /dev/null
@@ -1,108 +0,0 @@
-apiVersion: v1
-kind: Namespace
-metadata:
-  name: ${CABPR_NAMESPACE}
----
-apiVersion: cluster.x-k8s.io/v1beta1
-kind: Cluster 
-metadata:
-  namespace: ${CABPR_NAMESPACE}
-  name: ${CLUSTER_NAME} 
-spec:
-  clusterNetwork:
-    pods:
-      cidrBlocks:
-      - 10.45.0.0/16
-    services:
-      cidrBlocks:
-      - 10.46.0.0/16
-    serviceDomain: cluster.local
-  controlPlaneRef:
-    apiVersion: controlplane.cluster.x-k8s.io/v1alpha1
-    kind: RKE2ControlPlane
-    name: ${CLUSTER_NAME}-control-plane
-  infrastructureRef:
-    apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-    kind: DockerCluster
-    name: ${CLUSTER_NAME}
----
-apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-kind: DockerCluster
-metadata:
-  name: ${CLUSTER_NAME}
-  namespace: ${CABPR_NAMESPACE}
----
-apiVersion: controlplane.cluster.x-k8s.io/v1alpha1
-kind: RKE2ControlPlane
-metadata:
-  name: ${CLUSTER_NAME}-control-plane
-  namespace: ${CABPR_NAMESPACE}
-spec: 
-  replicas: ${CABPR_CP_REPLICAS}
-  agentConfig:
-    version: ${KUBERNETES_VERSION}+rke2r1
-  infrastructureRef:
-    apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-    kind: DockerMachineTemplate
-    name: controlplane
-  serverConfig:
-   disableComponents:
-      pluginComponents:
-      - "rke2-ingress-nginx"
-  nodeDrainTimeout: 2m
----
-apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-kind: DockerMachineTemplate
-metadata:
-  name: controlplane
-  namespace: ${CABPR_NAMESPACE}
-spec:
-  template:
-    spec: {} 
----
-apiVersion: cluster.x-k8s.io/v1beta1
-kind: MachineDeployment
-metadata:
-  name: worker-md-0
-  namespace: ${CABPR_NAMESPACE}
-spec:
-  clusterName: ${CLUSTER_NAME}
-  replicas: ${CABPR_WK_REPLICAS}
-  selector:
-    matchLabels:
-      cluster.x-k8s.io/cluster-name: ${CLUSTER_NAME}
-  template:
-    spec:
-      version: ${KUBERNETES_VERSION}
-      clusterName: ${CLUSTER_NAME}
-      bootstrap:
-        configRef:
-          apiVersion: bootstrap.cluster.x-k8s.io/v1alpha1
-          kind: RKE2ConfigTemplate
-          name: ${CLUSTER_NAME}-agent
-          namespace: ${CABPR_NAMESPACE}
-      infrastructureRef:
-        apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-        kind: DockerMachineTemplate
-        name: worker
-        namespace: ${CABPR_NAMESPACE}
----
-apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
-kind: DockerMachineTemplate
-metadata:
-  name: worker
-  namespace: ${CABPR_NAMESPACE}
-spec:
-  template:
-    spec: {} 
----
-apiVersion: bootstrap.cluster.x-k8s.io/v1alpha1
-kind: RKE2ConfigTemplate
-metadata:
-  namespace: ${CABPR_NAMESPACE}
-  name: ${CLUSTER_NAME}-agent
-spec: 
-  template:
-    spec:
-      agentConfig:
-        version: ${KUBERNETES_VERSION}+rke2r1


Test patch:
diff --git a/bootstrap/internal/ignition/clc/clc_test.go b/bootstrap/internal/ignition/clc/clc_test.go
new file mode 100644
index 0000000..88d3edd
--- /dev/null
+++ b/bootstrap/internal/ignition/clc/clc_test.go
@@ -0,0 +1,164 @@
+/*
+Copyright 2023 SUSE.
+
+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 clc
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	ignition "github.com/flatcar/ignition/config/v2_3"
+	"k8s.io/utils/pointer"
+
+	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1alpha1"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/cloudinit"
+)
+
+func TestCLC(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "CLC Suite")
+}
+
+var additionalIgnition = `---
+systemd:
+  units:
+    - name: test.service
+      enabled: true
+      contents: |
+        [Unit]
+        Description=test
+        [Service]
+        ExecStart=/etc/test.sh
+        [Install]
+        WantedBy=test.target
+`
+
+var _ = Describe("Render", func() {
+	var (
+		input            *cloudinit.BaseUserData
+		additionalConfig *bootstrapv1.AdditionalUserData
+	)
+
+	BeforeEach(func() {
+		input = &cloudinit.BaseUserData{
+			PreRKE2Commands: []string{
+				"echo 'test'",
+			},
+			PostRKE2Commands: []string{
+				"echo 'test'",
+			},
+			NTPServers: []string{
+				"test",
+			},
+			RKE2Version: "v1.21.3+rke2r1",
+			WriteFiles: []bootstrapv1.File{
+				{
+					Path:        "/test/file",
+					Content:     "test",
+					Permissions: "0644",
+				},
+				{
+					Path:        "/test/base64",
+					Encoding:    bootstrapv1.Base64,
+					Content:     "Zm9vCg==",
+					Permissions: "0644",
+				},
+			},
+			ConfigFile: bootstrapv1.File{
+				Path:        "/test/config",
+				Content:     "test",
+				Permissions: "0644",
+			},
+		}
+
+		additionalConfig = &bootstrapv1.AdditionalUserData{
+			Config: additionalIgnition,
+			Strict: true,
+		}
+	})
+
+	It("should render a valid ignition config", func() {
+		ignitionJson, err := Render(input, additionalConfig)
+		Expect(err).ToNot(HaveOccurred())
+
+		ign, reports, err := ignition.Parse(ignitionJson)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(reports.IsFatal()).To(BeFalse())
+
+		Expect(ign.Ignition.Version).To(Equal("2.3.0"))
+
+		Expect(ign.Storage.Files).To(HaveLen(5))
+
+		Expect(ign.Storage.Files[0].Filesystem).To(Equal("root"))
+		Expect(ign.Storage.Files[0].Path).To(Equal("/etc/ssh/sshd_config"))
+
+		Expect(ign.Storage.Files[1].Filesystem).To(Equal("root"))
+		Expect(ign.Storage.Files[1].Path).To(Equal("/test/file"))
+
+		Expect(ign.Storage.Files[2].Filesystem).To(Equal("root"))
+		Expect(ign.Storage.Files[2].Path).To(Equal("/test/base64"))
+
+		Expect(ign.Storage.Files[3].Filesystem).To(Equal("root"))
+		Expect(ign.Storage.Files[3].Path).To(Equal("/etc/rke2-install.sh"))
+
+		Expect(ign.Storage.Files[4].Filesystem).To(Equal("root"))
+		Expect(ign.Storage.Files[4].Path).To(Equal("/etc/ntp.conf"))
+
+		Expect(ign.Systemd.Units).To(HaveLen(3))
+		Expect(ign.Systemd.Units[0].Name).To(Equal("rke2-install.service"))
+		Expect(ign.Systemd.Units[0].Contents).To(Equal("[Unit]\nDescription=rke2-install\n[Service]\n# To not restart the unit when it exits, as it is expected.\nType=oneshot\nExecStart=/etc/rke2-install.sh\n[Install]\nWantedBy=multi-user.target\n"))
+		Expect(ign.Systemd.Units[0].Enabled).To(Equal(pointer.Bool(true)))
+
+		Expect(ign.Systemd.Units[1].Name).To(Equal("ntpd.service"))
+		Expect(ign.Systemd.Units[1].Enabled).To(Equal(pointer.Bool(true)))
+
+		Expect(ign.Systemd.Units[2].Name).To(Equal("test.service"))
+		Expect(ign.Systemd.Units[2].Contents).To(Equal("[Unit]\nDescription=test\n[Service]\nExecStart=/etc/test.sh\n[Install]\nWantedBy=test.target\n"))
+		Expect(ign.Systemd.Units[2].Enabled).To(Equal(pointer.Bool(true)))
+	})
+
+	It("accepts empty additional config", func() {
+		additionalConfig = nil
+		_, err := Render(input, additionalConfig)
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("should return error if input is nil", func() {
+		_, err := Render(nil, additionalConfig)
+		Expect(err).To(HaveOccurred())
+	})
+
+	It("treats warnings as errors in strict mode", func() {
+		// Should generate an Ignition warning about the colon in the partition label.
+		configWithIgnitionWarning := `---
+storage:
+  disks:
+  - device: /dev/sda
+    partitions:
+    - label: foo:bar
+`
+
+		additionalConfig = &bootstrapv1.AdditionalUserData{
+			Config: configWithIgnitionWarning,
+			Strict: true,
+		}
+
+		_, err := Render(input, additionalConfig)
+		Expect(err).To(HaveOccurred())
+	})
+})
diff --git a/bootstrap/internal/ignition/ignition_test.go b/bootstrap/internal/ignition/ignition_test.go
new file mode 100644
index 0000000..359fd40
--- /dev/null
+++ b/bootstrap/internal/ignition/ignition_test.go
@@ -0,0 +1,274 @@
+/*
+Copyright 2023 SUSE.
+
+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 ignition
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1alpha1"
+	"github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/internal/cloudinit"
+)
+
+func TestIgnition(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Ignition Suite")
+}
+
+var additionalIgnition = `---
+systemd:
+  units:
+    - name: test.service
+      enabled: true
+      contents: |
+        [Unit]
+        Description=test
+        [Service]
+        ExecStart=/etc/test.sh
+        [Install]
+        WantedBy=test.target
+`
+
+var _ = Describe("NewJoinWorker", func() {
+	var input *JoinWorkerInput
+
+	BeforeEach(func() {
+		input = &JoinWorkerInput{
+			BaseUserData: &cloudinit.BaseUserData{
+				RKE2Version: "v1.21.3+rke2r1",
+				WriteFiles: []bootstrapv1.File{
+					{
+						Path:        "/test/file",
+						Content:     "test",
+						Permissions: "0644",
+					},
+				},
+				ConfigFile: bootstrapv1.File{
+					Path:        "/test/config",
+					Content:     "test",
+					Permissions: "0644",
+				},
+			},
+			AdditionalIgnition: &bootstrapv1.AdditionalUserData{
+				Config: additionalIgnition,
+				Strict: true,
+			},
+		}
+	})
+
+	It("should return ignition data for worker", func() {
+		ignition, err := NewJoinWorker(input)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(ignition).ToNot(BeNil())
+	})
+
+	It("should return error if input is nil", func() {
+		input = nil
+		ignition, err := NewJoinWorker(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+
+	It("should return error if base userdata is nil", func() {
+		input.BaseUserData = nil
+		ignition, err := NewJoinWorker(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+})
+
+var _ = Describe("NewJoinControlPlane", func() {
+	var input *ControlPlaneInput
+
+	BeforeEach(func() {
+		input = &ControlPlaneInput{
+			ControlPlaneInput: &cloudinit.ControlPlaneInput{
+				BaseUserData: cloudinit.BaseUserData{
+					RKE2Version: "v1.21.3+rke2r1",
+					WriteFiles: []bootstrapv1.File{
+						{
+							Path:        "/test/file",
+							Content:     "test",
+							Permissions: "0644",
+						},
+					},
+					ConfigFile: bootstrapv1.File{
+						Path:        "/test/config",
+						Content:     "test",
+						Permissions: "0644",
+					},
+				},
+			},
+			AdditionalIgnition: &bootstrapv1.AdditionalUserData{
+				Config: additionalIgnition,
+				Strict: true,
+			},
+		}
+	})
+
+	It("should return ignition data for control plane", func() {
+		ignition, err := NewJoinControlPlane(input)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(ignition).ToNot(BeNil())
+	})
+
+	It("should return error if input is nil", func() {
+		input = nil
+		ignition, err := NewJoinControlPlane(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+
+	It("should return error if control plane input is nil", func() {
+		input.ControlPlaneInput = nil
+		ignition, err := NewJoinControlPlane(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+})
+
+var _ = Describe("NewInitControlPlane", func() {
+	var input *ControlPlaneInput
+
+	BeforeEach(func() {
+		input = &ControlPlaneInput{
+			ControlPlaneInput: &cloudinit.ControlPlaneInput{
+				BaseUserData: cloudinit.BaseUserData{
+					RKE2Version: "v1.21.3+rke2r1",
+					WriteFiles: []bootstrapv1.File{
+						{
+							Path:        "/test/file",
+							Content:     "test",
+							Permissions: "0644",
+						},
+					},
+					ConfigFile: bootstrapv1.File{
+						Path:        "/test/config",
+						Content:     "test",
+						Permissions: "0644",
+					},
+				},
+			},
+			AdditionalIgnition: &bootstrapv1.AdditionalUserData{
+				Config: additionalIgnition,
+				Strict: true,
+			},
+		}
+	})
+
+	It("should return ignition data for control plane", func() {
+		ignition, err := NewInitControlPlane(input)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(ignition).ToNot(BeNil())
+	})
+
+	It("should return error if input is nil", func() {
+		input = nil
+		ignition, err := NewInitControlPlane(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+
+	It("should return error if control plane input is nil", func() {
+		input.ControlPlaneInput = nil
+		ignition, err := NewInitControlPlane(input)
+		Expect(err).To(HaveOccurred())
+		Expect(ignition).To(BeNil())
+	})
+})
+
+var _ = Describe("getControlPlaneRKE2Commands", func() {
+	var baseUserData *cloudinit.BaseUserData
+
+	BeforeEach(func() {
+		baseUserData = &cloudinit.BaseUserData{
+			RKE2Version: "v1.21.3+rke2r1",
+		}
+	})
+
+	It("should return slice of control plane commands", func() {
+		commands, err := getControlPlaneRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(3))
+		Expect(commands).To(ContainElements(fmt.Sprintf(controlPlaneCommand, baseUserData.RKE2Version), serverSystemdServices[0], serverSystemdServices[1]))
+	})
+
+	It("should return slice of control plane commands with air gapped", func() {
+		baseUserData.AirGapped = true
+		commands, err := getControlPlaneRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(3))
+		Expect(commands).To(ContainElements(airGappedControlPlaneCommand, serverSystemdServices[0], serverSystemdServices[1]))
+	})
+
+	It("should return error if base userdata is nil", func() {
+		baseUserData = nil
+		commands, err := getControlPlaneRKE2Commands(baseUserData)
+		Expect(err).To(HaveOccurred())
+		Expect(commands).To(BeNil())
+	})
+
+	It("should return error if rke2 version is not set", func() {
+		baseUserData.RKE2Version = ""
+		commands, err := getControlPlaneRKE2Commands(baseUserData)
+		Expect(err).To(HaveOccurred())
+		Expect(commands).To(BeNil())
+	})
+})
+
+var _ = Describe("getWorkerRKE2Commands", func() {
+	var baseUserData *cloudinit.BaseUserData
+
+	BeforeEach(func() {
+		baseUserData = &cloudinit.BaseUserData{
+			RKE2Version: "v1.21.3+rke2r1",
+			AirGapped:   false,
+		}
+	})
+
+	It("should return slice of worker commands", func() {
+		commands, err := getWorkerRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(3))
+		Expect(commands).To(ContainElements(fmt.Sprintf(workerCommand, baseUserData.RKE2Version), workerSystemdServices[0], workerSystemdServices[1]))
+	})
+
+	It("should return slice of worker commands with air gapped", func() {
+		baseUserData.AirGapped = true
+		commands, err := getWorkerRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(3))
+		Expect(commands).To(ContainElements(airGappedWorkerCommand, workerSystemdServices[0], workerSystemdServices[1]))
+	})
+
+	It("should return error if base userdata is nil", func() {
+		baseUserData = nil
+		commands, err := getWorkerRKE2Commands(baseUserData)
+		Expect(err).To(HaveOccurred())
+		Expect(commands).To(BeNil())
+	})
+
+	It("should return error if rke2 version is not set", func() {
+		baseUserData.RKE2Version = ""
+		commands, err := getWorkerRKE2Commands(baseUserData)
+		Expect(err).To(HaveOccurred())
+		Expect(commands).To(BeNil())
+	})
+})
diff --git a/test/e2e/config/e2e_conf.yaml b/test/e2e/config/e2e_conf.yaml
index ce2f273..5d95cba 100644
--- a/test/e2e/config/e2e_conf.yaml
+++ b/test/e2e/config/e2e_conf.yaml
@@ -85,11 +85,11 @@ variables:
 intervals:
   default/wait-controllers: ["3m", "10s"]
   default/wait-cluster: ["20m", "10s"]
-  default/wait-control-plane: ["45m", "10s"]
-  default/wait-worker-nodes: ["45m", "10s"]
+  default/wait-control-plane: ["30m", "10s"]
+  default/wait-worker-nodes: ["30m", "10s"]
   default/wait-delete-cluster: ["20m", "10s"]
   default/wait-machine-upgrade: ["50m", "10s"]
   default/wait-machine-remediation: ["30m", "10s"]
   default/wait-deployment: ["5m", "10s"]
   default/wait-job: ["5m", "10s"]
-  default/wait-service: ["3m", "10s"]
+  default/wait-service: ["3m", "10s"]
\ No newline at end of file