Browse Source

Add support for CPU ranges in desired nodes (#86434)

This commit adds support for CPU ranges in the desired nodes API. 

This aligns better with environments where administrators/orchestrators
can define lower and upper bounds for the amount of CPUs that the
desired node would get once deployed. 

This allows to provide information about the expected CPU and possible
allowed overcommit that the desired node will run on.

This was the previous expected body for the desired nodes API (we still support it):
```
PUT /_internal/desired_nodes/history/1
{
    "nodes" : [
        {
            "settings" : {
                 "node.name" : "instance-000187",
                 "node.external_id": "instance-000187",
                 "node.roles" : ["data_hot", "master"],
                 "node.attr.data" : "hot",
                 "node.attr.logical_availability_zone" : "zone-0"
            },
            "processors" : 8, 
            "memory" : "58gb",
            "storage" : "1700gb",
            "node_version" : "8.3.0"
        }
    ]
}
```

Now it's possible to define `processors` or `processors_range` as in:
```
PUT /_internal/desired_nodes/history/1
{
    "nodes" : [
        {
            "settings" : {
                 "node.name" : "instance-000187",
                 "node.external_id": "instance-000187",
                 "node.roles" : ["data_hot", "master"],
                 "node.attr.data" : "hot",
                 "node.attr.logical_availability_zone" : "zone-0"
            },
            "processors_range" : {"min": 8.0, "max": 16.0},
            "memory" : "58gb",
            "storage" : "1700gb",
            "node_version" : "8.3.0"
        }
    ]
}
```
Note that `max` in `processors_range` is optional.

This commit also moves from representing CPUs as integers to
accept floating point numbers.

Note: I disabled the bwc yamlRestTests for versions < 8.3 since we introduced
a few "breaking changes" but since this is an internal API it should be fine.
Francisco Fernández Castaño 3 years ago
parent
commit
e91e7e653b
17 changed files with 1042 additions and 124 deletions
  1. 5 0
      docs/changelog/86434.yaml
  2. 2 2
      docs/reference/cluster/delete-desired-nodes.asciidoc
  3. 2 2
      docs/reference/cluster/get-desired-nodes.asciidoc
  4. 32 4
      docs/reference/cluster/update-desired-nodes.asciidoc
  5. 1 1
      libs/x-content/src/main/java/org/elasticsearch/xcontent/ObjectParser.java
  6. 1 1
      modules/reindex/src/main/java/org/elasticsearch/reindex/remote/RemoteResponseParsers.java
  7. 117 0
      qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/DesiredNodesUpgradeIT.java
  8. 442 62
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/10_basic.yml
  9. 16 0
      server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesAction.java
  10. 9 1
      server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequest.java
  11. 4 2
      server/src/main/java/org/elasticsearch/cluster/desirednodes/DesiredNodesSettingsValidator.java
  12. 241 37
      server/src/main/java/org/elasticsearch/cluster/metadata/DesiredNode.java
  13. 1 1
      server/src/main/java/org/elasticsearch/cluster/metadata/DesiredNodes.java
  14. 11 1
      server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequestTests.java
  15. 1 1
      server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodeSerializationTests.java
  16. 131 2
      server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodeTests.java
  17. 26 7
      server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodesTestCase.java

+ 5 - 0
docs/changelog/86434.yaml

@@ -0,0 +1,5 @@
+pr: 86434
+summary: Add support for CPU ranges in desired nodes
+area: Autoscaling
+type: enhancement
+issues: []

+ 2 - 2
docs/reference/cluster/delete-desired-nodes.asciidoc

@@ -25,9 +25,9 @@ PUT /_internal/desired_nodes/history/1
                  "node.attr.data" : "hot",
                  "node.attr.logical_availability_zone" : "zone-0"
             },
-            "processors" : 8,
+            "processors" : 8.0,
             "memory" : "58gb",
-            "storage" : "1700gb",
+            "storage" : "2tb",
             "node_version" : "{version}"
         }
     ]

+ 2 - 2
docs/reference/cluster/get-desired-nodes.asciidoc

@@ -25,9 +25,9 @@ PUT /_internal/desired_nodes/my_history/1
                  "node.attr.data" : "hot",
                  "node.attr.logical_availability_zone" : "zone-0"
             },
-            "processors" : 8,
+            "processors" : 8.0,
             "memory" : "59gb",
-            "storage" : "1700gb",
+            "storage" : "2tb",
             "node_version" : "{version}"
         }
     ]

+ 32 - 4
docs/reference/cluster/update-desired-nodes.asciidoc

@@ -24,9 +24,9 @@ PUT /_internal/desired_nodes/<history_id>/<version>
                  "node.attr.data" : "hot",
                  "node.attr.logical_availability_zone" : "zone-0"
             },
-            "processors" : 8,
+            "processors" : 8.0,
             "memory" : "58gb",
-            "storage" : "1700gb",
+            "storage" : "2tb",
             "node_version" : "{version}"
         }
     ]
@@ -77,9 +77,9 @@ PUT /_internal/desired_nodes/Ywkh3INLQcuPT49f6kcppA/100
                  "node.attr.data" : "hot",
                  "node.attr.logical_availability_zone" : "zone-0"
             },
-            "processors" : 8,
+            "processors" : 8.0,
             "memory" : "58gb",
-            "storage" : "1700gb",
+            "storage" : "2tb",
             "node_version" : "{version}"
         }
     ]
@@ -96,6 +96,34 @@ The API returns the following result:
 }
 --------------------------------------------------
 
+Additionally, it is possible to specify a processors range.
+This is helpful in environments where Elasticsearch nodes can
+be deployed in hosts where the number of processors that the
+Elasticsearch process can use is guaranteed to be at least the
+lower range and up to the upper range. This is a common scenario
+in Linux deployments where cgroups is used.
+[source,console]
+--------------------------------------------------
+PUT /_internal/desired_nodes/Ywkh3INLQcuPT49f6kcppA/101
+{
+    "nodes" : [
+        {
+            "settings" : {
+                 "node.name" : "instance-000187",
+                 "node.external_id": "instance-000187",
+                 "node.roles" : ["data_hot", "master"],
+                 "node.attr.data" : "hot",
+                 "node.attr.logical_availability_zone" : "zone-0"
+            },
+            "processors_range" : {"min": 8.0, "max": 10.0},
+            "memory" : "58gb",
+            "storage" : "2tb",
+            "node_version" : "{version}"
+        }
+    ]
+}
+--------------------------------------------------
+
 //////////////////////////
 
 [source,console]

