sigs.k8s.io/cluster-api@v1.7.1/internal/topology/check/compatibility.go (about)

     1  /*
     2  Copyright 2021 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package check implements checks for managed topology.
    18  package check
    19  
    20  import (
    21  	"fmt"
    22  	"strings"
    23  
    24  	"k8s.io/apimachinery/pkg/runtime/schema"
    25  	"k8s.io/apimachinery/pkg/util/sets"
    26  	"k8s.io/apimachinery/pkg/util/validation"
    27  	"k8s.io/apimachinery/pkg/util/validation/field"
    28  	"sigs.k8s.io/controller-runtime/pkg/client"
    29  
    30  	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
    31  )
    32  
    33  // ObjectsAreStrictlyCompatible checks if two referenced objects are strictly compatible, meaning that
    34  // they are compatible and the name of the objects do not change.
    35  func ObjectsAreStrictlyCompatible(current, desired client.Object) field.ErrorList {
    36  	var allErrs field.ErrorList
    37  	if current.GetName() != desired.GetName() {
    38  		allErrs = append(allErrs, field.Forbidden(
    39  			field.NewPath("metadata", "name"),
    40  			fmt.Sprintf("metadata.name of %s/%s cannot be changed from %q to %q to prevent incompatible changes in the Cluster",
    41  				current.GetObjectKind().GroupVersionKind().GroupKind().String(), current.GetName(), current.GetName(), desired.GetName()),
    42  		))
    43  	}
    44  	allErrs = append(allErrs, ObjectsAreCompatible(current, desired)...)
    45  	return allErrs
    46  }
    47  
    48  // ObjectsAreCompatible checks if two referenced objects are compatible, meaning that
    49  // they are of the same GroupKind and in the same namespace.
    50  func ObjectsAreCompatible(current, desired client.Object) field.ErrorList {
    51  	var allErrs field.ErrorList
    52  
    53  	currentGK := current.GetObjectKind().GroupVersionKind().GroupKind()
    54  	desiredGK := desired.GetObjectKind().GroupVersionKind().GroupKind()
    55  	if currentGK.Group != desiredGK.Group {
    56  		allErrs = append(allErrs, field.Forbidden(
    57  			field.NewPath("metadata", "apiVersion"),
    58  			fmt.Sprintf("apiVersion.group of %s/%s cannot be changed from %q to %q to prevent incompatible changes in the Cluster",
    59  				currentGK.String(), current.GetName(), currentGK.Group, desiredGK.Group),
    60  		))
    61  	}
    62  	if currentGK.Kind != desiredGK.Kind {
    63  		allErrs = append(allErrs, field.Forbidden(
    64  			field.NewPath("metadata", "kind"),
    65  			fmt.Sprintf("apiVersion.kind of %s/%s cannot be changed from %q to %q to prevent incompatible changes in the Cluster",
    66  				currentGK.String(), current.GetName(), currentGK.Kind, desiredGK.Kind),
    67  		))
    68  	}
    69  	allErrs = append(allErrs, ObjectsAreInTheSameNamespace(current, desired)...)
    70  	return allErrs
    71  }
    72  
    73  // ObjectsAreInTheSameNamespace checks if two referenced objects are in the same namespace.
    74  func ObjectsAreInTheSameNamespace(current, desired client.Object) field.ErrorList {
    75  	var allErrs field.ErrorList
    76  
    77  	// NOTE: this should never happen (webhooks prevent it), but checking for extra safety.
    78  	if current.GetNamespace() != desired.GetNamespace() {
    79  		allErrs = append(allErrs, field.Forbidden(
    80  			field.NewPath("metadata", "namespace"),
    81  			fmt.Sprintf("metadata.namespace of %s/%s cannot be changed from %q to %q because templates must be in the same namespace as the Cluster",
    82  				current.GetObjectKind().GroupVersionKind().GroupKind().String(), current.GetName(), current.GetNamespace(), desired.GetNamespace()),
    83  		))
    84  	}
    85  	return allErrs
    86  }
    87  
    88  // LocalObjectTemplatesAreCompatible checks if two referenced objects are compatible, meaning that
    89  // they are of the same GroupKind and in the same namespace.
    90  func LocalObjectTemplatesAreCompatible(current, desired clusterv1.LocalObjectTemplate, pathPrefix *field.Path) field.ErrorList {
    91  	var allErrs field.ErrorList
    92  
    93  	currentGK := current.Ref.GetObjectKind().GroupVersionKind().GroupKind()
    94  	desiredGK := desired.Ref.GetObjectKind().GroupVersionKind().GroupKind()
    95  
    96  	if currentGK.Group != desiredGK.Group {
    97  		allErrs = append(allErrs, field.Forbidden(
    98  			pathPrefix.Child("ref", "apiVersion"),
    99  			fmt.Sprintf("apiVersion.group cannot be changed from %q to %q to prevent incompatible changes in the Clusters",
   100  				currentGK.Group, desiredGK.Group),
   101  		))
   102  	}
   103  	if currentGK.Kind != desiredGK.Kind {
   104  		allErrs = append(allErrs, field.Forbidden(
   105  			pathPrefix.Child("ref", "kind"),
   106  			fmt.Sprintf("apiVersion.kind cannot be changed from %q to %q to prevent incompatible changes in the Clusters",
   107  				currentGK.Kind, desiredGK.Kind),
   108  		))
   109  	}
   110  	allErrs = append(allErrs, LocalObjectTemplatesAreInSameNamespace(current, desired, pathPrefix)...)
   111  	return allErrs
   112  }
   113  
   114  // LocalObjectTemplatesAreInSameNamespace checks if two referenced objects are in the same namespace.
   115  func LocalObjectTemplatesAreInSameNamespace(current, desired clusterv1.LocalObjectTemplate, pathPrefix *field.Path) field.ErrorList {
   116  	var allErrs field.ErrorList
   117  	if current.Ref.Namespace != desired.Ref.Namespace {
   118  		allErrs = append(allErrs, field.Forbidden(
   119  			pathPrefix.Child("ref", "namespace"),
   120  			fmt.Sprintf("templates must be in the same namespace as the ClusterClass (%s)",
   121  				current.Ref.Namespace),
   122  		))
   123  	}
   124  	return allErrs
   125  }
   126  
   127  // LocalObjectTemplateIsValid ensures the template is in the correct namespace, has no nil references, and has a valid Kind and GroupVersion.
   128  func LocalObjectTemplateIsValid(template *clusterv1.LocalObjectTemplate, namespace string, pathPrefix *field.Path) field.ErrorList {
   129  	var allErrs field.ErrorList
   130  
   131  	// check if ref is not nil.
   132  	if template.Ref == nil {
   133  		return field.ErrorList{field.Required(
   134  			pathPrefix.Child("ref"),
   135  			"template reference must be defined",
   136  		)}
   137  	}
   138  
   139  	// check if a name is provided
   140  	if template.Ref.Name == "" {
   141  		allErrs = append(allErrs,
   142  			field.Required(
   143  				pathPrefix.Child("ref", "name"),
   144  				"template name must be defined",
   145  			),
   146  		)
   147  	}
   148  
   149  	// validate if namespace matches the provided namespace
   150  	if namespace != "" && template.Ref.Namespace != namespace {
   151  		allErrs = append(
   152  			allErrs,
   153  			field.Invalid(
   154  				pathPrefix.Child("ref", "namespace"),
   155  				template.Ref.Namespace,
   156  				fmt.Sprintf("template must be in the same namespace as the ClusterClass (%s)", namespace),
   157  			),
   158  		)
   159  	}
   160  
   161  	// check if kind is a template
   162  	if len(template.Ref.Kind) <= len(clusterv1.TemplateSuffix) || !strings.HasSuffix(template.Ref.Kind, clusterv1.TemplateSuffix) {
   163  		allErrs = append(allErrs,
   164  			field.Invalid(
   165  				pathPrefix.Child("ref", "kind"),
   166  				template.Ref.Kind,
   167  				fmt.Sprintf("template kind must be of form \"<name>%s\"", clusterv1.TemplateSuffix),
   168  			),
   169  		)
   170  	}
   171  
   172  	// check if apiVersion is valid
   173  	gv, err := schema.ParseGroupVersion(template.Ref.APIVersion)
   174  	if err != nil {
   175  		allErrs = append(allErrs,
   176  			field.Invalid(
   177  				pathPrefix.Child("ref", "apiVersion"),
   178  				template.Ref.APIVersion,
   179  				fmt.Sprintf("template apiVersion must be a valid Kubernetes apiVersion: %v", err),
   180  			),
   181  		)
   182  	}
   183  	if err == nil && gv.Empty() {
   184  		allErrs = append(allErrs,
   185  			field.Required(
   186  				pathPrefix.Child("ref", "apiVersion"),
   187  				"template apiVersion must be defined",
   188  			),
   189  		)
   190  	}
   191  	return allErrs
   192  }
   193  
   194  // ClusterClassesAreCompatible checks the compatibility between new and old versions of a Cluster Class.
   195  // It checks that:
   196  // 1) InfrastructureCluster Templates are compatible.
   197  // 2) ControlPlane Templates are compatible.
   198  // 3) ControlPlane InfrastructureMachineTemplates are compatible.
   199  // 4) MachineDeploymentClasses are compatible.
   200  // 5) MachinePoolClasses are compatible.
   201  func ClusterClassesAreCompatible(current, desired *clusterv1.ClusterClass) field.ErrorList {
   202  	var allErrs field.ErrorList
   203  	if current == nil {
   204  		return append(allErrs, field.Invalid(field.NewPath(""), "", "could not compare ClusterClass compatibility: current ClusterClass must not be nil"))
   205  	}
   206  	if desired == nil {
   207  		return append(allErrs, field.Invalid(field.NewPath(""), "", "could not compare ClusterClass compatibility: desired ClusterClass must not be nil"))
   208  	}
   209  
   210  	// Validate InfrastructureClusterTemplate changes desired a compatible way.
   211  	allErrs = append(allErrs, LocalObjectTemplatesAreCompatible(current.Spec.Infrastructure, desired.Spec.Infrastructure,
   212  		field.NewPath("spec", "infrastructure"))...)
   213  
   214  	// Validate control plane changes desired a compatible way.
   215  	allErrs = append(allErrs, LocalObjectTemplatesAreCompatible(current.Spec.ControlPlane.LocalObjectTemplate, desired.Spec.ControlPlane.LocalObjectTemplate,
   216  		field.NewPath("spec", "controlPlane"))...)
   217  	if desired.Spec.ControlPlane.MachineInfrastructure != nil && current.Spec.ControlPlane.MachineInfrastructure != nil {
   218  		allErrs = append(allErrs, LocalObjectTemplatesAreCompatible(*current.Spec.ControlPlane.MachineInfrastructure, *desired.Spec.ControlPlane.MachineInfrastructure,
   219  			field.NewPath("spec", "controlPlane", "machineInfrastructure"))...)
   220  	}
   221  
   222  	// Validate changes to MachineDeployments.
   223  	allErrs = append(allErrs, MachineDeploymentClassesAreCompatible(current, desired)...)
   224  
   225  	// Validate changes to MachinePools.
   226  	allErrs = append(allErrs, MachinePoolClassesAreCompatible(current, desired)...)
   227  
   228  	return allErrs
   229  }
   230  
   231  // MachineDeploymentClassesAreCompatible checks if each MachineDeploymentClass in the new ClusterClass is a compatible change from the previous ClusterClass.
   232  // It checks if the MachineDeploymentClass.Template.Infrastructure reference has changed its Group or Kind.
   233  func MachineDeploymentClassesAreCompatible(current, desired *clusterv1.ClusterClass) field.ErrorList {
   234  	var allErrs field.ErrorList
   235  
   236  	// Ensure previous MachineDeployment class was modified in a compatible way.
   237  	for _, class := range desired.Spec.Workers.MachineDeployments {
   238  		for i, oldClass := range current.Spec.Workers.MachineDeployments {
   239  			if class.Class == oldClass.Class {
   240  				// NOTE: class.Template.Metadata and class.Template.Bootstrap are allowed to change;
   241  
   242  				// class.Template.Bootstrap is ensured syntactically correct by LocalObjectTemplateIsValid.
   243  
   244  				// Validates class.Template.Infrastructure template changes in a compatible way
   245  				allErrs = append(allErrs, LocalObjectTemplatesAreCompatible(oldClass.Template.Infrastructure, class.Template.Infrastructure,
   246  					field.NewPath("spec", "workers", "machineDeployments").Index(i))...)
   247  			}
   248  		}
   249  	}
   250  	return allErrs
   251  }
   252  
   253  // MachineDeploymentClassesAreUnique checks that no two MachineDeploymentClasses in a ClusterClass share a name.
   254  func MachineDeploymentClassesAreUnique(clusterClass *clusterv1.ClusterClass) field.ErrorList {
   255  	var allErrs field.ErrorList
   256  	classes := sets.Set[string]{}
   257  	for i, class := range clusterClass.Spec.Workers.MachineDeployments {
   258  		if classes.Has(class.Class) {
   259  			allErrs = append(allErrs,
   260  				field.Invalid(
   261  					field.NewPath("spec", "workers", "machineDeployments").Index(i).Child("class"),
   262  					class.Class,
   263  					fmt.Sprintf("MachineDeployment class must be unique. MachineDeployment with class %q is defined more than once", class.Class),
   264  				),
   265  			)
   266  		}
   267  		classes.Insert(class.Class)
   268  	}
   269  	return allErrs
   270  }
   271  
   272  // MachinePoolClassesAreCompatible checks if each MachinePoolClass in the new ClusterClass is a compatible change from the previous ClusterClass.
   273  // It checks if the MachinePoolClass.Template.Infrastructure reference has changed its Group or Kind.
   274  func MachinePoolClassesAreCompatible(current, desired *clusterv1.ClusterClass) field.ErrorList {
   275  	var allErrs field.ErrorList
   276  
   277  	// Ensure previous MachinePool class was modified in a compatible way.
   278  	for _, class := range desired.Spec.Workers.MachinePools {
   279  		for i, oldClass := range current.Spec.Workers.MachinePools {
   280  			if class.Class == oldClass.Class {
   281  				// NOTE: class.Template.Metadata and class.Template.Bootstrap are allowed to change;
   282  
   283  				// class.Template.Bootstrap is ensured syntactically correct by LocalObjectTemplateIsValid.
   284  
   285  				// Validates class.Template.Infrastructure template changes in a compatible way
   286  				allErrs = append(allErrs, LocalObjectTemplatesAreCompatible(oldClass.Template.Infrastructure, class.Template.Infrastructure,
   287  					field.NewPath("spec", "workers", "machinePools").Index(i))...)
   288  			}
   289  		}
   290  	}
   291  	return allErrs
   292  }
   293  
   294  // MachinePoolClassesAreUnique checks that no two MachinePoolClasses in a ClusterClass share a name.
   295  func MachinePoolClassesAreUnique(clusterClass *clusterv1.ClusterClass) field.ErrorList {
   296  	var allErrs field.ErrorList
   297  	classes := sets.Set[string]{}
   298  	for i, class := range clusterClass.Spec.Workers.MachinePools {
   299  		if classes.Has(class.Class) {
   300  			allErrs = append(allErrs,
   301  				field.Invalid(
   302  					field.NewPath("spec", "workers", "machinePools").Index(i).Child("class"),
   303  					class.Class,
   304  					fmt.Sprintf("MachinePool class must be unique. MachinePool with class %q is defined more than once", class.Class),
   305  				),
   306  			)
   307  		}
   308  		classes.Insert(class.Class)
   309  	}
   310  	return allErrs
   311  }
   312  
   313  // MachineDeploymentTopologiesAreValidAndDefinedInClusterClass checks that each MachineDeploymentTopology name is not empty
   314  // and unique, and each class in use is defined in ClusterClass.spec.Workers.MachineDeployments.
   315  func MachineDeploymentTopologiesAreValidAndDefinedInClusterClass(desired *clusterv1.Cluster, clusterClass *clusterv1.ClusterClass) field.ErrorList {
   316  	var allErrs field.ErrorList
   317  	if desired.Spec.Topology.Workers == nil {
   318  		return nil
   319  	}
   320  	if len(desired.Spec.Topology.Workers.MachineDeployments) == 0 {
   321  		return nil
   322  	}
   323  	// MachineDeployment clusterClass must be defined in the ClusterClass.
   324  	machineDeploymentClasses := mdClassNamesFromWorkerClass(clusterClass.Spec.Workers)
   325  	names := sets.Set[string]{}
   326  	for i, md := range desired.Spec.Topology.Workers.MachineDeployments {
   327  		if errs := validation.IsValidLabelValue(md.Name); len(errs) != 0 {
   328  			for _, err := range errs {
   329  				allErrs = append(
   330  					allErrs,
   331  					field.Invalid(
   332  						field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("name"),
   333  						md.Name,
   334  						fmt.Sprintf("must be a valid label value %s", err),
   335  					),
   336  				)
   337  			}
   338  		}
   339  
   340  		if !machineDeploymentClasses.Has(md.Class) {
   341  			allErrs = append(allErrs,
   342  				field.Invalid(
   343  					field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("class"),
   344  					md.Class,
   345  					fmt.Sprintf("MachineDeploymentClass with name %q does not exist in ClusterClass %q",
   346  						md.Class, clusterClass.Name),
   347  				),
   348  			)
   349  		}
   350  
   351  		// MachineDeploymentTopology name should not be empty.
   352  		if md.Name == "" {
   353  			allErrs = append(
   354  				allErrs,
   355  				field.Required(
   356  					field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("name"),
   357  					"name must not be empty",
   358  				),
   359  			)
   360  			continue
   361  		}
   362  
   363  		if names.Has(md.Name) {
   364  			allErrs = append(allErrs,
   365  				field.Invalid(
   366  					field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("name"),
   367  					md.Name,
   368  					fmt.Sprintf("name must be unique. MachineDeployment with name %q is defined more than once", md.Name),
   369  				),
   370  			)
   371  		}
   372  		names.Insert(md.Name)
   373  	}
   374  	return allErrs
   375  }
   376  
   377  // MachinePoolTopologiesAreValidAndDefinedInClusterClass checks that each MachinePoolTopology name is not empty
   378  // and unique, and each class in use is defined in ClusterClass.spec.Workers.MachinePools.
   379  func MachinePoolTopologiesAreValidAndDefinedInClusterClass(desired *clusterv1.Cluster, clusterClass *clusterv1.ClusterClass) field.ErrorList {
   380  	var allErrs field.ErrorList
   381  	if desired.Spec.Topology.Workers == nil {
   382  		return nil
   383  	}
   384  	if len(desired.Spec.Topology.Workers.MachinePools) == 0 {
   385  		return nil
   386  	}
   387  	// MachinePool clusterClass must be defined in the ClusterClass.
   388  	machinePoolClasses := mpClassNamesFromWorkerClass(clusterClass.Spec.Workers)
   389  	names := sets.Set[string]{}
   390  	for i, mp := range desired.Spec.Topology.Workers.MachinePools {
   391  		if errs := validation.IsValidLabelValue(mp.Name); len(errs) != 0 {
   392  			for _, err := range errs {
   393  				allErrs = append(
   394  					allErrs,
   395  					field.Invalid(
   396  						field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("name"),
   397  						mp.Name,
   398  						fmt.Sprintf("must be a valid label value %s", err),
   399  					),
   400  				)
   401  			}
   402  		}
   403  
   404  		if !machinePoolClasses.Has(mp.Class) {
   405  			allErrs = append(allErrs,
   406  				field.Invalid(
   407  					field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("class"),
   408  					mp.Class,
   409  					fmt.Sprintf("MachinePoolClass with name %q does not exist in ClusterClass %q",
   410  						mp.Class, clusterClass.Name),
   411  				),
   412  			)
   413  		}
   414  
   415  		// MachinePoolTopology name should not be empty.
   416  		if mp.Name == "" {
   417  			allErrs = append(
   418  				allErrs,
   419  				field.Required(
   420  					field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("name"),
   421  					"name must not be empty",
   422  				),
   423  			)
   424  			continue
   425  		}
   426  
   427  		if names.Has(mp.Name) {
   428  			allErrs = append(allErrs,
   429  				field.Invalid(
   430  					field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("name"),
   431  					mp.Name,
   432  					fmt.Sprintf("name must be unique. MachinePool with name %q is defined more than once", mp.Name),
   433  				),
   434  			)
   435  		}
   436  		names.Insert(mp.Name)
   437  	}
   438  	return allErrs
   439  }
   440  
   441  // ClusterClassReferencesAreValid checks that each template reference in the ClusterClass is valid .
   442  func ClusterClassReferencesAreValid(clusterClass *clusterv1.ClusterClass) field.ErrorList {
   443  	var allErrs field.ErrorList
   444  
   445  	allErrs = append(allErrs, LocalObjectTemplateIsValid(&clusterClass.Spec.Infrastructure, clusterClass.Namespace,
   446  		field.NewPath("spec", "infrastructure"))...)
   447  	allErrs = append(allErrs, LocalObjectTemplateIsValid(&clusterClass.Spec.ControlPlane.LocalObjectTemplate, clusterClass.Namespace,
   448  		field.NewPath("spec", "controlPlane"))...)
   449  	if clusterClass.Spec.ControlPlane.MachineInfrastructure != nil {
   450  		allErrs = append(allErrs, LocalObjectTemplateIsValid(clusterClass.Spec.ControlPlane.MachineInfrastructure, clusterClass.Namespace, field.NewPath("spec", "controlPlane", "machineInfrastructure"))...)
   451  	}
   452  
   453  	for i := range clusterClass.Spec.Workers.MachineDeployments {
   454  		mdc := clusterClass.Spec.Workers.MachineDeployments[i]
   455  		allErrs = append(allErrs, LocalObjectTemplateIsValid(&mdc.Template.Bootstrap, clusterClass.Namespace,
   456  			field.NewPath("spec", "workers", "machineDeployments").Index(i).Child("template", "bootstrap"))...)
   457  		allErrs = append(allErrs, LocalObjectTemplateIsValid(&mdc.Template.Infrastructure, clusterClass.Namespace,
   458  			field.NewPath("spec", "workers", "machineDeployments").Index(i).Child("template", "infrastructure"))...)
   459  	}
   460  
   461  	for i := range clusterClass.Spec.Workers.MachinePools {
   462  		mpc := clusterClass.Spec.Workers.MachinePools[i]
   463  		allErrs = append(allErrs, LocalObjectTemplateIsValid(&mpc.Template.Bootstrap, clusterClass.Namespace,
   464  			field.NewPath("spec", "workers", "machinePools").Index(i).Child("template", "bootstrap"))...)
   465  		allErrs = append(allErrs, LocalObjectTemplateIsValid(&mpc.Template.Infrastructure, clusterClass.Namespace,
   466  			field.NewPath("spec", "workers", "machinePools").Index(i).Child("template", "infrastructure"))...)
   467  	}
   468  
   469  	return allErrs
   470  }
   471  
   472  // mdClassNamesFromWorkerClass returns the set of MachineDeployment class names.
   473  func mdClassNamesFromWorkerClass(w clusterv1.WorkersClass) sets.Set[string] {
   474  	classes := sets.Set[string]{}
   475  	for _, class := range w.MachineDeployments {
   476  		classes.Insert(class.Class)
   477  	}
   478  	return classes
   479  }
   480  
   481  // mpClassNamesFromWorkerClass returns the set of MachinePool class names.
   482  func mpClassNamesFromWorkerClass(w clusterv1.WorkersClass) sets.Set[string] {
   483  	classes := sets.Set[string]{}
   484  	for _, class := range w.MachinePools {
   485  		classes.Insert(class.Class)
   486  	}
   487  	return classes
   488  }