Repository: metal3-io/baremetal-operator
Pull request #1501 (2023-12-29 18:26:45)

✨ Add new CRD for FirmwareComponents Update
This PR adds a new CRD for the HostFirmwareComponents described in the Firmware Interface proposal - metal3-io/metal3-docs#364.



metal3-io-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
**Once this PR has been reviewed and has the lgtm label**, please assign [honza](https://github.com/honza) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=metal3-io%2Fbaremetal-operator).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/metal3-io/baremetal-operator/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["honza"]} -->
iurygregory: /test-centos-e2e-integration-main
/test-ubuntu-integration-main
iurygregory: /metal3-bmo-e2e-test
tuminoid: /metal3-bmo-e2e-test
tuminoid: > /metal3-bmo-e2e-test

Oh you meant the action based e2e. I re-ran that as well, in progress right now.
metal3-io-bot: @iurygregory: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
generate | 9584a9bba9f83d28390987a7016185722bc6b215 | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/1501/generate/1748017827811430400) | true | `/test generate`

[Full PR test history](https://prow.apps.test.metal3.io/pr-history?org=metal3-io&repo=baremetal-operator&pr=1501). [Your PR dashboard](https://prow.apps.test.metal3.io/pr?query=is:pr+state:open+author:iurygregory).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->

Patch:
diff --git a/PROJECT b/PROJECT
index f8f1d412..5241f3d5 100644
--- a/PROJECT
+++ b/PROJECT
@@ -61,4 +61,12 @@ resources:
   kind: HardwareData
   path: github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  domain: metal3.io
+  group: metal3.io
+  kind: HostFirmwareComponents
+  path: github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha
+  version: v1alpha1
 version: "3"
diff --git a/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types.go b/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types.go
new file mode 100644
index 00000000..763168f4
--- /dev/null
+++ b/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types.go
@@ -0,0 +1,118 @@
+/*
+
+
+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 v1alpha1
+
+import (
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// FirmwareUpdate defines a firmware update specification.
+type FirmwareUpdate struct {
+	Component string `json:"component"`
+	URL       string `json:"url"`
+}
+
+// FirmwareComponentStatus defines the status of a firmware component.
+type FirmwareComponentStatus struct {
+	Component          string      `json:"component"`
+	InitialVersion     string      `json:"initialVersion"`
+	CurrentVersion     string      `json:"currentVersion"`
+	LastVersionFlashed string      `json:"lastVersionFlashed"`
+	UpdatedAt          metav1.Time `json:"updatedAt"`
+}
+
+type UpdatesConditionType string
+
+const (
+	// Indicates that the updates in the Spec are different than Status
+	HostFirmwareComponentsChangeDetected UpdatesConditionType = "ChangeDetected"
+
+	// Indicates if the updates are valid and can be configured on the host
+	HostFirmwareComponentsValid UpdatesConditionType = "Valid"
+)
+
+// HostFirmwareComponentsSpec defines the desired state of HostFirmwareComponents
+type HostFirmwareComponentsSpec struct {
+	Updates []FirmwareUpdate `json:"updates"`
+}
+
+// HostFirmwareComponentsStatus defines the observed state of HostFirmwareComponents
+type HostFirmwareComponentsStatus struct {
+	// Updates is the list of all firmware components that should be updated
+	// they are specified via name and url fields.
+	Updates []FirmwareUpdate `json:"updates"`
+
+	// Components is the list of all available firmware components and their information.
+	Components []FirmwareComponentStatus `json:"components"`
+
+	// Time that the status was last updated
+	// +optional
+	LastUpdated *metav1.Time `json:"lastUpdated,omitempty"`
+
+	// Track whether updates stored in the spec are valid based on the schema
+	// +patchMergeKey=type
+	// +patchStrategy=merge
+	// +listType=map
+	// +listMapKey=type
+	// +optional
+	Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:resource:shortName=hfc
+//+kubebuilder:subresource:status
+
+// HostFirmwareComponents is the Schema for the hostfirmwarecomponents API
+type HostFirmwareComponents struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   HostFirmwareComponentsSpec   `json:"spec,omitempty"`
+	Status HostFirmwareComponentsStatus `json:"status,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// HostFirmwareComponentsList contains a list of HostFirmwareComponents
+type HostFirmwareComponentsList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []HostFirmwareComponents `json:"items"`
+}
+
+// Check whether the updates's names are valid
+func (host *HostFirmwareComponents) ValidateHostFirmwareComponents() error {
+
+	allowedNames := map[string]struct{}{"bmc": {}, "bios": {}}
+	for _, update := range host.Spec.Updates {
+		componentName := update.Component
+		if _, ok := allowedNames[componentName]; !ok {
+			return fmt.Errorf("Component %s is invalid, only 'bmc' or 'bios' are allowed as update names", update.Component)
+		}
+
+	}
+
+	return nil
+}
+
+func init() {
+	SchemeBuilder.Register(&HostFirmwareComponents{}, &HostFirmwareComponentsList{})
+}
diff --git a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
index f5793855..59c29f2d 100644
--- a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
@@ -413,6 +413,22 @@ func (in *Firmware) DeepCopy() *Firmware {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirmwareComponentStatus) DeepCopyInto(out *FirmwareComponentStatus) {
+	*out = *in
+	in.UpdatedAt.DeepCopyInto(&out.UpdatedAt)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirmwareComponentStatus.
+func (in *FirmwareComponentStatus) DeepCopy() *FirmwareComponentStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(FirmwareComponentStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *FirmwareConfig) DeepCopyInto(out *FirmwareConfig) {
 	*out = *in
@@ -523,6 +539,21 @@ func (in *FirmwareSchemaSpec) DeepCopy() *FirmwareSchemaSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirmwareUpdate) DeepCopyInto(out *FirmwareUpdate) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirmwareUpdate.
+func (in *FirmwareUpdate) DeepCopy() *FirmwareUpdate {
+	if in == nil {
+		return nil
+	}
+	out := new(FirmwareUpdate)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HardwareData) DeepCopyInto(out *HardwareData) {
 	*out = *in
@@ -683,6 +714,123 @@ func (in *HardwareSystemVendor) DeepCopy() *HardwareSystemVendor {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostFirmwareComponents) DeepCopyInto(out *HostFirmwareComponents) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostFirmwareComponents.
+func (in *HostFirmwareComponents) DeepCopy() *HostFirmwareComponents {
+	if in == nil {
+		return nil
+	}
+	out := new(HostFirmwareComponents)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HostFirmwareComponents) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostFirmwareComponentsList) DeepCopyInto(out *HostFirmwareComponentsList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]HostFirmwareComponents, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostFirmwareComponentsList.
+func (in *HostFirmwareComponentsList) DeepCopy() *HostFirmwareComponentsList {
+	if in == nil {
+		return nil
+	}
+	out := new(HostFirmwareComponentsList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HostFirmwareComponentsList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostFirmwareComponentsSpec) DeepCopyInto(out *HostFirmwareComponentsSpec) {
+	*out = *in
+	if in.Updates != nil {
+		in, out := &in.Updates, &out.Updates
+		*out = make([]FirmwareUpdate, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostFirmwareComponentsSpec.
+func (in *HostFirmwareComponentsSpec) DeepCopy() *HostFirmwareComponentsSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(HostFirmwareComponentsSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostFirmwareComponentsStatus) DeepCopyInto(out *HostFirmwareComponentsStatus) {
+	*out = *in
+	if in.Updates != nil {
+		in, out := &in.Updates, &out.Updates
+		*out = make([]FirmwareUpdate, len(*in))
+		copy(*out, *in)
+	}
+	if in.Components != nil {
+		in, out := &in.Components, &out.Components
+		*out = make([]FirmwareComponentStatus, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.LastUpdated != nil {
+		in, out := &in.LastUpdated, &out.LastUpdated
+		*out = (*in).DeepCopy()
+	}
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostFirmwareComponentsStatus.
+func (in *HostFirmwareComponentsStatus) DeepCopy() *HostFirmwareComponentsStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(HostFirmwareComponentsStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HostFirmwareSettings) DeepCopyInto(out *HostFirmwareSettings) {
 	*out = *in
diff --git a/config/base/crds/bases/metal3.io_hostfirmwarecomponents.yaml b/config/base/crds/bases/metal3.io_hostfirmwarecomponents.yaml
new file mode 100644
index 00000000..f9bb7008
--- /dev/null
+++ b/config/base/crds/bases/metal3.io_hostfirmwarecomponents.yaml
@@ -0,0 +1,186 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.12.1
+  name: hostfirmwarecomponents.metal3.io
+spec:
+  group: metal3.io
+  names:
+    kind: HostFirmwareComponents
+    listKind: HostFirmwareComponentsList
+    plural: hostfirmwarecomponents
+    shortNames:
+    - hfc
+    singular: hostfirmwarecomponents
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: HostFirmwareComponents is the Schema for the hostfirmwarecomponents
+          API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: HostFirmwareComponentsSpec defines the desired state of HostFirmwareComponents
+            properties:
+              updates:
+                items:
+                  description: FirmwareUpdate defines a firmware update specification.
+                  properties:
+                    component:
+                      type: string
+                    url:
+                      type: string
+                  required:
+                  - component
+                  - url
+                  type: object
+                type: array
+            required:
+            - updates
+            type: object
+          status:
+            description: HostFirmwareComponentsStatus defines the observed state of
+              HostFirmwareComponents
+            properties:
+              components:
+                description: Components is the list of all available firmware components
+                  and their information.
+                items:
+                  description: FirmwareComponentStatus defines the status of a firmware
+                    component.
+                  properties:
+                    component:
+                      type: string
+                    currentVersion:
+                      type: string
+                    initialVersion:
+                      type: string
+                    lastVersionFlashed:
+                      type: string
+                    updatedAt:
+                      format: date-time
+                      type: string
+                  required:
+                  - component
+                  - currentVersion
+                  - initialVersion
+                  - lastVersionFlashed
+                  - updatedAt
+                  type: object
+                type: array
+              conditions:
+                description: Track whether updates stored in the spec are valid based
+                  on the schema
+                items:
+                  description: "Condition contains details for one aspect of the current
+                    state of this API Resource. --- This struct is intended for direct
+                    use as an array at the field path .status.conditions.  For example,
+                    \n type FooStatus struct{ // Represents the observations of a
+                    foo's current state. // Known .status.conditions.type are: \"Available\",
+                    \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge
+                    // +listType=map // +listMapKey=type Conditions []metav1.Condition
+                    `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\"
+                    protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }"
+                  properties:
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the condition
+                        transitioned from one status to another. This should be when
+                        the underlying condition changed.  If that is not known, then
+                        using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: message is a human readable message indicating
+                        details about the transition. This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: observedGeneration represents the .metadata.generation
+                        that the condition was set based upon. For instance, if .metadata.generation
+                        is currently 12, but the .status.conditions[x].observedGeneration
+                        is 9, the condition is out of date with respect to the current
+                        state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: reason contains a programmatic identifier indicating
+                        the reason for the condition's last transition. Producers
+                        of specific condition types may define expected values and
+                        meanings for this field, and whether the values are considered
+                        a guaranteed API. The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        --- Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              lastUpdated:
+                description: Time that the status was last updated
+                format: date-time
+                type: string
+              updates:
+                description: Updates is the list of all firmware components that should
+                  be updated they are specified via name and url fields.
+                items:
+                  description: FirmwareUpdate defines a firmware update specification.
+                  properties:
+                    component:
+                      type: string
+                    url:
+                      type: string
+                  required:
+                  - component
+                  - url
+                  type: object
+                type: array
+            required:
+            - components
+            - updates
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/config/base/rbac/hostfirmwarecomponents_editor_role.yaml b/config/base/rbac/hostfirmwarecomponents_editor_role.yaml
new file mode 100644
index 00000000..ae97a230
--- /dev/null
+++ b/config/base/rbac/hostfirmwarecomponents_editor_role.yaml
@@ -0,0 +1,24 @@
+# permissions for end users to edit hostfirmwarecomponents.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: hostfirmwarecomponents-editor-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - hostfirmwarecomponents
+  verbs:
+  - get
+  - list
+  - watch
+  - create
+  - update
+  - patch
+  - delete
+- apiGroups:
+  - metal3.io
+  resources:
+  - hostfirmwarecomponents/status
+  verbs:
+  - get
\ No newline at end of file
diff --git a/config/base/rbac/hostfirmwarecomponents_viewer_role.yaml b/config/base/rbac/hostfirmwarecomponents_viewer_role.yaml
new file mode 100644
index 00000000..05cd38c6
--- /dev/null
+++ b/config/base/rbac/hostfirmwarecomponents_viewer_role.yaml
@@ -0,0 +1,20 @@
+# permissions for end users to edit hostfirmwarecomponents.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: hostfirmwarecomponents-viewer-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - hostfirmwarecomponents
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - hostfirmwarecomponents/status
+  verbs:
+  - get
\ No newline at end of file
diff --git a/config/samples/metal3.io_v1alpha1_hostfirmwarecomponents.yaml b/config/samples/metal3.io_v1alpha1_hostfirmwarecomponents.yaml
new file mode 100644
index 00000000..3283a8c7
--- /dev/null
+++ b/config/samples/metal3.io_v1alpha1_hostfirmwarecomponents.yaml
@@ -0,0 +1,28 @@
+apiVersion: metal3.io/v1alpha1
+kind: HostFirmwareComponents
+metadata:
+  namespace: host3firmwarecomponents.metal3.io
+spec:
+  updates:
+  - name: bios
+    url: https://myurl.with.firmware.for.bios
+  - name: bmc
+    url: https://myurl.with.firmware.for.bmc
+status:
+  components:
+  - component: bios
+    initialVersion: "v1.0.0"
+    currentVersion: "v1.5.0"
+    lastVersionFlashed: "v1.5.0"
+    updatedAt: "2023-10-13T13:50:06Z"
+  - component: bmc
+    initialVersion: "v1.0.5"
+    currentVersion: "v1.2.0"
+    lastVersionFlashed: "v1.2.0"
+    updatedAt: "2023-10-13T13:50:06Z"
+  updates:
+  - name: bios
+    url: https://myurl.with.firmware.for.bios
+  - name: bmc
+    url: https://myurl.with.firmware.for.bmc
+  lastUpdated: "2023-10-13T13:50:06Z"


Test patch:
diff --git a/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types_test.go b/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types_test.go
new file mode 100644
index 00000000..4b8e6ea6
--- /dev/null
+++ b/apis/metal3.io/v1alpha1/hostfirmwarecomponents_types_test.go
@@ -0,0 +1,95 @@
+package v1alpha1
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestValidateHostFirmwareComponents(t *testing.T) {
+	for _, tc := range []struct {
+		Scenario      string
+		Components    []FirmwareComponentStatus
+		Updates       []FirmwareUpdate
+		LastUpdated   *metav1.Time
+		Conditions    []metav1.Condition
+		ExpectedError string
+	}{
+		{
+			Scenario: "ValidHostFirmwareComponents",
+			Components: []FirmwareComponentStatus{
+				{
+					Component:      "bios",
+					InitialVersion: "1.0",
+					CurrentVersion: "1.0",
+					UpdatedAt:      metav1.NewTime(time.Now()),
+				},
+				{
+					Component:          "bmc",
+					InitialVersion:     "1.0",
+					CurrentVersion:     "2.0",
+					LastVersionFlashed: "2.0",
+					UpdatedAt:          metav1.NewTime(time.Now()),
+				},
+			},
+			Updates: []FirmwareUpdate{
+				{
+					Component: "bmc",
+					URL:       "https://example.com/bmcupdate",
+				},
+			},
+			Conditions: []metav1.Condition{
+				{
+					Type:               string(HostFirmwareComponentsChangeDetected),
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: metav1.NewTime(time.Now()),
+				},
+			},
+			ExpectedError: "",
+		},
+		{
+			Scenario:   "InvalidHostFirmwareComponents",
+			Components: []FirmwareComponentStatus{},
+			Updates: []FirmwareUpdate{
+				{
+					Component: "nic",
+					URL:       "https://example.com/bmcupdate",
+				},
+			},
+			Conditions: []metav1.Condition{
+				{
+					Type:               string(HostFirmwareComponentsValid),
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: metav1.NewTime(time.Now()),
+				},
+			},
+			ExpectedError: "Component nic is invalid, only 'bmc' or 'bios' are allowed as update names",
+		},
+	} {
+		t.Run(tc.Scenario, func(t *testing.T) {
+			hostFirmwareComponents := &HostFirmwareComponents{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhostfirmware",
+					Namespace: "myns",
+				},
+				Spec: HostFirmwareComponentsSpec{
+					Updates: tc.Updates,
+				},
+				Status: HostFirmwareComponentsStatus{
+					Components:  tc.Components,
+					LastUpdated: tc.LastUpdated,
+					Conditions:  tc.Conditions,
+				},
+			}
+
+			err := hostFirmwareComponents.ValidateHostFirmwareComponents()
+			if err == nil {
+				assert.Equal(t, tc.ExpectedError, "")
+			} else {
+				assert.Equal(t, tc.ExpectedError, err.Error())
+			}
+		})
+	}
+}