Browse Source

Support "dry run" mode for updating Desired Nodes (#88305)

Add the dry_run query parameter to support simulating of updating of desired nodes. The update request will be validated, but no cluster state updates will be performed. In order to indicate that the response was a result of a dry run, we add the dry_run run field to the JSON representation of a response.

See #82975
Artem Prigoda 3 years ago
parent
commit
72a6fdc2b8
16 changed files with 267 additions and 33 deletions
  1. 5 0
      docs/changelog/88305.yaml
  2. 9 1
      docs/reference/cluster/update-desired-nodes.asciidoc
  3. 6 0
      rest-api-spec/src/main/resources/rest-api-spec/api/_internal.update_desired_nodes.json
  4. 107 0
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/20_dry_run.yml
  5. 58 6
      server/src/internalClusterTest/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportDesiredNodesActionsIT.java
  6. 2 1
      server/src/internalClusterTest/java/org/elasticsearch/cluster/DesiredNodesSnapshotsIT.java
  7. 12 6
      server/src/internalClusterTest/java/org/elasticsearch/cluster/DesiredNodesStatusIT.java
  8. 12 3
      server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesAction.java
  9. 21 5
      server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequest.java
  10. 20 2
      server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesResponse.java
  11. 2 1
      server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestUpdateDesiredNodesAction.java
  12. 7 4
      server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesActionTests.java
  13. 1 1
      server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequestSerializationTests.java
  14. 2 1
      server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequestTests.java
  15. 2 1
      server/src/test/java/org/elasticsearch/cluster/metadata/DesiredNodesTestCase.java
  16. 1 1
      x-pack/plugin/core/src/internalClusterTest/java/org/elasticsearch/xpack/cluster/routing/allocation/DataTierAllocationDeciderIT.java

+ 5 - 0
docs/changelog/88305.yaml

@@ -0,0 +1,5 @@
+pr: 88305
+summary: Support "dry run" mode for updating Desired Nodes
+area: Distributed
+type: enhancement
+issues: []

+ 9 - 1
docs/reference/cluster/update-desired-nodes.asciidoc

@@ -50,6 +50,10 @@ DELETE /_internal/desired_nodes
 
 include::{es-repo-dir}/rest-api/common-parms.asciidoc[tag=master-timeout]
 
+`dry_run`::
+    (Optional, Boolean) If `true`, then the request simulates the update and
+    returns a response with `dry_run` field set to `true`.
+
 [[update-desired-nodes-desc]]
 ==== {api-description-title}
 
@@ -58,6 +62,9 @@ this API to let Elasticsearch know about the cluster topology, including future
 changes such as adding or removing nodes. Using this information, the system is
 able to take better decisions.
 
+It's possible to run the update in "dry run" mode by adding the
+`?dry_run` query parameter. This will validate the request result, but will not actually perform the update.
+
 [[update-desired-nodes-examples]]
 ==== {api-examples-title}
 
@@ -92,7 +99,8 @@ The API returns the following result:
 [source,console-result]
 --------------------------------------------------
 {
-  "replaced_existing_history_id": false
+  "replaced_existing_history_id": false,
+  "dry_run": false
 }
 --------------------------------------------------
 

+ 6 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/_internal.update_desired_nodes.json

@@ -30,6 +30,12 @@
         }
       ]
     },
