Utility tool for upgrading talos nodes.
1package talos
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/cosi-project/runtime/pkg/resource"
10 "github.com/siderolabs/talos/pkg/machinery/api/common"
11 "github.com/siderolabs/talos/pkg/machinery/api/machine"
12 talosclient "github.com/siderolabs/talos/pkg/machinery/client"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 corev1 "k8s.io/api/core/v1"
16 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17 "k8s.io/client-go/kubernetes/fake"
18)
19
20// ============================================================================
21// MockClient Tests
22// ============================================================================
23
24func TestMockClient_DefaultBehavior(t *testing.T) {
25 mock := &MockClient{}
26
27 t.Run("Close returns nil", func(t *testing.T) {
28 err := mock.Close()
29 assert.NoError(t, err)
30 })
31
32 t.Run("GetVersion returns default", func(t *testing.T) {
33 version, err := mock.GetVersion(context.Background(), "192.168.1.1")
34 require.NoError(t, err)
35 assert.Equal(t, "1.7.0", version)
36 })
37
38 t.Run("GetMachineType returns unknown", func(t *testing.T) {
39 machineType, err := mock.GetMachineType(context.Background(), "192.168.1.1")
40 require.NoError(t, err)
41 assert.Equal(t, "unknown", machineType)
42 })
43
44 t.Run("IsReachable returns true", func(t *testing.T) {
45 reachable := mock.IsReachable(context.Background(), "192.168.1.1")
46 assert.True(t, reachable)
47 })
48
49 t.Run("Upgrade returns nil", func(t *testing.T) {
50 err := mock.Upgrade(context.Background(), "192.168.1.1", "image:v1.7.0", true)
51 assert.NoError(t, err)
52 })
53
54 t.Run("WaitForNode returns nil", func(t *testing.T) {
55 err := mock.WaitForNode(context.Background(), "192.168.1.1", time.Minute)
56 assert.NoError(t, err)
57 })
58
59 t.Run("GetNodeStatus returns populated status", func(t *testing.T) {
60 status := mock.GetNodeStatus(context.Background(), "192.168.1.1", "profile-a", "worker", true)
61 assert.Equal(t, "192.168.1.1", status.IP)
62 assert.Equal(t, "profile-a", status.Profile)
63 assert.Equal(t, "worker", status.Role)
64 assert.Equal(t, "1.7.0", status.Version)
65 assert.True(t, status.Secureboot)
66 assert.True(t, status.Reachable)
67 })
68
69 t.Run("WatchUpgrade returns nil", func(t *testing.T) {
70 err := mock.WatchUpgrade(context.Background(), "192.168.1.1", time.Minute, nil)
71 assert.NoError(t, err)
72 })
73
74 t.Run("WaitForServices returns nil", func(t *testing.T) {
75 err := mock.WaitForServices(context.Background(), "192.168.1.1", []string{"etcd", "kubelet"}, time.Minute)
76 assert.NoError(t, err)
77 })
78
79 t.Run("WaitForStaticPods returns nil", func(t *testing.T) {
80 err := mock.WaitForStaticPods(context.Background(), "192.168.1.1", time.Minute)
81 assert.NoError(t, err)
82 })
83}
84
85func TestMockClient_CustomFunctions(t *testing.T) {
86 t.Run("custom GetVersion", func(t *testing.T) {
87 mock := &MockClient{
88 GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
89 if nodeIP == "192.168.1.1" {
90 return "1.8.0", nil
91 }
92 return "", errors.New("node not found")
93 },
94 }
95
96 version, err := mock.GetVersion(context.Background(), "192.168.1.1")
97 require.NoError(t, err)
98 assert.Equal(t, "1.8.0", version)
99
100 _, err = mock.GetVersion(context.Background(), "192.168.1.99")
101 assert.Error(t, err)
102 })
103
104 t.Run("custom IsReachable", func(t *testing.T) {
105 unreachableNodes := map[string]bool{"192.168.1.2": true}
106 mock := &MockClient{
107 IsReachableFunc: func(ctx context.Context, nodeIP string) bool {
108 return !unreachableNodes[nodeIP]
109 },
110 }
111
112 assert.True(t, mock.IsReachable(context.Background(), "192.168.1.1"))
113 assert.False(t, mock.IsReachable(context.Background(), "192.168.1.2"))
114 })
115
116 t.Run("custom Upgrade with error", func(t *testing.T) {
117 mock := &MockClient{
118 UpgradeFunc: func(ctx context.Context, nodeIP, image string, preserve bool) error {
119 return errors.New("upgrade failed")
120 },
121 }
122
123 err := mock.Upgrade(context.Background(), "192.168.1.1", "image:v1.7.0", true)
124 assert.Error(t, err)
125 assert.Contains(t, err.Error(), "upgrade failed")
126 })
127
128 t.Run("custom WaitForNode timeout", func(t *testing.T) {
129 mock := &MockClient{
130 WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
131 return errors.New("timeout waiting for node")
132 },
133 }
134
135 err := mock.WaitForNode(context.Background(), "192.168.1.1", time.Minute)
136 assert.Error(t, err)
137 })
138
139 t.Run("custom GetNodeStatus unreachable", func(t *testing.T) {
140 mock := &MockClient{
141 GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus {
142 return NodeStatus{
143 IP: nodeIP,
144 Profile: profile,
145 Role: role,
146 Version: "N/A",
147 Reachable: false,
148 }
149 },
150 }
151
152 status := mock.GetNodeStatus(context.Background(), "192.168.1.1", "test", "worker", false)
153 assert.False(t, status.Reachable)
154 assert.Equal(t, "N/A", status.Version)
155 })
156
157 t.Run("custom WatchUpgrade with callback", func(t *testing.T) {
158 var callbackCalls []UpgradeProgress
159 mock := &MockClient{
160 WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
161 // Simulate progress events
162 if onProgress != nil {
163 onProgress(UpgradeProgress{Stage: "upgrading", Phase: "prepare"})
164 onProgress(UpgradeProgress{Stage: "rebooting"})
165 onProgress(UpgradeProgress{Stage: "running", Done: true})
166 }
167 return nil
168 },
169 }
170
171 err := mock.WatchUpgrade(context.Background(), "192.168.1.1", time.Minute, func(p UpgradeProgress) {
172 callbackCalls = append(callbackCalls, p)
173 })
174 require.NoError(t, err)
175 assert.Len(t, callbackCalls, 3)
176 assert.Equal(t, "upgrading", callbackCalls[0].Stage)
177 assert.Equal(t, "running", callbackCalls[2].Stage)
178 assert.True(t, callbackCalls[2].Done)
179 })
180
181 t.Run("custom WaitForServices with error", func(t *testing.T) {
182 mock := &MockClient{
183 WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
184 return errors.New("service not healthy")
185 },
186 }
187 err := mock.WaitForServices(context.Background(), "192.168.1.1", []string{"etcd"}, time.Minute)
188 assert.Error(t, err)
189 assert.Contains(t, err.Error(), "service not healthy")
190 })
191
192 t.Run("custom WaitForStaticPods with error", func(t *testing.T) {
193 mock := &MockClient{
194 WaitForStaticPodsFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
195 return errors.New("pods not ready")
196 },
197 }
198 err := mock.WaitForStaticPods(context.Background(), "192.168.1.1", time.Minute)
199 assert.Error(t, err)
200 assert.Contains(t, err.Error(), "pods not ready")
201 })
202}
203
204// ============================================================================
205// NodeStatus Tests
206// ============================================================================
207
208func TestNodeStatus_Struct(t *testing.T) {
209 status := NodeStatus{
210 IP: "192.168.1.1",
211 Profile: "amd64-intel",
212 Role: "controlplane",
213 Version: "1.7.0",
214 MachineType: "controlplane",
215 Secureboot: true,
216 Reachable: true,
217 }
218
219 assert.Equal(t, "192.168.1.1", status.IP)
220 assert.Equal(t, "amd64-intel", status.Profile)
221 assert.Equal(t, "controlplane", status.Role)
222 assert.Equal(t, "1.7.0", status.Version)
223 assert.Equal(t, "controlplane", status.MachineType)
224 assert.True(t, status.Secureboot)
225 assert.True(t, status.Reachable)
226}
227
228func TestNodeStatus_Unreachable(t *testing.T) {
229 status := NodeStatus{
230 IP: "192.168.1.99",
231 Profile: "unknown",
232 Role: "worker",
233 Version: "N/A",
234 MachineType: "unknown",
235 Secureboot: false,
236 Reachable: false,
237 }
238
239 assert.Equal(t, "192.168.1.99", status.IP)
240 assert.Equal(t, "N/A", status.Version)
241 assert.False(t, status.Reachable)
242}
243
244// ============================================================================
245// UpgradeProgress Tests
246// ============================================================================
247
248func TestUpgradeProgress_Struct(t *testing.T) {
249 progress := UpgradeProgress{
250 Stage: "upgrading",
251 Phase: "install",
252 Task: "downloading image",
253 Action: "START",
254 Done: false,
255 }
256
257 assert.Equal(t, "upgrading", progress.Stage)
258 assert.Equal(t, "install", progress.Phase)
259 assert.Equal(t, "downloading image", progress.Task)
260 assert.Equal(t, "START", progress.Action)
261 assert.False(t, progress.Done)
262}
263
264func TestUpgradeProgress_Done(t *testing.T) {
265 progress := UpgradeProgress{
266 Stage: "running",
267 Done: true,
268 }
269
270 assert.True(t, progress.Done)
271}
272
273func TestUpgradeProgress_Error(t *testing.T) {
274 progress := UpgradeProgress{
275 Stage: "upgrading",
276 Error: "failed to download image",
277 }
278
279 assert.NotEmpty(t, progress.Error)
280}
281
282// ============================================================================
283// Interface Compliance Tests
284// ============================================================================
285
286func TestMockClient_ImplementsInterface(t *testing.T) {
287 // This test verifies that MockClient implements TalosClientInterface
288 var client TalosClientInterface = &MockClient{}
289 assert.NotNil(t, client)
290}
291
292// ============================================================================
293// Context Handling Tests
294// ============================================================================
295
296func TestMockClient_ContextCancellation(t *testing.T) {
297 mock := &MockClient{
298 WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
299 select {
300 case <-ctx.Done():
301 return ctx.Err()
302 case <-time.After(timeout):
303 return nil
304 }
305 },
306 }
307
308 ctx, cancel := context.WithCancel(context.Background())
309 cancel() // Cancel immediately
310
311 err := mock.WaitForNode(ctx, "192.168.1.1", time.Hour)
312 assert.Error(t, err)
313 assert.Equal(t, context.Canceled, err)
314}
315
316func TestMockClient_ContextTimeout(t *testing.T) {
317 mock := &MockClient{
318 WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
319 select {
320 case <-ctx.Done():
321 return ctx.Err()
322 case <-time.After(time.Second): // Simulates long operation
323 return nil
324 }
325 },
326 }
327
328 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
329 defer cancel()
330
331 err := mock.WaitForNode(ctx, "192.168.1.1", time.Hour)
332 assert.Error(t, err)
333 assert.Equal(t, context.DeadlineExceeded, err)
334}
335
336// ============================================================================
337// Upgrade Workflow Simulation Tests
338// ============================================================================
339
340func TestMockClient_SimulateUpgradeWorkflow(t *testing.T) {
341 // Track upgrade state
342 upgradeStarted := false
343 nodeVersion := "1.6.0"
344
345 mock := &MockClient{
346 GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
347 return nodeVersion, nil
348 },
349 UpgradeFunc: func(ctx context.Context, nodeIP, image string, preserve bool) error {
350 upgradeStarted = true
351 return nil
352 },
353 WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
354 if !upgradeStarted {
355 return errors.New("upgrade not started")
356 }
357 // Simulate upgrade completing
358 nodeVersion = "1.7.0"
359 if onProgress != nil {
360 onProgress(UpgradeProgress{Stage: "running", Done: true})
361 }
362 return nil
363 },
364 }
365
366 ctx := context.Background()
367
368 // Check initial version
369 version, err := mock.GetVersion(ctx, "192.168.1.1")
370 require.NoError(t, err)
371 assert.Equal(t, "1.6.0", version)
372
373 // Start upgrade
374 err = mock.Upgrade(ctx, "192.168.1.1", "image:v1.7.0", true)
375 require.NoError(t, err)
376 assert.True(t, upgradeStarted)
377
378 // Watch for completion
379 var finalProgress UpgradeProgress
380 err = mock.WatchUpgrade(ctx, "192.168.1.1", time.Minute, func(p UpgradeProgress) {
381 finalProgress = p
382 })
383 require.NoError(t, err)
384 assert.True(t, finalProgress.Done)
385
386 // Check new version
387 version, err = mock.GetVersion(ctx, "192.168.1.1")
388 require.NoError(t, err)
389 assert.Equal(t, "1.7.0", version)
390}
391
392// ============================================================================
393// Service List Tests
394// ============================================================================
395
396func TestGetControlPlaneServices(t *testing.T) {
397 services := GetControlPlaneServices()
398 expected := []string{"etcd", "kubelet", "apid", "trustd"}
399 assert.Equal(t, expected, services)
400 assert.Len(t, services, 4)
401}
402
403func TestGetWorkerServices(t *testing.T) {
404 services := GetWorkerServices()
405 expected := []string{"kubelet", "apid", "trustd"}
406 assert.Equal(t, expected, services)
407 assert.Len(t, services, 3)
408}
409
410// ============================================================================
411// parseEvent Tests
412// ============================================================================
413
414func TestParseEvent_SequenceEvent(t *testing.T) {
415 c := &Client{} // nil talos/k8s clients are fine - parseEvent doesn't use them
416
417 t.Run("sequence start", func(t *testing.T) {
418 event := talosclient.Event{
419 Payload: &machine.SequenceEvent{
420 Sequence: "upgrade",
421 Action: machine.SequenceEvent_START,
422 },
423 }
424 progress := c.parseEvent(event)
425 require.NotNil(t, progress)
426 assert.Equal(t, "upgrade", progress.Phase)
427 assert.Equal(t, "START", progress.Action)
428 assert.Empty(t, progress.Error)
429 })
430
431 t.Run("sequence stop", func(t *testing.T) {
432 event := talosclient.Event{
433 Payload: &machine.SequenceEvent{
434 Sequence: "reboot",
435 Action: machine.SequenceEvent_STOP,
436 },
437 }
438 progress := c.parseEvent(event)
439 require.NotNil(t, progress)
440 assert.Equal(t, "reboot", progress.Phase)
441 assert.Equal(t, "STOP", progress.Action)
442 })
443
444 t.Run("sequence with error", func(t *testing.T) {
445 event := talosclient.Event{
446 Payload: &machine.SequenceEvent{
447 Sequence: "upgrade",
448 Action: machine.SequenceEvent_START,
449 Error: &common.Error{
450 Message: "upgrade failed: disk full",
451 },
452 },
453 }
454 progress := c.parseEvent(event)
455 require.NotNil(t, progress)
456 assert.Equal(t, "upgrade failed: disk full", progress.Error)
457 })
458}
459
460func TestParseEvent_PhaseEvent(t *testing.T) {
461 c := &Client{}
462
463 t.Run("phase start", func(t *testing.T) {
464 event := talosclient.Event{
465 Payload: &machine.PhaseEvent{
466 Phase: "install",
467 Action: machine.PhaseEvent_START,
468 },
469 }
470 progress := c.parseEvent(event)
471 require.NotNil(t, progress)
472 assert.Equal(t, "install", progress.Phase)
473 assert.Equal(t, "START", progress.Action)
474 })
475
476 t.Run("phase stop", func(t *testing.T) {
477 event := talosclient.Event{
478 Payload: &machine.PhaseEvent{
479 Phase: "boot",
480 Action: machine.PhaseEvent_STOP,
481 },
482 }
483 progress := c.parseEvent(event)
484 require.NotNil(t, progress)
485 assert.Equal(t, "boot", progress.Phase)
486 assert.Equal(t, "STOP", progress.Action)
487 })
488}
489
490func TestParseEvent_TaskEvent(t *testing.T) {
491 c := &Client{}
492
493 t.Run("task start", func(t *testing.T) {
494 event := talosclient.Event{
495 Payload: &machine.TaskEvent{
496 Task: "downloading image",
497 Action: machine.TaskEvent_START,
498 },
499 }
500 progress := c.parseEvent(event)
501 require.NotNil(t, progress)
502 assert.Equal(t, "downloading image", progress.Task)
503 assert.Equal(t, "START", progress.Action)
504 })
505
506 t.Run("task stop", func(t *testing.T) {
507 event := talosclient.Event{
508 Payload: &machine.TaskEvent{
509 Task: "writing disk",
510 Action: machine.TaskEvent_STOP,
511 },
512 }
513 progress := c.parseEvent(event)
514 require.NotNil(t, progress)
515 assert.Equal(t, "writing disk", progress.Task)
516 assert.Equal(t, "STOP", progress.Action)
517 })
518}
519
520func TestParseEvent_MachineStatusEvent(t *testing.T) {
521 c := &Client{}
522
523 t.Run("running state", func(t *testing.T) {
524 event := talosclient.Event{
525 Payload: &machine.MachineStatusEvent{
526 Stage: machine.MachineStatusEvent_RUNNING,
527 },
528 }
529 progress := c.parseEvent(event)
530 require.NotNil(t, progress)
531 assert.Equal(t, "running", progress.Stage)
532 // Note: parseEvent no longer sets Done - caller decides based on context
533 assert.False(t, progress.Done)
534 })
535
536 t.Run("booting state", func(t *testing.T) {
537 event := talosclient.Event{
538 Payload: &machine.MachineStatusEvent{
539 Stage: machine.MachineStatusEvent_BOOTING,
540 },
541 }
542 progress := c.parseEvent(event)
543 require.NotNil(t, progress)
544 assert.Equal(t, "booting", progress.Stage)
545 assert.False(t, progress.Done)
546 })
547
548 t.Run("upgrading state", func(t *testing.T) {
549 event := talosclient.Event{
550 Payload: &machine.MachineStatusEvent{
551 Stage: machine.MachineStatusEvent_UPGRADING,
552 },
553 }
554 progress := c.parseEvent(event)
555 require.NotNil(t, progress)
556 assert.Equal(t, "upgrading", progress.Stage)
557 assert.False(t, progress.Done)
558 })
559
560 t.Run("rebooting state", func(t *testing.T) {
561 event := talosclient.Event{
562 Payload: &machine.MachineStatusEvent{
563 Stage: machine.MachineStatusEvent_REBOOTING,
564 },
565 }
566 progress := c.parseEvent(event)
567 require.NotNil(t, progress)
568 assert.Equal(t, "rebooting", progress.Stage)
569 assert.False(t, progress.Done)
570 })
571}
572
573func TestParseEvent_UnknownPayload(t *testing.T) {
574 c := &Client{}
575
576 t.Run("unknown proto message returns nil", func(t *testing.T) {
577 // Use a proto message type that isn't handled in the switch
578 event := talosclient.Event{
579 Payload: &machine.Version{},
580 }
581 progress := c.parseEvent(event)
582 assert.Nil(t, progress)
583 })
584
585 t.Run("nil payload returns nil", func(t *testing.T) {
586 event := talosclient.Event{
587 Payload: nil,
588 }
589 progress := c.parseEvent(event)
590 assert.Nil(t, progress)
591 })
592}
593
594// ============================================================================
595// K8s Client Tests (using fake clientset)
596// ============================================================================
597
598// makeNode creates a K8s Node for testing
599func makeNode(name, ip string, ready bool) *corev1.Node {
600 status := corev1.ConditionFalse
601 if ready {
602 status = corev1.ConditionTrue
603 }
604 return &corev1.Node{
605 ObjectMeta: metav1.ObjectMeta{Name: name},
606 Status: corev1.NodeStatus{
607 Addresses: []corev1.NodeAddress{
608 {Type: corev1.NodeInternalIP, Address: ip},
609 },
610 Conditions: []corev1.NodeCondition{
611 {Type: corev1.NodeReady, Status: status},
612 },
613 },
614 }
615}
616
617func TestIsK8sNodeReady(t *testing.T) {
618 ctx := context.Background()
619
620 t.Run("nil k8s client returns true", func(t *testing.T) {
621 c := &Client{k8s: nil}
622 assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
623 })
624
625 t.Run("node ready", func(t *testing.T) {
626 fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", true))
627 c := &Client{k8s: fakeClient}
628 assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
629 })
630
631 t.Run("node not ready", func(t *testing.T) {
632 fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", false))
633 c := &Client{k8s: fakeClient}
634 assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
635 })
636
637 t.Run("node not found", func(t *testing.T) {
638 fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.99", true))
639 c := &Client{k8s: fakeClient}
640 assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
641 })
642
643 t.Run("empty cluster", func(t *testing.T) {
644 fakeClient := fake.NewSimpleClientset()
645 c := &Client{k8s: fakeClient}
646 assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
647 })
648
649 t.Run("multiple nodes finds correct one", func(t *testing.T) {
650 fakeClient := fake.NewSimpleClientset(
651 makeNode("node1", "192.168.1.1", false),
652 makeNode("node2", "192.168.1.2", true),
653 makeNode("node3", "192.168.1.3", true),
654 )
655 c := &Client{k8s: fakeClient}
656 assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
657 assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.2"))
658 assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.3"))
659 })
660}
661
662func TestGetK8sNodeName(t *testing.T) {
663 ctx := context.Background()
664
665 t.Run("nil k8s client returns empty", func(t *testing.T) {
666 c := &Client{k8s: nil}
667 assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.1"))
668 })
669
670 t.Run("node found", func(t *testing.T) {
671 fakeClient := fake.NewSimpleClientset(makeNode("my-node", "192.168.1.1", true))
672 c := &Client{k8s: fakeClient}
673 assert.Equal(t, "my-node", c.GetK8sNodeName(ctx, "192.168.1.1"))
674 })
675
676 t.Run("node not found", func(t *testing.T) {
677 fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.99", true))
678 c := &Client{k8s: fakeClient}
679 assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.1"))
680 })
681
682 t.Run("multiple nodes finds correct one", func(t *testing.T) {
683 fakeClient := fake.NewSimpleClientset(
684 makeNode("controlplane-1", "192.168.1.1", true),
685 makeNode("worker-1", "192.168.1.2", true),
686 makeNode("worker-2", "192.168.1.3", true),
687 )
688 c := &Client{k8s: fakeClient}
689 assert.Equal(t, "controlplane-1", c.GetK8sNodeName(ctx, "192.168.1.1"))
690 assert.Equal(t, "worker-1", c.GetK8sNodeName(ctx, "192.168.1.2"))
691 assert.Equal(t, "worker-2", c.GetK8sNodeName(ctx, "192.168.1.3"))
692 assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.99"))
693 })
694}
695
696// ============================================================================
697// SDK-Dependent Function Tests (using MockTalosMachineClient)
698// ============================================================================
699
700func TestClient_GetVersion(t *testing.T) {
701 ctx := context.Background()
702
703 t.Run("success with v prefix", func(t *testing.T) {
704 mockTalos := &MockTalosMachineClient{
705 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
706 return &machine.VersionResponse{
707 Messages: []*machine.Version{
708 {Version: &machine.VersionInfo{Tag: "v1.8.0"}},
709 },
710 }, nil
711 },
712 }
713 c := &Client{talos: mockTalos}
714 version, err := c.GetVersion(ctx, "192.168.1.1")
715 require.NoError(t, err)
716 assert.Equal(t, "1.8.0", version)
717 })
718
719 t.Run("success without v prefix", func(t *testing.T) {
720 mockTalos := &MockTalosMachineClient{
721 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
722 return &machine.VersionResponse{
723 Messages: []*machine.Version{
724 {Version: &machine.VersionInfo{Tag: "1.7.5"}},
725 },
726 }, nil
727 },
728 }
729 c := &Client{talos: mockTalos}
730 version, err := c.GetVersion(ctx, "192.168.1.1")
731 require.NoError(t, err)
732 assert.Equal(t, "1.7.5", version)
733 })
734
735 t.Run("error from SDK", func(t *testing.T) {
736 mockTalos := &MockTalosMachineClient{
737 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
738 return nil, errors.New("connection refused")
739 },
740 }
741 c := &Client{talos: mockTalos}
742 _, err := c.GetVersion(ctx, "192.168.1.1")
743 require.Error(t, err)
744 assert.Contains(t, err.Error(), "failed to get version")
745 })
746
747 t.Run("empty messages", func(t *testing.T) {
748 mockTalos := &MockTalosMachineClient{
749 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
750 return &machine.VersionResponse{Messages: []*machine.Version{}}, nil
751 },
752 }
753 c := &Client{talos: mockTalos}
754 _, err := c.GetVersion(ctx, "192.168.1.1")
755 require.Error(t, err)
756 assert.Contains(t, err.Error(), "no version in response")
757 })
758
759 t.Run("nil version in message", func(t *testing.T) {
760 mockTalos := &MockTalosMachineClient{
761 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
762 return &machine.VersionResponse{
763 Messages: []*machine.Version{
764 {Version: nil},
765 },
766 }, nil
767 },
768 }
769 c := &Client{talos: mockTalos}
770 _, err := c.GetVersion(ctx, "192.168.1.1")
771 require.Error(t, err)
772 assert.Contains(t, err.Error(), "no version in response")
773 })
774
775 t.Run("empty tag", func(t *testing.T) {
776 mockTalos := &MockTalosMachineClient{
777 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
778 return &machine.VersionResponse{
779 Messages: []*machine.Version{
780 {Version: &machine.VersionInfo{Tag: ""}},
781 },
782 }, nil
783 },
784 }
785 c := &Client{talos: mockTalos}
786 version, err := c.GetVersion(ctx, "192.168.1.1")
787 require.NoError(t, err)
788 assert.Equal(t, "", version)
789 })
790}
791
792func TestClient_GetMachineType(t *testing.T) {
793 ctx := context.Background()
794
795 t.Run("returns unknown by design", func(t *testing.T) {
796 mockTalos := &MockTalosMachineClient{
797 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
798 return &machine.VersionResponse{
799 Messages: []*machine.Version{
800 {
801 Version: &machine.VersionInfo{Tag: "v1.7.0"},
802 Platform: &machine.PlatformInfo{Name: "metal"},
803 },
804 },
805 }, nil
806 },
807 }
808 c := &Client{talos: mockTalos}
809 machineType, err := c.GetMachineType(ctx, "192.168.1.1")
810 require.NoError(t, err)
811 assert.Equal(t, "unknown", machineType)
812 })
813
814 t.Run("error from SDK", func(t *testing.T) {
815 mockTalos := &MockTalosMachineClient{
816 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
817 return nil, errors.New("connection refused")
818 },
819 }
820 c := &Client{talos: mockTalos}
821 _, err := c.GetMachineType(ctx, "192.168.1.1")
822 require.Error(t, err)
823 assert.Contains(t, err.Error(), "failed to get version")
824 })
825}
826
827func TestClient_IsReachable(t *testing.T) {
828 ctx := context.Background()
829
830 t.Run("reachable node", func(t *testing.T) {
831 mockTalos := &MockTalosMachineClient{
832 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
833 return &machine.VersionResponse{
834 Messages: []*machine.Version{
835 {Version: &machine.VersionInfo{Tag: "v1.7.0"}},
836 },
837 }, nil
838 },
839 }
840 c := &Client{talos: mockTalos}
841 assert.True(t, c.IsReachable(ctx, "192.168.1.1"))
842 })
843
844 t.Run("unreachable node", func(t *testing.T) {
845 mockTalos := &MockTalosMachineClient{
846 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
847 return nil, errors.New("connection refused")
848 },
849 }
850 c := &Client{talos: mockTalos}
851 assert.False(t, c.IsReachable(ctx, "192.168.1.1"))
852 })
853}
854
855func TestClient_Upgrade(t *testing.T) {
856 ctx := context.Background()
857
858 t.Run("success", func(t *testing.T) {
859 mockTalos := &MockTalosMachineClient{
860 UpgradeWithOptionsFunc: func(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
861 return &machine.UpgradeResponse{}, nil
862 },
863 }
864 c := &Client{talos: mockTalos}
865 err := c.Upgrade(ctx, "192.168.1.1", "ghcr.io/siderolabs/installer:v1.8.0", true)
866 require.NoError(t, err)
867 })
868
869 t.Run("error from SDK", func(t *testing.T) {
870 mockTalos := &MockTalosMachineClient{
871 UpgradeWithOptionsFunc: func(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
872 return nil, errors.New("upgrade in progress")
873 },
874 }
875 c := &Client{talos: mockTalos}
876 err := c.Upgrade(ctx, "192.168.1.1", "ghcr.io/siderolabs/installer:v1.8.0", false)
877 require.Error(t, err)
878 assert.Contains(t, err.Error(), "upgrade failed")
879 })
880}
881
882func TestClient_Close(t *testing.T) {
883 t.Run("nil talos client", func(t *testing.T) {
884 c := &Client{talos: nil}
885 err := c.Close()
886 require.NoError(t, err)
887 })
888
889 t.Run("close success", func(t *testing.T) {
890 mockTalos := &MockTalosMachineClient{
891 CloseFunc: func() error {
892 return nil
893 },
894 }
895 c := &Client{talos: mockTalos}
896 err := c.Close()
897 require.NoError(t, err)
898 })
899
900 t.Run("close error", func(t *testing.T) {
901 mockTalos := &MockTalosMachineClient{
902 CloseFunc: func() error {
903 return errors.New("close failed")
904 },
905 }
906 c := &Client{talos: mockTalos}
907 err := c.Close()
908 require.Error(t, err)
909 assert.Contains(t, err.Error(), "close failed")
910 })
911}
912
913func TestClient_GetNodeStatus(t *testing.T) {
914 ctx := context.Background()
915
916 t.Run("reachable node with version", func(t *testing.T) {
917 mockTalos := &MockTalosMachineClient{
918 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
919 return &machine.VersionResponse{
920 Messages: []*machine.Version{
921 {Version: &machine.VersionInfo{Tag: "v1.8.0"}},
922 },
923 }, nil
924 },
925 }
926 c := &Client{talos: mockTalos, k8s: nil}
927 status := c.GetNodeStatus(ctx, "192.168.1.1", "amd64-intel", "controlplane", true)
928
929 assert.Equal(t, "192.168.1.1", status.IP)
930 assert.Equal(t, "amd64-intel", status.Profile)
931 assert.Equal(t, "controlplane", status.Role)
932 assert.Equal(t, "1.8.0", status.Version)
933 assert.Equal(t, "unknown", status.MachineType)
934 assert.True(t, status.Secureboot)
935 assert.True(t, status.Reachable)
936 })
937
938 t.Run("unreachable node", func(t *testing.T) {
939 mockTalos := &MockTalosMachineClient{
940 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
941 return nil, errors.New("connection refused")
942 },
943 }
944 c := &Client{talos: mockTalos, k8s: nil}
945 status := c.GetNodeStatus(ctx, "192.168.1.1", "amd64-intel", "worker", false)
946
947 assert.Equal(t, "192.168.1.1", status.IP)
948 assert.Equal(t, "amd64-intel", status.Profile)
949 assert.Equal(t, "worker", status.Role)
950 assert.Equal(t, "N/A", status.Version)
951 assert.Equal(t, "unknown", status.MachineType)
952 assert.False(t, status.Secureboot)
953 assert.False(t, status.Reachable)
954 })
955}
956
957// ============================================================================
958// MockTalosMachineClient Tests
959// ============================================================================
960
961func TestMockTalosMachineClient_DefaultBehavior(t *testing.T) {
962 mock := &MockTalosMachineClient{}
963 ctx := context.Background()
964
965 t.Run("Close returns nil", func(t *testing.T) {
966 err := mock.Close()
967 assert.NoError(t, err)
968 })
969
970 t.Run("Version returns default", func(t *testing.T) {
971 resp, err := mock.Version(ctx)
972 require.NoError(t, err)
973 require.NotNil(t, resp)
974 require.Len(t, resp.Messages, 1)
975 assert.Equal(t, "v1.7.0", resp.Messages[0].Version.Tag)
976 })
977
978 t.Run("UpgradeWithOptions returns empty response", func(t *testing.T) {
979 resp, err := mock.UpgradeWithOptions(ctx)
980 require.NoError(t, err)
981 assert.NotNil(t, resp)
982 })
983
984 t.Run("ServiceInfo returns empty slice", func(t *testing.T) {
985 resp, err := mock.ServiceInfo(ctx, "etcd")
986 require.NoError(t, err)
987 assert.Empty(t, resp)
988 })
989
990 t.Run("EventsWatchV2 returns nil", func(t *testing.T) {
991 eventCh := make(chan talosclient.EventResult, 10)
992 err := mock.EventsWatchV2(ctx, eventCh)
993 assert.NoError(t, err)
994 })
995
996 t.Run("COSIList returns empty list", func(t *testing.T) {
997 list, err := mock.COSIList(ctx, resource.NewMetadata("test", "type", "id", resource.VersionUndefined))
998 require.NoError(t, err)
999 assert.Empty(t, list.Items)
1000 })
1001}
1002
1003func TestMockTalosMachineClient_ImplementsInterface(t *testing.T) {
1004 var client TalosMachineClient = &MockTalosMachineClient{}
1005 assert.NotNil(t, client)
1006}
1007
1008// ============================================================================
1009// Clock-Based Tests for Timeout Functions
1010// ============================================================================
1011
1012func TestClient_WaitForNode(t *testing.T) {
1013 ctx := context.Background()
1014
1015 t.Run("returns immediately when node is reachable", func(t *testing.T) {
1016 mockTalos := &MockTalosMachineClient{
1017 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1018 return &machine.VersionResponse{
1019 Messages: []*machine.Version{
1020 {Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1021 },
1022 }, nil
1023 },
1024 }
1025 mockClock := NewMockClock(time.Now())
1026
1027 c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1028 err := c.WaitForNode(ctx, "192.168.1.1", time.Minute)
1029 require.NoError(t, err)
1030 })
1031
1032 t.Run("times out when node is unreachable", func(t *testing.T) {
1033 mockTalos := &MockTalosMachineClient{
1034 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1035 return nil, errors.New("connection refused")
1036 },
1037 }
1038 startTime := time.Now()
1039 mockClock := &MockClock{
1040 CurrentTime: startTime,
1041 AdvanceOnAfter: true,
1042 }
1043 // Mock clock that advances time on each Sleep call
1044 sleepCount := 0
1045 mockClock.SleepFunc = func(d time.Duration) {
1046 sleepCount++
1047 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1048 }
1049
1050 c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1051 err := c.WaitForNode(ctx, "192.168.1.1", 30*time.Second)
1052 require.Error(t, err)
1053 assert.Contains(t, err.Error(), "timeout waiting for node")
1054 // Verify multiple retries occurred (5 second sleep, 30 second timeout = ~6 retries)
1055 assert.GreaterOrEqual(t, sleepCount, 5, "expected at least 5 sleep calls")
1056 })
1057
1058 t.Run("context cancellation", func(t *testing.T) {
1059 mockTalos := &MockTalosMachineClient{
1060 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1061 return nil, errors.New("connection refused")
1062 },
1063 }
1064 mockClock := NewMockClock(time.Now())
1065
1066 c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1067
1068 canceledCtx, cancel := context.WithCancel(ctx)
1069 cancel()
1070
1071 err := c.WaitForNode(canceledCtx, "192.168.1.1", time.Minute)
1072 require.Error(t, err)
1073 assert.Equal(t, context.Canceled, err)
1074 })
1075
1076 t.Run("node reachable but k8s not ready then becomes ready", func(t *testing.T) {
1077 mockTalos := &MockTalosMachineClient{
1078 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1079 return &machine.VersionResponse{
1080 Messages: []*machine.Version{
1081 {Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1082 },
1083 }, nil
1084 },
1085 }
1086
1087 // Start with unready node, then make it ready
1088 k8sReady := false
1089 fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", false))
1090
1091 mockClock := NewMockClock(time.Now())
1092 mockClock.SleepFunc = func(d time.Duration) {
1093 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1094 // After first sleep, make k8s node ready
1095 if !k8sReady {
1096 k8sReady = true
1097 // Update the fake client's node to be ready
1098 node := makeNode("node1", "192.168.1.1", true)
1099 fakeClient.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{})
1100 }
1101 }
1102
1103 c := &Client{talos: mockTalos, k8s: fakeClient, clock: mockClock}
1104 err := c.WaitForNode(ctx, "192.168.1.1", time.Minute)
1105 require.NoError(t, err)
1106 })
1107}
1108
1109func TestClient_WaitForServices(t *testing.T) {
1110 ctx := context.Background()
1111
1112 t.Run("returns immediately when all services are healthy", func(t *testing.T) {
1113 mockTalos := &MockTalosMachineClient{
1114 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1115 return []talosclient.ServiceInfo{
1116 {
1117 Service: &machine.ServiceInfo{
1118 Id: service,
1119 State: "Running",
1120 Health: &machine.ServiceHealth{Healthy: true},
1121 },
1122 },
1123 }, nil
1124 },
1125 }
1126 mockClock := NewMockClock(time.Now())
1127
1128 c := &Client{talos: mockTalos, clock: mockClock}
1129 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd", "kubelet"}, time.Minute)
1130 require.NoError(t, err)
1131 })
1132
1133 t.Run("times out when service is unhealthy", func(t *testing.T) {
1134 mockTalos := &MockTalosMachineClient{
1135 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1136 return []talosclient.ServiceInfo{
1137 {
1138 Service: &machine.ServiceInfo{
1139 Id: service,
1140 State: "Running",
1141 Health: &machine.ServiceHealth{Healthy: false},
1142 },
1143 },
1144 }, nil
1145 },
1146 }
1147 startTime := time.Now()
1148 mockClock := &MockClock{CurrentTime: startTime}
1149 mockClock.SleepFunc = func(d time.Duration) {
1150 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1151 }
1152
1153 c := &Client{talos: mockTalos, clock: mockClock}
1154 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1155 require.Error(t, err)
1156 assert.Contains(t, err.Error(), "timeout waiting for services")
1157 })
1158
1159 t.Run("times out when service is not running", func(t *testing.T) {
1160 mockTalos := &MockTalosMachineClient{
1161 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1162 return []talosclient.ServiceInfo{
1163 {
1164 Service: &machine.ServiceInfo{
1165 Id: service,
1166 State: "Starting",
1167 Health: nil,
1168 },
1169 },
1170 }, nil
1171 },
1172 }
1173 startTime := time.Now()
1174 mockClock := &MockClock{CurrentTime: startTime}
1175 mockClock.SleepFunc = func(d time.Duration) {
1176 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1177 }
1178
1179 c := &Client{talos: mockTalos, clock: mockClock}
1180 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1181 require.Error(t, err)
1182 assert.Contains(t, err.Error(), "timeout waiting for services")
1183 })
1184
1185 t.Run("times out when service info errors", func(t *testing.T) {
1186 mockTalos := &MockTalosMachineClient{
1187 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1188 return nil, errors.New("service not found")
1189 },
1190 }
1191 startTime := time.Now()
1192 mockClock := &MockClock{CurrentTime: startTime}
1193 mockClock.SleepFunc = func(d time.Duration) {
1194 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1195 }
1196
1197 c := &Client{talos: mockTalos, clock: mockClock}
1198 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1199 require.Error(t, err)
1200 assert.Contains(t, err.Error(), "timeout waiting for services")
1201 })
1202
1203 t.Run("context cancellation", func(t *testing.T) {
1204 mockTalos := &MockTalosMachineClient{
1205 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1206 return nil, errors.New("service not found")
1207 },
1208 }
1209 mockClock := NewMockClock(time.Now())
1210
1211 c := &Client{talos: mockTalos, clock: mockClock}
1212
1213 canceledCtx, cancel := context.WithCancel(ctx)
1214 cancel()
1215
1216 err := c.WaitForServices(canceledCtx, "192.168.1.1", []string{"etcd"}, time.Minute)
1217 require.Error(t, err)
1218 assert.Equal(t, context.Canceled, err)
1219 })
1220
1221 t.Run("service becomes healthy after retry", func(t *testing.T) {
1222 attempts := 0
1223 mockTalos := &MockTalosMachineClient{
1224 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1225 attempts++
1226 if attempts < 3 {
1227 return []talosclient.ServiceInfo{
1228 {
1229 Service: &machine.ServiceInfo{
1230 Id: service,
1231 State: "Starting",
1232 Health: nil,
1233 },
1234 },
1235 }, nil
1236 }
1237 return []talosclient.ServiceInfo{
1238 {
1239 Service: &machine.ServiceInfo{
1240 Id: service,
1241 State: "Running",
1242 Health: &machine.ServiceHealth{Healthy: true},
1243 },
1244 },
1245 }, nil
1246 },
1247 }
1248 mockClock := NewMockClock(time.Now())
1249 mockClock.SleepFunc = func(d time.Duration) {
1250 mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1251 }
1252
1253 c := &Client{talos: mockTalos, clock: mockClock}
1254 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, time.Minute)
1255 require.NoError(t, err)
1256 assert.Equal(t, 3, attempts)
1257 })
1258}
1259
1260// ============================================================================
1261// MockClock Tests
1262// ============================================================================
1263
1264func TestMockClock(t *testing.T) {
1265 t.Run("Now returns CurrentTime", func(t *testing.T) {
1266 now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1267 clock := NewMockClock(now)
1268 assert.Equal(t, now, clock.Now())
1269 })
1270
1271 t.Run("Advance moves time forward", func(t *testing.T) {
1272 now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1273 clock := NewMockClock(now)
1274 clock.Advance(time.Hour)
1275 assert.Equal(t, now.Add(time.Hour), clock.Now())
1276 })
1277
1278 t.Run("Sleep advances time", func(t *testing.T) {
1279 now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1280 clock := NewMockClock(now)
1281 clock.Sleep(5 * time.Second)
1282 assert.Equal(t, now.Add(5*time.Second), clock.Now())
1283 })
1284
1285 t.Run("After returns immediately with value", func(t *testing.T) {
1286 now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1287 clock := NewMockClock(now)
1288
1289 ch := clock.After(time.Second)
1290 select {
1291 case receivedTime := <-ch:
1292 assert.Equal(t, now, receivedTime)
1293 default:
1294 assert.Fail(t, "After should return immediately in mock")
1295 }
1296 })
1297
1298 t.Run("AdvanceOnAfter option", func(t *testing.T) {
1299 now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1300 clock := &MockClock{
1301 CurrentTime: now,
1302 AdvanceOnAfter: true,
1303 }
1304
1305 clock.After(5 * time.Second)
1306 assert.Equal(t, now.Add(5*time.Second), clock.Now())
1307 })
1308
1309 t.Run("After with custom AfterFunc", func(t *testing.T) {
1310 customTime := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
1311 clock := &MockClock{
1312 CurrentTime: time.Now(),
1313 AfterFunc: func(d time.Duration) <-chan time.Time {
1314 ch := make(chan time.Time, 1)
1315 ch <- customTime
1316 return ch
1317 },
1318 }
1319
1320 ch := clock.After(time.Second)
1321 select {
1322 case receivedTime := <-ch:
1323 assert.Equal(t, customTime, receivedTime)
1324 default:
1325 assert.Fail(t, "After should return immediately")
1326 }
1327 })
1328}
1329
1330// Test nil clock fallback paths
1331func TestClient_NilClockFallback(t *testing.T) {
1332 ctx := context.Background()
1333
1334 t.Run("WaitForServices with nil clock uses real clock", func(t *testing.T) {
1335 // This test verifies the nil clock fallback path
1336 // We use a very short timeout and a mock that returns healthy immediately
1337 mockTalos := &MockTalosMachineClient{
1338 ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1339 return []talosclient.ServiceInfo{
1340 {
1341 Service: &machine.ServiceInfo{
1342 Id: service,
1343 State: "Running",
1344 Health: &machine.ServiceHealth{Healthy: true},
1345 },
1346 },
1347 }, nil
1348 },
1349 }
1350 // Note: clock is nil here - exercises the fallback
1351 c := &Client{talos: mockTalos, clock: nil}
1352 err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, time.Second)
1353 require.NoError(t, err)
1354 })
1355
1356 t.Run("WaitForNode with nil clock uses real clock", func(t *testing.T) {
1357 mockTalos := &MockTalosMachineClient{
1358 VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1359 return &machine.VersionResponse{
1360 Messages: []*machine.Version{
1361 {Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1362 },
1363 }, nil
1364 },
1365 }
1366 // Note: clock is nil here - exercises the fallback
1367 c := &Client{talos: mockTalos, k8s: nil, clock: nil}
1368 err := c.WaitForNode(ctx, "192.168.1.1", time.Second)
1369 require.NoError(t, err)
1370 })
1371}