Repository: rancher-sandbox/cluster-api-provider-rke2 (go)

Issue:
in airGapped mode, ensure that the desired k8s version is used
We noticed [while developing Sylva](https://gitlab.com/sylva-projects/sylva-core/-/issues/1081) that, in airGapped mode (`spec.agentConfig.airGapped: true`), the combination of RKE2 bootstrap provider  and RK2 itself will result in using _whatever version of RKE2 is present in `/opt/rke2-artifacts`, even if this version does not match `spec.agentConfig.version`.

It creates a risk of unintendedly using an undesired version, and possibly unintendedly upgrading or downgrading, if the OS image is not selected carefully.

A safer behavior for air-gapped mode would be to abort RKE2 installation if the RKE2 version that can be found on disk does not match `spec.agentConfig.version`.

/cc @richardcase 

Pull request:
Add airGappedChecksum field to check workload version before rollout
<!--
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**:
This change adds `AirGappedChecksum` field to agent config. By using this field, a user might declare expected sha256sum checksum for the air gapped manifests and prevent incomplete or different package installation on the host.

<!-- 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 #297 
Depends on #313 for fuzzy test changes to make CI green

**Special notes for your reviewer**:

Internals of the `curl -sfL https://get.rke2.io` in the air-gapped mode already verify the contents of provided tar files based on the checksum from `sha256sum-<arch>.txt`. Therefore the `AirGappedChecksum` declares the expected checksum for this file only to connect API definition with the prepared host pre-bootstrap state.

**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 -->

- [ ] squashed commits into logical changes
- [ ] includes documentation
- [x] adds unit tests
- [ ] adds or updates e2e tests


Patch:
diff --git a/bootstrap/api/v1alpha1/conversion.go b/bootstrap/api/v1alpha1/conversion.go
index d470e92..1637ade 100644
--- a/bootstrap/api/v1alpha1/conversion.go
+++ b/bootstrap/api/v1alpha1/conversion.go
@@ -24,6 +24,7 @@ import (
 	apiconversion "k8s.io/apimachinery/pkg/conversion"
 
 	bootstrapv1 "github.com/rancher-sandbox/cluster-api-provider-rke2/bootstrap/api/v1beta1"
+	utilconversion "sigs.k8s.io/cluster-api/util/conversion"
 )
 
 func (src *RKE2Config) ConvertTo(dstRaw conversion.Hub) error {
@@ -36,6 +37,16 @@ func (src *RKE2Config) ConvertTo(dstRaw conversion.Hub) error {
 		return err
 	}
 
+	// Manually restore data.
+	restored := &bootstrapv1.RKE2Config{}
+	if ok, err := utilconversion.UnmarshalData(src, restored); err != nil || !ok {
+		return err
+	}
+
+	if restored.Spec.AgentConfig.AirGappedChecksum != "" {
+		dst.Spec.AgentConfig.AirGappedChecksum = restored.Spec.AgentConfig.AirGappedChecksum
+	}
+
 	return nil
 }
 
@@ -49,7 +60,8 @@ func (dst *RKE2Config) ConvertFrom(srcRaw conversion.Hub) error {
 		return err
 	}
 
-	return nil
+	// Preserve Hub data on down-conversion except for metadata
+	return utilconversion.MarshalData(src, dst)
 }
 
 func (src *RKE2ConfigList) ConvertTo(dstRaw conversion.Hub) error {
@@ -80,6 +92,16 @@ func (src *RKE2ConfigTemplate) ConvertTo(dstRaw conversion.Hub) error {
 		return err
 	}
 
+	// Manually restore data.
+	restored := &bootstrapv1.RKE2ConfigTemplate{}
+	if ok, err := utilconversion.UnmarshalData(src, restored); err != nil || !ok {
+		return err
+	}
+
+	if restored.Spec.Template.Spec.AgentConfig.AirGappedChecksum != "" {
+		dst.Spec.Template.Spec.AgentConfig.AirGappedChecksum = restored.Spec.Template.Spec.AgentConfig.AirGappedChecksum
+	}
+
 	return nil
 }
 