+    "params": {
+      "dry_run": {
+        "type": "boolean",
+        "description": "Simulate the update"
+      }
+    },
     "body":{
       "description":"the specification of the desired nodes",
       "required":true

+ 107 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/20_dry_run.yml

@@ -0,0 +1,107 @@
+---
+setup:
+  - skip:
+      version: " - 8.3.99"
+      reason: "Support for the dry run option was added in in 8.4.0"
+
+---
+"Test dry run doesn't update empty desired nodes":
+  - do:
+      cluster.state: {}
+
+  - set: { master_node: master }
+
+  - do:
+      nodes.info: {}
+  - set: { nodes.$master.version: es_version }
+
+  - do:
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 1
+        dry_run: true
+        body:
+          nodes:
+            - { settings: { "node.name": "instance-000187" }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { replaced_existing_history_id: false }
+  - match: { dry_run: true }
+
+  - do:
+      catch: missing
+      _internal.get_desired_nodes: {}
+  - match: { status: 404 }
+
+---
+"Test dry run doesn't update existing desired nodes":
+  - do:
+      cluster.state: {}
+
+  - 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: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
+  - match: { replaced_existing_history_id: false }
+  - match: { dry_run: false }
+
+  - do:
+      _internal.get_desired_nodes: {}
+  - match:
+      $body:
+        history_id: "test"
+        version: 1
+        nodes:
+          - { settings: { node: { name: "instance-000187" } }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
+
+  - do:
+      _internal.update_desired_nodes:
+        history_id: "test"
+        version: 2
+        dry_run: "true"
+        body:
+          nodes:
+            - { 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 }
+  - match: { dry_run: true }
+
+  - do:
+      _internal.get_desired_nodes: { }
+  - match:
+      $body:
+        history_id: "test"
+        version: 1
+        nodes:
+          - { settings: { node: { name: "instance-000187" } }, processors: 8.5, memory: "64gb", storage: "128gb", node_version: $es_version }
+---
+"Test validation works for dry run updates":
+  - do:
+      cluster.state: { }
+
+  - 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
+        dry_run: "true"
+        body:
+          nodes:
+            - { settings: { "node.external_id": "instance-000245", "random_setting": -42 }, processors: 16.0, memory: "128gb", storage: "1tb", node_version: $es_version }
+  - match: { status: 400 }
+  - match: { error.type: illegal_argument_exception }
+  - match: { error.reason: "Nodes with ids [instance-000245] in positions [0] contain invalid settings" }
+  - match: { error.suppressed.0.reason: "unknown setting [random_setting] please check that any required plugins are installed, or check the breaking changes documentation for removed settings" }

+ 58 - 6
server/src/internalClusterTest/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportDesiredNodesActionsIT.java

@@ -54,11 +54,48 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
         final var updateDesiredNodesRequest = randomUpdateDesiredNodesRequest();
         final var response = updateDesiredNodes(updateDesiredNodesRequest);
         assertThat(response.hasReplacedExistingHistoryId(), is(equalTo(false)));
+        assertThat(response.dryRun(), is(equalTo(false)));
 
         final DesiredNodes latestDesiredNodes = getLatestDesiredNodes();
         assertStoredDesiredNodesAreCorrect(updateDesiredNodesRequest, latestDesiredNodes);
     }
 
+    public void testDryRunUpdateDoesNotUpdateEmptyDesiredNodes() {
+        UpdateDesiredNodesResponse dryRunResponse = updateDesiredNodes(
+            randomDryRunUpdateDesiredNodesRequest(Version.CURRENT, Settings.EMPTY)
+        );
+        assertThat(dryRunResponse.dryRun(), is(equalTo(true)));
+
+        expectThrows(ResourceNotFoundException.class, this::getLatestDesiredNodes);
+    }
+
+    public void testDryRunUpdateDoesNotUpdateExistingDesiredNodes() {
+        UpdateDesiredNodesResponse response = updateDesiredNodes(randomUpdateDesiredNodesRequest(Version.CURRENT, Settings.EMPTY));
+        assertThat(response.dryRun(), is(equalTo(false)));
+
+        DesiredNodes desiredNodes = getLatestDesiredNodes();
+
+        UpdateDesiredNodesResponse dryRunResponse = updateDesiredNodes(
+            randomDryRunUpdateDesiredNodesRequest(Version.CURRENT, Settings.EMPTY)
+        );
+        assertThat(dryRunResponse.dryRun(), is(equalTo(true)));
+
+        assertEquals(getLatestDesiredNodes(), desiredNodes);
+    }
+
+    public void testSettingsAreValidatedWithDryRun() {
+        var exception = expectThrows(
+            IllegalArgumentException.class,
+            () -> updateDesiredNodes(
+                randomDryRunUpdateDesiredNodesRequest(
+                    Version.CURRENT,
+                    Settings.builder().put(SETTING_HTTP_TCP_KEEP_IDLE.getKey(), Integer.MIN_VALUE).build()
+                )
+            )
+        );
+        assertThat(exception.getMessage(), containsString("contain invalid settings"));
+    }
+
     public void testUpdateDesiredNodesIsIdempotent() {
         final var updateDesiredNodesRequest = randomUpdateDesiredNodesRequest();
         updateDesiredNodes(updateDesiredNodesRequest);
@@ -71,7 +108,8 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
         final var equivalentUpdateRequest = new UpdateDesiredNodesRequest(
             updateDesiredNodesRequest.getHistoryID(),
             updateDesiredNodesRequest.getVersion(),
-            desiredNodesList
+            desiredNodesList,
+            false
         );
 
         updateDesiredNodes(equivalentUpdateRequest);
@@ -88,7 +126,8 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
         final var backwardsUpdateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             updateDesiredNodesRequest.getHistoryID(),
             updateDesiredNodesRequest.getVersion() - 1,
-            updateDesiredNodesRequest.getNodes()
+            updateDesiredNodesRequest.getNodes(),
+            false
         );
 
         final VersionConflictException exception = expectThrows(
@@ -105,7 +144,8 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
         final var updateDesiredNodesRequestWithSameHistoryIdAndVersionAndDifferentSpecs = new UpdateDesiredNodesRequest(
             updateDesiredNodesRequest.getHistoryID(),
             updateDesiredNodesRequest.getVersion(),
-            randomList(1, 10, DesiredNodesTestCase::randomDesiredNode)
+            randomList(1, 10, DesiredNodesTestCase::randomDesiredNode),
+            false
         );
 
         final IllegalArgumentException exception = expectThrows(
@@ -228,7 +268,8 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
                         Settings.builder().put(NODE_PROCESSORS_SETTING.getKey(), numProcessors + 1).build(),
                         numProcessors
                     )
-                )
+                ),
+                false
             );
 
             final IllegalArgumentException exception = expectThrows(
@@ -265,7 +306,8 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
                         Settings.builder().put(NODE_PROCESSORS_SETTING.getKey(), numProcessors).build(),
                         numProcessors
                     )
