Skip to content

Commit 88378bb

Browse files
authored
feat(k8s): add WaitUntilDaemonSetAvailable helpers (#1815)
* feat(k8s): add WaitUntilDaemonSetAvailable helpers Mirror the deployment-wait pattern for DaemonSet: Context/ContextE plus deprecated non-context variants, IsDaemonSetAvailable for status checks, and a DaemonSetNotAvailable error. Availability is gated on observed generation matching spec generation, DesiredNumberScheduled > 0, and NumberAvailable == DesiredNumberScheduled, since DaemonSetCondition is not always populated. * review: align IsDaemonSetAvailable with kubectl rollout status - Drop the DesiredNumberScheduled == 0 gate. A DaemonSet whose selector matches no nodes is "rolled out" per kubectl; the gate caused a wait to spin until the retry budget was exhausted in that case. - Add UpdatedNumberScheduled == DesiredNumberScheduled to the readiness predicate, mirroring kubectl's daemonset rollout-status logic. Otherwise the wait could return success during a rolling update, while old pods still satisfy availability. - Expand DaemonSetNotAvailable.Error to include UpdatedNumberScheduled, NumberReady, and NumberMisscheduled so timeout messages identify whether the rollout is stuck on update progress, readiness, or misscheduled pods. - Add control-plane toleration alongside the existing master toleration so the integration test schedules on Kubernetes 1.24+ clusters. - Cover the new behavior with unit-test cases for zero-desired, mid-rolling-update, and stale-observed-generation states. * lint: satisfy wsl_v5 and govet fieldalignment - Add the required blank line between the back-to-back if statements in IsDaemonSetAvailable. - Inline err.daemonSet.Status references in DaemonSetNotAvailable.Error rather than aliasing to a local var, since wsl_v5 flags the cuddled intermediate assign+return. - Reorder the IsDaemonSetAvailable test case struct so the pointer field precedes string and bool, satisfying govet fieldalignment.
1 parent ac1e875 commit 88378bb

3 files changed

Lines changed: 231 additions & 2 deletions

File tree

modules/k8s/daemonset.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package k8s //nolint:dupl // structural pattern for k8s resource operations
22

33
import (
44
"context"
5+
"fmt"
6+
"time"
57

68
"github.com/stretchr/testify/require"
79
appsv1 "k8s.io/api/apps/v1"
810
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
911

12+
"github.com/gruntwork-io/terratest/modules/retry"
1013
"github.com/gruntwork-io/terratest/modules/testing"
1114
)
1215

@@ -100,3 +103,99 @@ func GetDaemonSet(t testing.TestingT, options *KubectlOptions, daemonSetName str
100103
func GetDaemonSetE(t testing.TestingT, options *KubectlOptions, daemonSetName string) (*appsv1.DaemonSet, error) {
101104
return GetDaemonSetContextE(t, context.Background(), options, daemonSetName)
102105
}
106+
107+
// WaitUntilDaemonSetAvailableContextE waits until all desired pods of the daemonset are available on their nodes,
108+
// retrying the check for the specified amount of times, sleeping for the provided duration between each try.
109+
// The ctx parameter supports cancellation and timeouts.
110+
func WaitUntilDaemonSetAvailableContextE( //nolint:dupl // similar retry pattern across resource types is intentional
111+
t testing.TestingT,
112+
ctx context.Context,
113+
options *KubectlOptions,
114+
daemonSetName string,
115+
retries int,
116+
sleepBetweenRetries time.Duration,
117+
) error {
118+
statusMsg := fmt.Sprintf("Wait for daemonset %s to be provisioned.", daemonSetName)
119+
120+
message, err := retry.DoWithRetryContextE(
121+
t,
122+
ctx,
123+
statusMsg,
124+
retries,
125+
sleepBetweenRetries,
126+
func() (string, error) {
127+
daemonSet, err := GetDaemonSetContextE(t, ctx, options, daemonSetName)
128+
if err != nil {
129+
return "", err
130+
}
131+
132+
if !IsDaemonSetAvailable(daemonSet) {
133+
return "", NewDaemonSetNotAvailableError(daemonSet)
134+
}
135+
136+
return "DaemonSet is now available", nil
137+
},
138+
)
139+
if err != nil {
140+
options.Logger.Logf(t, "Timedout waiting for DaemonSet to be provisioned: %s", err)
141+
return err
142+
}
143+
144+
options.Logger.Logf(t, "%s", message)
145+
146+
return nil
147+
}
148+
149+
// WaitUntilDaemonSetAvailableContext waits until all desired pods of the daemonset are available on their nodes,
150+
// retrying the check for the specified amount of times, sleeping for the provided duration between each try.
151+
// The ctx parameter supports cancellation and timeouts.
152+
// This will fail the test if there is an error.
153+
func WaitUntilDaemonSetAvailableContext(t testing.TestingT, ctx context.Context, options *KubectlOptions, daemonSetName string, retries int, sleepBetweenRetries time.Duration) {
154+
t.Helper()
155+
require.NoError(t, WaitUntilDaemonSetAvailableContextE(t, ctx, options, daemonSetName, retries, sleepBetweenRetries))
156+
}
157+
158+
// WaitUntilDaemonSetAvailable waits until all desired pods of the daemonset are available on their nodes,
159+
// retrying the check for the specified amount of times, sleeping
160+
// for the provided duration between each try.
161+
// This will fail the test if there is an error.
162+
//
163+
// Deprecated: Use [WaitUntilDaemonSetAvailableContext] instead.
164+
func WaitUntilDaemonSetAvailable(t testing.TestingT, options *KubectlOptions, daemonSetName string, retries int, sleepBetweenRetries time.Duration) {
165+
t.Helper()
166+
WaitUntilDaemonSetAvailableContext(t, context.Background(), options, daemonSetName, retries, sleepBetweenRetries)
167+
}
168+
169+
// WaitUntilDaemonSetAvailableE waits until all desired pods of the daemonset are available on their nodes,
170+
// retrying the check for the specified amount of times, sleeping
171+
// for the provided duration between each try.
172+
//
173+
// Deprecated: Use [WaitUntilDaemonSetAvailableContextE] instead.
174+
func WaitUntilDaemonSetAvailableE(
175+
t testing.TestingT,
176+
options *KubectlOptions,
177+
daemonSetName string,
178+
retries int,
179+
sleepBetweenRetries time.Duration,
180+
) error {
181+
return WaitUntilDaemonSetAvailableContextE(t, context.Background(), options, daemonSetName, retries, sleepBetweenRetries)
182+
}
183+
184+
// IsDaemonSetAvailable returns true once the daemonset's rollout is complete. The check mirrors `kubectl rollout
185+
// status ds`: the controller has observed the latest spec, every scheduled pod has been updated to the current
186+
// generation, and every desired pod is available. Status fields are used directly rather than DaemonSetCondition
187+
// because the controller does not always populate that field.
188+
//
189+
// A daemonset whose node selector matches zero nodes (DesiredNumberScheduled == 0) is treated as available — this
190+
// matches the kubectl behavior where such a daemonset is considered "successfully rolled out".
191+
func IsDaemonSetAvailable(ds *appsv1.DaemonSet) bool {
192+
if ds.Status.ObservedGeneration < ds.Generation {
193+
return false
194+
}
195+
196+
if ds.Status.UpdatedNumberScheduled < ds.Status.DesiredNumberScheduled {
197+
return false
198+
}
199+
200+
return ds.Status.NumberAvailable >= ds.Status.DesiredNumberScheduled
201+
}

modules/k8s/daemonset_test.go

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
//go:build kubernetes
2-
// +build kubernetes
1+
//go:build kubeall || kubernetes
2+
// +build kubeall kubernetes
33

44
// NOTE: we have build tags to differentiate kubernetes tests from non-kubernetes tests. This is done because minikube
55
// is heavy and can interfere with docker related tests in terratest. Specifically, many of the tests start to fail with
@@ -10,9 +10,11 @@ package k8s_test
1010

1111
import (
1212
"fmt"
13+
"time"
1314

1415
"github.com/gruntwork-io/terratest/modules/k8s"
1516

17+
appsv1 "k8s.io/api/apps/v1"
1618
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1719

1820
"strings"
@@ -63,6 +65,105 @@ func TestListDaemonSetsReturnsCorrectServiceInCorrectNamespace(t *testing.T) {
6365
require.Equal(t, daemonSet.Namespace, uniqueID)
6466
}
6567

68+
func TestWaitUntilDaemonSetAvailable(t *testing.T) {
69+
t.Parallel()
70+
71+
uniqueID := strings.ToLower(random.UniqueID())
72+
options := k8s.NewKubectlOptions("", "", uniqueID)
73+
configData := fmt.Sprintf(exampleDaemonSetYAMLTemplate, uniqueID, uniqueID)
74+
75+
k8s.KubectlApplyFromString(t, options, configData)
76+
defer k8s.KubectlDeleteFromString(t, options, configData)
77+
78+
k8s.WaitUntilDaemonSetAvailable(t, options, "sample-ds", 60, 1*time.Second)
79+
}
80+
81+
func TestIsDaemonSetAvailable(t *testing.T) {
82+
t.Parallel()
83+
84+
testCases := []struct {
85+
ds *appsv1.DaemonSet
86+
title string
87+
expectedResult bool
88+
}{
89+
{
90+
title: "AvailableWhenAllPodsUpdatedAndAvailable",
91+
ds: &appsv1.DaemonSet{
92+
ObjectMeta: metav1.ObjectMeta{Generation: 1},
93+
Status: appsv1.DaemonSetStatus{
94+
ObservedGeneration: 1,
95+
DesiredNumberScheduled: 3,
96+
UpdatedNumberScheduled: 3,
97+
NumberAvailable: 3,
98+
},
99+
},
100+
expectedResult: true,
101+
},
102+
{
103+
title: "AvailableWhenNoNodesMatchSelector",
104+
ds: &appsv1.DaemonSet{
105+
ObjectMeta: metav1.ObjectMeta{Generation: 1},
106+
Status: appsv1.DaemonSetStatus{
107+
ObservedGeneration: 1,
108+
DesiredNumberScheduled: 0,
109+
UpdatedNumberScheduled: 0,
110+
NumberAvailable: 0,
111+
},
112+
},
113+
expectedResult: true,
114+
},
115+
{
116+
title: "NotAvailableWhenSomePodsNotYetAvailable",
117+
ds: &appsv1.DaemonSet{
118+
ObjectMeta: metav1.ObjectMeta{Generation: 1},
119+
Status: appsv1.DaemonSetStatus{
120+
ObservedGeneration: 1,
121+
DesiredNumberScheduled: 3,
122+
UpdatedNumberScheduled: 3,
123+
NumberAvailable: 2,
124+
},
125+
},
126+
expectedResult: false,
127+
},
128+
{
129+
title: "NotAvailableMidRollingUpdate",
130+
ds: &appsv1.DaemonSet{
131+
ObjectMeta: metav1.ObjectMeta{Generation: 2},
132+
Status: appsv1.DaemonSetStatus{
133+
ObservedGeneration: 2,
134+
DesiredNumberScheduled: 3,
135+
UpdatedNumberScheduled: 1,
136+
NumberAvailable: 3,
137+
},
138+
},
139+
expectedResult: false,
140+
},
141+
{
142+
title: "NotAvailableWhenObservedGenerationStale",
143+
ds: &appsv1.DaemonSet{
144+
ObjectMeta: metav1.ObjectMeta{Generation: 2},
145+
Status: appsv1.DaemonSetStatus{
146+
ObservedGeneration: 1,
147+
DesiredNumberScheduled: 3,
148+
UpdatedNumberScheduled: 3,
149+
NumberAvailable: 3,
150+
},
151+
},
152+
expectedResult: false,
153+
},
154+
}
155+
156+
for _, tc := range testCases {
157+
tc := tc
158+
t.Run(tc.title, func(t *testing.T) {
159+
t.Parallel()
160+
161+
actualResult := k8s.IsDaemonSetAvailable(tc.ds)
162+
require.Equal(t, tc.expectedResult, actualResult)
163+
})
164+
}
165+
}
166+
66167
const exampleDaemonSetYAMLTemplate = `---
67168
apiVersion: v1
68169
kind: Namespace
@@ -88,6 +189,8 @@ spec:
88189
tolerations:
89190
- key: node-role.kubernetes.io/master
90191
effect: NoSchedule
192+
- key: node-role.kubernetes.io/control-plane
193+
effect: NoSchedule
91194
containers:
92195
- name: alpine
93196
image: alpine:3.8

modules/k8s/errors.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,33 @@ func NewDeploymentNotAvailableError(deploy *appsv1.Deployment) DeploymentNotAvai
111111
return DeploymentNotAvailable{deploy}
112112
}
113113

114+
// DaemonSetNotAvailable is returned when a Kubernetes daemonset has not yet rolled out the desired number of pods.
115+
type DaemonSetNotAvailable struct {
116+
daemonSet *appsv1.DaemonSet
117+
}
118+
119+
// Error is a simple function to return a formatted error message as a string
120+
func (err DaemonSetNotAvailable) Error() string {
121+
return fmt.Sprintf(
122+
"DaemonSet %s is not available: generation observed %d/%d, updated %d/%d, ready %d/%d, available %d/%d, misscheduled %d",
123+
err.daemonSet.Name,
124+
err.daemonSet.Status.ObservedGeneration,
125+
err.daemonSet.Generation,
126+
err.daemonSet.Status.UpdatedNumberScheduled,
127+
err.daemonSet.Status.DesiredNumberScheduled,
128+
err.daemonSet.Status.NumberReady,
129+
err.daemonSet.Status.DesiredNumberScheduled,
130+
err.daemonSet.Status.NumberAvailable,
131+
err.daemonSet.Status.DesiredNumberScheduled,
132+
err.daemonSet.Status.NumberMisscheduled,
133+
)
134+
}
135+
136+
// NewDaemonSetNotAvailableError returns a DaemonSetNotAvailable struct when Kubernetes deems a daemonset is not available
137+
func NewDaemonSetNotAvailableError(ds *appsv1.DaemonSet) DaemonSetNotAvailable {
138+
return DaemonSetNotAvailable{ds}
139+
}
140+
114141
// PodNotAvailable is returned when a Kubernetes service is not yet available to accept traffic.
115142
type PodNotAvailable struct {
116143
pod *corev1.Pod

0 commit comments

Comments
 (0)