@@ -93,7 +115,8 @@ func (dst *RKE2ConfigTemplate) ConvertFrom(srcRaw conversion.Hub) error {
 		return err
 	}
 
-	return nil
+	// Preserve Hub data on down-conversion except for metadata
+	return utilconversion.MarshalData(src, dst)
 }
 
 func (src *RKE2ConfigTemplateList) ConvertTo(dstRaw conversion.Hub) error {
@@ -118,3 +141,8 @@ func Convert_v1alpha1_RKE2AgentConfig_To_v1beta1_RKE2AgentConfig(in *RKE2AgentCo
 	// We have to invoke conversion manually because of the removed RKE2Config.Spec.Version field.
 	return autoConvert_v1alpha1_RKE2AgentConfig_To_v1beta1_RKE2AgentConfig(in, out, s)
 }
+
+func Convert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in *bootstrapv1.RKE2AgentConfig, out *RKE2AgentConfig, s apiconversion.Scope) error {
+	// We have to invoke conversion manually because of the added AirGappedChecksum field.
+	return autoConvert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in, out, s)
+}
diff --git a/bootstrap/api/v1alpha1/zz_generated.conversion.go b/bootstrap/api/v1alpha1/zz_generated.conversion.go
index a837656..0af1ded 100644
--- a/bootstrap/api/v1alpha1/zz_generated.conversion.go
+++ b/bootstrap/api/v1alpha1/zz_generated.conversion.go
@@ -97,11 +97,6 @@ func RegisterConversions(s *runtime.Scheme) error {
 	}); err != nil {
 		return err
 	}
-	if err := s.AddGeneratedConversionFunc((*v1beta1.RKE2AgentConfig)(nil), (*RKE2AgentConfig)(nil), func(a, b interface{}, scope conversion.Scope) error {
-		return Convert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(a.(*v1beta1.RKE2AgentConfig), b.(*RKE2AgentConfig), scope)
-	}); err != nil {
-		return err
-	}
 	if err := s.AddGeneratedConversionFunc((*RKE2Config)(nil), (*v1beta1.RKE2Config)(nil), func(a, b interface{}, scope conversion.Scope) error {
 		return Convert_v1alpha1_RKE2Config_To_v1beta1_RKE2Config(a.(*RKE2Config), b.(*v1beta1.RKE2Config), scope)
 	}); err != nil {
@@ -227,6 +222,11 @@ func RegisterConversions(s *runtime.Scheme) error {
 	}); err != nil {
 		return err
 	}
+	if err := s.AddConversionFunc((*v1beta1.RKE2AgentConfig)(nil), (*RKE2AgentConfig)(nil), func(a, b interface{}, scope conversion.Scope) error {
+		return Convert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(a.(*v1beta1.RKE2AgentConfig), b.(*RKE2AgentConfig), scope)
+	}); err != nil {
+		return err
+	}
 	return nil
 }
 
@@ -428,6 +428,7 @@ func autoConvert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in *v1beta1
 	out.RuntimeImage = in.RuntimeImage
 	out.LoadBalancerPort = in.LoadBalancerPort
 	out.AirGapped = in.AirGapped
+	// WARNING: in.AirGappedChecksum requires manual conversion: does not exist in peer-type
 	out.Format = Format(in.Format)
 	if err := Convert_v1beta1_AdditionalUserData_To_v1alpha1_AdditionalUserData(&in.AdditionalUserData, &out.AdditionalUserData, s); err != nil {
 		return err
@@ -435,11 +436,6 @@ func autoConvert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in *v1beta1
 	return nil
 }
 
