Browse Source

Add 'state' query param to GET snapshots API (#128635)

This change introduces a new optional 'state' query parameter for the Get Snapshots API,
allowing users to filter snapshots by state.  The parameter accepts comma-separated
values for states: SUCCESS, IN_PROGRESS, FAILED, PARTIAL, INCOMPATIBLE (case-insensitive).

A new 'snapshots.get.state_parameter' NodeFeature has been added with this change.
The new state query parameter will only be supported in clusters where all nodes support
this feature.

---------

Co-authored-by: Elena Stoeva <elenastoeva99@gmail.com>
Jeremy Dahlgren 4 months ago
parent
commit
d43198ea3e

+ 6 - 0
docs/changelog/128635.yaml

@@ -0,0 +1,6 @@
+pr: 128635
+summary: Add `state` query param to Get snapshots API
+area: Snapshot/Restore
+type: enhancement
+issues:
+ - 97446

+ 4 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/snapshot.get.json

@@ -85,6 +85,10 @@
       "verbose":{
         "type":"boolean",
         "description":"Whether to show verbose snapshot info or only show the basic info found in the repository index blob"
+      },
+      "state": {
+        "type": "list",
+        "description": "Filter snapshots by a comma-separated list of states. Valid state values are 'SUCCESS', 'IN_PROGRESS', 'FAILED', 'PARTIAL', or 'INCOMPATIBLE'."
       }
     }
   }

+ 69 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/snapshot.get/10_basic.yml

@@ -303,3 +303,72 @@ setup:
       snapshot.delete:
         repository: test_repo_get_1
         snapshot: test_snapshot_no_repo_name
+
+---
+"Get snapshot using state parameter":
+  - requires:
+      cluster_features: "snapshots.get.state_parameter"
+      test_runner_features: capabilities
+      capabilities:
+        - method: GET
+          path: /_snapshot/{repository}/{snapshot}
+          parameters: [ state ]
+      reason: "state parameter was introduced in 9.1"
+
+  - do:
+      indices.create:
+        index: test_index
+        body:
+          settings:
+            number_of_shards: 1
+            number_of_replicas: 0
+
+  - do:
+      snapshot.create:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param
+        wait_for_completion: true
+
+  - do:
+      snapshot.get:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param
+        state: SUCCESS
+
+  - is_true: snapshots
+  - match: { snapshots.0.snapshot: test_snapshot_with_state_param }
+  - match: { snapshots.0.state: SUCCESS }
+
+  - do:
+      snapshot.get:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param
+        state: SUCCESS,PARTIAL
+
+  - is_true: snapshots
+  - match: { snapshots.0.snapshot: test_snapshot_with_state_param }
+  - match: { snapshots.0.state: SUCCESS }
+
+  - do:
+      snapshot.get:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param
+        state: FAILED
+
+  - is_true: snapshots
+  - length: { snapshots: 0 }
+
+  - do:
+      catch: bad_request
+      snapshot.get:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param
+        state: FOO
+
+  - match: { error.type: "illegal_argument_exception" }
+  - match: { error.reason: "No enum constant org.elasticsearch.snapshots.SnapshotState.FOO" }
+
+  - do:
+      snapshot.delete:
+        repository: test_repo_get_1
+        snapshot: test_snapshot_with_state_param

+ 70 - 2
server/src/internalClusterTest/java/org/elasticsearch/snapshots/GetSnapshotsIT.java

@@ -55,13 +55,16 @@ import org.elasticsearch.xcontent.json.JsonXContent;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
@@ -635,6 +638,63 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         expectThrows(RepositoryMissingException.class, multiRepoFuture::actionGet);
     }
 
