Skip to content

🐛 Fix validation of worker topology names in Cluster resource #12069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
54 changes: 48 additions & 6 deletions internal/topology/check/compatibility.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,9 @@ func MachinePoolClassesAreUnique(clusterClass *clusterv1.ClusterClass) field.Err
return allErrs
}

// MachineDeploymentTopologiesAreValidAndDefinedInClusterClass checks that each MachineDeploymentTopology name is not empty
// and unique, and each class in use is defined in ClusterClass.spec.Workers.MachineDeployments.
// MachineDeploymentTopologiesAreValidAndDefinedInClusterClass checks that each MachineDeploymentTopology name is not empty,
// is a valid Kubernetes resource name, is a valid label value, and is unique. It also checks that each class in use is defined
// in ClusterClass.spec.Workers.MachineDeployments.
func MachineDeploymentTopologiesAreValidAndDefinedInClusterClass(desired *clusterv1.Cluster, clusterClass *clusterv1.ClusterClass) field.ErrorList {
var allErrs field.ErrorList
if desired.Spec.Topology.Workers == nil {
Expand All @@ -310,14 +311,34 @@ func MachineDeploymentTopologiesAreValidAndDefinedInClusterClass(desired *cluste
machineDeploymentClasses := mdClassNamesFromWorkerClass(clusterClass.Spec.Workers)
names := sets.Set[string]{}
for i, md := range desired.Spec.Topology.Workers.MachineDeployments {
// The Name must be a valid Kubernetes resource name, because it is used to generate the MachineDeployment name.
if errs := validation.IsDNS1123Subdomain(md.Name); len(errs) != 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JoelSpeed @sbueringer wondering if we should move (or duplicate) those validation on the API as a CRD markers

Name string `json:"name"`

Name string `json:"name"`

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to read through this thread first for context: #12069 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we were already had some validation for this field in code, and I could add a failing unit test to demonstrate the problem, I decided to modify the code.

However, I think we can express this validation in our API.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// name of the IPAddressClaim.
// name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`

Is an example of the same validation in openapi, personally I prefer CEL matching rules here as we can provide better validation feedback, but I believe we skipped on that for the moment because the errors returned were not including the invalid value

for _, err := range errs {
allErrs = append(
allErrs,
field.Invalid(
field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("name"),
md.Name,
fmt.Sprintf("must be a valid resource name: %s", err),
),
)
}
}

// The Name must also be a valid label value, because it is used in some label values.
//
// NOTE This check always returns true in practice, because OpenAPI validation in the
// Cluster CRD ensures that md.Name is <= 63 characters, and the IsDNS1123Subdomain check
// accepts a smaller set of characters than IsValidLabelValue. We keep this check to be able
// to unit test this function.
if errs := validation.IsValidLabelValue(md.Name); len(errs) != 0 {
for _, err := range errs {
allErrs = append(
allErrs,
field.Invalid(
field.NewPath("spec", "topology", "workers", "machineDeployments").Index(i).Child("name"),
md.Name,
fmt.Sprintf("must be a valid label value %s", err),
fmt.Sprintf("must be a valid label value: %s", err),
),
)
}
Expand Down Expand Up @@ -360,8 +381,9 @@ func MachineDeploymentTopologiesAreValidAndDefinedInClusterClass(desired *cluste
return allErrs
}

// MachinePoolTopologiesAreValidAndDefinedInClusterClass checks that each MachinePoolTopology name is not empty
// and unique, and each class in use is defined in ClusterClass.spec.Workers.MachinePools.
// MachinePoolTopologiesAreValidAndDefinedInClusterClass checks that each MachinePoolTopology name is not empty,
// is a valid Kubernetes resource name, is a valid label value, and is unique. It also checks that each class in use is defined
// in ClusterClass.spec.Workers.MachinePools.
func MachinePoolTopologiesAreValidAndDefinedInClusterClass(desired *clusterv1.Cluster, clusterClass *clusterv1.ClusterClass) field.ErrorList {
var allErrs field.ErrorList
if desired.Spec.Topology.Workers == nil {
Expand All @@ -374,14 +396,34 @@ func MachinePoolTopologiesAreValidAndDefinedInClusterClass(desired *clusterv1.Cl
machinePoolClasses := mpClassNamesFromWorkerClass(clusterClass.Spec.Workers)
names := sets.Set[string]{}
for i, mp := range desired.Spec.Topology.Workers.MachinePools {
// The Name must be a valid Kubernetes resource name, because it is used to generate the MachineDeployment name.
if errs := validation.IsDNS1123Subdomain(mp.Name); len(errs) != 0 {
for _, err := range errs {
allErrs = append(
allErrs,
field.Invalid(
field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("name"),
mp.Name,
fmt.Sprintf("must be a valid resource name: %s", err),
),
)
}
}

// The Name must also be a valid label value, because it is used in some label values.
//
// NOTE This check always returns true in practice, because OpenAPI validation in the
// Cluster CRD ensures that md.Name is <= 63 characters, and the IsDNS1123Subdomain check
// accepts a smaller set of characters than IsValidLabelValue. We keep this check to be able
// to unit test this function.
if errs := validation.IsValidLabelValue(mp.Name); len(errs) != 0 {
for _, err := range errs {
allErrs = append(
allErrs,
field.Invalid(
field.NewPath("spec", "topology", "workers", "machinePools").Index(i).Child("name"),
mp.Name,
fmt.Sprintf("must be a valid label value %s", err),
fmt.Sprintf("must be a valid label value: %s", err),
),
)
}
Expand Down
120 changes: 120 additions & 0 deletions internal/topology/check/compatibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,66 @@ func TestMachineDeploymentTopologiesAreUniqueAndDefinedInClusterClass(t *testing
Build(),
wantErr: true,
},
{
name: "fail if MachineDeploymentTopology name is not a valid Kubernetes resource name",
clusterClass: builder.ClusterClass(metav1.NamespaceDefault, "class1").
WithInfrastructureClusterTemplate(
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithControlPlaneTemplate(
builder.ControlPlane(metav1.NamespaceDefault, "cp1").Build()).
WithControlPlaneInfrastructureMachineTemplate(
builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "cpinfra1").Build()).
WithWorkerMachineDeploymentClasses(
*builder.MachineDeploymentClass("aa").
WithInfrastructureTemplate(
builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithBootstrapTemplate(
builder.BootstrapTemplate(metav1.NamespaceDefault, "bootstrap1").Build()).
Build()).
Build(),
cluster: builder.Cluster(metav1.NamespaceDefault, "cluster1").
WithTopology(
builder.ClusterTopology().
WithClass("class1").
WithVersion("v1.22.2").
WithMachineDeployment(
builder.MachineDeploymentTopology("under_score").
WithClass("aa").
Build()).
Build()).
Build(),
wantErr: true,
},
{
name: "fail if MachineDeploymentTopology name is not a valid Kubernetes label value",
clusterClass: builder.ClusterClass(metav1.NamespaceDefault, "class1").
WithInfrastructureClusterTemplate(
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithControlPlaneTemplate(
builder.ControlPlane(metav1.NamespaceDefault, "cp1").Build()).
WithControlPlaneInfrastructureMachineTemplate(
builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "cpinfra1").Build()).
WithWorkerMachineDeploymentClasses(
*builder.MachineDeploymentClass("aa").
WithInfrastructureTemplate(
builder.InfrastructureMachineTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithBootstrapTemplate(
builder.BootstrapTemplate(metav1.NamespaceDefault, "bootstrap1").Build()).
Build()).
Build(),
cluster: builder.Cluster(metav1.NamespaceDefault, "cluster1").
WithTopology(
builder.ClusterTopology().
WithClass("class1").
WithVersion("v1.22.2").
WithMachineDeployment(
builder.MachineDeploymentTopology("forward/slash").
WithClass("aa").
Build()).
Build()).
Build(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -1609,6 +1669,66 @@ func TestMachinePoolTopologiesAreUniqueAndDefinedInClusterClass(t *testing.T) {
Build(),
wantErr: true,
},
{
name: "fail if MachinePoolTopology name is not a valid Kubernetes resource name",
clusterClass: builder.ClusterClass(metav1.NamespaceDefault, "class1").
WithInfrastructureClusterTemplate(
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithControlPlaneTemplate(
builder.ControlPlane(metav1.NamespaceDefault, "cp1").Build()).
WithControlPlaneInfrastructureMachineTemplate(
builder.InfrastructureMachinePoolTemplate(metav1.NamespaceDefault, "cpinfra1").Build()).
WithWorkerMachinePoolClasses(
*builder.MachinePoolClass("aa").
WithInfrastructureTemplate(
builder.InfrastructureMachinePoolTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithBootstrapTemplate(
builder.BootstrapTemplate(metav1.NamespaceDefault, "bootstrap1").Build()).
Build()).
Build(),
cluster: builder.Cluster(metav1.NamespaceDefault, "cluster1").
WithTopology(
builder.ClusterTopology().
WithClass("class1").
WithVersion("v1.22.2").
WithMachinePool(
builder.MachinePoolTopology("under_score").
WithClass("aa").
Build()).
Build()).
Build(),
wantErr: true,
},
{
name: "fail if MachinePoolTopology name is not a valid Kubernetes label value",
clusterClass: builder.ClusterClass(metav1.NamespaceDefault, "class1").
WithInfrastructureClusterTemplate(
builder.InfrastructureClusterTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithControlPlaneTemplate(
builder.ControlPlane(metav1.NamespaceDefault, "cp1").Build()).
WithControlPlaneInfrastructureMachineTemplate(
builder.InfrastructureMachinePoolTemplate(metav1.NamespaceDefault, "cpinfra1").Build()).
WithWorkerMachinePoolClasses(
*builder.MachinePoolClass("aa").
WithInfrastructureTemplate(
builder.InfrastructureMachinePoolTemplate(metav1.NamespaceDefault, "infra1").Build()).
WithBootstrapTemplate(
builder.BootstrapTemplate(metav1.NamespaceDefault, "bootstrap1").Build()).
Build()).
Build(),
cluster: builder.Cluster(metav1.NamespaceDefault, "cluster1").
WithTopology(
builder.ClusterTopology().
WithClass("class1").
WithVersion("v1.22.2").
WithMachinePool(
builder.MachinePoolTopology("forward/slash").
WithClass("aa").
Build()).
Build()).
Build(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
84 changes: 84 additions & 0 deletions internal/webhooks/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,90 @@ func TestClusterTopologyValidation(t *testing.T) {
Build()).
Build(),
},
{
name: "should return error when MachineDeploymentTopology name is not a valid Kubernetes resource name",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachineDeployment(
builder.MachineDeploymentTopology("under_score").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should return error when MachinePoolTopology name is not a valid Kubernetes resource name",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachinePool(
builder.MachinePoolTopology("under_score").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should return error when MachineDeploymentTopology name is not a valid Kubernetes label value",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachineDeployment(
builder.MachineDeploymentTopology("forward/slash").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should return error when MachinePoolTopology name is not a valid Kubernetes label value",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachinePool(
builder.MachinePoolTopology("forward/slash").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should return error when MachineDeploymentTopology name exceeds 63 characters (the maximum length of a Kubernetes label value)",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachineDeployment(
builder.MachineDeploymentTopology("thisnameisreallymuchlongerthanthemaximumlengthofsixtythreecharacters").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should return error when MachinePoolTopology name exceeds 63 characters (the maximum length of a Kubernetes label value)",
expectErr: true,
in: builder.Cluster("fooboo", "cluster1").
WithTopology(builder.ClusterTopology().
WithClass("foo").
WithVersion("v1.19.1").
WithMachinePool(
builder.MachinePoolTopology("thisnameisreallymuchlongerthanthemaximumlengthofsixtythreecharacters").
WithClass("aa").
Build()).
Build()).
Build(),
},
{
name: "should update",
expectErr: false,
Expand Down