Skip to content

Conversation

@thiyyakat
Copy link
Member

@thiyyakat thiyyakat commented Dec 10, 2025

What this PR does / why we need it:

This PR introduces a feature that allows operators and endusers to preserve a machine/node and the backing VM for diagnostic purposes.

The expected behaviour, use cases and usage are detailed in the proposal that can be found here

Which issue(s) this PR fixes:
Fixes #1008

Special notes for your reviewer:

Note: This section will be changed once I raise the PR

  1. The code changes have been manually tested using the mcm-provider-virtual.
  2. Some lines have been introduced in machine.go for simulating failure and recovery of nodes with the help of label "test-failed". These will be removed when I raise the PR.
  3. All the unit tests pass
  4. The following cases have been tested manually:
  • When random value assigned to preserve on machine:
    • if node exists with annotation, node value synced
    • print log ()
  • When preserve=now is set on node and machine has no annotations:
    • CA scaledown disable annotations must be set on node
    • Node condition must be set for Preserved
    • annotation should be synced to MC
    • machine status.currentstatus.preserveexpirytime should be set
    • when changed to preserve=false:
      • annotation synced to MC
      • node condition should be set to false
      • CA annotation should be removed
      • machine status.currentstatus.preserveexpirytime should be set to nil (removed)
  • When preserve=now is set on machine and node has no annotations:
    • CA scaledown disable annotations must be set on node
    • Node condition must be set for Preserved
    • Annotations must not be synced to node
    • machine status.currentstatus.preserveexpirytime should be set
    • when changed to preserve=false:
      • annotation should not be synced to node
      • node condition should be set to false
      • CA annotation should be removed
      • machine status.currentstatus.preserveexpirytime should be set to nil (removed)
  • When preserve=when-failed is set on machine:
    • annotation should not be set on the node
    • check if status.currentstatus.phase== failed, if yes:
      • machine status.currentstatus.preserveexpirytime should be set on machine
      • CA annotation should be added on node
      • node condition should be added on node
    • when changed to preserve=false:
      • machine status.currentstatus.preserveexpirytime should be set to nil,if preserved
      • CA annotations should be removed from node and machine
      • if machine still in Failed phase, terminate machine
  • When annotation value for preserve changes from now to when-failed:
    • annotation should be changed on node too
    • check if phase is Failed:
      • if yes, only change preserveexpirytime
      • if no, set preserveexpirytime to nil
  • When annotation value changes from when-failed to now:
    • annotation should be changed on node too
    • check if machine already preserved:
      • update preserveexpirytime if yes
      • if no,
        • set preserveexpirytime
        • CA annotations should be added to node
  • deletion should be de-prioritised in case of k scale mcd , and if machine is preserved
    • in case of scale down anyway, deletion must go through to maintain replica count
  • If a preserved Failed Machine moves to Running, pods must get scheduled on to it
  1. Additionally, I have manually tested if the code interferes with RollingUpdates - it doesn't seem to. I have also checked if drain works, only with the virtual-provider though.

TODO:

  1. If autoscaler annotation is modified by user, it should be reset to true
  2. Add unit tests
  3. Test behaviour if isMachineCandidateForPreservation() in manageReplicas() returns an error.

Release note:


@gardener-robot gardener-robot added kind/api-change API change with impact on API users needs/second-opinion Needs second review by someone else needs/rebase Needs git rebase labels Dec 10, 2025
@gardener-robot
Copy link

@thiyyakat You need rebase this pull request with latest master branch. Please check.

@gardener-robot gardener-robot added needs/review Needs review size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Dec 10, 2025
# Conflicts:
#	pkg/util/provider/machinecontroller/machine.go
#	pkg/util/provider/machinecontroller/machine_util.go
* remove use of machineStatusUpdate in machine preservation code since it uses a similarity check
* introduce check of phase change in updateMachine() to initiate drain of preserved machine on failure. This check is only for preserved machines
* Introduce new annotation value for preservation `PreserveMachineAnnotationValuePreservedByMCM`
* Update Condition.Reason and Condition.Message to reflect preservation by user and auto-preservation
* Update Machine Deployment Spec to include AutoPreservedFailedMachineMax
* Modify MachineSet controller to update status with count of auto-preserved machines
* Add updated CRDs and generated code
* Split larger functions into smaller ones
* Remove debug comments
* Add comments where required
@thiyyakat thiyyakat force-pushed the feat/preserve-machine branch from dece79a to 06ecf58 Compare December 10, 2025 11:47
@thiyyakat thiyyakat force-pushed the feat/preserve-machine branch from 06ecf58 to 89f2900 Compare December 10, 2025 12:06
@thiyyakat
Copy link
Member Author

