Skip to content

Commit e58f40f

Browse files
authored
GEODE-10613: Add configurable export directories for export data (#8042)
1 parent 4bdf539 commit e58f40f

9 files changed

Lines changed: 317 additions & 4 deletions

File tree

geode-docs/security/implementing_authorization.html.md.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ This table classifies the permissions assigned for `gfsh` operations.
154154
| execute function | Defaults to DATA:WRITE. Override `Function.getRequiredPermissions` to change the permission. |
155155
| export cluster-configuration | CLUSTER:READ |
156156
| export config | CLUSTER:READ |
157-
| export data | CLUSTER:READ |
157+
| export data | DATA:READ:RegionName and CLUSTER:WRITE |
158158
| export logs | CLUSTER:READ |
159159
| export offline-disk-store | CLUSTER:READ |
160160
| export stack-traces | CLUSTER:READ |

geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,22 @@ In this scenario, partitioned region data is exported simultaneously on all host
165165
| <span class="keyword parmname">&#8209;&#8209;dir</span> | Directory to which the exported data is to be written. Required if &#8209;&#8209;parallel is true. Cannot be specified at the same time as &#8209;&#8209;file.|
166166
| <span class="keyword parmname">&#8209;&#8209;parallel</span> | Export local data on each node to a directory on that machine. Available for partitioned regions only. |
167167

168+
**Export locations:**
169+
170+
The snapshot is written by the member named in `--member`, on that member's host. A member writes
171+
exports into its own working directory (and sub-directories of it). To export somewhere else, such
172+
as a mounted backup location, set the `gemfire.export.data.dirs` system property on the member to
173+
the additional directories, separated by the platform's path separator:
174+
175+
``` pre
176+
-Dgemfire.export.data.dirs=/mnt/backup/geode:/var/exports/geode
177+
```
178+
179+
A path containing a `..` segment is not accepted, and a path that resolves outside the configured
180+
directories is rejected by the member.
181+
182+
**Required permission:** `DATA:READ` on the exported region, plus `CLUSTER:WRITE`.
183+
168184
**Example Commands:**
169185

170186
``` pre

geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.apache.geode.management.internal.cli.commands;
1818

1919
import static org.apache.geode.cache.Region.SEPARATOR;
20+
import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
2021
import static org.assertj.core.api.Assertions.assertThat;
2122
import static org.junit.Assert.assertFalse;
2223

@@ -31,6 +32,7 @@
3132
import org.junit.ClassRule;
3233
import org.junit.Rule;
3334
import org.junit.Test;
35+
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
3436
import org.junit.rules.TemporaryFolder;
3537

3638
import org.apache.geode.DataSerializable;
@@ -58,6 +60,9 @@ public class ExportDataIntegrationTest {
5860
@Rule
5961
public TemporaryFolder tempDir = new TemporaryFolder();
6062

63+
@Rule
64+
public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
65+
6166
private Region<String, Object> region;
6267
private Path snapshotFile;
6368
private Path snapshotDir;
@@ -87,6 +92,8 @@ public void setup() throws Exception {
8792
region = server.getCache().getRegion(TEST_REGION_NAME);
8893
loadRegion("value");
8994
Path basePath = tempDir.getRoot().toPath();
95+
// configure the test's temporary folder as an export destination
96+
System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString());
9097
snapshotFile = basePath.resolve(SNAPSHOT_FILE);
9198
snapshotDir = basePath.resolve(SNAPSHOT_DIR);
9299
}

geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.apache.geode.management.internal.cli.commands;
1818

1919
import static org.apache.geode.cache.Region.SEPARATOR;
20+
import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY;
2021
import static org.assertj.core.api.Assertions.assertThat;
2122
import static org.junit.Assert.assertEquals;
2223

@@ -30,6 +31,7 @@
3031
import org.junit.ClassRule;
3132
import org.junit.Rule;
3233
import org.junit.Test;
34+
import org.junit.contrib.java.lang.system.RestoreSystemProperties;
3335
import org.junit.rules.TemporaryFolder;
3436

3537
import org.apache.geode.cache.Region;
@@ -55,6 +57,9 @@ public class ImportDataIntegrationTest {
5557
@Rule
5658
public TemporaryFolder tempDir = new TemporaryFolder();
5759

60+
@Rule
61+
public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties();
62+
5863
private Region<String, String> region;
5964
private Path snapshotFile;
6065
private Path snapshotDir;
@@ -65,6 +70,8 @@ public void setup() throws Exception {
6570
region = server.getCache().getRegion(TEST_REGION_NAME);
6671
loadRegion("value");
6772
Path basePath = tempDir.getRoot().toPath();
73+
// configure the test's temporary folder as an export destination
74+
System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString());
6875
snapshotFile = basePath.resolve(SNAPSHOT_FILE);
6976
snapshotDir = basePath.resolve(SNAPSHOT_DIR);
7077
}

geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package org.apache.geode.management.internal.cli.commands;
1717

1818
import java.io.File;
19+
import java.nio.file.Path;
20+
import java.nio.file.Paths;
1921
import java.util.List;
2022
import java.util.Optional;
2123

@@ -33,6 +35,7 @@
3335
import org.apache.geode.management.internal.cli.result.model.ResultModel;
3436
import org.apache.geode.management.internal.functions.CliFunctionResult;
3537
import org.apache.geode.management.internal.i18n.CliStrings;
38+
import org.apache.geode.security.ResourcePermission;
3639
import org.apache.geode.security.ResourcePermission.Operation;
3740
import org.apache.geode.security.ResourcePermission.Resource;
3841

@@ -54,6 +57,7 @@ public ResultModel exportData(
5457
help = CliStrings.EXPORT_DATA__PARALLEL_HELP) boolean parallel) {
5558

5659
authorize(Resource.DATA, Operation.READ, regionName);
60+
authorize(Resource.CLUSTER, Operation.WRITE, ResourcePermission.ALL);
5761
final DistributedMember targetMember = getMember(memberNameOrId);
5862

5963
Optional<ResultModel> validationResult = validatePath(filePath, dirPath, parallel);
@@ -100,6 +104,28 @@ private Optional<ResultModel> validatePath(String filePath, String dirPath, bool
100104
return Optional.of(ResultModel.createError(CliStrings.format(
101105
CliStrings.INVALID_FILE_EXTENSION, CliStrings.GEODE_DATA_FILE_EXTENSION)));
102106
}
107+
108+
if (filePath != null && containsParentDirectorySegment(filePath)) {
109+
return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__FILE, filePath));
110+
}
111+
if (dirPath != null && containsParentDirectorySegment(dirPath)) {
112+
return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__DIR, dirPath));
113+
}
114+
103115
return Optional.empty();
104116
}
117+
118+
private static boolean containsParentDirectorySegment(String path) {
119+
for (Path element : Paths.get(path)) {
120+
if ("..".equals(element.toString())) {
121+
return true;
122+
}
123+
}
124+
return false;
125+
}
126+
127+
private static ResultModel invalidPathError(String option, String path) {
128+
return ResultModel.createError(String.format(
129+
"Option \"%s\" must not contain a \"..\" path segment: %s", option, path));
130+
}
105131
}

geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
package org.apache.geode.management.internal.cli.functions;
1616

1717
import java.io.File;
18+
import java.io.IOException;
19+
import java.util.ArrayList;
20+
import java.util.List;
1821

1922
import org.apache.geode.cache.Cache;
2023
import org.apache.geode.cache.Region;
@@ -27,19 +30,34 @@
2730
import org.apache.geode.management.cli.CliFunction;
2831
import org.apache.geode.management.internal.functions.CliFunctionResult;
2932
import org.apache.geode.management.internal.i18n.CliStrings;
33+
import org.apache.geode.util.internal.GeodeGlossary;
3034

3135
/***
3236
* Function which carries out the export of a region to a file on a member. Uses the
3337
* RegionSnapshotService to export the data
3438
*
35-
*
39+
* <p>
40+
* Export destinations are resolved to their canonical form and must be within the export
41+
* directories configured for this member.
3642
*/
3743
public class ExportDataFunction extends CliFunction<String[]> {
3844
private static final long serialVersionUID = 1L;
3945

4046
private static final String ID =
4147
"org.apache.geode.management.internal.cli.functions.ExportDataFunction";
4248

49+
/**
50+
* System property naming additional directories this member writes {@code export data} snapshots
51+
* into. Several directories may be listed, separated by {@link File#pathSeparator}. Exports into
52+
* sub-directories of a configured directory are included.
53+
*
54+
* <p>
55+
* The member's working directory is always configured, since that is where a relative export
56+
* path resolves to, so when this property is not set it is the only export destination.
57+
*/
58+
public static final String EXPORT_DATA_DIRS_PROPERTY =
59+
GeodeGlossary.GEMFIRE_PREFIX + "export.data.dirs";
60+
4361
@Override
4462
public String getId() {
4563
return ID;
@@ -62,7 +80,7 @@ public CliFunctionResult executeFunction(FunctionContext<String[]> context) thro
6280
String hostName = cache.getDistributedSystem().getDistributedMember().getHost();
6381
if (region != null) {
6482
RegionSnapshotService<Object, Object> snapshotService = region.getSnapshotService();
65-
final File exportFile = new File(fileName);
83+
final File exportFile = resolveExportFile(fileName);
6684
if (parallel) {
6785
SnapshotOptions<Object, Object> options = new SnapshotOptionsImpl<>().setParallelMode(true);
6886
snapshotService.save(exportFile, SnapshotFormat.GEODE, options);
@@ -81,4 +99,42 @@ public CliFunctionResult executeFunction(FunctionContext<String[]> context) thro
8199

82100
return result;
83101
}
102+
103+
/**
104+
* Resolves the requested export path against the export directories configured for this member.
105+
*
106+
* @param fileName the path requested by the caller, which may be relative or absolute
107+
* @return the canonical file to export to
108+
* @throws IllegalArgumentException if the path is not within a configured export directory
109+
*/
110+
static File resolveExportFile(String fileName) throws IOException {
111+
File exportFile = new File(fileName).getCanonicalFile();
112+
List<File> exportDirs = configuredExportDirs();
113+
114+
for (File exportDir : exportDirs) {
115+
if (exportFile.toPath().startsWith(exportDir.toPath())) {
116+
return exportFile;
117+
}
118+
}
119+
120+
throw new IllegalArgumentException(String.format(
121+
"Cannot export to %s: the path is not within the export directories configured for this member (%s). Use the %s system property to configure additional directories.",
122+
exportFile, exportDirs, EXPORT_DATA_DIRS_PROPERTY));
123+
}
124+
125+
private static List<File> configuredExportDirs() throws IOException {
126+
List<File> exportDirs = new ArrayList<>();
127+
exportDirs.add(new File(System.getProperty("user.dir")).getCanonicalFile());
128+
129+
String configuredDirs = System.getProperty(EXPORT_DATA_DIRS_PROPERTY);
130+
if (configuredDirs != null) {
131+
for (String configuredDir : configuredDirs.split(File.pathSeparator)) {
132+
if (!configuredDir.trim().isEmpty()) {
133+
exportDirs.add(new File(configuredDir.trim()).getCanonicalFile());
134+
}
135+
}
136+
}
137+
138+
return exportDirs;
139+
}
84140
}

geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,18 @@
1515

1616
package org.apache.geode.management.internal.cli.commands;
1717

18+
import static org.mockito.ArgumentMatchers.any;
19+
import static org.mockito.ArgumentMatchers.anyString;
20+
import static org.mockito.Mockito.doNothing;
21+
import static org.mockito.Mockito.doReturn;
22+
import static org.mockito.Mockito.mock;
23+
import static org.mockito.Mockito.spy;
24+
1825
import org.junit.Before;
1926
import org.junit.ClassRule;
2027
import org.junit.Test;
2128

29+
import org.apache.geode.distributed.DistributedMember;
2230
import org.apache.geode.test.junit.rules.GfshParserRule;
2331

2432

@@ -34,11 +42,35 @@ public void setUp() {
3442
command = new ExportDataCommand();
3543
}
3644

45+
/** A command whose option values are checked without contacting a member. */
46+
private ExportDataCommand commandWithMember() {
47+
ExportDataCommand withMember = spy(ExportDataCommand.class);
48+
doNothing().when(withMember).authorize(any(), any(), anyString());
49+
doReturn(mock(DistributedMember.class)).when(withMember).getMember(anyString());
50+
return withMember;
51+
}
52+
3753
@Test
3854
public void missingMember() throws Exception {
3955
// Command parses successfully but fails during execution because cache is null
4056
gfsh.executeAndAssertThat(command, "export data --region=regionA --file=test")
4157
.statusIsError()
4258
.containsOutput("cache");
4359
}
60+
61+
@Test
62+
public void fileOptionWithParentDirectorySegmentIsRejected() {
63+
gfsh.executeAndAssertThat(commandWithMember(),
64+
"export data --member=server1 --region=regionA --file=exports/../regionA.gfd")
65+
.statusIsError()
66+
.containsOutput("must not contain a \"..\" path segment");
67+
}
68+
69+
@Test
70+
public void dirOptionWithParentDirectorySegmentIsRejected() {
71+
gfsh.executeAndAssertThat(commandWithMember(),
72+
"export data --member=server1 --region=regionA --dir=exports/../elsewhere")
73+
.statusIsError()
74+
.containsOutput("must not contain a \"..\" path segment");
75+
}
4476
}

0 commit comments

Comments
 (0)