+ 1 - 1
libs/x-content/src/main/java/org/elasticsearch/xcontent/ObjectParser.java

@@ -751,7 +751,7 @@ public final class ObjectParser<Value, Context> extends AbstractObjectParser<Val
         OBJECT_ARRAY_OR_NULL(START_OBJECT, START_ARRAY, VALUE_NULL),
         OBJECT_OR_BOOLEAN(START_OBJECT, VALUE_BOOLEAN),
         OBJECT_OR_STRING(START_OBJECT, VALUE_STRING),
-        OBJECT_OR_LONG(START_OBJECT, VALUE_NUMBER),
+        OBJECT_OR_NUMBER(START_OBJECT, VALUE_NUMBER),
         OBJECT_ARRAY_BOOLEAN_OR_STRING(START_OBJECT, START_ARRAY, VALUE_BOOLEAN, VALUE_STRING),
         OBJECT_ARRAY_OR_STRING(START_OBJECT, START_ARRAY, VALUE_STRING),
         OBJECT_ARRAY_STRING_OR_NUMBER(START_OBJECT, START_ARRAY, VALUE_STRING, VALUE_NUMBER),

+ 1 - 1
modules/reindex/src/main/java/org/elasticsearch/reindex/remote/RemoteResponseParsers.java

@@ -102,7 +102,7 @@ final class RemoteResponseParsers {
                 // For BWC with nodes pre 7.0
                 return p.longValue();
             }
-        }, new ParseField("total"), ValueType.OBJECT_OR_LONG);
+        }, new ParseField("total"), ValueType.OBJECT_OR_NUMBER);
         HITS_PARSER.declareObjectArray(constructorArg(), HIT_PARSER, new ParseField("hits"));
     }
 

+ 117 - 0
qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/DesiredNodesUpgradeIT.java