-                )
+                ),
+                false
             );
 
             updateDesiredNodes(updateDesiredNodesRequest);
@@ -377,7 +419,17 @@ public class TransportDesiredNodesActionsIT extends ESIntegTestCase {
         return new UpdateDesiredNodesRequest(
             UUIDs.randomBase64UUID(),
             randomIntBetween(2, 20),
-            randomList(2, 10, () -> randomDesiredNode(version, settings))
+            randomList(2, 10, () -> randomDesiredNode(version, settings)),
+            false
+        );
+    }
+
+    private UpdateDesiredNodesRequest randomDryRunUpdateDesiredNodesRequest(Version version, Settings settings) {
+        return new UpdateDesiredNodesRequest(
+            UUIDs.randomBase64UUID(),
+            randomIntBetween(2, 20),
+            randomList(2, 10, () -> randomDesiredNode(version, settings)),
+            true
         );
     }
 

+ 2 - 1
server/src/internalClusterTest/java/org/elasticsearch/cluster/DesiredNodesSnapshotsIT.java

@@ -73,7 +73,8 @@ public class DesiredNodesSnapshotsIT extends AbstractSnapshotIntegTestCase {
                     ByteSizeValue.ofGb(randomIntBetween(128, 256)),
                     Version.CURRENT
                 )
-            )
+            ),
+            false
         );
     }
 }

+ 12 - 6
server/src/internalClusterTest/java/org/elasticsearch/cluster/DesiredNodesStatusIT.java

