Skip to content

Commit 9e00c95

Browse files
authored
Merge pull request #8076 from killianmuldoon/pr-exvars-validate-on-status
🌱 Default and Validate Cluster variables based on ClusterClass status
2 parents 0537f78 + 5bd69e6 commit 9e00c95

File tree

12 files changed

+677
-279
lines changed

12 files changed

+677
-279
lines changed

api/v1beta1/condition_consts.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ const (
5555

5656
// ANCHOR_END: CommonConditions
5757

58+
// Conditions and condition Reasons for the ClusterClass object.
59+
const (
60+
// ClusterClassVariablesReconciledCondition reports if the ClusterClass variables, including both inline and external
61+
// variables, have been successfully reconciled.
62+
// This signals that the ClusterClass is ready to be used to default and validate variables on Clusters using
63+
// this ClusterClass.
64+
ClusterClassVariablesReconciledCondition ConditionType = "VariablesReconciled"
65+
66+
// VariableDiscoveryFailedReason (Severity=Error) documents a ClusterClass with VariableDiscovery extensions that
67+
// failed.
68+
VariableDiscoveryFailedReason = "VariableDiscoveryFailed"
69+
)
70+
5871
// Conditions and condition Reasons for the Cluster object.
5972

6073
const (

cmd/clusterctl/client/cluster/assets/topology-test/new-clusterclass-and-cluster.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ metadata:
1010
name: my-cluster-class
1111
namespace: default
1212
spec:
13+
variables:
14+
- name: imageRepository
15+
required: true
16+
schema:
17+
openAPIV3Schema:
18+
type: string
19+
default: "registry.k8s.io"
20+
example: "registry.k8s.io"
1321
controlPlane:
1422
ref:
1523
apiVersion: controlplane.cluster.x-k8s.io/v1beta1

cmd/clusterctl/client/cluster/topology.go

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ import (
3939

4040
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
4141
"sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster/internal/dryrun"
42+
"sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme"
4243
logf "sigs.k8s.io/cluster-api/cmd/clusterctl/log"
4344
"sigs.k8s.io/cluster-api/feature"
45+
clusterclasscontroller "sigs.k8s.io/cluster-api/internal/controllers/clusterclass"
4446
clustertopologycontroller "sigs.k8s.io/cluster-api/internal/controllers/topology/cluster"
4547
"sigs.k8s.io/cluster-api/internal/webhooks"
4648
"sigs.k8s.io/cluster-api/util/contract"
@@ -134,6 +136,7 @@ func (t *topologyClient) Plan(in *TopologyPlanInput) (*TopologyPlanOutput, error
134136
if err := t.prepareInput(ctx, in, c); err != nil {
135137
return nil, errors.Wrap(err, "failed preparing input")
136138
}
139+
137140
// Run defaulting and validation on core CAPI objects - Cluster and ClusterClasses.
138141
// This mimics the defaulting and validation webhooks that will run on the objects during a real execution.
139142
// Running defaulting and validation on these objects helps to improve the UX of using the plan operation.
@@ -397,18 +400,28 @@ func (t *topologyClient) runDefaultAndValidationWebhooks(ctx context.Context, in
397400
return errors.Wrap(err, "failed to run defaulting and validation on ClusterClasses")
398401
}
399402

400-
// From the inputs gather all the objects that are not Clusters.
403+
// From the inputs gather all the objects that are not Clusters or ClusterClasses.
401404
// These objects will be used when initializing a dryrun client to use in the webhooks.
402-
// We want to keep ClusterClasses in the webhook client. This is because validation of
403-
// Cluster objects might need access to ClusterClass objects that are in the input.
404405
filteredObjs = filterObjects(
405406
in.Objs,
406407
clusterv1.GroupVersion.WithKind("Cluster"),
408+
clusterv1.GroupVersion.WithKind("ClusterClass"),
407409
)
410+
408411
objs = []client.Object{}
409412
for _, o := range filteredObjs {
410413
objs = append(objs, o)
411414
}
415+
// Reconcile the ClusterClasses and add the reconciled version of them to the webhook client.
416+
// This is required as validation of Cluster objects might need access to ClusterClass objects that are in the input.
417+
// Cluster variable defaulting and validation relies on the ClusterClass `.status.variables` which is added
418+
// during ClusterClass reconciliation.
419+
reconciledClusterClasses, err := t.reconcileClusterClasses(ctx, in.Objs, apiReader)
420+
if err != nil {
421+
return errors.Wrapf(err, "failed to reconcile ClusterClasses for defaulting and validating")
422+
}
423+
objs = append(objs, reconciledClusterClasses...)
424+
412425
webhookClient = dryrun.NewClient(apiReader, objs)
413426

414427
// Run defaulting and validation on Clusters.
@@ -427,6 +440,108 @@ func (t *topologyClient) runDefaultAndValidationWebhooks(ctx context.Context, in
427440
return nil
428441
}
429442

443+
func (t *topologyClient) reconcileClusterClasses(ctx context.Context, inputObjects []*unstructured.Unstructured, apiReader client.Reader) ([]client.Object, error) {
444+
reconciliationObjects := []client.Object{}
445+
// From the inputs gather all the objects that are not ClusterClasses.
446+
// These objects will be used when initializing a dryrun client to use in the reconciler.
447+
for _, o := range filterObjects(inputObjects, clusterv1.GroupVersion.WithKind("ClusterClass")) {
448+
reconciliationObjects = append(reconciliationObjects, o)
449+
}
450+
// Add mock CRDs of all the provider objects in the input to the list used when initializing the client.
451+
// Adding these CRDs makes sure that UpdateReferenceAPIContract calls in the reconciler can work.
452+
for _, o := range t.generateCRDs(inputObjects) {
453+
reconciliationObjects = append(reconciliationObjects, o)
454+
}
455+
456+
// Create a list of all ClusterClasses, including those in the dry run input and those in the management Cluster
457+
// API Server.
458+
allClusterClasses := []client.Object{}
459+
ccList := &clusterv1.ClusterClassList{}
460+
// If an APIReader is available add the ClusterClasses from the management cluster
461+
if apiReader != nil {
462+
if err := apiReader.List(ctx, ccList); err != nil {
463+
return nil, errors.Wrap(err, "failed to find ClusterClasses to default and validate Clusters")
464+
}
465+
for i := range ccList.Items {
466+
allClusterClasses = append(allClusterClasses, &ccList.Items[i])
467+
}
468+
}
469+
470+
// Add ClusterClasses from the input
471+
inClusterClasses := getClusterClasses(inputObjects)
472+
cc := clusterv1.ClusterClass{}
473+
for _, class := range inClusterClasses {
474+
if err := scheme.Scheme.Convert(class, &cc, ctx); err != nil {
475+
return nil, errors.Wrapf(err, "failed to convert object %s/%s to ClusterClass", class.GetNamespace(), class.GetName())
476+
}
477+
allClusterClasses = append(allClusterClasses, &cc)
478+
}
479+
480+
// Each ClusterClass should be reconciled in order to ensure variables are correctly added to `status.variables`.
481+
// This is required as Clusters are validated based of variable definitions in the ClusterClass `.status.variables`.
482+
reconciledClusterClasses := []client.Object{}
483+
for _, class := range allClusterClasses {
484+
reconciledClusterClass, err := reconcileClusterClass(apiReader, class, reconciliationObjects)
485+
if err != nil {
486+
return nil, errors.Wrapf(err, "ClusterClass %s could not be reconciled for dry run", class.GetName())
487+
}
488+
reconciledClusterClasses = append(reconciledClusterClasses, reconciledClusterClass)
489+
}
490+
491+
// Remove the ClusterClasses from the input objects and replace them with the reconciled version.
492+
for i, obj := range inputObjects {
493+
if obj.GroupVersionKind() == clusterv1.GroupVersion.WithKind("ClusterClass") {
494+
// remove the clusterclass from the list of reconciled clusterclasses if it was not in the input.
495+
inputObjects = append(inputObjects[:i], inputObjects[i+1:]...)
496+
}
497+
}
498+
for _, class := range reconciledClusterClasses {
499+
obj := &unstructured.Unstructured{}
500+
if err := localScheme.Convert(class, obj, nil); err != nil {
501+
return nil, errors.Wrapf(err, "failed to convert %s to object", obj.GetKind())
502+
}
503+
inputObjects = append(inputObjects, obj)
504+
}
505+
506+
// Return a list of successfully reconciled ClusterClasses.
507+
return reconciledClusterClasses, nil
508+
}
509+
510+
func reconcileClusterClass(apiReader client.Reader, class client.Object, reconciliationObjects []client.Object) (*unstructured.Unstructured, error) {
511+
targetClusterClass := client.ObjectKey{Namespace: class.GetNamespace(), Name: class.GetName()}
512+
reconciliationObjects = append(reconciliationObjects, class)
513+
514+
// Create a reconcilerClient that has access to all of the necessary templates to complete a successful reconcile
515+
// of the ClusterClass.
516+
reconcilerClient := dryrun.NewClient(apiReader, reconciliationObjects)
517+
518+
clusterClassReconciler := &clusterclasscontroller.Reconciler{
519+
Client: reconcilerClient,
520+
APIReader: reconcilerClient,
521+
UnstructuredCachingClient: reconcilerClient,
522+
}
523+
524+
if _, err := clusterClassReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: targetClusterClass}); err != nil {
525+
return nil, errors.Wrap(err, "failed to dry run the ClusterClass controller")
526+
}
527+
528+
// Pull the reconciled ClusterClass using the reconcilerClient, and return the version with the updated status.
529+
reconciledClusterClass := &clusterv1.ClusterClass{}
530+
if err := reconcilerClient.Get(ctx, targetClusterClass, reconciledClusterClass); err != nil {
531+
return nil, fmt.Errorf("could not retrieve ClusterClass")
532+
}
533+
534+
obj := &unstructured.Unstructured{}
535+
// Converted the defaulted and validated object back into unstructured.
536+
// Note: This step also makes sure that modified object is updated into the
537+
// original unstructured object.
538+
if err := localScheme.Convert(reconciledClusterClass, obj, nil); err != nil {
539+
return nil, errors.Wrapf(err, "failed to convert %s to object", obj.GetKind())
540+
}
541+
542+
return obj, nil
543+
}
544+
430545
func (t *topologyClient) defaultAndValidateObjs(ctx context.Context, objs []*unstructured.Unstructured, o client.Object, defaulter crwebhook.CustomDefaulter, validator crwebhook.CustomValidator, apiReader client.Reader) error {
431546
for _, obj := range objs {
432547
// The defaulter and validator need a typed object. Convert the unstructured obj to a typed object.

internal/controllers/clusterclass/clusterclass_controller.go

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Re
131131
}
132132

133133
func (r *Reconciler) reconcile(ctx context.Context, clusterClass *clusterv1.ClusterClass) (ctrl.Result, error) {
134+
if err := r.reconcileVariables(ctx, clusterClass); err != nil {
135+
return ctrl.Result{}, err
136+
}
137+
outdatedRefs, err := r.reconcileExternalReferences(ctx, clusterClass)
138+
if err != nil {
139+
return ctrl.Result{}, err
140+
}
141+
142+
reconcileConditions(clusterClass, outdatedRefs)
143+
144+
return ctrl.Result{}, nil
145+
}
146+
147+
func (r *Reconciler) reconcileExternalReferences(ctx context.Context, clusterClass *clusterv1.ClusterClass) (map[*corev1.ObjectReference]*corev1.ObjectReference, error) {
134148
// Collect all the reference from the ClusterClass to templates.
135149
refs := []*corev1.ObjectReference{}
136150

@@ -192,28 +206,19 @@ func (r *Reconciler) reconcile(ctx context.Context, clusterClass *clusterv1.Clus
192206
}
193207
}
194208
if len(errs) > 0 {
195-
return ctrl.Result{}, kerrors.NewAggregate(errs)
196-
}
197-
198-
// Reconcile variables
199-
if err := r.reconcileVariables(ctx, clusterClass); err != nil {
200-
return ctrl.Result{}, err
209+
return nil, kerrors.NewAggregate(errs)
201210
}
202-
203-
reconcileConditions(clusterClass, outdatedRefs)
204-
205-
return ctrl.Result{}, nil
211+
return outdatedRefs, nil
206212
}
207213

208214
func (r *Reconciler) reconcileVariables(ctx context.Context, clusterClass *clusterv1.ClusterClass) error {
209215
errs := []error{}
210216
uniqueVars := map[string]bool{}
211-
217+
allVars := []clusterv1.ClusterClassStatusVariable{}
212218
// Add inline variable definitions to the ClusterClass status.
213-
clusterClass.Status.Variables = []clusterv1.ClusterClassStatusVariable{}
214219
for _, variable := range clusterClass.Spec.Variables {
215220
uniqueVars[variable.Name] = true
216-
clusterClass.Status.Variables = append(clusterClass.Status.Variables, statusVariableFromClusterClassVariable(variable, clusterv1.VariableDefinitionFromInline))
221+
allVars = append(allVars, statusVariableFromClusterClassVariable(variable, clusterv1.VariableDefinitionFromInline))
217222
}
218223

219224
// If RuntimeSDK is enabled call the DiscoverVariables hook for all associated Runtime Extensions and add the variables
@@ -244,14 +249,19 @@ func (r *Reconciler) reconcileVariables(ctx context.Context, clusterClass *clust
244249
continue
245250
}
246251
uniqueVars[variable.Name] = true
247-
clusterClass.Status.Variables = append(clusterClass.Status.Variables, statusVariableFromClusterClassVariable(variable, patch.Name))
252+
allVars = append(allVars, statusVariableFromClusterClassVariable(variable, patch.Name))
248253
}
249254
}
250255
}
251256
}
252257
if len(errs) > 0 {
258+
// TODO: Decide whether to remove old variables if discovery fails.
259+
conditions.MarkFalse(clusterClass, clusterv1.ClusterClassVariablesReconciledCondition, clusterv1.VariableDiscoveryFailedReason, clusterv1.ConditionSeverityError,
260+
"VariableDiscovery failed: %s", kerrors.NewAggregate(errs))
253261
return errors.Wrapf(kerrors.NewAggregate(errs), "failed to discover variables for ClusterClass %s", clusterClass.Name)
254262
}
263+
clusterClass.Status.Variables = allVars
264+
conditions.MarkTrue(clusterClass, clusterv1.ClusterClassVariablesReconciledCondition)
255265
return nil
256266
}
257267
func reconcileConditions(clusterClass *clusterv1.ClusterClass, outdatedRefs map[*corev1.ObjectReference]*corev1.ObjectReference) {
@@ -302,9 +312,9 @@ func (r *Reconciler) reconcileExternal(ctx context.Context, clusterClass *cluste
302312
obj, err := external.Get(ctx, r.UnstructuredCachingClient, ref, clusterClass.Namespace)
303313
if err != nil {
304314
if apierrors.IsNotFound(errors.Cause(err)) {
305-
return errors.Wrapf(err, "Could not find external object for the cluster class. refGroupVersionKind: %s, refName: %s", ref.GroupVersionKind(), ref.Name)
315+
return errors.Wrapf(err, "Could not find external object for the ClusterClass. refGroupVersionKind: %s, refName: %s", ref.GroupVersionKind(), ref.Name)
306316
}
307-
return errors.Wrapf(err, "failed to get the external object for the cluster class. refGroupVersionKind: %s, refName: %s", ref.GroupVersionKind(), ref.Name)
317+
return errors.Wrapf(err, "failed to get the external object for the ClusterClass. refGroupVersionKind: %s, refName: %s", ref.GroupVersionKind(), ref.Name)
308318
}
309319

310320
// If referenced object is paused, return early.
@@ -321,7 +331,7 @@ func (r *Reconciler) reconcileExternal(ctx context.Context, clusterClass *cluste
321331

322332
// Set external object ControllerReference to the ClusterClass.
323333
if err := controllerutil.SetOwnerReference(clusterClass, obj, r.Client.Scheme()); err != nil {
324-
return errors.Wrapf(err, "failed to set cluster class owner reference for %s", tlog.KObj{Obj: obj})
334+
return errors.Wrapf(err, "failed to set ClusterClass owner reference for %s", tlog.KObj{Obj: obj})
325335
}
326336

327337
// Patch the external object.

0 commit comments

Comments
 (0)