thiyyakat commented Dec 11, 2025

Questions that remain unanswered:

  1. On recovery of a preserved machine, it transitions from Failed to Running. However, if the preserve annotation was when-failed, then the node continues to be preserved in Running even though the annotation says when-failed - is that okay? The node needs to be preserved so that pods can get scheduled onto it without CA scaling it down.
  2. drain timeout is checked currently by calculating time from LastUpdateTime (from when machine moved to Failed) to now. Is there a better way to do it?
    timeOutOccurred = utiltime.HasTimeOutOccurred(machine.Status.CurrentStatus.LastUpdateTime, timeOutDuration)
    In the normal drain, it is checked wrt DeletionTimestamp
  3. In some parts of the code, checks are performed to see if the returned error is due to a Conflict, and ConflictRetry rather than ShortRetry is returned. When should these checks be performed? The preservation flow has a lot of update calls.

Copy link
Member Author

@thiyyakat thiyyakat left a comment

Choose a reason for hiding this comment

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

Note: A review meeting was held today for this PR. The comments were given during the meeting.

During the meeting, we revisited the decision to move drain to Failed state for preserved machine. The reason discussed previously was that it didn't make sense semantically to move the machine to Terminating and then do the drain, because there is a possibility that the machine may recover. Since Terminating is a final state, the drain (separate from the drain in triggerDeletionFlow) will be performed in Failed phase. There was no change proposed during the meeting. This design decision was only reconfirmed.

Copy link
Member

@takoverflow takoverflow left a comment

Choose a reason for hiding this comment

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

Have only gone through half of the PR, have some suggestions PTAL.