+    public void testFilterByState() throws Exception {
+        final String repoName = "test-repo";
+        final Path repoPath = randomRepoPath();
+        createRepository(repoName, "mock", repoPath);
+
+        // Create a successful snapshot
+        createFullSnapshot(repoName, "snapshot-success");
+
+        final Function<EnumSet<SnapshotState>, List<SnapshotInfo>> getSnapshotsForStates = (states) -> {
+            return clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).setStates(states).get().getSnapshots();
+        };
+
+        // Fetch snapshots with state=SUCCESS
+        var snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.SUCCESS));
+        assertThat(snapshots, hasSize(1));
+        assertThat(snapshots.getFirst().state(), is(SnapshotState.SUCCESS));
+
+        // Create a snapshot in progress
+        blockAllDataNodes(repoName);
+        startFullSnapshot(repoName, "snapshot-in-progress");
+        awaitNumberOfSnapshotsInProgress(1);
+
+        // Fetch snapshots with state=IN_PROGRESS
+        snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.IN_PROGRESS));
+        assertThat(snapshots, hasSize(1));
+        assertThat(snapshots.getFirst().state(), is(SnapshotState.IN_PROGRESS));
+
+        // Fetch snapshots with multiple states (SUCCESS, IN_PROGRESS)
+        snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.SUCCESS, SnapshotState.IN_PROGRESS));
+        assertThat(snapshots, hasSize(2));
+        var states = snapshots.stream().map(SnapshotInfo::state).collect(Collectors.toSet());
+        assertTrue(states.contains(SnapshotState.SUCCESS));
+        assertTrue(states.contains(SnapshotState.IN_PROGRESS));
+
+        // Fetch all snapshots (without state)
+        snapshots = clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).get().getSnapshots();
+        assertThat(snapshots, hasSize(2));
+
+        // Fetch snapshots with an invalid state
+        IllegalArgumentException e = expectThrows(
+            IllegalArgumentException.class,
+            () -> getSnapshotsForStates.apply(EnumSet.of(SnapshotState.valueOf("FOO")))
+        );
+        assertThat(e.getMessage(), is("No enum constant org.elasticsearch.snapshots.SnapshotState.FOO"));
+
+        // Allow the IN_PROGRESS snapshot to finish, then verify GET using SUCCESS has results and IN_PROGRESS does not.
+        unblockAllDataNodes(repoName);
+        awaitNumberOfSnapshotsInProgress(0);
+        snapshots = clusterAdmin().prepareGetSnapshots(TEST_REQUEST_TIMEOUT, repoName).get().getSnapshots();
+        assertThat(snapshots, hasSize(2));
+        states = snapshots.stream().map(SnapshotInfo::state).collect(Collectors.toSet());
+        assertThat(states, hasSize(1));
+        assertTrue(states.contains(SnapshotState.SUCCESS));
+        snapshots = getSnapshotsForStates.apply(EnumSet.of(SnapshotState.IN_PROGRESS));
+        assertThat(snapshots, hasSize(0));
+    }
+
     public void testRetrievingSnapshotsWhenRepositoryIsUnreadable() throws Exception {
         final String repoName = randomIdentifier();
         final Path repoPath = randomRepoPath();
@@ -956,6 +1016,12 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         // INDICES and by SHARDS. The actual sorting behaviour for these cases is tested elsewhere, here we're just checking that sorting
         // interacts correctly with the other parameters to the API.
 
+        final EnumSet<SnapshotState> states = EnumSet.copyOf(randomNonEmptySubsetOf(Arrays.asList(SnapshotState.values())));
+        // Note: The selected state(s) may not match any existing snapshots.
+        // The actual filtering behaviour for such cases is tested in the dedicated test.
+        // Here we're just checking that states interacts correctly with the other parameters to the API.
+        snapshotInfoPredicate = snapshotInfoPredicate.and(si -> states.contains(si.state()));
+
         // compute the ordered sequence of snapshots which match the repository/snapshot name filters and SLM policy filter
         final var selectedSnapshots = snapshotInfos.stream()
             .filter(snapshotInfoPredicate)
@@ -967,7 +1033,8 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
         )
             // apply sorting params
             .sort(sortKey)
-            .order(order);
+            .order(order)
+            .states(states);
 
         // sometimes use ?from_sort_value to skip some items; note that snapshots skipped in this way are subtracted from
         // GetSnapshotsResponse.totalCount whereas snapshots skipped by ?after and ?offset are not
