Skip to content

Commit ad110b5

Browse files
authored
Merge pull request #8746 from killianmuldoon/pr-add-clusterclass-missing-warning
✨ Add webhook warning for missing ClusterClass
2 parents 6ce4942 + 32e5431 commit ad110b5

File tree

4 files changed

+208
-45
lines changed

4 files changed

+208
-45
lines changed

internal/topology/check/compatibility.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ func LocalObjectTemplateIsValid(template *clusterv1.LocalObjectTemplate, namespa
200200
func ClusterClassesAreCompatible(current, desired *clusterv1.ClusterClass) field.ErrorList {
201201
var allErrs field.ErrorList
202202
if current == nil {
203-
return nil
203+
return append(allErrs, field.Invalid(field.NewPath(""), "", "could not compare ClusterClass compatibility: current ClusterClass must not be nil"))
204+
}
205+
if desired == nil {
206+
return append(allErrs, field.Invalid(field.NewPath(""), "", "could not compare ClusterClass compatibility: desired ClusterClass must not be nil"))
204207
}
205208

206209
// Validate InfrastructureClusterTemplate changes desired a compatible way.

internal/topology/check/compatibility_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,33 @@ func TestClusterClassesAreCompatible(t *testing.T) {
422422
desired *clusterv1.ClusterClass
423423
wantErr bool
424424
}{
425+
{
426+
name: "error if current is nil",
427+
current: nil,
428+
desired: builder.ClusterClass(metav1.NamespaceDefault, "class1").
429+
WithInfrastructureClusterTemplate(
430+
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
431+
WithControlPlaneTemplate(
432+
refToUnstructured(ref)).
433+
WithControlPlaneInfrastructureMachineTemplate(
434+
refToUnstructured(ref)).
435+
Build(),
436+
wantErr: true,
437+
},
438+
{
439+
name: "error if desired is nil",
440+
current: builder.ClusterClass(metav1.NamespaceDefault, "class1").
441+
WithInfrastructureClusterTemplate(
442+
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
443+
WithControlPlaneTemplate(
444+
refToUnstructured(ref)).
445+
WithControlPlaneInfrastructureMachineTemplate(
446+
refToUnstructured(ref)).
447+
Build(),
448+
desired: nil,
449+
wantErr: true,
450+
},
451+
425452
{
426453
name: "pass for compatible clusterClasses",
427454
current: builder.ClusterClass(metav1.NamespaceDefault, "class1").

internal/webhooks/cluster.go

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func (webhook *Cluster) ValidateDelete(_ context.Context, _ runtime.Object) (adm
139139

140140
func (webhook *Cluster) validate(ctx context.Context, oldCluster, newCluster *clusterv1.Cluster) (admission.Warnings, error) {
141141
var allErrs field.ErrorList
142+
var allWarnings admission.Warnings
142143
// The Cluster name is used as a label value. This check ensures that names which are not valid label values are rejected.
143144
if errs := validation.IsValidLabelValue(newCluster.Name); len(errs) != 0 {
144145
for _, err := range errs {
@@ -191,7 +192,9 @@ func (webhook *Cluster) validate(ctx context.Context, oldCluster, newCluster *cl
191192

192193
// Validate the managed topology, if defined.
193194
if newCluster.Spec.Topology != nil {
194-
allErrs = append(allErrs, webhook.validateTopology(ctx, oldCluster, newCluster, topologyPath)...)
195+
topologyWarnings, topologyErrs := webhook.validateTopology(ctx, oldCluster, newCluster, topologyPath)
196+
allWarnings = append(allWarnings, topologyWarnings...)
197+
allErrs = append(allErrs, topologyErrs...)
195198
}
196199

197200
// On update.
@@ -206,16 +209,18 @@ func (webhook *Cluster) validate(ctx context.Context, oldCluster, newCluster *cl
206209
}
207210

208211
if len(allErrs) > 0 {
209-
return nil, apierrors.NewInvalid(clusterv1.GroupVersion.WithKind("Cluster").GroupKind(), newCluster.Name, allErrs)
212+
return allWarnings, apierrors.NewInvalid(clusterv1.GroupVersion.WithKind("Cluster").GroupKind(), newCluster.Name, allErrs)
210213
}
211-
return nil, nil
214+
return allWarnings, nil
212215
}
213216

214-
func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newCluster *clusterv1.Cluster, fldPath *field.Path) field.ErrorList {
217+
func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newCluster *clusterv1.Cluster, fldPath *field.Path) (admission.Warnings, field.ErrorList) {
218+
var allWarnings admission.Warnings
219+
215220
// NOTE: ClusterClass and managed topologies are behind ClusterTopology feature gate flag; the web hook
216221
// must prevent the usage of Cluster.Topology in case the feature flag is disabled.
217222
if !feature.Gates.Enabled(feature.ClusterTopology) {
218-
return field.ErrorList{
223+
return allWarnings, field.ErrorList{
219224
field.Forbidden(
220225
fldPath,
221226
"can be set only if the ClusterTopology feature flag is enabled",
@@ -234,6 +239,8 @@ func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newClu
234239
"class cannot be empty",
235240
),
236241
)
242+
// Return early if there is no defined class to validate.
243+
return allWarnings, allErrs
237244
}
238245

239246
// version should be valid.
@@ -268,18 +275,21 @@ func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newClu
268275
}
269276

270277
// Get the ClusterClass referenced in the Cluster.
271-
clusterClass, clusterClassPollErr := webhook.pollClusterClassForCluster(ctx, newCluster)
272-
if clusterClassPollErr != nil &&
273-
// If the error is anything other than "NotFound" or "NotReconciled" return all errors at this point.
274-
!(apierrors.IsNotFound(clusterClassPollErr) || errors.Is(clusterClassPollErr, errClusterClassNotReconciled)) {
278+
clusterClass, warnings, clusterClassPollErr := webhook.validateClusterClassExistsAndIsReconciled(ctx, newCluster)
279+
// If the error is anything other than "NotFound" or "NotReconciled" return all errors.
280+
if clusterClassPollErr != nil && !(apierrors.IsNotFound(clusterClassPollErr) || errors.Is(clusterClassPollErr, errClusterClassNotReconciled)) {
275281
allErrs = append(
276282
allErrs, field.InternalError(
277283
fldPath.Child("class"),
278284
clusterClassPollErr))
279-
return allErrs
285+
return allWarnings, allErrs
280286
}
287+
288+
// Add the warnings if no error was returned.
289+
allWarnings = append(allWarnings, warnings...)
290+
291+
// If there's no error validate the Cluster based on the ClusterClass.
281292
if clusterClassPollErr == nil {
282-
// If there's no error validate the Cluster based on the ClusterClass.
283293
allErrs = append(allErrs, ValidateClusterForClusterClass(newCluster, clusterClass)...)
284294
}
285295
if oldCluster != nil { // On update
@@ -290,13 +300,13 @@ func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newClu
290300
allErrs, field.InternalError(
291301
fldPath.Child("class"),
292302
clusterClassPollErr))
293-
return allErrs
303+
return allWarnings, allErrs
294304
}
295305

296306
// Topology or Class can not be added on update unless ClusterTopologyUnsafeUpdateClassNameAnnotation is set.
297307
if oldCluster.Spec.Topology == nil || oldCluster.Spec.Topology.Class == "" {
298308
if _, ok := newCluster.Annotations[clusterv1.ClusterTopologyUnsafeUpdateClassNameAnnotation]; ok {
299-
return allErrs
309+
return allWarnings, allErrs
300310
}
301311

302312
allErrs = append(
@@ -307,7 +317,7 @@ func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newClu
307317
),
308318
)
309319
// return early here if there is no class to compare.
310-
return allErrs
320+
return allWarnings, allErrs
311321
}
312322

313323
// Version could only be increased.
@@ -368,18 +378,18 @@ func (webhook *Cluster) validateTopology(ctx context.Context, oldCluster, newClu
368378
allErrs = append(
369379
allErrs, field.Forbidden(
370380
fldPath.Child("class"),
371-
fmt.Sprintf("valid ClusterClass with name %q could not be found, change from class %[1]q to class %q cannot be validated",
372-
oldCluster.Spec.Topology.Class, newCluster.Spec.Topology.Class)))
381+
fmt.Sprintf("valid ClusterClass with name %q could not be retrieved, change from class %[1]q to class %q cannot be validated. Error: %s",
382+
oldCluster.Spec.Topology.Class, newCluster.Spec.Topology.Class, err.Error())))
373383

374384
// Return early with errors if the ClusterClass can't be retrieved.
375-
return allErrs
385+
return allWarnings, allErrs
376386
}
377387

378388
// Check if the new and old ClusterClasses are compatible with one another.
379389
allErrs = append(allErrs, check.ClusterClassesAreCompatible(oldClusterClass, clusterClass)...)
380390
}
381391
}
382-
return allErrs
392+
return allWarnings, allErrs
383393
}
384394

385395
func validateMachineHealthChecks(cluster *clusterv1.Cluster, clusterClass *clusterv1.ClusterClass) field.ErrorList {
@@ -551,11 +561,43 @@ func ValidateClusterForClusterClass(cluster *clusterv1.Cluster, clusterClass *cl
551561
return allErrs
552562
}
553563

564+
// validateClusterClassExistsAndIsReconciled will try to get the ClusterClass referenced in the Cluster. If it does not exist or is not reconciled it will add a warning.
565+
// In any other case it will return an error.
566+
func (webhook *Cluster) validateClusterClassExistsAndIsReconciled(ctx context.Context, newCluster *clusterv1.Cluster) (*clusterv1.ClusterClass, admission.Warnings, error) {
567+
var allWarnings admission.Warnings
568+
clusterClass, clusterClassPollErr := webhook.pollClusterClassForCluster(ctx, newCluster)
569+
if clusterClassPollErr != nil {
570+
// Add a warning if the Class does not exist or if it has not been successfully reconciled.
571+
switch {
572+
case apierrors.IsNotFound(clusterClassPollErr):
573+
allWarnings = append(allWarnings,
574+
fmt.Sprintf(
575+
"Cluster refers to ClusterClass %s in the topology but it does not exist. "+
576+
"Cluster topology has not been fully validated. "+
577+
"The ClusterClass must be created to reconcile the Cluster", newCluster.Spec.Topology.Class),
578+
)
579+
case errors.Is(clusterClassPollErr, errClusterClassNotReconciled):
580+
allWarnings = append(allWarnings,
581+
fmt.Sprintf(
582+
"Cluster refers to ClusterClass %s but this object which hasn't yet been reconciled. "+
583+
"Cluster topology has not been fully validated. ", newCluster.Spec.Topology.Class),
584+
)
585+
// If there's any other error return a generic warning with the error message.
586+
default:
587+
allWarnings = append(allWarnings,
588+
fmt.Sprintf(
589+
"Cluster refers to ClusterClass %s in the topology but it could not be retrieved. "+
590+
"Cluster topology has not been fully validated: %s", newCluster.Spec.Topology.Class, clusterClassPollErr.Error()),
591+
)
592+
}
593+
}
594+
return clusterClass, allWarnings, clusterClassPollErr
595+
}
596+
554597
// pollClusterClassForCluster will retry getting the ClusterClass referenced in the Cluster for two seconds.
555598
func (webhook *Cluster) pollClusterClassForCluster(ctx context.Context, cluster *clusterv1.Cluster) (*clusterv1.ClusterClass, error) {
556599
clusterClass := &clusterv1.ClusterClass{}
557600
var clusterClassPollErr error
558-
// TODO: Add a webhook warning if the ClusterClass is not up to date or not found.
559601
_ = wait.PollUntilContextTimeout(ctx, 200*time.Millisecond, 2*time.Second, true, func(ctx context.Context) (bool, error) {
560602
if clusterClassPollErr = webhook.Client.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Spec.Topology.Class}, clusterClass); clusterClassPollErr != nil {
561603
return false, nil //nolint:nilerr
@@ -567,7 +609,10 @@ func (webhook *Cluster) pollClusterClassForCluster(ctx context.Context, cluster
567609
clusterClassPollErr = nil
568610
return true, nil
569611
})
570-
return clusterClass, clusterClassPollErr
612+
if clusterClassPollErr != nil {
613+
return nil, clusterClassPollErr
614+
}
615+
return clusterClass, nil
571616
}
572617

573618
// clusterClassIsReconciled returns errClusterClassNotReconciled if the ClusterClass has not successfully reconciled or if the

0 commit comments

Comments
 (0)