-// Convert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig is an autogenerated conversion function.
-func Convert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in *v1beta1.RKE2AgentConfig, out *RKE2AgentConfig, s conversion.Scope) error {
-	return autoConvert_v1beta1_RKE2AgentConfig_To_v1alpha1_RKE2AgentConfig(in, out, s)
-}
-
 func autoConvert_v1alpha1_RKE2Config_To_v1beta1_RKE2Config(in *RKE2Config, out *v1beta1.RKE2Config, s conversion.Scope) error {
 	out.ObjectMeta = in.ObjectMeta
 	if err := Convert_v1alpha1_RKE2ConfigSpec_To_v1beta1_RKE2ConfigSpec(&in.Spec, &out.Spec, s); err != nil {
diff --git a/bootstrap/api/v1beta1/rke2config_types.go b/bootstrap/api/v1beta1/rke2config_types.go
index a3b47e7..405f3c2 100644
--- a/bootstrap/api/v1beta1/rke2config_types.go
+++ b/bootstrap/api/v1beta1/rke2config_types.go
@@ -150,6 +150,12 @@ type RKE2AgentConfig struct {
 	// basically supposing that online container registries and RKE2 install scripts are not reachable.
 	AirGapped bool `json:"airGapped,omitempty"`
 
+	// AirGappedChecksum is a string value with a sha256sum checksum to compare with checksum
+	// of existing sha256sum-<arch>.txt file for packages already available on the machine
+	// before performing air-gapped installation.
+	//+optional
+	AirGappedChecksum string `json:"airGappedChecksum,omitempty"`
+
 	// Format specifies the output format of the bootstrap data. Defaults to cloud-config.
 	// +optional
 	Format Format `json:"format,omitempty"`
diff --git a/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configs.yaml b/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configs.yaml
index 91d7ada..25aa141 100644
--- a/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configs.yaml
+++ b/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configs.yaml
@@ -655,6 +655,12 @@ spec:
                       AirGapped is a boolean value to define if the bootstrapping should be air-gapped,
                       basically supposing that online container registries and RKE2 install scripts are not reachable.
                     type: boolean
+                  airGappedChecksum:
+                    description: |-
+                      AirGappedChecksum is a string value with a sha256sum checksum to compare with checksum
+                      of existing sha256sum-<arch>.txt file for packages already available on the machine
+                      before performing air-gapped installation.
+                    type: string
                   cisProfile:
                     description: CISProfile activates CIS compliance of RKE2 for a
                       certain profile
diff --git a/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configtemplates.yaml b/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configtemplates.yaml
index 1414655..b2557be 100644
--- a/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configtemplates.yaml
+++ b/bootstrap/config/crd/bases/bootstrap.cluster.x-k8s.io_rke2configtemplates.yaml
@@ -630,6 +630,12 @@ spec:
                               AirGapped is a boolean value to define if the bootstrapping should be air-gapped,
                               basically supposing that online container registries and RKE2 install scripts are not reachable.
                             type: boolean
+                          airGappedChecksum:
+                            description: |-
+                              AirGappedChecksum is a string value with a sha256sum checksum to compare with checksum
+                              of existing sha256sum-<arch>.txt file for packages already available on the machine
+                              before performing air-gapped installation.
+                            type: string
                           cisProfile:
                             description: CISProfile activates CIS compliance of RKE2
                               for a certain profile
diff --git a/bootstrap/internal/cloudinit/cloudinit.go b/bootstrap/internal/cloudinit/cloudinit.go
index d93ca28..e8f502b 100644
--- a/bootstrap/internal/cloudinit/cloudinit.go
+++ b/bootstrap/internal/cloudinit/cloudinit.go
@@ -105,6 +105,7 @@ type BaseUserData struct {
 	RKE2Version             string
 	SentinelFileCommand     string
 	AirGapped               bool
+	AirGappedChecksum       string
 	NTPServers              []string
 	CISEnabled              bool
 	AdditionalCloudInit     string
diff --git a/bootstrap/internal/cloudinit/controlplane_init.go b/bootstrap/internal/cloudinit/controlplane_init.go
index 881ede6..4ae4381 100644
--- a/bootstrap/internal/cloudinit/controlplane_init.go
+++ b/bootstrap/internal/cloudinit/controlplane_init.go
@@ -30,6 +30,8 @@ const (
 {{template "arbitrary" .AdditionalArbitraryData}}
 runcmd:
 {{- template "commands" .PreRKE2Commands }}
+{{- if .AirGappedChecksum }}
+  - [[ $(sha256sum /opt/rke2-artifacts/sha256sum*.txt | awk '{print $1}') == {{ .AirGappedChecksum }} ]] || exit 1{{ end }}
   - {{ if .AirGapped }}INSTALL_RKE2_ARTIFACT_PATH=/opt/rke2-artifacts sh /opt/install.sh{{ else }}'curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=%[1]s sh -s - server'{{ end }} 
 {{- if .CISEnabled }}
   - '/opt/rke2-cis-script.sh'{{ end }}
diff --git a/bootstrap/internal/cloudinit/worker_join.go b/bootstrap/internal/cloudinit/worker_join.go
index 4c546d8..9e960be 100644
--- a/bootstrap/internal/cloudinit/worker_join.go
+++ b/bootstrap/internal/cloudinit/worker_join.go
@@ -28,6 +28,8 @@ const (
 {{template "arbitrary" .AdditionalArbitraryData}}
 runcmd:
 {{- template "commands" .PreRKE2Commands }}
+{{- if .AirGappedChecksum }}
+  - [[ $(sha256sum /opt/rke2-artifacts/sha256sum*.txt | awk '{print $1}') == {{ .AirGappedChecksum }} ]] || exit 1{{ end }}
   - '{{ if .AirGapped }}INSTALL_RKE2_ARTIFACT_PATH=/opt/rke2-artifacts INSTALL_RKE2_TYPE="agent" sh /opt/install.sh{{ else }}curl -sfL https://get.rke2.io | INSTALL_RKE2_VERSION=%[1]s INSTALL_RKE2_TYPE="agent" sh -s -{{end}}'
 {{- if .CISEnabled }}
   - '/opt/rke2-cis-script.sh'{{ end }}
diff --git a/bootstrap/internal/controllers/rke2config_controller.go b/bootstrap/internal/controllers/rke2config_controller.go
index 90c1bd1..01d81f2 100644
--- a/bootstrap/internal/controllers/rke2config_controller.go
+++ b/bootstrap/internal/controllers/rke2config_controller.go
@@ -418,6 +418,7 @@ func (r *RKE2ConfigReconciler) handleClusterNotInitialized(ctx context.Context,
 	cpinput := &cloudinit.ControlPlaneInput{
 		BaseUserData: cloudinit.BaseUserData{
 			AirGapped:               scope.Config.Spec.AgentConfig.AirGapped,
+			AirGappedChecksum:       scope.Config.Spec.AgentConfig.AirGappedChecksum,
 			CISEnabled:              scope.Config.Spec.AgentConfig.CISProfile != "",
 			PreRKE2Commands:         scope.Config.Spec.PreRKE2Commands,
 			PostRKE2Commands:        scope.Config.Spec.PostRKE2Commands,
@@ -629,6 +630,7 @@ func (r *RKE2ConfigReconciler) joinControlplane(ctx context.Context, scope *Scop
 	cpinput := &cloudinit.ControlPlaneInput{
 		BaseUserData: cloudinit.BaseUserData{
 			AirGapped:           scope.Config.Spec.AgentConfig.AirGapped,
+			AirGappedChecksum:   scope.Config.Spec.AgentConfig.AirGappedChecksum,
 			CISEnabled:          scope.Config.Spec.AgentConfig.CISProfile != "",
 			PreRKE2Commands:     scope.Config.Spec.PreRKE2Commands,
 			PostRKE2Commands:    scope.Config.Spec.PostRKE2Commands,
@@ -742,6 +744,7 @@ func (r *RKE2ConfigReconciler) joinWorker(ctx context.Context, scope *Scope) (re
 	wkInput := &cloudinit.BaseUserData{
 		PreRKE2Commands:         scope.Config.Spec.PreRKE2Commands,
 		AirGapped:               scope.Config.Spec.AgentConfig.AirGapped,
+		AirGappedChecksum:       scope.Config.Spec.AgentConfig.AirGappedChecksum,
 		CISEnabled:              scope.Config.Spec.AgentConfig.CISProfile != "",
 		PostRKE2Commands:        scope.Config.Spec.PostRKE2Commands,
 		ConfigFile:              wkJoinConfigFile,
diff --git a/bootstrap/internal/ignition/ignition.go b/bootstrap/internal/ignition/ignition.go
index cbfcd22..ba2803f 100644
--- a/bootstrap/internal/ignition/ignition.go
+++ b/bootstrap/internal/ignition/ignition.go
@@ -24,6 +24,7 @@ import (
 )
 
 const (
+	airGappedChecksumCommand     = "[[ $(sha256sum /opt/rke2-artifacts/sha256sum*.txt | awk '{print $1}') == %[1]s ]] || exit 1"
 	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"
@@ -160,7 +161,9 @@ func getRKE2Commands(baseUserData *cloudinit.BaseUserData, command, airgappedCom
 
 	rke2Commands := []string{}
 
-	if baseUserData.AirGapped {
+	if baseUserData.AirGapped && baseUserData.AirGappedChecksum != "" {
+		rke2Commands = append(rke2Commands, fmt.Sprintf(airGappedChecksumCommand, baseUserData.AirGappedChecksum), airgappedCommand)
+	} else if baseUserData.AirGapped {
 		rke2Commands = append(rke2Commands, airgappedCommand)
 	} else {
 		rke2Commands = append(rke2Commands, fmt.Sprintf(command, baseUserData.RKE2Version))
diff --git a/controlplane/api/v1alpha1/conversion.go b/controlplane/api/v1alpha1/conversion.go
index bb91d9a..999ce4a 100644
--- a/controlplane/api/v1alpha1/conversion.go
+++ b/controlplane/api/v1alpha1/conversion.go
@@ -46,6 +46,11 @@ func (src *RKE2ControlPlane) ConvertTo(dstRaw conversion.Hub) error {
 	if restored.Spec.Version != "" {
 		dst.Spec.Version = restored.Spec.Version
 	}
+
+	if restored.Spec.AgentConfig.AirGappedChecksum != "" {
+		dst.Spec.AgentConfig.AirGappedChecksum = restored.Spec.AgentConfig.AirGappedChecksum
+	}
+
 	dst.Spec.MachineTemplate = restored.Spec.MachineTemplate
 	dst.Status = restored.Status
 
@@ -114,6 +119,10 @@ func (src *RKE2ControlPlaneTemplate) ConvertTo(dstRaw conversion.Hub) error {
 		return err
 	}
 
+	if restored.Spec.Template.Spec.AgentConfig.AirGappedChecksum != "" {
+		dst.Spec.Template.Spec.AgentConfig.AirGappedChecksum = restored.Spec.Template.Spec.AgentConfig.AirGappedChecksum
+	}
+
 	dst.Spec.Template = restored.Spec.Template
 	dst.Status = restored.Status
 
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 6467d9a..4eeadf7 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
@@ -1300,6 +1300,12 @@ spec:
                       AirGapped is a boolean value to define if the bootstrapping should be air-gapped,
                       basically supposing that online container registries and RKE2 install scripts are not reachable.
                     type: boolean
+                  airGappedChecksum:
+                    description: |-
+                      AirGappedChecksum is a string value with a sha256sum checksum to compare with checksum
+                      of existing sha256sum-<arch>.txt file for packages already available on the machine
+                      before performing air-gapped installation.
+                    type: string
                   cisProfile:
                     description: CISProfile activates CIS compliance of RKE2 for a
                       certain profile
diff --git a/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanetemplates.yaml b/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanetemplates.yaml
index 69c7723..65e6185 100644
--- a/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanetemplates.yaml
+++ b/controlplane/config/crd/bases/controlplane.cluster.x-k8s.io_rke2controlplanetemplates.yaml
@@ -123,6 +123,12 @@ spec:
                               AirGapped is a boolean value to define if the bootstrapping should be air-gapped,
                               basically supposing that online container registries and RKE2 install scripts are not reachable.
                             type: boolean
+                          airGappedChecksum:
+                            description: |-
+                              AirGappedChecksum is a string value with a sha256sum checksum to compare with checksum
+                              of existing sha256sum-<arch>.txt file for packages already available on the machine
+                              before performing air-gapped installation.
+                            type: string
                           cisProfile:
                             description: CISProfile activates CIS compliance of RKE2
                               for a certain profile


Test patch:
diff --git a/bootstrap/internal/cloudinit/cloudinit_test.go b/bootstrap/internal/cloudinit/cloudinit_test.go
index 1725f21..acdc08a 100644
--- a/bootstrap/internal/cloudinit/cloudinit_test.go
+++ b/bootstrap/internal/cloudinit/cloudinit_test.go
@@ -54,6 +54,41 @@ runcmd:
 	})
 })
 
+var _ = Describe("WorkerAirGappedWithChecksumCloudInitTest", func() {
+	var input *BaseUserData
+
+	BeforeEach(func() {
+		input = &BaseUserData{
+			AirGapped:         true,
+			AirGappedChecksum: "abcd",
+		}
+	})
+	It("Should use the image embedded install.sh method and check the checksum first", func() {
+		workerCloudInitData, err := NewJoinWorker(input)
+		Expect(err).ToNot(HaveOccurred())
+		workerCloudInitString := string(workerCloudInitData)
+		_, err = GinkgoWriter.Write(workerCloudInitData)
+		Expect(err).NotTo(HaveOccurred())
+		Expect(workerCloudInitString).To(Equal(`## template: jinja
+#cloud-config
+
+write_files:
+-   path: 
+    content: |
+      
+
+
+runcmd:
+  - [[ $(sha256sum /opt/rke2-artifacts/sha256sum*.txt | awk '{print $1}') == abcd ]] || exit 1
+  - 'INSTALL_RKE2_ARTIFACT_PATH=/opt/rke2-artifacts INSTALL_RKE2_TYPE="agent" sh /opt/install.sh'
+  - 'systemctl enable rke2-agent.service'
+  - 'systemctl start rke2-agent.service'
+  - 'mkdir -p /run/cluster-api'
+  - 'echo success > /run/cluster-api/bootstrap-success.complete'
+`))
+	})
+})
+
 var _ = Describe("WorkerOnlineCloudInitTest", func() {
 	var input *BaseUserData
 
diff --git a/bootstrap/internal/ignition/ignition_test.go b/bootstrap/internal/ignition/ignition_test.go
index 7448e7b..12d2ea1 100644
--- a/bootstrap/internal/ignition/ignition_test.go
+++ b/bootstrap/internal/ignition/ignition_test.go
@@ -219,6 +219,15 @@ var _ = Describe("getControlPlaneRKE2Commands", func() {
 		Expect(commands).To(ContainElements(airGappedControlPlaneCommand, serverDeployCommands[0], serverDeployCommands[1]))
 	})
 
+	It("should return slice of control plane commands with air gapped and checksum verify", func() {
+		baseUserData.AirGapped = true
+		baseUserData.AirGappedChecksum = "abcd"
+		commands, err := getControlPlaneRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(10))
+		Expect(commands).To(ContainElements(fmt.Sprintf(airGappedChecksumCommand, "abcd"), airGappedControlPlaneCommand, serverDeployCommands[0], serverDeployCommands[1]))
+	})
+
 	It("should return error if base userdata is nil", func() {
 		baseUserData = nil
 		commands, err := getControlPlaneRKE2Commands(baseUserData)
@@ -259,6 +268,15 @@ var _ = Describe("getWorkerRKE2Commands", func() {
 		Expect(commands).To(ContainElements(airGappedWorkerCommand, workerDeployCommands[0], workerDeployCommands[1]))
 	})
 
+	It("should return slice of worker commands with air gapped and checksum verify", func() {
+		baseUserData.AirGapped = true
+		baseUserData.AirGappedChecksum = "abcd"
+		commands, err := getWorkerRKE2Commands(baseUserData)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(commands).To(HaveLen(9))
+		Expect(commands).To(ContainElements(fmt.Sprintf(airGappedChecksumCommand, "abcd"), workerDeployCommands[0], workerDeployCommands[1]))
+	})
+
 	It("should return error if base userdata is nil", func() {
 		baseUserData = nil
 		commands, err := getWorkerRKE2Commands(baseUserData)