@@ -0,0 +1,117 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.upgrades;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.cluster.metadata.DesiredNode;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+public class DesiredNodesUpgradeIT extends AbstractRollingTestCase {
+    public void testUpgradeDesiredNodes() throws Exception {
+        // Desired nodes was introduced in 8.1
+        if (UPGRADE_FROM_VERSION.before(Version.V_8_1_0)) {
+            return;
+        }
+
+        switch (CLUSTER_TYPE) {
+            case OLD -> {
+                var response = updateDesiredNodes(1, desiredNodesWithIntegerProcessor());
+                var statusCode = response.getStatusLine().getStatusCode();
+                assertThat(statusCode, equalTo(200));
+            }
+            case MIXED -> {
+                final var historyVersion = FIRST_MIXED_ROUND ? 2 : 3;
+                if (UPGRADE_FROM_VERSION.onOrAfter(DesiredNode.RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
+                    var response = updateDesiredNodes(historyVersion, desiredNodesWithRangeOrFloatProcessors());
+                    var statusCode = response.getStatusLine().getStatusCode();
+                    assertThat(statusCode, equalTo(200));
+                } else {
+                    // Processor ranges or float processors are forbidden during upgrades: 8.2 -> 8.3 clusters
+                    final var responseException = expectThrows(
+                        ResponseException.class,
+                        () -> updateDesiredNodes(historyVersion, desiredNodesWithRangeOrFloatProcessors())
+                    );
+                    var statusCode = responseException.getResponse().getStatusLine().getStatusCode();
+                    assertThat(statusCode, is(equalTo(400)));
+                }
+            }
+            case UPGRADED -> {
+                var response = updateDesiredNodes(4, desiredNodesWithRangeOrFloatProcessors());
+                var statusCode = response.getStatusLine().getStatusCode();
+                assertThat(statusCode, equalTo(200));
+            }
+        }
+
+        final var getDesiredNodesRequest = new Request("GET", "/_internal/desired_nodes/_latest");
+        Response response = client().performRequest(getDesiredNodesRequest);
+        assertThat(response.getStatusLine().getStatusCode(), is(equalTo(200)));
+    }
+
+    private Response updateDesiredNodes(int version, String body) throws Exception {
+        final var updateDesiredNodesRequest = new Request("PUT", "/_internal/desired_nodes/history/" + version);
+        updateDesiredNodesRequest.setJsonEntity(body);
+        return client().performRequest(updateDesiredNodesRequest);
+    }
+
+    private String desiredNodesWithRangeOrFloatProcessors() {
+        if (randomBoolean()) {
+            return """
+                {
+                    "nodes" : [
+                        {
+                            "settings" : {
+                                 "node.name" : "instance-000187"
+                            },
+                            "processors_range" : {"min": 9.0, "max": 10.0},
+                            "memory" : "58gb",
+                            "storage" : "1tb",
+                            "node_version" : "99.1.0"
+                        }
+                    ]
+                }""";
+        } else {
+            return """
+                {
+                    "nodes" : [
+                        {
+                            "settings" : {
+                                 "node.name" : "instance-000187"
+                            },
+                            "processors" : 9.5,
+                            "memory" : "58gb",
+                            "storage" : "1tb",
+                            "node_version" : "99.1.0"
+                        }
+                    ]
+                }""";
+        }
+    }
+
+    private String desiredNodesWithIntegerProcessor() {
+        return """
+            {
+                "nodes" : [
+                    {
+                        "settings" : {
+                             "node.name" : "instance-000187"
+                        },
+                        "processors" : 9,
+                        "memory" : "58gb",
+                        "storage" : "1tb",
+                        "node_version" : "99.1.0"
+                    }
+                ]
+            }""";
+    }
+}

+ 442 - 62
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/10_basic.yml

@@ -1,8 +1,8 @@
 ---
 setup:
   - skip:
-      version: " - 8.0.99"
-      reason: "API added in in 8.1.0"
+      version: " - 8.2.99"
+      reason: "API added in in 8.1.0 but modified in 8.3"
 ---
 teardown:
   - do:
@@ -28,7 +28,7 @@ teardown:
         version: 1
         body:
             nodes:
-              - { settings: { "node.name": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+              - { settings: { "node.name": "instance-000187" }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -38,7 +38,7 @@ teardown:
         history_id: "test"
         version: 1
         nodes:
-          - { settings: { node: { name: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+          - { settings: { node: { name: "instance-000187" } }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
 
   - do:
       _internal.update_desired_nodes:
@@ -46,8 +46,8 @@ teardown:
         version: 2
         body:
           nodes:
-            - { settings: { "node.name": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.name": "instance-000188" }, processors: 16, memory: "128gb", storage: "1tb", node_version: $es_version }
+            - { settings: { "node.name": "instance-000187" }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.name": "instance-000188" }, processors: 16.0, memory: "128gb", storage: "1tb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -56,8 +56,8 @@ teardown:
   - match: { history_id: "test" }
   - match: { version: 2 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { name: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { name: "instance-000188" } }, processors: 16, memory: "128gb", storage: "1tb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { name: "instance-000187" } }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { name: "instance-000188" } }, processors: 16.0, memory: "128gb", storage: "1tb", node_version: $es_version } }
 ---
 "Test update move to a new history id":
   - skip:
@@ -88,7 +88,7 @@ teardown:
         history_id: "test"
         version: 1
         nodes:
-          - { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+          - { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 
   - do:
       _internal.update_desired_nodes:
@@ -105,8 +105,8 @@ teardown:
   - match: { history_id: "new_history" }
   - match: { version: 1 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 16, memory: "128gb", storage: "1tb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 16.0, memory: "128gb", storage: "1tb", node_version: $es_version } }
 ---
 "Test delete desired nodes":
   - do:
@@ -124,7 +124,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -134,7 +134,7 @@ teardown:
         history_id: "test"
         version: 1
         nodes:
-          - { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+          - { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 
   - do:
       _internal.delete_desired_nodes: {}
@@ -163,8 +163,8 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.external_id": "instance-000188" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000188" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -173,8 +173,8 @@ teardown:
   - match: { history_id: "test" }
   - match: { version: 1 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
 
   - do:
       _internal.update_desired_nodes:
@@ -182,8 +182,8 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.external_id": "instance-000188" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000188" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 
   - match: { replaced_existing_history_id: false }
 
@@ -193,14 +193,13 @@ teardown:
   - match: { history_id: "test" }
   - match: { version: 1 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
 ---
 "Test update desired nodes is idempotent with different order":
   - skip:
-      version: " - 8.2.99"
       features: contains
-      reason: "Bug fixed in 8.3.0 and uses contains feature"
+      reason: "Uses contains feature"
   - do:
       cluster.state: {}
 
@@ -216,8 +215,8 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.external_id": "instance-000188" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000188" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -226,8 +225,8 @@ teardown:
   - match: { history_id: "test" }
   - match: { version: 1 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
 
   - do:
       _internal.update_desired_nodes:
@@ -235,8 +234,8 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000188" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000188" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 
   - match: { replaced_existing_history_id: false }
 
@@ -246,8 +245,8 @@ teardown:
   - match: { history_id: "test" }
   - match: { version: 1 }
   - length: { nodes: 2 }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
-  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
+  - contains: { nodes: { settings: { node: { external_id: "instance-000188" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version } }
 ---
 "Test going backwards within the same history is forbidden":
   - do:
@@ -265,7 +264,7 @@ teardown:
         version: 2
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -275,7 +274,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187", "http.tcp.keep_idle": 100 }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187", "http.tcp.keep_idle": 100 }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 409 }
   - match: { error.type: version_conflict_exception }
   - match: { error.reason: "version [1] has been superseded by version [2] for history [test]" }
@@ -287,7 +286,7 @@ teardown:
         history_id: "test"
         version: 2
         nodes:
-          - { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+          - { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 ---
 "Test using the same version with different definition is forbidden":
   - do:
@@ -305,7 +304,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { replaced_existing_history_id: false }
 
   - do:
@@ -315,7 +314,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 64, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 64.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Desired nodes with history [test] and version [1] already exists with a different definition" }
@@ -327,7 +326,7 @@ teardown:
         history_id: "test"
         version: 1
         nodes:
-          - { settings: { node: { external_id: "instance-000187" } }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+          - { settings: { node: { external_id: "instance-000187" } }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
 ---
 "Test settings are validated":
   - do:
@@ -346,7 +345,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187", "http.tcp.keep_idle": -1000 }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187", "http.tcp.keep_idle": -1000 }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Nodes with ids [instance-000187] in positions [0] contain invalid settings" }
@@ -369,7 +368,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187", "unknown_setting": -1000 }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187", "unknown_setting": -1000 }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Nodes with ids [instance-000187] in positions [0] contain invalid settings" }
@@ -382,7 +381,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187", "unknown_setting": -1000 }, processors: 8, memory: "64gb", storage: "128gb", node_version: "99.1.0" }
+            - { settings: { "node.external_id": "instance-000187", "unknown_setting": -1000 }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: "99.1.0" }
   - match: { replaced_existing_history_id: false }
 ---
 "Test some settings can be overridden":
@@ -405,9 +404,6 @@ teardown:
   - match: { replaced_existing_history_id: false }
 ---
 "Test external_id or node.name is required":
-  - skip:
-      version: " - 8.2.99"
-      reason: "Change error code in 8.3"
   - do:
       cluster.state: {}
 
@@ -424,15 +420,12 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
-  - match: { error.caused_by.caused_by.caused_by.reason: "[node.name] or [node.external_id] is missing or empty" }
+  - match: { error.caused_by.caused_by.reason: "[node.name] or [node.external_id] is missing or empty" }
 ---
 "Test external_id must have content":
-  - skip:
-      version: " - 8.2.99"
-      reason: "Change error code in 8.3"
   - do:
       cluster.state: {}
 
@@ -449,10 +442,10 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "  " }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "  " }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
-  - match: { error.caused_by.caused_by.caused_by.reason: "[node.name] or [node.external_id] is missing or empty" }
+  - match: { error.caused_by.caused_by.reason: "[node.name] or [node.external_id] is missing or empty" }
 ---
 "Test duplicated external ids are not allowed":
   - do:
@@ -471,8 +464,8 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187"}, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
-            - { settings: { "node.external_id": "instance-000187"}, processors: 16, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187"}, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187"}, processors: 16.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Some nodes contain the same setting value [instance-000187] for [node.external_id]" }
@@ -494,7 +487,7 @@ teardown:
         version: "asa"
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187"}, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187"}, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Failed to parse long parameter [version] with value [asa]" }
@@ -516,7 +509,7 @@ teardown:
         version: -1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187"}, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187"}, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: action_request_validation_exception }
   - match: { error.reason: "Validation Failed: 1: version must be positive;" }
@@ -529,7 +522,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187"}, processors: 8, memory: "64gb", storage: "128gb", node_version: "7.16.0" }
+            - { settings: { "node.external_id": "instance-000187"}, processors: 8.0, memory: "64gb", storage: "128gb", node_version: "7.16.0" }
   - match: { status: 400 }
   - match: { error.type: illegal_argument_exception }
   - match: { error.reason: "Nodes with ids [instance-000187] in positions [0] contain invalid settings" }
@@ -553,7 +546,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: action_request_validation_exception }
   - match: { error.reason: "Validation Failed: 1: historyID should not be empty;" }
@@ -587,7 +580,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: { "node.external_id": "instance-000187", "node.roles": "data_hot" }, processors: 8, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: { "node.external_id": "instance-000187", "node.roles": "data_hot" }, processors: 8.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: action_request_validation_exception }
   - match: { error.reason: "Validation Failed: 1: nodes must contain at least one master node;" }
@@ -609,7 +602,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { processors: 64, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { processors: 64.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
 ---
@@ -630,7 +623,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: null, processors: 64, memory: "64gb", storage: "128gb", node_version: $es_version }
+            - { settings: null, processors: 64.0, memory: "64gb", storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
 ---
@@ -672,7 +665,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: {}, processors: 8, storage: "128gb", node_version: $es_version }
+            - { settings: {}, processors: 8.0, storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
 ---
@@ -693,7 +686,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: {}, processors: 8, memory: null, storage: "128gb", node_version: $es_version }
+            - { settings: {}, processors: 8.0, memory: null, storage: "128gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
 ---
@@ -714,7 +707,7 @@ teardown:
         version: 1
         body:
           nodes:
-            - { settings: {}, processors: 8, memory: "64gb", node_version: $es_version }
+            - { settings: {}, processors: 8.0, memory: "64gb", node_version: $es_version }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
 ---
@@ -774,3 +767,390 @@ teardown:
             - { settings: { "node.external_id": "instance-000187"}, processors: 64, memory: "1b", storage: "1b", node_version: null }
   - match: { status: 400 }
   - match: { error.type: x_content_parse_exception }
+---
+"Test update desired nodes using processors range field":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 16.0, max: 20.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { replaced_existing_history_id: false }
+
+  - do:
+      _internal.get_desired_nodes: {}
+  - match:
+      $body:
+        history_id: "test"
+        version: 1
+        nodes:
+          - { settings: { node: { name: "instance-000187" } }, processors_range: {min: 16.0, max: 20.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+---
+"Test processors min and max are required":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: { }, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors min is required":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {max: 8.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors max is not required":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 8.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+
+  - do:
+      _internal.get_desired_nodes: {}
+  - match:
+      $body:
+        history_id: "test"
+        version: 1
+        nodes:
+          - { settings: { node: { name: "instance-000187" } }, processors_range: {min: 8.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+---
+"Test min processors should be less than or equal to max processors":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 8.0, max: 1.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors reject NaN":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors: NaN, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors reject positive infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors: Infinity, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors reject negative infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors: -Infinity, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range min reject NaN":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: NaN, max: 1.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range min reject positive infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: Infinity, max: 1.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range min reject negative infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: -Infinity, max: 1.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range min rejects 0.0":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 0.0, max: 1.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range max reject NaN":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 1.0, max: NaN}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range max reject positive infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 1.0, max: Infinity}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range max reject negative infinity number":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 1.0, max: -Infinity}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors range max rejects 0.0":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors_range: {min: 1.0, max: 0.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }
+---
+"Test processors and processors range are exclusive":
+  - do:
+      cluster.state: {}
+
+  # Get master node id
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      catch: bad_request
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors: 1.0, processors_range: {min: 1.0, max: 2.0}, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: x_content_parse_exception }

+ 16 - 0
server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesAction.java

@@ -90,6 +90,21 @@ public class TransportUpdateDesiredNodesAction extends TransportMasterNodeAction
         }
     }
 
+    @Override
+    protected void doExecute(Task task, UpdateDesiredNodesRequest request, ActionListener<UpdateDesiredNodesResponse> listener) {
+        final var minNodeVersion = clusterService.state().nodes().getMinNodeVersion();
+        if (request.isCompatibleWithVersion(minNodeVersion) == false) {
+            listener.onFailure(
+                new IllegalArgumentException(
+                    "Unable to use processor ranges or floating-point processors in mixed-clusters with nodes in version: " + minNodeVersion
+                )
+            );
+            return;
+        }
+
+        super.doExecute(task, request, listener);
+    }
+
     static ClusterState replaceDesiredNodes(ClusterState clusterState, DesiredNodes newDesiredNodes) {
         return clusterState.copyAndUpdateMetadata(
             metadata -> metadata.putCustom(DesiredNodesMetadata.TYPE, new DesiredNodesMetadata(newDesiredNodes))
@@ -149,6 +164,7 @@ public class TransportUpdateDesiredNodesAction extends TransportMasterNodeAction
             final var initialDesiredNodes = DesiredNodesMetadata.fromClusterState(currentState).getLatestDesiredNodes();
             var desiredNodes = initialDesiredNodes;
             for (final var taskContext : taskContexts) {
+
                 final var previousDesiredNodes = desiredNodes;
                 try {
                     desiredNodes = updateDesiredNodes(desiredNodes, taskContext.getTask().request());

+ 9 - 1
server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequest.java

@@ -8,6 +8,7 @@
 
 package org.elasticsearch.action.admin.cluster.desirednodes;
 
+import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.action.ValidateActions;
 import org.elasticsearch.action.support.master.AcknowledgedRequest;
@@ -52,7 +53,7 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         super(in);
         this.historyID = in.readString();
         this.version = in.readLong();
-        this.nodes = in.readList(DesiredNode::new);
+        this.nodes = in.readList(DesiredNode::readFrom);
     }
 
     @Override
@@ -80,6 +81,13 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         return nodes;
     }
 
+    public boolean isCompatibleWithVersion(Version version) {
+        if (version.onOrAfter(DesiredNode.RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
+            return true;
+        }
+        return nodes.stream().allMatch(desiredNode -> desiredNode.isCompatibleWithVersion(version));
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;

+ 4 - 2
server/src/main/java/org/elasticsearch/cluster/desirednodes/DesiredNodesSettingsValidator.java

@@ -89,8 +89,10 @@ public class DesiredNodesSettingsValidator {
         // we create a new setting just to run the validations using the desired node
         // number of available processors
         if (settings.hasValue(NODE_PROCESSORS_SETTING.getKey())) {
-            Setting.intSetting(NODE_PROCESSORS_SETTING.getKey(), node.processors(), 1, node.processors(), Setting.Property.NodeScope)
-                .get(settings);
+            int minProcessors = node.roundedDownMinProcessors();
+            Integer roundedUpMaxProcessors = node.roundedUpMaxProcessors();
+            int maxProcessors = roundedUpMaxProcessors == null ? minProcessors : roundedUpMaxProcessors;
+            Setting.intSetting(NODE_PROCESSORS_SETTING.getKey(), minProcessors, 1, maxProcessors, Setting.Property.NodeScope).get(settings);
             final Settings.Builder updatedSettings = Settings.builder().put(settings);
             updatedSettings.remove(NODE_PROCESSORS_SETTING.getKey());
             settings = updatedSettings.build();

+ 241 - 37
server/src/main/java/org/elasticsearch/cluster/metadata/DesiredNode.java

@@ -16,6 +16,7 @@ import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.io.stream.Writeable;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.unit.ByteSizeValue;
+import org.elasticsearch.core.Nullable;
 import org.elasticsearch.xcontent.ConstructingObjectParser;
 import org.elasticsearch.xcontent.ObjectParser;
 import org.elasticsearch.xcontent.ParseField;
@@ -36,9 +37,11 @@ import static org.elasticsearch.node.Node.NODE_NAME_SETTING;
 import static org.elasticsearch.node.NodeRoleSettings.NODE_ROLES_SETTING;
 
 public final class DesiredNode implements Writeable, ToXContentObject, Comparable<DesiredNode> {
+    public static final Version RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION = Version.V_8_3_0;
 
     private static final ParseField SETTINGS_FIELD = new ParseField("settings");
     private static final ParseField PROCESSORS_FIELD = new ParseField("processors");
+    private static final ParseField PROCESSORS_RANGE_FIELD = new ParseField("processors_range");
     private static final ParseField MEMORY_FIELD = new ParseField("memory");
     private static final ParseField STORAGE_FIELD = new ParseField("storage");
     private static final ParseField VERSION_FIELD = new ParseField("node_version");
@@ -48,16 +51,23 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
         false,
         (args, name) -> new DesiredNode(
             (Settings) args[0],
-            (int) args[1],
-            (ByteSizeValue) args[2],
+            (Float) args[1],
+            (ProcessorsRange) args[2],
             (ByteSizeValue) args[3],
-            (Version) args[4]
+            (ByteSizeValue) args[4],
+            (Version) args[5]
         )
     );
 
     static {
         PARSER.declareObject(ConstructingObjectParser.constructorArg(), (p, c) -> Settings.fromXContent(p), SETTINGS_FIELD);
-        PARSER.declareInt(ConstructingObjectParser.constructorArg(), PROCESSORS_FIELD);
+        PARSER.declareFloat(ConstructingObjectParser.optionalConstructorArg(), PROCESSORS_FIELD);
+        PARSER.declareObjectOrNull(
+            ConstructingObjectParser.optionalConstructorArg(),
+            (p, c) -> ProcessorsRange.fromXContent(p),
+            null,
+            PROCESSORS_RANGE_FIELD
+        );
         PARSER.declareField(
             ConstructingObjectParser.constructorArg(),
             (p, c) -> ByteSizeValue.parseBytesSizeValue(p.text(), MEMORY_FIELD.getPreferredName()),
@@ -86,7 +96,8 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
     }
 
     private final Settings settings;
-    private final int processors;
+    private final Float processors;
+    private final ProcessorsRange processorsRange;
     private final ByteSizeValue memory;
     private final ByteSizeValue storage;
     private final Version version;
@@ -94,12 +105,52 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
     private final Set<DiscoveryNodeRole> roles;
 
     public DesiredNode(Settings settings, int processors, ByteSizeValue memory, ByteSizeValue storage, Version version) {
+        this(settings, (float) processors, memory, storage, version);
+    }
+
+    public DesiredNode(Settings settings, ProcessorsRange processorsRange, ByteSizeValue memory, ByteSizeValue storage, Version version) {
+        this(settings, null, processorsRange, memory, storage, version);
+    }
+
+    public DesiredNode(Settings settings, float processors, ByteSizeValue memory, ByteSizeValue storage, Version version) {
+        this(settings, processors, null, memory, storage, version);
+    }
+
+    private DesiredNode(
+        Settings settings,
+        Float processors,
+        ProcessorsRange processorsRange,
+        ByteSizeValue memory,
+        ByteSizeValue storage,
+        Version version
+    ) {
         assert settings != null;
         assert memory != null;
         assert storage != null;
         assert version != null;
-        if (processors <= 0) {
-            throw new IllegalArgumentException("processors must be greater than 0, but got " + processors);
+
+        if (processors == null && processorsRange == null) {
+            throw new IllegalArgumentException(
+                PROCESSORS_FIELD.getPreferredName()
+                    + " or "
+                    + PROCESSORS_RANGE_FIELD.getPreferredName()
+                    + " should be specified and none was specified"
+            );
+        }
+
+        if (processors != null && processorsRange != null) {
+            throw new IllegalArgumentException(
+                PROCESSORS_FIELD.getPreferredName()
+                    + " and "
+                    + PROCESSORS_RANGE_FIELD.getPreferredName()
+                    + " were specified, but only one should be specified"
+            );
+        }
+
+        if (processors != null && invalidNumberOfProcessors(processors)) {
+            throw new IllegalArgumentException(
+                format(Locale.ROOT, "Only a positive number of [processors] are allowed and [%f] was provided", processors)
+            );
         }
 
         if (NODE_EXTERNAL_ID_SETTING.get(settings).isBlank()) {
@@ -110,6 +161,7 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
 
         this.settings = settings;
         this.processors = processors;
+        this.processorsRange = processorsRange;
         this.memory = memory;
         this.storage = storage;
         this.version = version;
@@ -117,14 +169,35 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
         this.roles = Collections.unmodifiableSortedSet(new TreeSet<>(DiscoveryNode.getRolesFromSettings(settings)));
     }
 
-    public DesiredNode(StreamInput in) throws IOException {
-        this(Settings.readSettingsFromStream(in), in.readInt(), new ByteSizeValue(in), new ByteSizeValue(in), Version.readVersion(in));
+    public static DesiredNode readFrom(StreamInput in) throws IOException {
+        final var settings = Settings.readSettingsFromStream(in);
+        final Float processors;
+        final ProcessorsRange processorsRange;
+        if (in.getVersion().onOrAfter(RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
+            processors = in.readOptionalFloat();
+            processorsRange = in.readOptionalWriteable(ProcessorsRange::readFrom);
+        } else {
+            processors = (float) in.readInt();
+            processorsRange = null;
+        }
+        final var memory = new ByteSizeValue(in);
+        final var storage = new ByteSizeValue(in);
+        final var version = Version.readVersion(in);
+        return new DesiredNode(settings, processors, processorsRange, memory, storage, version);
     }
 
     @Override
     public void writeTo(StreamOutput out) throws IOException {
         Settings.writeSettingsToStream(settings, out);
-        out.writeInt(processors);
+        if (out.getVersion().onOrAfter(RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
+            out.writeOptionalFloat(processors);
+            out.writeOptionalWriteable(processorsRange);
+        } else {
+            assert processorsRange == null;
+            assert processors != null;
+            assert processorHasDecimals() == false;
+            out.writeInt((int) (float) processors);
+        }
         memory.writeTo(out);
         storage.writeTo(out);
         Version.writeVersion(version, out);
@@ -140,7 +213,12 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
         builder.startObject(SETTINGS_FIELD.getPreferredName());
         settings.toXContent(builder, params);
         builder.endObject();
-        builder.field(PROCESSORS_FIELD.getPreferredName(), processors);
+        if (processors != null) {
+            builder.field(PROCESSORS_FIELD.getPreferredName(), processors);
+        }
+        if (processorsRange != null) {
+            builder.field(PROCESSORS_RANGE_FIELD.getPreferredName(), processorsRange);
+        }
         builder.field(MEMORY_FIELD.getPreferredName(), memory);
         builder.field(STORAGE_FIELD.getPreferredName(), storage);
         builder.field(VERSION_FIELD.getPreferredName(), version);
@@ -156,8 +234,35 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
         return settings;
     }
 
-    public int processors() {
-        return processors;
+    public float minProcessors() {
+        if (processors != null) {
+            return processors;
+        }
+        return processorsRange.min();
+    }
+
+    public int roundedDownMinProcessors() {
+        return roundDown(minProcessors());
+    }
+
+    public Float maxProcessors() {
+        if (processors != null) {
+            return processors;
+        }
+
+        return processorsRange.max();
+    }
+
+    public Integer roundedUpMaxProcessors() {
+        if (maxProcessors() == null) {
+            return null;
+        }
+
+        return roundUp(maxProcessors());
+    }
+
+    private boolean processorHasDecimals() {
+        return processors != null && ((int) (float) processors) != Math.ceil(processors);
     }
 
     public ByteSizeValue memory() {
@@ -180,25 +285,31 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
         return roles;
     }
 
+    public boolean isCompatibleWithVersion(Version version) {
+        if (version.onOrAfter(RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
+            return true;
+        }
+        return processorsRange == null && processorHasDecimals() == false;
+    }
+
     @Override
-    public boolean equals(Object obj) {
-        if (obj == this) return true;
-        if (obj == null || obj.getClass() != this.getClass()) return false;
-        var that = (DesiredNode) obj;
-        // Note that we might consider a DesiredNode different if the order
-        // in some settings is different, i.e. we convert node roles to a set in this class,
-        // so it can be confusing if we compare two DesiredNode instances that only differ
-        // in the node.roles setting order, but that's the semantics provided by the Settings class.
-        return Objects.equals(this.settings, that.settings)
-            && this.processors == that.processors
-            && Objects.equals(this.memory, that.memory)
-            && Objects.equals(this.storage, that.storage)
-            && Objects.equals(this.version, that.version);
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        DesiredNode that = (DesiredNode) o;
+        return Objects.equals(settings, that.settings)
+            && Objects.equals(processors, that.processors)
+            && Objects.equals(processorsRange, that.processorsRange)
+            && Objects.equals(memory, that.memory)
+            && Objects.equals(storage, that.storage)
+            && Objects.equals(version, that.version)
+            && Objects.equals(externalId, that.externalId)
+            && Objects.equals(roles, that.roles);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(settings, processors, memory, storage, version);
+        return Objects.hash(settings, processors, processorsRange, memory, storage, version, externalId, roles);
     }
 
     @Override
@@ -208,22 +319,115 @@ public final class DesiredNode implements Writeable, ToXContentObject, Comparabl
 
     @Override
     public String toString() {
-        return "DesiredNode["
+        return "DesiredNode{"
             + "settings="
             + settings
-            + ", "
-            + "processors="
+            + ", processors="
             + processors
-            + ", "
-            + "memory="
+            + ", processorsRange="
+            + processorsRange
+            + ", memory="
             + memory
-            + ", "
-            + "storage="
+            + ", storage="
             + storage
-            + ", "
-            + "version="
+            + ", version="
             + version
-            + ']';
+            + ", externalId='"
+            + externalId
+            + '\''
+            + ", roles="
+            + roles
+            + '}';
+    }
+
+    private static boolean invalidNumberOfProcessors(float processors) {
+        return processors <= 0 || Float.isInfinite(processors) || Float.isNaN(processors);
+    }
+
+    private static int roundUp(float value) {
+        return (int) Math.ceil(value);
+    }
+
+    private static int roundDown(float value) {
+        return Math.max(1, (int) Math.floor(value));
     }
 
+    public record ProcessorsRange(float min, Float max) implements Writeable, ToXContentObject {
+
+        private static final ParseField MIN_FIELD = new ParseField("min");
+        private static final ParseField MAX_FIELD = new ParseField("max");
+
+        public static final ConstructingObjectParser<ProcessorsRange, String> PROCESSORS_PARSER = new ConstructingObjectParser<>(
+            "processors",
+            false,
+            (args, name) -> new ProcessorsRange((float) args[0], (Float) args[1])
+        );
+
+        static {
+            PROCESSORS_PARSER.declareFloat(ConstructingObjectParser.constructorArg(), MIN_FIELD);
+            PROCESSORS_PARSER.declareFloat(ConstructingObjectParser.optionalConstructorArg(), MAX_FIELD);
+        }
+
+        static ProcessorsRange fromXContent(XContentParser parser) throws IOException {
+            if (parser.currentToken() == XContentParser.Token.START_OBJECT) {
+                return PROCESSORS_PARSER.parse(parser, null);
+            } else {
+                // For BWC with nodes pre 8.3
+                float processors = parser.floatValue();
+                return new ProcessorsRange(processors, processors);
+            }
+        }
+
+        public ProcessorsRange {
+            if (invalidNumberOfProcessors(min)) {
+                throw new IllegalArgumentException(
+                    format(
+                        Locale.ROOT,
+                        "Only a positive number of [%s] processors are allowed and [%f] was provided",
+                        MIN_FIELD.getPreferredName(),
+                        min
+                    )
+                );
+            }
+
+            if (max != null && invalidNumberOfProcessors(max)) {
+                throw new IllegalArgumentException(
+                    format(
+                        Locale.ROOT,
+                        "Only a positive number of [%s] processors are allowed and [%f] was provided",
+                        MAX_FIELD.getPreferredName(),
+                        max
+                    )
+                );
+            }
+
+            if (max != null && min > max) {
+                throw new IllegalArgumentException(
+                    "min processors must be less than or equal to max processors and it was: min: " + min + " max: " + max
+                );
+            }
+        }
+
+        @Nullable
+        private static ProcessorsRange readFrom(StreamInput in) throws IOException {
+            return new ProcessorsRange(in.readFloat(), in.readOptionalFloat());
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeFloat(min);
+            out.writeOptionalFloat(max);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field(MIN_FIELD.getPreferredName(), min);
+            if (max != null) {
+                builder.field(MAX_FIELD.getPreferredName(), max);
+            }
+            builder.endObject();
+            return builder;
+        }
+    }
 }

+ 1 - 1
server/src/main/java/org/elasticsearch/cluster/metadata/DesiredNodes.java

@@ -69,7 +69,7 @@ public class DesiredNodes implements Writeable, ToXContentObject, Iterable<Desir
     }
 
     public DesiredNodes(StreamInput in) throws IOException {
-        this(in.readString(), in.readLong(), in.readList(DesiredNode::new));
+        this(in.readString(), in.readLong(), in.readList(DesiredNode::readFrom));
     }
 
     @Override

+ 11 - 1
server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequestTests.java

@@ -44,6 +44,16 @@ public class UpdateDesiredNodesRequestTests extends ESTestCase {
             .put(NODE_ROLES_SETTING.getKey(), "data_hot")
             .build();
 
-        return new DesiredNode(settings, 1, ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT);
+        if (randomBoolean()) {
+            return new DesiredNode(settings, randomFloat(), ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT);
+        } else {
+            return new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange(1, randomBoolean() ? null : (float) 1),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            );
+        }
     }
 }

+ 1 - 1
server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodeSerializationTests.java

@@ -22,7 +22,7 @@ public class DesiredNodeSerializationTests extends AbstractSerializingTestCase<D
 
     @Override
     protected Writeable.Reader<DesiredNode> instanceReader() {
-        return DesiredNode::new;
+        return DesiredNode::readFrom;
     }
 
     @Override

+ 131 - 2
server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodeTests.java

@@ -53,12 +53,57 @@ public class DesiredNodeTests extends ESTestCase {
         assertThat(desiredNode.externalId(), is(equalTo(nodeName)));
     }
 
-    public void testDesiredNodeMustHaveAtLeastOneProcessor() {
+    public void testNumberOfProcessorsValidation() {
         final Settings settings = Settings.builder().put(NODE_NAME_SETTING.getKey(), randomAlphaOfLength(10)).build();
 
         expectThrows(
             IllegalArgumentException.class,
-            () -> new DesiredNode(settings, -1, ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT)
+            () -> new DesiredNode(settings, randomInvalidFloatProcessor(), ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT)
+        );
+
+        // Processor ranges
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange(randomInvalidFloatProcessor(), randomFrom(random(), null, 1.0f)),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            )
+        );
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange(randomFloat() + 0.1f, randomInvalidFloatProcessor()),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            )
+        );
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange(randomInvalidFloatProcessor(), randomInvalidFloatProcessor()),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            )
+        );
+
+        final var lowerBound = randomFloatBetween(0.1f, 10);
+        final var upperBound = randomFloatBetween(0.01f, lowerBound - Math.ulp(lowerBound));
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange(lowerBound, upperBound),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            )
         );
     }
 
@@ -108,4 +153,88 @@ public class DesiredNodeTests extends ESTestCase {
             assertThat(desiredNode.getRoles(), contains(NODE_ROLES_SETTING.get(Settings.EMPTY).toArray()));
         }
     }
+
+    public void testNodeCPUsRoundUp() {
+        final var settings = Settings.builder().put(NODE_NAME_SETTING.getKey(), randomAlphaOfLength(10)).build();
+
+        {
+            final var desiredNode = new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange((float) 0.4, (float) 1.2),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            );
+
+            assertThat(desiredNode.minProcessors(), is(equalTo((float) 0.4)));
+            assertThat(desiredNode.roundedDownMinProcessors(), is(equalTo(1)));
+            assertThat(desiredNode.maxProcessors(), is(equalTo((float) 1.2)));
+            assertThat(desiredNode.roundedUpMaxProcessors(), is(equalTo(2)));
+        }
+
+        {
+            final var desiredNode = new DesiredNode(settings, 1.2f, ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT);
+
+            assertThat(desiredNode.minProcessors(), is(equalTo((float) 1.2)));
+            assertThat(desiredNode.roundedDownMinProcessors(), is(equalTo(1)));
+            assertThat(desiredNode.maxProcessors(), is(equalTo((float) 1.2)));
+            assertThat(desiredNode.roundedUpMaxProcessors(), is(equalTo(2)));
+        }
+
+        {
+            final var desiredNode = new DesiredNode(settings, 1024, ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT);
+
+            assertThat(desiredNode.minProcessors(), is(equalTo((float) 1024)));
+            assertThat(desiredNode.roundedDownMinProcessors(), is(equalTo(1024)));
+            assertThat(desiredNode.maxProcessors(), is(equalTo((float) 1024)));
+            assertThat(desiredNode.roundedUpMaxProcessors(), is(equalTo(1024)));
+        }
+    }
+
+    public void testDesiredNodeIsCompatible() {
+        final var settings = Settings.builder().put(NODE_NAME_SETTING.getKey(), randomAlphaOfLength(10)).build();
+
+        {
+            final var desiredNode = new DesiredNode(
+                settings,
+                new DesiredNode.ProcessorsRange((float) 0.4, (float) 1.2),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            );
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_2_0), is(equalTo(false)));
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_3_0), is(equalTo(true)));
+        }
+
+        {
+            final var desiredNode = new DesiredNode(
+                settings,
+                randomIntBetween(0, 10) + randomFloat(),
+                ByteSizeValue.ofGb(1),
+                ByteSizeValue.ofGb(1),
+                Version.CURRENT
+            );
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_2_0), is(equalTo(false)));
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_3_0), is(equalTo(true)));
+        }
+
+        {
+            final var desiredNode = new DesiredNode(settings, 2.0f, ByteSizeValue.ofGb(1), ByteSizeValue.ofGb(1), Version.CURRENT);
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_2_0), is(equalTo(true)));
+            assertThat(desiredNode.isCompatibleWithVersion(Version.V_8_3_0), is(equalTo(true)));
+        }
+    }
+
+    private Float randomInvalidFloatProcessor() {
+        return randomFrom(0.0f, -1.0f, Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY);
+    }
+
+    private float randomFloatBetween(float start, float end) {
+        float result = 0.0f;
+        while (result < start || result > end || Float.isNaN(result)) {
+            result = start + randomFloat() * (end - start);
+        }
+
+        return result;
+    }
 }

+ 26 - 7
server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodesTestCase.java

@@ -55,18 +55,18 @@ public abstract class DesiredNodesTestCase extends ESTestCase {
     }
 
     public static DesiredNode randomDesiredNodeWithRandomSettings(Version version) {
-        return randomDesiredNodeWithRandomSettings(version, randomIntBetween(1, 256));
-    }
-
-    public static DesiredNode randomDesiredNodeWithRandomSettings(Version version, int numProcessors) {
-        return randomDesiredNode(version, numProcessors, DesiredNodesTestCase::putRandomSetting);
+        return randomDesiredNode(version, DesiredNodesTestCase::putRandomSetting);
     }
 
     public static DesiredNode randomDesiredNode(Version version, Consumer<Settings.Builder> settingsProvider) {
-        return randomDesiredNode(version, randomIntBetween(1, 256), settingsProvider);
+        if (randomBoolean()) {
+            return randomDesiredNode(version, randomProcessor(), settingsProvider);
+        } else {
+            return randomDesiredNode(version, randomIntBetween(1, 256) + randomFloat(), settingsProvider);
+        }
     }
 
-    public static DesiredNode randomDesiredNode(Version version, int processors, Consumer<Settings.Builder> settingsProvider) {
+    public static DesiredNode randomDesiredNode(Version version, float processors, Consumer<Settings.Builder> settingsProvider) {
         return new DesiredNode(
             randomSettings(settingsProvider),
             processors,
@@ -76,6 +76,25 @@ public abstract class DesiredNodesTestCase extends ESTestCase {
         );
     }
 
+    public static DesiredNode randomDesiredNode(
+        Version version,
+        DesiredNode.ProcessorsRange processorsRange,
+        Consumer<Settings.Builder> settingsProvider
+    ) {
+        return new DesiredNode(
+            randomSettings(settingsProvider),
+            processorsRange,
+            ByteSizeValue.ofGb(randomIntBetween(1, 1024)),
+            ByteSizeValue.ofTb(randomIntBetween(1, 40)),
+            version
+        );
+    }
+
+    private static DesiredNode.ProcessorsRange randomProcessor() {
+        float minProcessors = randomFloat() + randomIntBetween(1, 16);
+        return new DesiredNode.ProcessorsRange(minProcessors, randomBoolean() ? null : minProcessors + randomIntBetween(0, 10));
+    }
+
     public static Settings randomSettings(Consumer<Settings.Builder> settingsProvider) {
         int numSettings = randomIntBetween(1, 20);
         Settings.Builder settingsBuilder = Settings.builder();