Skip to content

Commit 6eb4600

Browse files
authored
Add custom metadata to snapshots (#41281)
Adds a metadata field to snapshots which can be used to store arbitrary key-value information. This may be useful for attaching a description of why a snapshot was taken, tagging snapshots to make categorization easier, or identifying the source of automatically-created snapshots.
1 parent 1f4ff97 commit 6eb4600

File tree

24 files changed

+518
-49
lines changed

24 files changed

+518
-49
lines changed

client/rest-high-level/src/test/java/org/elasticsearch/client/SnapshotIT.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,13 @@
4141
import org.elasticsearch.repositories.fs.FsRepository;
4242
import org.elasticsearch.rest.RestStatus;
4343
import org.elasticsearch.snapshots.RestoreInfo;
44+
import org.elasticsearch.snapshots.SnapshotInfo;
4445

4546
import java.io.IOException;
4647
import java.util.Collections;
48+
import java.util.HashMap;
49+
import java.util.Map;
50+
import java.util.Optional;
4751
import java.util.stream.Collectors;
4852

4953
import static org.hamcrest.Matchers.contains;
@@ -139,6 +143,9 @@ public void testCreateSnapshot() throws IOException {
139143
CreateSnapshotRequest request = new CreateSnapshotRequest(repository, snapshot);
140144
boolean waitForCompletion = randomBoolean();
141145
request.waitForCompletion(waitForCompletion);
146+
if (randomBoolean()) {
147+
request.userMetadata(randomUserMetadata());
148+
}
142149
request.partial(randomBoolean());
143150
request.includeGlobalState(randomBoolean());
144151

@@ -167,6 +174,8 @@ public void testGetSnapshots() throws IOException {
167174
CreateSnapshotResponse putSnapshotResponse1 = createTestSnapshot(createSnapshotRequest1);
168175
CreateSnapshotRequest createSnapshotRequest2 = new CreateSnapshotRequest(repository, snapshot2);
169176
createSnapshotRequest2.waitForCompletion(true);
177+
Map<String, Object> originalMetadata = randomUserMetadata();
178+
createSnapshotRequest2.userMetadata(originalMetadata);
170179
CreateSnapshotResponse putSnapshotResponse2 = createTestSnapshot(createSnapshotRequest2);
171180
// check that the request went ok without parsing JSON here. When using the high level client, check acknowledgement instead.
172181
assertEquals(RestStatus.OK, putSnapshotResponse1.status());
@@ -186,6 +195,15 @@ public void testGetSnapshots() throws IOException {
186195
assertEquals(2, response.getSnapshots().size());
187196
assertThat(response.getSnapshots().stream().map((s) -> s.snapshotId().getName()).collect(Collectors.toList()),
188197
contains("test_snapshot1", "test_snapshot2"));
198+
Optional<Map<String, Object>> returnedMetadata = response.getSnapshots().stream()
199+
.filter(s -> s.snapshotId().getName().equals("test_snapshot2"))
200+
.findFirst()
201+
.map(SnapshotInfo::userMetadata);
202+
if (returnedMetadata.isPresent()) {
203+
assertEquals(originalMetadata, returnedMetadata.get());
204+
} else {
205+
assertNull("retrieved metadata is null, expected non-null metadata", originalMetadata);
206+
}
189207
}
190208

191209
public void testSnapshotsStatus() throws IOException {
@@ -231,6 +249,9 @@ public void testRestoreSnapshot() throws IOException {
231249
CreateSnapshotRequest createSnapshotRequest = new CreateSnapshotRequest(testRepository, testSnapshot);
232250
createSnapshotRequest.indices(testIndex);
233251
createSnapshotRequest.waitForCompletion(true);
252+
if (randomBoolean()) {
253+
createSnapshotRequest.userMetadata(randomUserMetadata());
254+
}
234255
CreateSnapshotResponse createSnapshotResponse = createTestSnapshot(createSnapshotRequest);
235256
assertEquals(RestStatus.OK, createSnapshotResponse.status());
236257

@@ -261,6 +282,9 @@ public void testDeleteSnapshot() throws IOException {
261282

262283
CreateSnapshotRequest createSnapshotRequest = new CreateSnapshotRequest(repository, snapshot);
263284
createSnapshotRequest.waitForCompletion(true);
285+
if (randomBoolean()) {
286+
createSnapshotRequest.userMetadata(randomUserMetadata());
287+
}
264288
CreateSnapshotResponse createSnapshotResponse = createTestSnapshot(createSnapshotRequest);
265289
// check that the request went ok without parsing JSON here. When using the high level client, check acknowledgement instead.
266290
assertEquals(RestStatus.OK, createSnapshotResponse.status());
@@ -270,4 +294,28 @@ public void testDeleteSnapshot() throws IOException {
270294

271295
assertTrue(response.isAcknowledged());
272296
}
297+
298+
private static Map<String, Object> randomUserMetadata() {
299+
if (randomBoolean()) {
300+
return null;
301+
}
302+
303+
Map<String, Object> metadata = new HashMap<>();
304+
long fields = randomLongBetween(0, 4);
305+
for (int i = 0; i < fields; i++) {
306+
if (randomBoolean()) {
307+
metadata.put(randomValueOtherThanMany(metadata::containsKey, () -> randomAlphaOfLengthBetween(2,10)),
308+
randomAlphaOfLengthBetween(5, 5));
309+
} else {
310+
Map<String, Object> nested = new HashMap<>();
311+
long nestedFields = randomLongBetween(0, 4);
312+
for (int j = 0; j < nestedFields; j++) {
313+
nested.put(randomValueOtherThanMany(nested::containsKey, () -> randomAlphaOfLengthBetween(2,10)),
314+
randomAlphaOfLengthBetween(5, 5));
315+
}
316+
metadata.put(randomValueOtherThanMany(metadata::containsKey, () -> randomAlphaOfLengthBetween(2,10)), nested);
317+
}
318+
}
319+
return metadata;
320+
}
273321
}

docs/reference/modules/snapshots.asciidoc

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,11 @@ PUT /_snapshot/my_backup/snapshot_2?wait_for_completion=true
349349
{
350350
"indices": "index_1,index_2",
351351
"ignore_unavailable": true,
352-
"include_global_state": false
352+
"include_global_state": false,
353+
"_meta": {
354+
"taken_by": "kimchy",
355+
"taken_because": "backup before upgrading"
356+
}
353357
}
354358
-----------------------------------
355359
// CONSOLE
@@ -363,6 +367,9 @@ By setting `include_global_state` to false it's possible to prevent the cluster
363367
the snapshot. By default, the entire snapshot will fail if one or more indices participating in the snapshot don't have
364368
all primary shards available. This behaviour can be changed by setting `partial` to `true`.
365369

370+
The `_meta` field can be used to attach arbitrary metadata to the snapshot. This may be a record of who took the snapshot,
371+
why it was taken, or any other data that might be useful.
372+
366373
Snapshot names can be automatically derived using <<date-math-index-names,date math expressions>>, similarly as when creating
367374
new indices. Note that special characters need to be URI encoded.
368375

rest-api-spec/src/main/resources/rest-api-spec/test/snapshot.get/10_basic.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ setup:
8787
- is_false: snapshots.0.failures
8888
- is_false: snapshots.0.shards
8989
- is_false: snapshots.0.version
90+
- is_false: snapshots.0._meta
9091

9192
- do:
9293
snapshot.delete:
@@ -152,3 +153,41 @@ setup:
152153
snapshot.delete:
153154
repository: test_repo_get_1
154155
snapshot: test_snapshot_without_include_global_state
156+
157+
---
158+
"Get snapshot info with metadata":
159+
- skip:
160+
version: " - 7.9.99"
161+
reason: "https://github.com/elastic/elasticsearch/pull/41281 not yet backported to 7.x"
162+
163+
- do:
164+
indices.create:
165+
index: test_index
166+
body:
167+
settings:
168+
number_of_shards: 1
169+
number_of_replicas: 0
170+
171+
- do:
172+
snapshot.create:
173+
repository: test_repo_get_1
174+
snapshot: test_snapshot_with_metadata
175+
wait_for_completion: true
176+
body: |
177+
{ "metadata": {"taken_by": "test", "foo": {"bar": "baz"}} }
178+
179+
- do:
180+
snapshot.get:
181+
repository: test_repo_get_1
182+
snapshot: test_snapshot_with_metadata
183+
184+
- is_true: snapshots
185+
- match: { snapshots.0.snapshot: test_snapshot_with_metadata }
186+
- match: { snapshots.0.state: SUCCESS }
187+
- match: { snapshots.0.metadata.taken_by: test }
188+
- match: { snapshots.0.metadata.foo.bar: baz }
189+
190+
- do:
191+
snapshot.delete:
192+
repository: test_repo_get_1
193+
snapshot: test_snapshot_with_metadata

server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequest.java

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919

2020
package org.elasticsearch.action.admin.cluster.snapshots.create;
2121

22+
import org.elasticsearch.ElasticsearchException;
2223
import org.elasticsearch.ElasticsearchGenerationException;
2324
import org.elasticsearch.action.ActionRequestValidationException;
2425
import org.elasticsearch.action.IndicesRequest;
2526
import org.elasticsearch.action.support.IndicesOptions;
2627
import org.elasticsearch.action.support.master.MasterNodeRequest;
2728
import org.elasticsearch.common.Strings;
29+
import org.elasticsearch.common.bytes.BytesReference;
2830
import org.elasticsearch.common.io.stream.StreamInput;
2931
import org.elasticsearch.common.io.stream.StreamOutput;
3032
import org.elasticsearch.common.settings.Settings;
@@ -46,6 +48,7 @@
4648
import static org.elasticsearch.common.settings.Settings.readSettingsFromStream;
4749
import static org.elasticsearch.common.settings.Settings.writeSettingsToStream;
4850
import static org.elasticsearch.common.xcontent.support.XContentMapValues.nodeBooleanValue;
51+
import static org.elasticsearch.snapshots.SnapshotInfo.METADATA_FIELD_INTRODUCED;
4952

5053
/**
5154
* Create snapshot request
@@ -63,6 +66,7 @@
6366
*/
6467
public class CreateSnapshotRequest extends MasterNodeRequest<CreateSnapshotRequest>
6568
implements IndicesRequest.Replaceable, ToXContentObject {
69+
public static int MAXIMUM_METADATA_BYTES = 1024; // chosen arbitrarily
6670

6771
private String snapshot;
6872

@@ -80,6 +84,8 @@ public class CreateSnapshotRequest extends MasterNodeRequest<CreateSnapshotReque
8084

8185
private boolean waitForCompletion;
8286

87+
private Map<String, Object> userMetadata;
88+
8389
public CreateSnapshotRequest() {
8490
}
8591

@@ -104,6 +110,9 @@ public CreateSnapshotRequest(StreamInput in) throws IOException {
104110
includeGlobalState = in.readBoolean();
105111
waitForCompletion = in.readBoolean();
106112
partial = in.readBoolean();
113+
if (in.getVersion().onOrAfter(METADATA_FIELD_INTRODUCED)) {
114+
userMetadata = in.readMap();
115+
}
107116
}
108117

109118
@Override
@@ -117,6 +126,9 @@ public void writeTo(StreamOutput out) throws IOException {
117126
out.writeBoolean(includeGlobalState);
118127
out.writeBoolean(waitForCompletion);
119128
out.writeBoolean(partial);
129+
if (out.getVersion().onOrAfter(METADATA_FIELD_INTRODUCED)) {
130+
out.writeMap(userMetadata);
131+
}
120132
}
121133

122134
@Override
@@ -144,9 +156,28 @@ public ActionRequestValidationException validate() {
144156
if (settings == null) {
145157
validationException = addValidationError("settings is null", validationException);
146158
}
159+
final int metadataSize = metadataSize(userMetadata);
160+
if (metadataSize > MAXIMUM_METADATA_BYTES) {
161+
validationException = addValidationError("metadata must be smaller than 1024 bytes, but was [" + metadataSize + "]",
162+
validationException);
163+
}
147164
return validationException;
148165
}
149166

167+
private static int metadataSize(Map<String, Object> userMetadata) {
168+
if (userMetadata == null) {
169+
return 0;
170+
}
171+
try (XContentBuilder builder = XContentFactory.jsonBuilder()) {
172+
builder.value(userMetadata);
173+
int size = BytesReference.bytes(builder).length();
174+
return size;
175+
} catch (IOException e) {
176+
// This should not be possible as we are just rendering the xcontent in memory
177+
throw new ElasticsearchException(e);
178+
}
179+
}
180+
150181
/**
151182
* Sets the snapshot name
152183
*
@@ -378,6 +409,15 @@ public boolean includeGlobalState() {
378409
return includeGlobalState;
379410
}
380411

412+
public Map<String, Object> userMetadata() {
413+
return userMetadata;
414+
}
415+
416+
public CreateSnapshotRequest userMetadata(Map<String, Object> userMetadata) {
417+
this.userMetadata = userMetadata;
418+
return this;
419+
}
420+
381421
/**
382422
* Parses snapshot definition.
383423
*
@@ -405,6 +445,11 @@ public CreateSnapshotRequest source(Map<String, Object> source) {
405445
settings((Map<String, Object>) entry.getValue());
406446
} else if (name.equals("include_global_state")) {
407447
includeGlobalState = nodeBooleanValue(entry.getValue(), "include_global_state");
448+
} else if (name.equals("metadata")) {
449+
if (entry.getValue() != null && (entry.getValue() instanceof Map == false)) {
450+
throw new IllegalArgumentException("malformed metadata, should be an object");
451+
}
452+
userMetadata((Map<String, Object>) entry.getValue());
408453
}
409454
}
410455
indicesOptions(IndicesOptions.fromMap(source, indicesOptions));
@@ -433,6 +478,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
433478
if (indicesOptions != null) {
434479
indicesOptions.toXContent(builder, params);
435480
}
481+
builder.field("metadata", userMetadata);
436482
builder.endObject();
437483
return builder;
438484
}
@@ -460,12 +506,14 @@ public boolean equals(Object o) {
460506
Arrays.equals(indices, that.indices) &&
461507
Objects.equals(indicesOptions, that.indicesOptions) &&
462508
Objects.equals(settings, that.settings) &&
463-
Objects.equals(masterNodeTimeout, that.masterNodeTimeout);
509+
Objects.equals(masterNodeTimeout, that.masterNodeTimeout) &&
510+
Objects.equals(userMetadata, that.userMetadata);
464511
}
465512

466513
@Override
467514
public int hashCode() {
468-
int result = Objects.hash(snapshot, repository, indicesOptions, partial, settings, includeGlobalState, waitForCompletion);
515+
int result = Objects.hash(snapshot, repository, indicesOptions, partial, settings, includeGlobalState,
516+
waitForCompletion, userMetadata);
469517
result = 31 * result + Arrays.hashCode(indices);
470518
return result;
471519
}
@@ -482,6 +530,7 @@ public String toString() {
482530
", includeGlobalState=" + includeGlobalState +
483531
", waitForCompletion=" + waitForCompletion +
484532
", masterNodeTimeout=" + masterNodeTimeout +
533+
", metadata=" + userMetadata +
485534
'}';
486535
}
487536
}

server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponse.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.elasticsearch.action.ActionResponse;
2323
import org.elasticsearch.common.ParseField;
24+
import org.elasticsearch.common.Strings;
2425
import org.elasticsearch.common.io.stream.StreamInput;
2526
import org.elasticsearch.common.io.stream.StreamOutput;
2627
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
@@ -117,4 +118,9 @@ public boolean equals(Object o) {
117118
public int hashCode() {
118119
return Objects.hash(snapshots);
119120
}
121+
122+
@Override
123+
public String toString() {
124+
return Strings.toString(this);
125+
}
120126
}

0 commit comments

Comments
 (0)