@@ -37,7 +37,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var updateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             randomAlphaOfLength(10),
             1,
-            concatLists(actualizedDesiredNodes, pendingDesiredNodes)
+            concatLists(actualizedDesiredNodes, pendingDesiredNodes),
+            false
         );
         updateDesiredNodes(updateDesiredNodesRequest);
 
@@ -49,7 +50,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var newVersionUpdateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             updateDesiredNodesRequest.getHistoryID(),
             updateDesiredNodesRequest.getVersion() + 1,
-            updateDesiredNodesRequest.getNodes()
+            updateDesiredNodesRequest.getNodes(),
+            false
         );
         updateDesiredNodes(newVersionUpdateDesiredNodesRequest);
 
@@ -70,7 +72,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var updateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             randomAlphaOfLength(10),
             1,
-            concatLists(actualizedDesiredNodes, pendingDesiredNodes)
+            concatLists(actualizedDesiredNodes, pendingDesiredNodes),
+            false
         );
         updateDesiredNodes(updateDesiredNodesRequest);
 
@@ -98,7 +101,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var updateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             randomAlphaOfLength(10),
             1,
-            concatLists(actualizedDesiredNodes, pendingDesiredNodes)
+            concatLists(actualizedDesiredNodes, pendingDesiredNodes),
+            false
         );
         updateDesiredNodes(updateDesiredNodesRequest);
 
@@ -130,7 +134,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var updateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             randomAlphaOfLength(10),
             1,
-            concatLists(actualizedDesiredNodes, pendingDesiredNodes)
+            concatLists(actualizedDesiredNodes, pendingDesiredNodes),
+            false
         );
         updateDesiredNodes(updateDesiredNodesRequest);
 