@@ -1054,7 +1121,8 @@ public class GetSnapshotsIT extends AbstractSnapshotIntegTestCase {
                 .sort(sortKey)
                 .order(order)
                 .size(nextSize)
-                .after(SnapshotSortKey.decodeAfterQueryParam(nextRequestAfter));
+                .after(SnapshotSortKey.decodeAfterQueryParam(nextRequestAfter))
+                .states(states);
             final GetSnapshotsResponse nextResponse = safeAwait(l -> client().execute(TransportGetSnapshotsAction.TYPE, nextRequest, l));
 
             assertEquals(

+ 1 - 0
server/src/main/java/module-info.java

@@ -425,6 +425,7 @@ module org.elasticsearch.server {
             org.elasticsearch.action.bulk.BulkFeatures,
             org.elasticsearch.features.InfrastructureFeatures,
             org.elasticsearch.rest.action.admin.cluster.ClusterRerouteFeatures,
+            org.elasticsearch.rest.action.admin.cluster.GetSnapshotsFeatures,
             org.elasticsearch.index.mapper.MapperFeatures,
             org.elasticsearch.index.IndexFeatures,
             org.elasticsearch.search.SearchFeatures,

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -299,6 +299,7 @@ public class TransportVersions {
     public static final TransportVersion NONE_CHUNKING_STRATEGY = def(9_097_0_00);
     public static final TransportVersion PROJECT_DELETION_GLOBAL_BLOCK = def(9_098_0_00);
     public static final TransportVersion SECURITY_CLOUD_API_KEY_REALM_AND_TYPE = def(9_099_0_00);
+    public static final TransportVersion STATE_PARAM_GET_SNAPSHOT = def(9_100_0_00);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 1 - 1
server/src/main/java/org/elasticsearch/action/ActionModule.java

@@ -864,7 +864,7 @@ public class ActionModule extends AbstractModule {
         registerHandler.accept(new RestDeleteRepositoryAction());
         registerHandler.accept(new RestVerifyRepositoryAction());
         registerHandler.accept(new RestCleanupRepositoryAction());
-        registerHandler.accept(new RestGetSnapshotsAction());
+        registerHandler.accept(new RestGetSnapshotsAction(clusterSupportsFeature));
         registerHandler.accept(new RestCreateSnapshotAction());
         registerHandler.accept(new RestCloneSnapshotAction());
         registerHandler.accept(new RestRestoreSnapshotAction());

+ 30 - 0
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsRequest.java

@@ -19,13 +19,16 @@ import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.core.Nullable;
 import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.search.sort.SortOrder;
+import org.elasticsearch.snapshots.SnapshotState;
 import org.elasticsearch.tasks.CancellableTask;
 import org.elasticsearch.tasks.Task;
 import org.elasticsearch.tasks.TaskId;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.EnumSet;
 import java.util.Map;
+import java.util.Objects;
 
 import static org.elasticsearch.action.ValidateActions.addValidationError;
 
@@ -39,6 +42,7 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
     public static final boolean DEFAULT_VERBOSE_MODE = true;
 
     private static final TransportVersion INDICES_FLAG_VERSION = TransportVersions.V_8_3_0;
+    private static final TransportVersion STATE_FLAG_VERSION = TransportVersions.STATE_PARAM_GET_SNAPSHOT;
 
     public static final int NO_LIMIT = -1;
 
@@ -77,6 +81,8 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
 
     private boolean includeIndexNames = true;
 
+    private EnumSet<SnapshotState> states = EnumSet.allOf(SnapshotState.class);
+
     public GetSnapshotsRequest(TimeValue masterNodeTimeout) {
         super(masterNodeTimeout);
     }
@@ -118,6 +124,11 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
         if (in.getTransportVersion().onOrAfter(INDICES_FLAG_VERSION)) {
             includeIndexNames = in.readBoolean();
         }
+        if (in.getTransportVersion().onOrAfter(STATE_FLAG_VERSION)) {
+            states = in.readEnumSet(SnapshotState.class);
+        } else {
+            states = EnumSet.allOf(SnapshotState.class);
+        }
     }
 
     @Override
@@ -137,6 +148,13 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
         if (out.getTransportVersion().onOrAfter(INDICES_FLAG_VERSION)) {
             out.writeBoolean(includeIndexNames);
         }
+        if (out.getTransportVersion().onOrAfter(STATE_FLAG_VERSION)) {
+            out.writeEnumSet(states);
+        } else if (states.equals(EnumSet.allOf(SnapshotState.class)) == false) {
+            final var errorString = "GetSnapshotsRequest [states] field is not supported on all nodes in the cluster";
+            assert false : errorString;
+            throw new IllegalStateException(errorString);
+        }
     }
 
     @Override
@@ -177,6 +195,9 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
         } else if (after != null && fromSortValue != null) {
             validationException = addValidationError("can't use after and from_sort_value simultaneously", validationException);
         }
+        if (states.isEmpty()) {
+            validationException = addValidationError("states is empty", validationException);
+        }
         return validationException;
     }
 
@@ -342,6 +363,15 @@ public class GetSnapshotsRequest extends MasterNodeRequest<GetSnapshotsRequest>
         return verbose;
     }
 