// or if it is a candidate for auto-preservation
// TODO@thiyyakat: find more suitable name for function
func (c *controller) isMachineCandidateForPreservation(ctx context.Context, machineSet *v1alpha1.MachineSet, machine *v1alpha1.Machine) (bool, error) {
if machineutils.IsPreserveExpiryTimeSet(machine) && !machineutils.HasPreservationTimedOut(machine) {
Copy link
Member

Choose a reason for hiding this comment

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

IsPreserveExpiryTimeSet already checks that the time is non-zero, then only HasPreservationTimedOut is called.
Is there any reason to perform the redundant IsZero check for PreserveExpiryTime again in HasPreservationTimedOut?
I don't see the function being called elsewhere as well.

If the zero check is removed, it could just be simplified to

func HasPreservationTimedOut(m *v1alpha1.Machine) bool {
	return !m.Status.CurrentStatus.PreserveExpiryTime.After(time.Now())
}

}
nodeName := machine.Labels[v1alpha1.NodeLabelKey]
if nodeName != "" {
preservedCondition := v1.NodeCondition{
Copy link
Member

Choose a reason for hiding this comment

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

Consider renaming this to preservedConditionFalse?

Comment on lines 2475 to 2493
err := nodeops.AddOrUpdateConditionsOnNode(ctx, c.targetCoreClient, nodeName, preservedCondition)
if err != nil {
return err
}
// Step 2: remove CA's scale-down disabled annotations to allow CA to scale down node if needed
CAAnnotations := make(map[string]string)
CAAnnotations[autoscaler.ClusterAutoscalerScaleDownDisabledAnnotationKey] = ""
latestNode, err := c.targetCoreClient.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{})
if err != nil {
klog.Errorf("error trying to get backing node %q for machine %s. Retrying, error: %v", nodeName, machine.Name, err)
return err
}
latestNodeCopy := latestNode.DeepCopy()
latestNodeCopy, _, _ = annotations.RemoveAnnotation(latestNodeCopy, CAAnnotations) // error can be ignored, always returns nil
_, err = c.targetCoreClient.CoreV1().Nodes().Update(ctx, latestNodeCopy, metav1.UpdateOptions{})
if err != nil {
klog.Errorf("Node UPDATE failed for node %q of machine %q. Retrying, error: %s", nodeName, machine.Name, err)
return err
}
Copy link
Member

@takoverflow takoverflow Dec 18, 2025

Choose a reason for hiding this comment

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

Is there a reason why there are two get and update calls made for a node, can these not be combined into a single atomic node object update?

And I know this is not part of your PR but can we update this RemoveAnnotation function, it's needlessly complicated.
All you have to do after fetching the object and checking that annotations are non-nil is

delete(obj.Annotations, annotationKey)

Creating a dummy annotation map, then passing it and then creating a new map which doesn't have the key. All of this complication can be avoided.

Copy link
Member Author

@thiyyakat thiyyakat Dec 23, 2025

Choose a reason for hiding this comment

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

By 2 Get() calls are you referring to the call within AddOrUpdateConditionsOnNode and the following Get() here:
latestNode, err := c.targetCoreClient.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{})?

The first one can be avoided if we didn't use the function. The second one is required because step 1 adds conditions to the node object, and the function does not return the updated node object. Fetching from the cache doesn't guarantee an up-to-date node object (tested this out empirically). I could potentially avoid fetching the objects if I didn't use the function. Will test it out.

The two update calls cannot be combined since step 1 requires an UpdateStatus() call, and step 2 updates the Spec, and requires an Update() call.

I will update the RemoveAnnotation function as recommended by you.

Edit: The RemoveAnnotation function returns a boolean indicating whether or not an update is needed. This value is being used in other usages of the function. The function cannot be updated. I will use your suggestion instead of using the function since the boolean value is not required in this case.

// stopMachinePreservation stops the preservation of the machine and node
func (c *controller) stopMachinePreservation(ctx context.Context, machine *v1alpha1.Machine) error {
// check if preserveExpiryTime is set, if not, no need to do anything
if !machineutils.IsPreserveExpiryTimeSet(machine) {
Copy link
Member

Choose a reason for hiding this comment

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

Can there be scenarios where the preserveExpiryTime hasn't been set but the node has preserve conditions
and scale-down disabled annotation added to it? If so, then the removal will never proceed right?

Please let me know if it's not a possible scenario.

Copy link
Member Author

Choose a reason for hiding this comment

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

The setting of the preserveExpiryTime is the first step in machine preservation. Node conditions and the CA annotation are added only if the step 1 completes successfully. However, if a user manually adds the CA annotation and the node condition, but not the preserveExpiryTime then the case you described may occur. I'm not sure we should handle that case though.

if nodeName == "" && isExpirySet {
return true, nil
}
node, err := c.nodeLister.Get(nodeName)
Copy link
Member

Choose a reason for hiding this comment

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

What happens when a machine doesn't have the nodeName set i.e. nodeName is "" and isExpirySet is false.
Wouldn't this always fail? Why even try to get the node in that case?

Why not move the check above outside of this function i.e. inside preserveMachine, fetch the nodeName and
use isExpirySet. WDYT?

	if nodeName == "" && isExpirySet {
		return true, nil
	}
    if isExpirySet {
        isComplete, err := c.isMachinePreservationComplete(machine, nodeName)
	    if err != nil {
		    return err
	    }
    }

Comment on lines 996 to 1009
// if machine is preserved, stop preservation. Else, do nothing.
// this check is done in case the annotation value has changed from preserve=now to preserve=when-failed, in which case preservation needs to be stopped
preserveExpirySet := machineutils.IsPreserveExpiryTimeSet(clone)
machineFailed := machineutils.IsMachineFailed(clone)
if !preserveExpirySet && !machineFailed {
return
} else if !preserveExpirySet {
err = c.preserveMachine(ctx, clone, preserveValue)
return
}
// Here, we do not stop preservation even when preserve expiry time is set but the machine is in Running.
// This is to accommodate the case where the annotation is when-failed and the machine has recovered from Failed to Running.
// In this case, we want the preservation to continue so that CA does not scale down the node before pods are assigned to it
return
Copy link
Member

@takoverflow takoverflow Dec 18, 2025

Choose a reason for hiding this comment

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

Please revisit this case, the comments and the code seem to contradict each other, if you wish to compare oldMachine annotation value with the newMachine
to make decisions to stop preservation etc, consider utilising updateMachine which would have both objects available.

@gardener-robot gardener-robot added the needs/changes Needs (more) changes label Dec 18, 2025
@thiyyakat thiyyakat force-pushed the feat/preserve-machine branch from 22c646e to 7c062b5 Compare December 19, 2025 08:30
* fix edge case of handling switch from preserve=now to when-failed
* Create map in package with valid preserve annotation values
* Fix big where node condition's reason wouldn't get updated after toggling of preservation
* remove duplicate function to check preservation timeout
* rename variables
* reduce get calls
* remove usage of RemoveAnnotations()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/api-change API change with impact on API users needs/changes Needs (more) changes needs/rebase Needs git rebase needs/review Needs review needs/second-opinion Needs second review by someone else size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Preservation of Failed Machines for diagnostics

3 participants