From a07f2c6a57ba437829bfa656575d5d2634cdfd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 11 Jun 2026 13:29:47 +0200 Subject: [PATCH 1/6] :seedling: re-read hardwareDetails --- pkg/services/baremetal/host/host.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go index fc4c50414..83dba37a8 100644 --- a/pkg/services/baremetal/host/host.go +++ b/pkg/services/baremetal/host/host.go @@ -698,13 +698,11 @@ func (s *Service) actionRegistering(ctx context.Context) actionResult { } record.Eventf(s.scope.HetznerBareMetalHost, "GetHardwareDetails", msg) - if s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails == nil { - hardwareDetails, err := getHardwareDetails(ctx, sshClient) - if err != nil { - return actionError{err: fmt.Errorf("failed to get hardware details: %w", err)} - } - s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails = &hardwareDetails + hardwareDetails, err := getHardwareDetails(ctx, sshClient) + if err != nil { + return actionError{err: fmt.Errorf("failed to get hardware details: %w", err)} } + s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails = &hardwareDetails if s.scope.HetznerBareMetalHost.Spec.RootDeviceHints == nil { v1beta1conditions.MarkFalse( From 7aeb96416761d7b60c3e82f43656bb91ad8af525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 11 Jun 2026 13:33:17 +0200 Subject: [PATCH 2/6] :seedling: log when HardwareDetails change during re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always refresh HardwareDetails in actionRegistering so hardware changes (e.g. NIC replacement → new IP) are picked up automatically. Log a message when the refreshed details differ from the previously stored ones so the change is visible in the controller logs. Fixes: https://github.com/syself/cluster-api-provider-hetzner/issues/1789 Co-Authored-By: Claude Sonnet 4.6 # Committing as: info@thomas-guettler.de --- pkg/services/baremetal/host/host.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go index 83dba37a8..7c1ffb21d 100644 --- a/pkg/services/baremetal/host/host.go +++ b/pkg/services/baremetal/host/host.go @@ -24,6 +24,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "slices" "strconv" "strings" @@ -702,6 +703,9 @@ func (s *Service) actionRegistering(ctx context.Context) actionResult { if err != nil { return actionError{err: fmt.Errorf("failed to get hardware details: %w", err)} } + if existing := s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails; existing != nil && !reflect.DeepEqual(*existing, hardwareDetails) { + s.scope.Info("HardwareDetails changed", "old", existing, "new", hardwareDetails) + } s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails = &hardwareDetails if s.scope.HetznerBareMetalHost.Spec.RootDeviceHints == nil { From 12182623b078dc0d76b37ed8ac2142a82f277eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Fri, 12 Jun 2026 14:39:19 +0200 Subject: [PATCH 3/6] create event for hardware changes. --- pkg/services/baremetal/host/host.go | 11 ++++-- .../baremetal/host/host_suite_test.go | 9 +++++ pkg/services/baremetal/host/host_test.go | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go index 56f4c0709..01f6d5e27 100644 --- a/pkg/services/baremetal/host/host.go +++ b/pkg/services/baremetal/host/host.go @@ -24,12 +24,12 @@ import ( "fmt" "os" "path/filepath" - "reflect" "slices" "strconv" "strings" "time" + "github.com/google/go-cmp/cmp" "github.com/stoewer/go-strcase" "github.com/syself/hrobot-go/models" "golang.org/x/crypto/ssh" @@ -703,8 +703,13 @@ func (s *Service) actionRegistering(ctx context.Context) actionResult { if err != nil { return actionError{err: fmt.Errorf("failed to get hardware details: %w", err)} } - if existing := s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails; existing != nil && !reflect.DeepEqual(*existing, hardwareDetails) { - s.scope.Info("HardwareDetails changed", "old", existing, "new", hardwareDetails) + + if s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails != nil { + diff := cmp.Diff(*s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails, hardwareDetails) + if diff != "" { + s.scope.Info("HardwareDetails changed", "diff", diff) + record.Eventf(s.scope.HetznerBareMetalHost, "HardwareDetails changed", diff) + } } s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails = &hardwareDetails diff --git a/pkg/services/baremetal/host/host_suite_test.go b/pkg/services/baremetal/host/host_suite_test.go index dff2c84f2..ec772d5c0 100644 --- a/pkg/services/baremetal/host/host_suite_test.go +++ b/pkg/services/baremetal/host/host_suite_test.go @@ -27,8 +27,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgorecord "k8s.io/client-go/tools/record" "k8s.io/klog/v2/textlogger" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/util/record" "sigs.k8s.io/controller-runtime/pkg/client" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -40,6 +42,13 @@ import ( "github.com/syself/cluster-api-provider-hetzner/test/helpers" ) +var testEventRecorder *clientgorecord.FakeRecorder + +var _ = BeforeSuite(func() { + testEventRecorder = clientgorecord.NewFakeRecorder(100) + record.InitFromRecorder(testEventRecorder) +}) + const ( sshFingerprint = "my-fingerprint" osSSHKeyName = "os-sshkey" diff --git a/pkg/services/baremetal/host/host_test.go b/pkg/services/baremetal/host/host_test.go index 7f129bdcd..a1c8c61ac 100644 --- a/pkg/services/baremetal/host/host_test.go +++ b/pkg/services/baremetal/host/host_test.go @@ -1550,6 +1550,45 @@ var _ = Describe("actionRegistering check RAID", func() { }) }) +var _ = Describe("actionRegistering emits event on hardwareDetails change", func() { + const storageStdOut = `NAME="nvme2n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVL22T0HBLB-00B00" VENDOR="" SERIAL="S677NF0R402742" SIZE="2048408248320" WWN="eui.002538b411b2cee8" ROTA="0" +NAME="nvme1n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVLB512HAJQ-00000" VENDOR="" SERIAL="S3W8NX0N811178" SIZE="512110190592" WWN="eui.0025388801b4dff2" ROTA="0"` + + ctx := context.Background() + + It("emits HardwareDetails Changed event when existing details differ", func() { + // drain events from previous tests + for len(testEventRecorder.Events) > 0 { + <-testEventRecorder.Events + } + + host := helpers.BareMetalHost( + "test-host", + "default", + helpers.WithRootDeviceHintWWN(), + helpers.WithIPv4(), + helpers.WithConsumerRef(), + ) + host.Spec.Status.InstallImage = &infrav1.InstallImage{} + host.Spec.Status.HardwareDetails = &infrav1.HardwareDetails{ + CPU: infrav1.CPU{Model: "old-model"}, + } + + sshMock := registeringSSHMock(storageStdOut) + service := newTestService(host, nil, bmmock.NewSSHFactory(sshMock, sshMock, sshMock), nil, helpers.GetDefaultSSHSecret(rescueSSHKeyName, "default")) + + service.actionRegistering(ctx) + + var events []string + for len(testEventRecorder.Events) > 0 { + events = append(events, <-testEventRecorder.Events) + } + Expect(events).To(ContainElement(ContainSubstring("HardwareDetails Changed"))) + }) + + +}) + var _ = Describe("getImageDetails", func() { type testCaseGetImageDetails struct { image infrav1.Image From 6cd7c181b091fb2a5a222927652168d956218235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Fri, 26 Jun 2026 16:00:29 +0200 Subject: [PATCH 4/6] :seedling: Fix double blank line flagged by gci linter Co-Authored-By: Claude Sonnet 4.6 # Committing as: thomas.guettler@syself.com --- pkg/services/baremetal/host/host_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/services/baremetal/host/host_test.go b/pkg/services/baremetal/host/host_test.go index a1c8c61ac..af63e8858 100644 --- a/pkg/services/baremetal/host/host_test.go +++ b/pkg/services/baremetal/host/host_test.go @@ -1585,8 +1585,6 @@ NAME="nvme1n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVLB512HAJ } Expect(events).To(ContainElement(ContainSubstring("HardwareDetails Changed"))) }) - - }) var _ = Describe("getImageDetails", func() { From 72bea1e0bf8f7ebed87bbb285daa8f466667040f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Jul 2026 10:17:28 +0200 Subject: [PATCH 5/6] add test, which ensures that hardware details get updated after disk got exchanged. --- pkg/services/baremetal/host/host_test.go | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg/services/baremetal/host/host_test.go b/pkg/services/baremetal/host/host_test.go index bc2e2d78d..bbbe331e1 100644 --- a/pkg/services/baremetal/host/host_test.go +++ b/pkg/services/baremetal/host/host_test.go @@ -1700,6 +1700,43 @@ NAME="nvme1n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVLB512HAJ } Expect(events).To(ContainElement(ContainSubstring("HardwareDetails Changed"))) }) + + It("still updates HardwareDetails when the hardware change makes RootDeviceHints invalid", func() { + host := helpers.BareMetalHost( + "test-host", + "default", + helpers.WithRootDeviceHintWWN(), + helpers.WithIPv4(), + helpers.WithConsumerRef(), + ) + host.Spec.Status.InstallImage = &infrav1.InstallImage{} + // The previously read hardware had a disk matching the configured root device hint WWN. + host.Spec.Status.HardwareDetails = &infrav1.HardwareDetails{ + Storage: []infrav1.Storage{ + {WWN: helpers.DefaultWWN}, + }, + } + + // The disk with the WWN referenced by RootDeviceHints is gone, e.g. because it was replaced. + const newStorageStdOut = `NAME="nvme2n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVL22T0HBLB-00B00" VENDOR="" SERIAL="S677NF0R402742" SIZE="2048408248320" WWN="eui.002538b411b2cee2" ROTA="0" +NAME="nvme1n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVLB512HAJQ-00000" VENDOR="" SERIAL="S3W8NX0N811178" SIZE="512110190592" WWN="eui.0025388801b4dff2" ROTA="0"` + + sshMock := registeringSSHMock(newStorageStdOut) + service := newTestService(host, nil, bmmock.NewSSHFactory(sshMock, sshMock, sshMock), nil, helpers.GetDefaultSSHSecret(rescueSSHKeyName, "default")) + + actResult := service.actionRegistering(ctx) + + Expect(actResult).To(BeAssignableToTypeOf(actionFailed{})) + Expect(host.Spec.Status.ErrorMessage).To(ContainSubstring("missing storage device for root device hint")) + + // Even though the action failed, the freshly read hardware details must be persisted, + // so that the controller can still update the object (e.g. surface the new storage layout). + Expect(host.Spec.Status.HardwareDetails).ToNot(BeNil()) + Expect(host.Spec.Status.HardwareDetails.Storage).To(ConsistOf( + infrav1.Storage{Model: "SAMSUNG MZVL22T0HBLB-00B00", SerialNumber: "S677NF0R402742", SizeBytes: 2048408248320, SizeGB: 2048, WWN: "eui.002538b411b2cee2"}, + infrav1.Storage{Model: "SAMSUNG MZVLB512HAJQ-00000", SerialNumber: "S3W8NX0N811178", SizeBytes: 512110190592, SizeGB: 512, WWN: "eui.0025388801b4dff2"}, + )) + }) }) var _ = Describe("getImageDetails", func() { From 19d7f4b87d2656c89e4f7682a2d6e9960cef3668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Jul 2026 14:38:14 +0200 Subject: [PATCH 6/6] pr feedback. --- pkg/services/baremetal/host/host.go | 3 +++ pkg/services/baremetal/host/host_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go index 4d67cda90..06d00b4dc 100644 --- a/pkg/services/baremetal/host/host.go +++ b/pkg/services/baremetal/host/host.go @@ -711,6 +711,9 @@ func (s *Service) actionRegistering(ctx context.Context) actionResult { record.Eventf(s.scope.HetznerBareMetalHost, "HardwareDetails changed", diff) } } + // In case of a change in the disks, the WWNs got updated. This might lead to an outdated + // RootDeviceHints, which the user sets to decide which disk to use for provisioning, even if + // the server was already provisioned before. This will get caught by validateRootDeviceHints below. s.scope.HetznerBareMetalHost.Spec.Status.HardwareDetails = &hardwareDetails if s.scope.HetznerBareMetalHost.Spec.RootDeviceHints == nil { diff --git a/pkg/services/baremetal/host/host_test.go b/pkg/services/baremetal/host/host_test.go index bbbe331e1..29b3aa2d9 100644 --- a/pkg/services/baremetal/host/host_test.go +++ b/pkg/services/baremetal/host/host_test.go @@ -1701,7 +1701,7 @@ NAME="nvme1n1" LABEL="" FSTYPE="" TYPE="disk" HCTL="" MODEL="SAMSUNG MZVLB512HAJ Expect(events).To(ContainElement(ContainSubstring("HardwareDetails Changed"))) }) - It("still updates HardwareDetails when the hardware change makes RootDeviceHints invalid", func() { + It("invalidates RootDeviceHints in cases where a hardware change leads to different wwns", func() { host := helpers.BareMetalHost( "test-host", "default",