Skip to content

Commit eaa3f87

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 8ffb5a1 commit eaa3f87

File tree

24 files changed

+514
-49
lines changed

24 files changed

+514
-49
lines changed

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@
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;
4750
import java.util.stream.Collectors;
4851

4952
import static org.hamcrest.Matchers.contains;
@@ -139,6 +142,9 @@ public void testCreateSnapshot() throws IOException {
139142
CreateSnapshotRequest request = new CreateSnapshotRequest(repository, snapshot);
140143
boolean waitForCompletion = randomBoolean();
141144
request.waitForCompletion(waitForCompletion);
145+
if (randomBoolean()) {
146+
request.userMetadata(randomUserMetadata());
147+
}
142148
request.partial(randomBoolean());
143149
request.includeGlobalState(randomBoolean());
144150

@@ -167,6 +173,8 @@ public void testGetSnapshots() throws IOException {
167173
CreateSnapshotResponse putSnapshotResponse1 = createTestSnapshot(createSnapshotRequest1);
168174
CreateSnapshotRequest createSnapshotRequest2 = new CreateSnapshotRequest(repository, snapshot2);
169175
createSnapshotRequest2.waitForCompletion(true);
176+
Map<String, Object> originalMetadata = randomUserMetadata();
177+
createSnapshotRequest2.userMetadata(originalMetadata);
170178
CreateSnapshotResponse putSnapshotResponse2 = createTestSnapshot(createSnapshotRequest2);
171179
// check that the request went ok without parsing JSON here. When using the high level client, check acknowledgement instead.
172180
assertEquals(RestStatus.OK, putSnapshotResponse1.status());
@@ -186,6 +194,12 @@ public void testGetSnapshots() throws IOException {
186194
assertEquals(2, response.getSnapshots().size());
187195
assertThat(response.getSnapshots().stream().map((s) -> s.snapshotId().getName()).collect(Collectors.toList()),
188196
contains("test_snapshot1", "test_snapshot2"));
197+
response.getSnapshots().stream()
198+
.filter(s -> s.snapshotId().getName().equals("test_snapshot2"))
199+
.findFirst()
200+
.map(SnapshotInfo::userMetadata)
201+
.ifPresentOrElse(metadata -> assertEquals(originalMetadata, metadata),
202+
() -> assertNull("retrieved metadata is null, expected non-null metadata", originalMetadata));
189203
}
190204

191205
public void testSnapshotsStatus() throws IOException {
@@ -231,6 +245,9 @@ public void testRestoreSnapshot() throws IOException {
231245
CreateSnapshotRequest createSnapshotRequest = new CreateSnapshotRequest(testRepository, testSnapshot);
232246
createSnapshotRequest.indices(testIndex);
233247
createSnapshotRequest.waitForCompletion(true);
248+
if (randomBoolean()) {
249+
createSnapshotRequest.userMetadata(randomUserMetadata());
250+
}
234251
CreateSnapshotResponse createSnapshotResponse = createTestSnapshot(createSnapshotRequest);
235252
assertEquals(RestStatus.OK, createSnapshotResponse.status());
236253

@@ -261,6 +278,9 @@ public void testDeleteSnapshot() throws IOException {
261278

262279
CreateSnapshotRequest createSnapshotRequest = new CreateSnapshotRequest(repository, snapshot);
263280
createSnapshotRequest.waitForCompletion(true);
281+
if (randomBoolean()) {
282+
createSnapshotRequest.userMetadata(randomUserMetadata());
283+
}
264284
CreateSnapshotResponse createSnapshotResponse = createTestSnapshot(createSnapshotRequest);
265285
// check that the request went ok without parsing JSON here. When using the high level client, check acknowledgement instead.
266286
assertEquals(RestStatus.OK, createSnapshotResponse.status());
@@ -270,4 +290,28 @@ public void testDeleteSnapshot() throws IOException {
270290

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

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