+    public EnumSet<SnapshotState> states() {
+        return states;
+    }
+
+    public GetSnapshotsRequest states(EnumSet<SnapshotState> states) {
+        this.states = Objects.requireNonNull(states);
+        return this;
+    }
+
     @Override
     public Task createTask(long id, String type, String action, TaskId parentTaskId, Map<String, String> headers) {
         return new CancellableTask(id, type, action, getDescription(), parentTaskId, headers);

+ 7 - 0
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsRequestBuilder.java

@@ -15,6 +15,9 @@ import org.elasticsearch.common.util.ArrayUtils;
 import org.elasticsearch.core.Nullable;
 import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.search.sort.SortOrder;
+import org.elasticsearch.snapshots.SnapshotState;
+
+import java.util.EnumSet;
 
 /**
  * Get snapshots request builder
@@ -150,4 +153,8 @@ public class GetSnapshotsRequestBuilder extends MasterNodeOperationRequestBuilde
 
     }
 
+    public GetSnapshotsRequestBuilder setStates(EnumSet<SnapshotState> states) {
+        request.states(states);
+        return this;
+    }
 }

+ 18 - 3
server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java

@@ -46,6 +46,7 @@ import org.elasticsearch.snapshots.Snapshot;
 import org.elasticsearch.snapshots.SnapshotId;
 import org.elasticsearch.snapshots.SnapshotInfo;
 import org.elasticsearch.snapshots.SnapshotMissingException;
+import org.elasticsearch.snapshots.SnapshotState;
 import org.elasticsearch.snapshots.SnapshotsService;
 import org.elasticsearch.tasks.CancellableTask;
 import org.elasticsearch.tasks.Task;
@@ -55,6 +56,7 @@ import org.elasticsearch.transport.TransportService;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -161,7 +163,8 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
             request.size(),
             SnapshotsInProgress.get(state),
             request.verbose(),
-            request.includeIndexNames()
+            request.includeIndexNames(),
+            request.states()
         ).runOperation(listener);
     }
 
@@ -182,6 +185,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
         private final SnapshotNamePredicate snapshotNamePredicate;
         private final SnapshotPredicates fromSortValuePredicates;
         private final Predicate<String> slmPolicyPredicate;
+        private final EnumSet<SnapshotState> states;
 
         // snapshot ordering/pagination
         private final SnapshotSortKey sortBy;
@@ -225,7 +229,8 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
             int size,
             SnapshotsInProgress snapshotsInProgress,
             boolean verbose,
-            boolean indices
+            boolean indices,
+            EnumSet<SnapshotState> states
         ) {
             this.cancellableTask = cancellableTask;
             this.repositories = repositories;
@@ -238,6 +243,7 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
             this.snapshotsInProgress = snapshotsInProgress;
             this.verbose = verbose;
             this.indices = indices;
+            this.states = states;
 
             this.snapshotNamePredicate = SnapshotNamePredicate.forSnapshots(ignoreUnavailable, snapshots);
             this.fromSortValuePredicates = SnapshotPredicates.forFromSortValue(fromSortValue, sortBy, order);
@@ -572,11 +578,16 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
                 return false;
             }
 
+            final var details = repositoryData.getSnapshotDetails(snapshotId);
+
+            if (details != null && details.getSnapshotState() != null && states.contains(details.getSnapshotState()) == false) {
+                return false;
+            }
+
             if (slmPolicyPredicate == SlmPolicyPredicate.MATCH_ALL_POLICIES) {
                 return true;
             }
 
-            final var details = repositoryData.getSnapshotDetails(snapshotId);
             return details == null || details.getSlmPolicy() == null || slmPolicyPredicate.test(details.getSlmPolicy());
         }
 
@@ -585,6 +596,10 @@ public class TransportGetSnapshotsAction extends TransportMasterNodeAction<GetSn
                 return false;
             }
 
+            if (snapshotInfo.state() != null && states.contains(snapshotInfo.state()) == false) {
+                return false;
+            }
+
             if (slmPolicyPredicate == SlmPolicyPredicate.MATCH_ALL_POLICIES) {
                 return true;
             }

+ 2 - 0
server/src/main/java/org/elasticsearch/rest/BaseRestHandler.java

@@ -154,6 +154,8 @@ public abstract class BaseRestHandler implements RestHandler {
             supportedAndCommon.removeAll(RestRequest.INTERNAL_MARKER_REQUEST_PARAMETERS);
             final var consumed = new TreeSet<>(request.consumedParams());
             consumed.removeAll(RestRequest.INTERNAL_MARKER_REQUEST_PARAMETERS);
+            // Response parameters are implicitly consumed since they are made available to response renderings.
+            consumed.addAll(responseParams(request.getRestApiVersion()));
             assert supportedAndCommon.equals(consumed)
                 : getName() + ": consumed params " + consumed + " while supporting " + supportedAndCommon;
         }

+ 24 - 0
server/src/main/java/org/elasticsearch/rest/action/admin/cluster/GetSnapshotsFeatures.java

@@ -0,0 +1,24 @@
+/*
+ * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.rest.action.admin.cluster;
+
+import org.elasticsearch.features.FeatureSpecification;
+import org.elasticsearch.features.NodeFeature;
+
+import java.util.Set;
+
+public class GetSnapshotsFeatures implements FeatureSpecification {
+    public static final NodeFeature GET_SNAPSHOTS_STATE_PARAMETER = new NodeFeature("snapshots.get.state_parameter");
+
+    @Override
+    public Set<NodeFeature> getFeatures() {
+        return Set.of(GET_SNAPSHOTS_STATE_PARAMETER);
+    }
+}

+ 59 - 2
server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestGetSnapshotsAction.java

@@ -13,17 +13,24 @@ import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.get.SnapshotSortKey;
 import org.elasticsearch.client.internal.node.NodeClient;
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.features.NodeFeature;
 import org.elasticsearch.rest.BaseRestHandler;
 import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestUtils;
 import org.elasticsearch.rest.Scope;
 import org.elasticsearch.rest.ServerlessScope;
 import org.elasticsearch.rest.action.RestCancellableNodeClient;
 import org.elasticsearch.rest.action.RestRefCountedChunkedToXContentListener;
 import org.elasticsearch.search.sort.SortOrder;
+import org.elasticsearch.snapshots.SnapshotState;
 
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
+import java.util.function.Predicate;
 
 import static org.elasticsearch.rest.RestRequest.Method.GET;
 import static org.elasticsearch.rest.RestUtils.getMasterNodeTimeout;
@@ -37,7 +44,35 @@ import static org.elasticsearch.snapshots.SnapshotInfo.INDEX_NAMES_XCONTENT_PARA
 @ServerlessScope(Scope.INTERNAL)
 public class RestGetSnapshotsAction extends BaseRestHandler {
 
-    public RestGetSnapshotsAction() {}
+    private static final Set<String> SUPPORTED_RESPONSE_PARAMETERS = Set.of(
+        INCLUDE_REPOSITORY_XCONTENT_PARAM,
+        INDEX_DETAILS_XCONTENT_PARAM,
+        INDEX_NAMES_XCONTENT_PARAM
+    );
+
+    private static final Set<String> SUPPORTED_QUERY_PARAMETERS = Set.of(
+        RestUtils.REST_MASTER_TIMEOUT_PARAM,
+        "after",
+        "from_sort_value",
+        "ignore_unavailable",
+        "offset",
+        "order",
+        "size",
+        "slm_policy_filter",
+        "sort",
+        "state",
+        "verbose"
+    );
+
+    private static final Set<String> ALL_SUPPORTED_PARAMETERS = Set.copyOf(
+        Sets.union(SUPPORTED_QUERY_PARAMETERS, SUPPORTED_RESPONSE_PARAMETERS, Set.of("repository", "snapshot"))
+    );
+
+    private final Predicate<NodeFeature> clusterSupportsFeature;
+
+    public RestGetSnapshotsAction(Predicate<NodeFeature> clusterSupportsFeature) {
+        this.clusterSupportsFeature = clusterSupportsFeature;
+    }
 
     @Override
     public List<Route> routes() {
@@ -51,7 +86,17 @@ public class RestGetSnapshotsAction extends BaseRestHandler {
 
     @Override
     protected Set<String> responseParams() {
-        return Set.of(INDEX_DETAILS_XCONTENT_PARAM, INCLUDE_REPOSITORY_XCONTENT_PARAM, INDEX_NAMES_XCONTENT_PARAM);
+        return SUPPORTED_RESPONSE_PARAMETERS;
+    }
+
+    @Override
+    public Set<String> supportedQueryParameters() {
+        return SUPPORTED_QUERY_PARAMETERS;
+    }
+
+    @Override
+    public Set<String> allSupportedParameters() {
+        return ALL_SUPPORTED_PARAMETERS;
     }
 
     @Override
@@ -82,6 +127,18 @@ public class RestGetSnapshotsAction extends BaseRestHandler {
         final SortOrder order = SortOrder.fromString(request.param("order", getSnapshotsRequest.order().toString()));
         getSnapshotsRequest.order(order);
         getSnapshotsRequest.includeIndexNames(request.paramAsBoolean(INDEX_NAMES_XCONTENT_PARAM, getSnapshotsRequest.includeIndexNames()));
+
+        final String stateString = request.param("state");
+        if (stateString == null) {
+            getSnapshotsRequest.states(EnumSet.allOf(SnapshotState.class));
+        } else if (Strings.hasText(stateString) == false) {
+            throw new IllegalArgumentException("[state] parameter must not be empty");
+        } else if (clusterSupportsFeature.test(GetSnapshotsFeatures.GET_SNAPSHOTS_STATE_PARAMETER)) {
+            getSnapshotsRequest.states(EnumSet.copyOf(Arrays.stream(stateString.split(",")).map(SnapshotState::valueOf).toList()));
+        } else {
+            throw new IllegalArgumentException("[state] parameter is not supported on all nodes in the cluster");
+        }
+
         return channel -> new RestCancellableNodeClient(client, request.getHttpChannel()).admin()
             .cluster()
             .getSnapshots(getSnapshotsRequest, new RestRefCountedChunkedToXContentListener<>(channel));

+ 1 - 0
server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification

@@ -10,6 +10,7 @@
 org.elasticsearch.action.bulk.BulkFeatures
 org.elasticsearch.features.InfrastructureFeatures
 org.elasticsearch.rest.action.admin.cluster.ClusterRerouteFeatures
+org.elasticsearch.rest.action.admin.cluster.GetSnapshotsFeatures
 org.elasticsearch.index.IndexFeatures
 org.elasticsearch.index.mapper.MapperFeatures
 org.elasticsearch.search.SearchFeatures