diff --git a/api/v1beta1/conversion.go b/api/v1beta1/conversion.go index 3a7a039877e1..fac29f39f3b9 100644 --- a/api/v1beta1/conversion.go +++ b/api/v1beta1/conversion.go @@ -573,3 +573,11 @@ func Convert_v1beta1_Condition_To_v1_Condition(_ *Condition, _ *metav1.Condition // NOTE: legacy (v1beta1) conditions should not be automatically converted into v1beta2 conditions. return nil } + +func Convert_v1beta2_Topology_To_v1beta1_Topology(in *clusterv1.Topology, out *Topology, s apimachineryconversion.Scope) error { + if err := autoConvert_v1beta2_Topology_To_v1beta1_Topology(in, out, s); err != nil { + return err + } + + return nil +} diff --git a/api/v1beta1/zz_generated.conversion.go b/api/v1beta1/zz_generated.conversion.go index d97375e884f0..703a885fb4df 100644 --- a/api/v1beta1/zz_generated.conversion.go +++ b/api/v1beta1/zz_generated.conversion.go @@ -1431,7 +1431,15 @@ func autoConvert_v1beta1_ClusterSpec_To_v1beta2_ClusterSpec(in *ClusterSpec, out } out.ControlPlaneRef = (*corev1.ObjectReference)(unsafe.Pointer(in.ControlPlaneRef)) out.InfrastructureRef = (*corev1.ObjectReference)(unsafe.Pointer(in.InfrastructureRef)) - out.Topology = (*v1beta2.Topology)(unsafe.Pointer(in.Topology)) + if in.Topology != nil { + in, out := &in.Topology, &out.Topology + *out = new(v1beta2.Topology) + if err := Convert_v1beta1_Topology_To_v1beta2_Topology(*in, *out, s); err != nil { + return err + } + } else { + out.Topology = nil + } out.AvailabilityGates = *(*[]v1beta2.ClusterAvailabilityGate)(unsafe.Pointer(&in.AvailabilityGates)) return nil } @@ -1449,7 +1457,15 @@ func autoConvert_v1beta2_ClusterSpec_To_v1beta1_ClusterSpec(in *v1beta2.ClusterS } out.ControlPlaneRef = (*corev1.ObjectReference)(unsafe.Pointer(in.ControlPlaneRef)) out.InfrastructureRef = (*corev1.ObjectReference)(unsafe.Pointer(in.InfrastructureRef)) - out.Topology = (*Topology)(unsafe.Pointer(in.Topology)) + if in.Topology != nil { + in, out := &in.Topology, &out.Topology + *out = new(Topology) + if err := Convert_v1beta2_Topology_To_v1beta1_Topology(*in, *out, s); err != nil { + return err + } + } else { + out.Topology = nil + } out.AvailabilityGates = *(*[]ClusterAvailabilityGate)(unsafe.Pointer(&in.AvailabilityGates)) return nil } @@ -3442,14 +3458,10 @@ func autoConvert_v1beta2_Topology_To_v1beta1_Topology(in *v1beta2.Topology, out } out.Workers = (*WorkersTopology)(unsafe.Pointer(in.Workers)) out.Variables = *(*[]ClusterVariable)(unsafe.Pointer(&in.Variables)) + // WARNING: in.NodeDeletionStrategy requires manual conversion: does not exist in peer-type return nil } -// Convert_v1beta2_Topology_To_v1beta1_Topology is an autogenerated conversion function. -func Convert_v1beta2_Topology_To_v1beta1_Topology(in *v1beta2.Topology, out *Topology, s conversion.Scope) error { - return autoConvert_v1beta2_Topology_To_v1beta1_Topology(in, out, s) -} - func autoConvert_v1beta1_UnhealthyCondition_To_v1beta2_UnhealthyCondition(in *UnhealthyCondition, out *v1beta2.UnhealthyCondition, s conversion.Scope) error { out.Type = corev1.NodeConditionType(in.Type) out.Status = corev1.ConditionStatus(in.Status) diff --git a/api/v1beta2/cluster_types.go b/api/v1beta2/cluster_types.go index c48ff2f8e36e..de8f4fd2c116 100644 --- a/api/v1beta2/cluster_types.go +++ b/api/v1beta2/cluster_types.go @@ -585,6 +585,15 @@ type Topology struct { // +listMapKey=name // +kubebuilder:validation:MaxItems=1000 Variables []ClusterVariable `json:"variables,omitempty"` + + // nodeDeletionStrategy specifies the strategy to delete nodes in the cluster. + // Valid values are Force, Graceful and omitted. + // When omitted, the default behaviour will be Force. + // Graceful means that nodes will be deleted with drain. + // Force means that nodes will be deleted immediately without drain. + // +optional + // +kubebuilder:validation:Enum=force;graceful + NodeDeletionStrategy NodeDeletionStrategyType `json:"nodeDeletionStrategy,omitempty"` } // ControlPlaneTopology specifies the parameters for the control plane nodes in the cluster. @@ -901,6 +910,16 @@ type MachinePoolVariables struct { Overrides []ClusterVariable `json:"overrides,omitempty"` } +// NodeDeletionStrategyType defines type of NodeDeletionStrategy. +type NodeDeletionStrategyType string + +const ( + // NodeDeletionStrategyForce defines a force type strategy that node will be deleted immediately without drain. + NodeDeletionStrategyForce NodeDeletionStrategyType = "Force" + // NodeDeletionStrategyGraceful defines a graceful type strategy that node will be deleted with drain. + NodeDeletionStrategyGraceful NodeDeletionStrategyType = "Graceful" +) + // ANCHOR_END: ClusterSpec // ANCHOR: ClusterNetwork diff --git a/api/v1beta2/zz_generated.openapi.go b/api/v1beta2/zz_generated.openapi.go index 1f585c6fbfbe..652de441bc45 100644 --- a/api/v1beta2/zz_generated.openapi.go +++ b/api/v1beta2/zz_generated.openapi.go @@ -4904,6 +4904,13 @@ func schema_sigsk8sio_cluster_api_api_v1beta2_Topology(ref common.ReferenceCallb }, }, }, + "nodeDeletionStrategy": { + SchemaProps: spec.SchemaProps{ + Description: "nodeDeletionStrategy specifies the strategy to delete nodes in the cluster. Valid values are Force, Graceful and omitted. When omitted, the default behaviour will be Force. Graceful means that nodes will be deleted with drain. Force means that nodes will be deleted immediately without drain.", + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"class", "version"}, }, diff --git a/config/crd/bases/cluster.x-k8s.io_clusters.yaml b/config/crd/bases/cluster.x-k8s.io_clusters.yaml index f1e5cc17e5e9..e65c61393af5 100644 --- a/config/crd/bases/cluster.x-k8s.io_clusters.yaml +++ b/config/crd/bases/cluster.x-k8s.io_clusters.yaml @@ -2682,6 +2682,17 @@ spec: x-kubernetes-list-type: map type: object type: object + nodeDeletionStrategy: + description: |- + nodeDeletionStrategy specifies the strategy to delete nodes in the cluster. + Valid values are Force, Graceful and omitted. + When omitted, the default behaviour will be Force. + Graceful means that nodes will be deleted with drain. + Force means that nodes will be deleted immediately without drain. + enum: + - force + - graceful + type: string rolloutAfter: description: |- rolloutAfter performs a rollout of the entire cluster one component at a time, diff --git a/internal/apis/core/v1alpha4/zz_generated.conversion.go b/internal/apis/core/v1alpha4/zz_generated.conversion.go index f28c0b10647b..8cb30d32c358 100644 --- a/internal/apis/core/v1alpha4/zz_generated.conversion.go +++ b/internal/apis/core/v1alpha4/zz_generated.conversion.go @@ -1884,6 +1884,7 @@ func autoConvert_v1beta2_Topology_To_v1alpha4_Topology(in *v1beta2.Topology, out out.Workers = nil } // WARNING: in.Variables requires manual conversion: does not exist in peer-type + // WARNING: in.NodeDeletionStrategy requires manual conversion: does not exist in peer-type return nil } diff --git a/internal/controllers/machine/machine_controller.go b/internal/controllers/machine/machine_controller.go index 4456c45dad23..90079020c474 100644 --- a/internal/controllers/machine/machine_controller.go +++ b/internal/controllers/machine/machine_controller.go @@ -714,8 +714,8 @@ func (r *Reconciler) nodeVolumeDetachTimeoutExceeded(machine *clusterv1.Machine) // and if the Machine is not the last control plane node in the cluster. func (r *Reconciler) isDeleteNodeAllowed(ctx context.Context, cluster *clusterv1.Cluster, machine *clusterv1.Machine) error { log := ctrl.LoggerFrom(ctx) - // Return early if the cluster is being deleted. - if !cluster.DeletionTimestamp.IsZero() { + // Return early if the cluster is being deleted and cluster's nodeDeletionStrategy is not set or set to `force`. + if !cluster.DeletionTimestamp.IsZero() && (cluster.Spec.Topology == nil || cluster.Spec.Topology.NodeDeletionStrategy != clusterv1.NodeDeletionStrategyGraceful) { return errClusterIsBeingDeleted } diff --git a/internal/controllers/machine/machine_controller_test.go b/internal/controllers/machine/machine_controller_test.go index ef164675515a..5a5795bb97d4 100644 --- a/internal/controllers/machine/machine_controller_test.go +++ b/internal/controllers/machine/machine_controller_test.go @@ -2664,10 +2664,66 @@ func TestIsDeleteNodeAllowed(t *testing.T) { DeletionTimestamp: &deletionts, Finalizers: []string{clusterv1.ClusterFinalizer}, }, + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{}, + }, }, machine: &clusterv1.Machine{}, expectedError: errClusterIsBeingDeleted, }, + { + name: "has nodeRef and cluster is being deleted, nodeDeletionStrategy set to force", + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: metav1.NamespaceDefault, + DeletionTimestamp: &deletionts, + Finalizers: []string{clusterv1.ClusterFinalizer}, + }, + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + NodeDeletionStrategy: clusterv1.NodeDeletionStrategyForce, + }, + }, + }, + machine: &clusterv1.Machine{}, + expectedError: errClusterIsBeingDeleted, + }, + { + name: "has nodeRef and control plane is healthy, with nodeDeletionStrategy set to graceful", + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: metav1.NamespaceDefault, + }, + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + NodeDeletionStrategy: clusterv1.NodeDeletionStrategyForce, + }, + }, + }, + machine: &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "created", + Namespace: metav1.NamespaceDefault, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: "test-cluster", + }, + Finalizers: []string{clusterv1.MachineFinalizer}, + }, + Spec: clusterv1.MachineSpec{ + ClusterName: "test-cluster", + InfrastructureRef: corev1.ObjectReference{}, + Bootstrap: clusterv1.Bootstrap{DataSecretName: ptr.To("data")}, + }, + Status: clusterv1.MachineStatus{ + NodeRef: &corev1.ObjectReference{ + Name: "test", + }, + }, + }, + expectedError: nil, + }, { name: "has nodeRef and control plane is healthy and externally managed", cluster: &clusterv1.Cluster{