@@ -146,7 +151,8 @@ public class DesiredNodesStatusIT extends ESIntegTestCase {
         final var updateDesiredNodesWithNewHistoryRequest = new UpdateDesiredNodesRequest(
             randomAlphaOfLength(10),
             1,
-            updateDesiredNodesRequest.getNodes()
+            updateDesiredNodesRequest.getNodes(),
+            false
         );
         final var response = updateDesiredNodes(updateDesiredNodesWithNewHistoryRequest);
         assertThat(response.hasReplacedExistingHistoryId(), is(equalTo(true)));

+ 12 - 3
server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesAction.java

@@ -181,10 +181,19 @@ public class TransportUpdateDesiredNodesAction extends TransportMasterNodeAction
             final var initialDesiredNodes = DesiredNodesMetadata.fromClusterState(currentState).getLatestDesiredNodes();
             var desiredNodes = initialDesiredNodes;
             for (final var taskContext : taskContexts) {
-
+                final UpdateDesiredNodesRequest request = taskContext.getTask().request();
+                if (request.isDryRun()) {
+                    try {
+                        updateDesiredNodes(desiredNodes, request);
+                        taskContext.success(() -> taskContext.getTask().listener().onResponse(new UpdateDesiredNodesResponse(false, true)));
+                    } catch (Exception e) {
+                        taskContext.onFailure(e);
+                    }
+                    continue;
+                }
                 final var previousDesiredNodes = desiredNodes;
                 try {
-                    desiredNodes = updateDesiredNodes(desiredNodes, taskContext.getTask().request());
+                    desiredNodes = updateDesiredNodes(desiredNodes, request);
                 } catch (Exception e) {
                     taskContext.onFailure(e);
                     continue;
@@ -192,7 +201,7 @@ public class TransportUpdateDesiredNodesAction extends TransportMasterNodeAction
                 final var replacedExistingHistoryId = previousDesiredNodes != null
                     && previousDesiredNodes.hasSameHistoryId(desiredNodes) == false;
                 taskContext.success(
-                    () -> taskContext.getTask().listener().onResponse(new UpdateDesiredNodesResponse(replacedExistingHistoryId))
+                    () -> taskContext.getTask().listener().onResponse(new UpdateDesiredNodesResponse(replacedExistingHistoryId, false))
                 );
             }
 

+ 21 - 5
server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesRequest.java

@@ -24,9 +24,12 @@ import java.util.List;
 import java.util.Objects;
 
 public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesiredNodesRequest> {
+    private static final Version DRY_RUN_VERSION = Version.V_8_4_0;
+
     private final String historyID;
     private final long version;
     private final List<DesiredNode> nodes;
+    private final boolean dryRun;
 
     public static final ParseField NODES_FIELD = new ParseField("nodes");
 
@@ -41,12 +44,13 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         PARSER.declareObjectArray(ConstructingObjectParser.constructorArg(), (p, c) -> DesiredNode.fromXContent(p), NODES_FIELD);
     }
 
-    public UpdateDesiredNodesRequest(String historyID, long version, List<DesiredNode> nodes) {
+    public UpdateDesiredNodesRequest(String historyID, long version, List<DesiredNode> nodes, boolean dryRun) {
         assert historyID != null;
         assert nodes != null;
         this.historyID = historyID;
         this.version = version;
         this.nodes = nodes;
+        this.dryRun = dryRun;
     }
 
     public UpdateDesiredNodesRequest(StreamInput in) throws IOException {
@@ -54,6 +58,7 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         this.historyID = in.readString();
         this.version = in.readLong();
         this.nodes = in.readList(DesiredNode::readFrom);
+        dryRun = in.getVersion().onOrAfter(DRY_RUN_VERSION) ? in.readBoolean() : false;
     }
 
     @Override
@@ -62,11 +67,15 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         out.writeString(historyID);
         out.writeLong(version);
         out.writeList(nodes);
+        if (out.getVersion().onOrAfter(DRY_RUN_VERSION)) {
+            out.writeBoolean(dryRun);
+        }
     }
 
-    public static UpdateDesiredNodesRequest fromXContent(String historyID, long version, XContentParser parser) throws IOException {
+    public static UpdateDesiredNodesRequest fromXContent(String historyID, long version, boolean dryRun, XContentParser parser)
+        throws IOException {
         List<DesiredNode> nodes = PARSER.parse(parser, null);
-        return new UpdateDesiredNodesRequest(historyID, version, nodes);
+        return new UpdateDesiredNodesRequest(historyID, version, nodes, dryRun);
     }
 
     public String getHistoryID() {
@@ -81,6 +90,10 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         return nodes;
     }
 
+    public boolean isDryRun() {
+        return dryRun;
+    }
+
     public boolean isCompatibleWithVersion(Version version) {
         if (version.onOrAfter(DesiredNode.RANGE_FLOAT_PROCESSORS_SUPPORT_VERSION)) {
             return true;
@@ -93,12 +106,15 @@ public class UpdateDesiredNodesRequest extends AcknowledgedRequest<UpdateDesired
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         UpdateDesiredNodesRequest that = (UpdateDesiredNodesRequest) o;
-        return version == that.version && Objects.equals(historyID, that.historyID) && Objects.equals(nodes, that.nodes);
+        return version == that.version
+            && Objects.equals(historyID, that.historyID)
+            && Objects.equals(nodes, that.nodes)
+            && Objects.equals(dryRun, that.dryRun);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(historyID, version, nodes);
+        return Objects.hash(historyID, version, nodes, dryRun);
     }
 
     @Override

+ 20 - 2
server/src/main/java/org/elasticsearch/action/admin/cluster/desirednodes/UpdateDesiredNodesResponse.java

@@ -8,6 +8,7 @@
 
 package org.elasticsearch.action.admin.cluster.desirednodes;
 
+import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionResponse;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
@@ -18,30 +19,47 @@ import java.io.IOException;
 import java.util.Objects;
 
 public class UpdateDesiredNodesResponse extends ActionResponse implements ToXContentObject {
+    private static final Version DRY_RUN_SUPPORTING_VERSION = Version.V_8_4_0;
+
     private final boolean replacedExistingHistoryId;
+    private final boolean dryRun;
 
     public UpdateDesiredNodesResponse(boolean replacedExistingHistoryId) {
+        this(replacedExistingHistoryId, false);
+    }
+
+    public UpdateDesiredNodesResponse(boolean replacedExistingHistoryId, boolean dryRun) {
         this.replacedExistingHistoryId = replacedExistingHistoryId;
+        this.dryRun = dryRun;
     }
 
     public UpdateDesiredNodesResponse(StreamInput in) throws IOException {
         super(in);
         this.replacedExistingHistoryId = in.readBoolean();
+        dryRun = in.getVersion().onOrAfter(DRY_RUN_SUPPORTING_VERSION) ? in.readBoolean() : false;
     }
 
     @Override
     public void writeTo(StreamOutput out) throws IOException {
         out.writeBoolean(replacedExistingHistoryId);
+        if (out.getVersion().onOrAfter(DRY_RUN_SUPPORTING_VERSION)) {
+            out.writeBoolean(dryRun);
+        }
     }
 
     public boolean hasReplacedExistingHistoryId() {
         return replacedExistingHistoryId;
     }
 
+    public boolean dryRun() {
+        return dryRun;
+    }
+
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
         builder.startObject();
         builder.field("replaced_existing_history_id", replacedExistingHistoryId);
+        builder.field("dry_run", dryRun);
         builder.endObject();
         return builder;
     }
@@ -51,11 +69,11 @@ public class UpdateDesiredNodesResponse extends ActionResponse implements ToXCon
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         UpdateDesiredNodesResponse that = (UpdateDesiredNodesResponse) o;
-        return replacedExistingHistoryId == that.replacedExistingHistoryId;
+        return replacedExistingHistoryId == that.replacedExistingHistoryId && dryRun == that.dryRun;
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(replacedExistingHistoryId);
+        return Objects.hash(replacedExistingHistoryId, dryRun);
     }
 }

+ 2 - 1
server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestUpdateDesiredNodesAction.java

@@ -34,10 +34,11 @@ public class RestUpdateDesiredNodesAction extends BaseRestHandler {
     protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
         final String historyId = request.param("history_id");
         final long version = request.paramAsLong("version", Long.MIN_VALUE);
+        boolean dryRun = request.paramAsBoolean("dry_run", false);
 
         final UpdateDesiredNodesRequest updateDesiredNodesRequest;
         try (XContentParser parser = request.contentParser()) {
-            updateDesiredNodesRequest = UpdateDesiredNodesRequest.fromXContent(historyId, version, parser);
+            updateDesiredNodesRequest = UpdateDesiredNodesRequest.fromXContent(historyId, version, dryRun, parser);
         }
 
         updateDesiredNodesRequest.masterNodeTimeout(request.paramAsTime("master_timeout", updateDesiredNodesRequest.masterNodeTimeout()));

+ 7 - 4
server/src/test/java/org/elasticsearch/action/admin/cluster/desirednodes/TransportUpdateDesiredNodesActionTests.java

@@ -147,7 +147,7 @@ public class TransportUpdateDesiredNodesActionTests extends DesiredNodesTestCase
                 .stream()
                 .map(DesiredNodeWithStatus::desiredNode)
                 .toList();
-            request = new UpdateDesiredNodesRequest(desiredNodes.historyID(), desiredNodes.version() + 1, updatedNodes);
+            request = new UpdateDesiredNodesRequest(desiredNodes.historyID(), desiredNodes.version() + 1, updatedNodes, false);
         } else {
             request = randomUpdateDesiredNodesRequest();
         }
@@ -180,7 +180,8 @@ public class TransportUpdateDesiredNodesActionTests extends DesiredNodesTestCase
         final UpdateDesiredNodesRequest equivalentDesiredNodesRequest = new UpdateDesiredNodesRequest(
             updateDesiredNodesRequest.getHistoryID(),
             updateDesiredNodesRequest.getVersion(),
-            equivalentDesiredNodesList
+            equivalentDesiredNodesList,
+            updateDesiredNodesRequest.isDryRun()
         );
 
         assertSame(
@@ -196,7 +197,8 @@ public class TransportUpdateDesiredNodesActionTests extends DesiredNodesTestCase
         final UpdateDesiredNodesRequest request = new UpdateDesiredNodesRequest(
             latestDesiredNodes.historyID(),
             latestDesiredNodes.version(),
-            randomList(1, 10, DesiredNodesTestCase::randomDesiredNode)
+            randomList(1, 10, DesiredNodesTestCase::randomDesiredNode),
+            false
         );
 
         IllegalArgumentException exception = expectThrows(
@@ -212,7 +214,8 @@ public class TransportUpdateDesiredNodesActionTests extends DesiredNodesTestCase
         final UpdateDesiredNodesRequest request = new UpdateDesiredNodesRequest(
             latestDesiredNodes.historyID(),
             latestDesiredNodes.version() - 1,
-            List.copyOf(latestDesiredNodes.nodes().stream().map(DesiredNodeWithStatus::desiredNode).toList())
+            List.copyOf(latestDesiredNodes.nodes().stream().map(DesiredNodeWithStatus::desiredNode).toList()),
+            false
         );
 
         VersionConflictException exception = expectThrows(

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

@@ -22,7 +22,7 @@ public class UpdateDesiredNodesRequestSerializationTests extends AbstractWireSer
 
     @Override
     protected UpdateDesiredNodesRequest mutateInstance(UpdateDesiredNodesRequest request) throws IOException {
-        return new UpdateDesiredNodesRequest(request.getHistoryID(), request.getVersion() + 1, request.getNodes());
+        return new UpdateDesiredNodesRequest(request.getHistoryID(), request.getVersion() + 1, request.getNodes(), request.isDryRun());
     }
 
     @Override

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

@@ -29,7 +29,8 @@ public class UpdateDesiredNodesRequestTests extends ESTestCase {
         final UpdateDesiredNodesRequest updateDesiredNodesRequest = new UpdateDesiredNodesRequest(
             randomBoolean() ? "" : "     ",
             -1,
-            randomBoolean() ? Collections.emptyList() : List.of(hotDesiredNode())
+            randomBoolean() ? Collections.emptyList() : List.of(hotDesiredNode()),
+            randomBoolean()
         );
         ActionRequestValidationException exception = updateDesiredNodesRequest.validate();
         assertThat(exception, is(notNullValue()));

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

@@ -127,7 +127,8 @@ public abstract class DesiredNodesTestCase extends ESTestCase {
         return new UpdateDesiredNodesRequest(
             UUIDs.randomBase64UUID(random()),
             randomLongBetween(0, Long.MAX_VALUE - 1000),
-            randomList(1, 100, DesiredNodesTestCase::randomDesiredNode)
+            randomList(1, 100, DesiredNodesTestCase::randomDesiredNode),
+            randomBoolean()
         );
     }
 

+ 1 - 1
x-pack/plugin/core/src/internalClusterTest/java/org/elasticsearch/xpack/cluster/routing/allocation/DataTierAllocationDeciderIT.java

@@ -583,7 +583,7 @@ public class DataTierAllocationDeciderIT extends ESIntegTestCase {
     private void updateDesiredNodes(List<DesiredNode> desiredNodes) {
         assertThat(desiredNodes.size(), is(greaterThan(0)));
 
-        final var request = new UpdateDesiredNodesRequest(randomAlphaOfLength(10), 1, desiredNodes);
+        final var request = new UpdateDesiredNodesRequest(randomAlphaOfLength(10), 1, desiredNodes, false);
         internalCluster().client().execute(UpdateDesiredNodesAction.INSTANCE, request).actionGet();
     }