Add EC2 credential test for repository-s3 (#31918)
Add EC2 credential test for repository-s3 Relates to #26913
This commit is contained in:
parent
5856c396dd
commit
8235b254ab
|
@ -137,7 +137,10 @@ class ClusterConfiguration {
|
||||||
this.project = project
|
this.project = project
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> systemProperties = new HashMap<>()
|
// **Note** for systemProperties, settings, keystoreFiles etc:
|
||||||
|
// value could be a GString that is evaluated to just a String
|
||||||
|
// there are cases when value depends on task that is not executed yet on configuration stage
|
||||||
|
Map<String, Object> systemProperties = new HashMap<>()
|
||||||
|
|
||||||
Map<String, Object> settings = new HashMap<>()
|
Map<String, Object> settings = new HashMap<>()
|
||||||
|
|
||||||
|
@ -157,7 +160,7 @@ class ClusterConfiguration {
|
||||||
List<Object> dependencies = new ArrayList<>()
|
List<Object> dependencies = new ArrayList<>()
|
||||||
|
|
||||||
@Input
|
@Input
|
||||||
void systemProperty(String property, String value) {
|
void systemProperty(String property, Object value) {
|
||||||
systemProperties.put(property, value)
|
systemProperties.put(property, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -609,7 +609,6 @@ class ClusterFormationTasks {
|
||||||
|
|
||||||
/** Adds a task to start an elasticsearch node with the given configuration */
|
/** Adds a task to start an elasticsearch node with the given configuration */
|
||||||
static Task configureStartTask(String name, Project project, Task setup, NodeInfo node) {
|
static Task configureStartTask(String name, Project project, Task setup, NodeInfo node) {
|
||||||
|
|
||||||
// this closure is converted into ant nodes by groovy's AntBuilder
|
// this closure is converted into ant nodes by groovy's AntBuilder
|
||||||
Closure antRunner = { AntBuilder ant ->
|
Closure antRunner = { AntBuilder ant ->
|
||||||
ant.exec(executable: node.executable, spawn: node.config.daemonize, dir: node.cwd, taskname: 'elasticsearch') {
|
ant.exec(executable: node.executable, spawn: node.config.daemonize, dir: node.cwd, taskname: 'elasticsearch') {
|
||||||
|
@ -630,13 +629,6 @@ class ClusterFormationTasks {
|
||||||
node.writeWrapperScript()
|
node.writeWrapperScript()
|
||||||
}
|
}
|
||||||
|
|
||||||
// we must add debug options inside the closure so the config is read at execution time, as
|
|
||||||
// gradle task options are not processed until the end of the configuration phase
|
|
||||||
if (node.config.debug) {
|
|
||||||
println 'Running elasticsearch in debug mode, suspending until connected on port 8000'
|
|
||||||
node.env['ES_JAVA_OPTS'] = '-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000'
|
|
||||||
}
|
|
||||||
|
|
||||||
node.getCommandString().eachLine { line -> logger.info(line) }
|
node.getCommandString().eachLine { line -> logger.info(line) }
|
||||||
|
|
||||||
if (logger.isInfoEnabled() || node.config.daemonize == false) {
|
if (logger.isInfoEnabled() || node.config.daemonize == false) {
|
||||||
|
@ -654,6 +646,27 @@ class ClusterFormationTasks {
|
||||||
}
|
}
|
||||||
start.doLast(elasticsearchRunner)
|
start.doLast(elasticsearchRunner)
|
||||||
start.doFirst {
|
start.doFirst {
|
||||||
|
// Configure ES JAVA OPTS - adds system properties, assertion flags, remote debug etc
|
||||||
|
List<String> esJavaOpts = [node.env.get('ES_JAVA_OPTS', '')]
|
||||||
|
String collectedSystemProperties = node.config.systemProperties.collect { key, value -> "-D${key}=${value}" }.join(" ")
|
||||||
|
esJavaOpts.add(collectedSystemProperties)
|
||||||
|
esJavaOpts.add(node.config.jvmArgs)
|
||||||
|
if (Boolean.parseBoolean(System.getProperty('tests.asserts', 'true'))) {
|
||||||
|
// put the enable assertions options before other options to allow
|
||||||
|
// flexibility to disable assertions for specific packages or classes
|
||||||
|
// in the cluster-specific options
|
||||||
|
esJavaOpts.add("-ea")
|
||||||
|
esJavaOpts.add("-esa")
|
||||||
|
}
|
||||||
|
// we must add debug options inside the closure so the config is read at execution time, as
|
||||||
|
// gradle task options are not processed until the end of the configuration phase
|
||||||
|
if (node.config.debug) {
|
||||||
|
println 'Running elasticsearch in debug mode, suspending until connected on port 8000'
|
||||||
|
esJavaOpts.add('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000')
|
||||||
|
}
|
||||||
|
node.env['ES_JAVA_OPTS'] = esJavaOpts.join(" ")
|
||||||
|
|
||||||
|
//
|
||||||
project.logger.info("Starting node in ${node.clusterName} distribution: ${node.config.distribution}")
|
project.logger.info("Starting node in ${node.clusterName} distribution: ${node.config.distribution}")
|
||||||
}
|
}
|
||||||
return start
|
return start
|
||||||
|
|
|
@ -180,15 +180,7 @@ class NodeInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
args.addAll("-E", "node.portsfile=true")
|
args.addAll("-E", "node.portsfile=true")
|
||||||
String collectedSystemProperties = config.systemProperties.collect { key, value -> "-D${key}=${value}" }.join(" ")
|
env = [:]
|
||||||
String esJavaOpts = config.jvmArgs.isEmpty() ? collectedSystemProperties : collectedSystemProperties + " " + config.jvmArgs
|
|
||||||
if (Boolean.parseBoolean(System.getProperty('tests.asserts', 'true'))) {
|
|
||||||
// put the enable assertions options before other options to allow
|
|
||||||
// flexibility to disable assertions for specific packages or classes
|
|
||||||
// in the cluster-specific options
|
|
||||||
esJavaOpts = String.join(" ", "-ea", "-esa", esJavaOpts)
|
|
||||||
}
|
|
||||||
env = ['ES_JAVA_OPTS': esJavaOpts]
|
|
||||||
for (Map.Entry<String, String> property : System.properties.entrySet()) {
|
for (Map.Entry<String, String> property : System.properties.entrySet()) {
|
||||||
if (property.key.startsWith('tests.es.')) {
|
if (property.key.startsWith('tests.es.')) {
|
||||||
args.add("-E")
|
args.add("-E")
|
||||||
|
|
|
@ -89,18 +89,26 @@ String s3TemporarySessionToken = System.getenv("amazon_s3_session_token_temporar
|
||||||
String s3TemporaryBucket = System.getenv("amazon_s3_bucket_temporary")
|
String s3TemporaryBucket = System.getenv("amazon_s3_bucket_temporary")
|
||||||
String s3TemporaryBasePath = System.getenv("amazon_s3_base_path_temporary")
|
String s3TemporaryBasePath = System.getenv("amazon_s3_base_path_temporary")
|
||||||
|
|
||||||
|
String s3EC2Bucket = System.getenv("amazon_s3_bucket_ec2")
|
||||||
|
String s3EC2BasePath = System.getenv("amazon_s3_base_path_ec2")
|
||||||
|
|
||||||
// If all these variables are missing then we are testing against the internal fixture instead, which has the following
|
// If all these variables are missing then we are testing against the internal fixture instead, which has the following
|
||||||
// credentials hard-coded in.
|
// credentials hard-coded in.
|
||||||
|
|
||||||
if (!s3PermanentAccessKey && !s3PermanentSecretKey && !s3PermanentBucket && !s3PermanentBasePath) {
|
if (!s3PermanentAccessKey && !s3PermanentSecretKey && !s3PermanentBucket && !s3PermanentBasePath
|
||||||
|
&& !s3EC2Bucket && !s3EC2BasePath) {
|
||||||
s3PermanentAccessKey = 's3_integration_test_permanent_access_key'
|
s3PermanentAccessKey = 's3_integration_test_permanent_access_key'
|
||||||
s3PermanentSecretKey = 's3_integration_test_permanent_secret_key'
|
s3PermanentSecretKey = 's3_integration_test_permanent_secret_key'
|
||||||
s3PermanentBucket = 'permanent-bucket-test'
|
s3PermanentBucket = 'permanent-bucket-test'
|
||||||
s3PermanentBasePath = 'integration_test'
|
s3PermanentBasePath = 'integration_test'
|
||||||
|
|
||||||
|
s3EC2Bucket = 'ec2-bucket-test'
|
||||||
|
s3EC2BasePath = 'integration_test'
|
||||||
|
|
||||||
useFixture = true
|
useFixture = true
|
||||||
|
|
||||||
} else if (!s3PermanentAccessKey || !s3PermanentSecretKey || !s3PermanentBucket || !s3PermanentBasePath) {
|
} else if (!s3PermanentAccessKey || !s3PermanentSecretKey || !s3PermanentBucket || !s3PermanentBasePath
|
||||||
|
|| !s3EC2Bucket || !s3EC2BasePath) {
|
||||||
throw new IllegalArgumentException("not all options specified to run against external S3 service")
|
throw new IllegalArgumentException("not all options specified to run against external S3 service")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -274,24 +282,52 @@ if (useFixture && minioDistribution) {
|
||||||
integTestMinioRunner.dependsOn(startMinio)
|
integTestMinioRunner.dependsOn(startMinio)
|
||||||
integTestMinioRunner.finalizedBy(stopMinio)
|
integTestMinioRunner.finalizedBy(stopMinio)
|
||||||
// Minio only supports a single access key, see https://github.com/minio/minio/pull/5968
|
// Minio only supports a single access key, see https://github.com/minio/minio/pull/5968
|
||||||
integTestMinioRunner.systemProperty 'tests.rest.blacklist', 'repository_s3/30_repository_temporary_credentials/*'
|
integTestMinioRunner.systemProperty 'tests.rest.blacklist', [
|
||||||
|
'repository_s3/30_repository_temporary_credentials/*',
|
||||||
|
'repository_s3/40_repository_ec2_credentials/*'
|
||||||
|
].join(",")
|
||||||
|
|
||||||
project.check.dependsOn(integTestMinio)
|
project.check.dependsOn(integTestMinio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
File parentFixtures = new File(project.buildDir, "fixtures")
|
||||||
|
File s3FixtureFile = new File(parentFixtures, 's3Fixture.properties')
|
||||||
|
|
||||||
|
task s3FixtureProperties {
|
||||||
|
outputs.file(s3FixtureFile)
|
||||||
|
def s3FixtureOptions = [
|
||||||
|
"tests.seed" : project.testSeed,
|
||||||
|
"s3Fixture.permanent_bucket_name" : s3PermanentBucket,
|
||||||
|
"s3Fixture.permanent_key" : s3PermanentAccessKey,
|
||||||
|
"s3Fixture.temporary_bucket_name" : s3TemporaryBucket,
|
||||||
|
"s3Fixture.temporary_key" : s3TemporaryAccessKey,
|
||||||
|
"s3Fixture.temporary_session_token": s3TemporarySessionToken,
|
||||||
|
"s3Fixture.ec2_bucket_name" : s3EC2Bucket
|
||||||
|
]
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
file(s3FixtureFile).text = s3FixtureOptions.collect { k, v -> "$k = $v" }.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A task to start the AmazonS3Fixture which emulates an S3 service **/
|
/** A task to start the AmazonS3Fixture which emulates an S3 service **/
|
||||||
task s3Fixture(type: AntFixture) {
|
task s3Fixture(type: AntFixture) {
|
||||||
dependsOn testClasses
|
dependsOn testClasses
|
||||||
|
dependsOn s3FixtureProperties
|
||||||
|
inputs.file(s3FixtureFile)
|
||||||
|
|
||||||
env 'CLASSPATH', "${ -> project.sourceSets.test.runtimeClasspath.asPath }"
|
env 'CLASSPATH', "${ -> project.sourceSets.test.runtimeClasspath.asPath }"
|
||||||
executable = new File(project.runtimeJavaHome, 'bin/java')
|
executable = new File(project.runtimeJavaHome, 'bin/java')
|
||||||
args 'org.elasticsearch.repositories.s3.AmazonS3Fixture', baseDir, s3PermanentBucket, s3TemporaryBucket
|
args 'org.elasticsearch.repositories.s3.AmazonS3Fixture', baseDir, s3FixtureFile.getAbsolutePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> expansions = [
|
Map<String, Object> expansions = [
|
||||||
'permanent_bucket': s3PermanentBucket,
|
'permanent_bucket': s3PermanentBucket,
|
||||||
'permanent_base_path': s3PermanentBasePath,
|
'permanent_base_path': s3PermanentBasePath,
|
||||||
'temporary_bucket': s3TemporaryBucket,
|
'temporary_bucket': s3TemporaryBucket,
|
||||||
'temporary_base_path': s3TemporaryBasePath
|
'temporary_base_path': s3TemporaryBasePath,
|
||||||
|
'ec2_bucket': s3EC2Bucket,
|
||||||
|
'ec2_base_path': s3EC2BasePath
|
||||||
]
|
]
|
||||||
|
|
||||||
processTestResources {
|
processTestResources {
|
||||||
|
@ -319,6 +355,10 @@ integTestCluster {
|
||||||
/* Use a closure on the string to delay evaluation until tests are executed */
|
/* Use a closure on the string to delay evaluation until tests are executed */
|
||||||
setting 's3.client.integration_test_permanent.endpoint', "http://${-> s3Fixture.addressAndPort}"
|
setting 's3.client.integration_test_permanent.endpoint', "http://${-> s3Fixture.addressAndPort}"
|
||||||
setting 's3.client.integration_test_temporary.endpoint', "http://${-> s3Fixture.addressAndPort}"
|
setting 's3.client.integration_test_temporary.endpoint', "http://${-> s3Fixture.addressAndPort}"
|
||||||
|
setting 's3.client.integration_test_ec2.endpoint', "http://${-> s3Fixture.addressAndPort}"
|
||||||
|
|
||||||
|
// to redirect InstanceProfileCredentialsProvider to custom auth point
|
||||||
|
systemProperty "com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", "http://${-> s3Fixture.addressAndPort}"
|
||||||
} else {
|
} else {
|
||||||
println "Using an external service to test the repository-s3 plugin"
|
println "Using an external service to test the repository-s3 plugin"
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,14 @@
|
||||||
*/
|
*/
|
||||||
package org.elasticsearch.repositories.s3;
|
package org.elasticsearch.repositories.s3;
|
||||||
|
|
||||||
|
import org.apache.http.client.methods.HttpDelete;
|
||||||
|
import org.apache.http.client.methods.HttpGet;
|
||||||
|
import org.apache.http.client.methods.HttpHead;
|
||||||
|
import org.apache.http.client.methods.HttpPost;
|
||||||
|
import org.apache.http.client.methods.HttpPut;
|
||||||
|
import org.elasticsearch.common.TriFunction;
|
||||||
|
import org.elasticsearch.common.io.PathUtils;
|
||||||
|
import org.elasticsearch.test.fixture.AbstractHttpFixture;
|
||||||
import com.amazonaws.util.DateUtils;
|
import com.amazonaws.util.DateUtils;
|
||||||
|
|
||||||
import org.elasticsearch.common.Strings;
|
import org.elasticsearch.common.Strings;
|
||||||
|
@ -26,20 +34,26 @@ import org.elasticsearch.common.path.PathTrie;
|
||||||
import org.elasticsearch.common.util.concurrent.ConcurrentCollections;
|
import org.elasticsearch.common.util.concurrent.ConcurrentCollections;
|
||||||
import org.elasticsearch.rest.RestStatus;
|
import org.elasticsearch.rest.RestStatus;
|
||||||
import org.elasticsearch.rest.RestUtils;
|
import org.elasticsearch.rest.RestUtils;
|
||||||
import org.elasticsearch.test.fixture.AbstractHttpFixture;
|
|
||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Properties;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static com.carrotsearch.randomizedtesting.generators.RandomStrings.randomAsciiAlphanumOfLength;
|
||||||
|
import static com.carrotsearch.randomizedtesting.generators.RandomStrings.randomAsciiAlphanumOfLengthBetween;
|
||||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link AmazonS3Fixture} emulates an AWS S3 service
|
* {@link AmazonS3Fixture} emulates an AWS S3 service
|
||||||
|
@ -47,63 +61,76 @@ import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
* he implementation is based on official documentation available at https://docs.aws.amazon.com/AmazonS3/latest/API/.
|
* he implementation is based on official documentation available at https://docs.aws.amazon.com/AmazonS3/latest/API/.
|
||||||
*/
|
*/
|
||||||
public class AmazonS3Fixture extends AbstractHttpFixture {
|
public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
|
private static final String AUTH = "AUTH";
|
||||||
|
private static final String NON_AUTH = "NON_AUTH";
|
||||||
|
|
||||||
|
private static final String EC2_PROFILE = "ec2Profile";
|
||||||
|
|
||||||
|
private final Properties properties;
|
||||||
|
private final Random random;
|
||||||
|
|
||||||
/** List of the buckets stored on this test server **/
|
/** List of the buckets stored on this test server **/
|
||||||
private final Map<String, Bucket> buckets = ConcurrentCollections.newConcurrentMap();
|
private final Map<String, Bucket> buckets = ConcurrentCollections.newConcurrentMap();
|
||||||
|
|
||||||
/** Request handlers for the requests made by the S3 client **/
|
/** Request handlers for the requests made by the S3 client **/
|
||||||
private final PathTrie<RequestHandler> handlers;
|
private final PathTrie<RequestHandler> handlers;
|
||||||
private final String permanentBucketName;
|
|
||||||
private final String temporaryBucketName;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a {@link AmazonS3Fixture}
|
* Creates a {@link AmazonS3Fixture}
|
||||||
*/
|
*/
|
||||||
private AmazonS3Fixture(final String workingDir, final String permanentBucketName, final String temporaryBucketName) {
|
private AmazonS3Fixture(final String workingDir, Properties properties) {
|
||||||
super(workingDir);
|
super(workingDir);
|
||||||
this.permanentBucketName = permanentBucketName;
|
this.properties = properties;
|
||||||
this.temporaryBucketName = temporaryBucketName;
|
this.random = new Random(Long.parseUnsignedLong(requireNonNull(properties.getProperty("tests.seed")), 16));
|
||||||
|
|
||||||
this.buckets.put(permanentBucketName, new Bucket(permanentBucketName));
|
new Bucket("s3Fixture.permanent", false);
|
||||||
this.buckets.put(temporaryBucketName, new Bucket(temporaryBucketName));
|
new Bucket("s3Fixture.temporary", true);
|
||||||
this.handlers = defaultHandlers(buckets);
|
final Bucket ec2Bucket = new Bucket("s3Fixture.ec2",
|
||||||
|
randomAsciiAlphanumOfLength(random, 10), randomAsciiAlphanumOfLength(random, 10));
|
||||||
|
|
||||||
|
this.handlers = defaultHandlers(buckets, ec2Bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nonAuthPath(Request request) {
|
||||||
|
return nonAuthPath(request.getMethod(), request.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nonAuthPath(String method, String path) {
|
||||||
|
return NON_AUTH + " " + method + " " + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String authPath(Request request) {
|
||||||
|
return authPath(request.getMethod(), request.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String authPath(String method, String path) {
|
||||||
|
return AUTH + " " + method + " " + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Response handle(final Request request) throws IOException {
|
protected Response handle(final Request request) throws IOException {
|
||||||
final RequestHandler handler = handlers.retrieve(request.getMethod() + " " + request.getPath(), request.getParameters());
|
final String nonAuthorizedPath = nonAuthPath(request);
|
||||||
|
final RequestHandler nonAuthorizedHandler = handlers.retrieve(nonAuthorizedPath, request.getParameters());
|
||||||
|
if (nonAuthorizedHandler != null) {
|
||||||
|
return nonAuthorizedHandler.handle(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String authorizedPath = authPath(request);
|
||||||
|
final RequestHandler handler = handlers.retrieve(authorizedPath, request.getParameters());
|
||||||
if (handler != null) {
|
if (handler != null) {
|
||||||
final String authorization = request.getHeader("Authorization");
|
final String bucketName = request.getParam("bucket");
|
||||||
final String permittedBucket;
|
if (bucketName == null) {
|
||||||
if (authorization.contains("s3_integration_test_permanent_access_key")) {
|
|
||||||
final String sessionToken = request.getHeader("x-amz-security-token");
|
|
||||||
if (sessionToken != null) {
|
|
||||||
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Unexpected session token", "");
|
|
||||||
}
|
|
||||||
permittedBucket = permanentBucketName;
|
|
||||||
} else if (authorization.contains("s3_integration_test_temporary_access_key")) {
|
|
||||||
final String sessionToken = request.getHeader("x-amz-security-token");
|
|
||||||
if (sessionToken == null) {
|
|
||||||
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "No session token", "");
|
|
||||||
}
|
|
||||||
if (sessionToken.equals("s3_integration_test_temporary_session_token") == false) {
|
|
||||||
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad session token", "");
|
|
||||||
}
|
|
||||||
permittedBucket = temporaryBucketName;
|
|
||||||
} else {
|
|
||||||
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad access key", "");
|
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad access key", "");
|
||||||
}
|
}
|
||||||
|
final Bucket bucket = buckets.get(bucketName);
|
||||||
final String bucket = request.getParam("bucket");
|
if (bucket == null) {
|
||||||
if (bucket != null && permittedBucket.equals(bucket) == false) {
|
return newBucketNotFoundError(request.getId(), bucketName);
|
||||||
// allow a null bucket to support the multi-object-delete API which
|
|
||||||
// passes the bucket name in the host header instead of the URL.
|
|
||||||
if (buckets.containsKey(bucket)) {
|
|
||||||
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad bucket", "");
|
|
||||||
} else {
|
|
||||||
return newBucketNotFoundError(request.getId(), bucket);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
final Response authResponse = authenticateBucket(request, bucket);
|
||||||
|
if (authResponse != null) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
|
||||||
return handler.handle(request);
|
return handler.handle(request);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
@ -111,24 +138,49 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(final String[] args) throws Exception {
|
private Response authenticateBucket(Request request, Bucket bucket) {
|
||||||
if (args == null || args.length != 3) {
|
final String authorization = request.getHeader("Authorization");
|
||||||
throw new IllegalArgumentException(
|
if (authorization == null) {
|
||||||
"AmazonS3Fixture <working directory> <bucket for permanent creds> <bucket for temporary creds>");
|
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad access key", "");
|
||||||
}
|
}
|
||||||
|
if (authorization.contains(bucket.key)) {
|
||||||
|
final String sessionToken = request.getHeader("x-amz-security-token");
|
||||||
|
if (bucket.token == null) {
|
||||||
|
if (sessionToken != null) {
|
||||||
|
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Unexpected session token", "");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sessionToken == null) {
|
||||||
|
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "No session token", "");
|
||||||
|
}
|
||||||
|
if (sessionToken.equals(bucket.token) == false) {
|
||||||
|
return newError(request.getId(), RestStatus.FORBIDDEN, "AccessDenied", "Bad session token", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final AmazonS3Fixture fixture = new AmazonS3Fixture(args[0], args[1], args[2]);
|
public static void main(final String[] args) throws Exception {
|
||||||
|
if (args == null || args.length != 2) {
|
||||||
|
throw new IllegalArgumentException("AmazonS3Fixture <working directory> <property file>");
|
||||||
|
}
|
||||||
|
final Properties properties = new Properties();
|
||||||
|
try (InputStream is = Files.newInputStream(PathUtils.get(args[1]))) {
|
||||||
|
properties.load(is);
|
||||||
|
}
|
||||||
|
final AmazonS3Fixture fixture = new AmazonS3Fixture(args[0], properties);
|
||||||
fixture.listen();
|
fixture.listen();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the default request handlers **/
|
/** Builds the default request handlers **/
|
||||||
private static PathTrie<RequestHandler> defaultHandlers(final Map<String, Bucket> buckets) {
|
private PathTrie<RequestHandler> defaultHandlers(final Map<String, Bucket> buckets, final Bucket ec2Bucket) {
|
||||||
final PathTrie<RequestHandler> handlers = new PathTrie<>(RestUtils.REST_DECODER);
|
final PathTrie<RequestHandler> handlers = new PathTrie<>(RestUtils.REST_DECODER);
|
||||||
|
|
||||||
// HEAD Object
|
// HEAD Object
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html
|
||||||
objectsPaths("HEAD /{bucket}").forEach(path ->
|
objectsPaths(authPath(HttpHead.METHOD_NAME, "/{bucket}")).forEach(path ->
|
||||||
handlers.insert(path, (request) -> {
|
handlers.insert(path, (request) -> {
|
||||||
final String bucketName = request.getParam("bucket");
|
final String bucketName = request.getParam("bucket");
|
||||||
|
|
||||||
|
@ -150,7 +202,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// PUT Object
|
// PUT Object
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html
|
||||||
objectsPaths("PUT /{bucket}").forEach(path ->
|
objectsPaths(authPath(HttpPut.METHOD_NAME, "/{bucket}")).forEach(path ->
|
||||||
handlers.insert(path, (request) -> {
|
handlers.insert(path, (request) -> {
|
||||||
final String destBucketName = request.getParam("bucket");
|
final String destBucketName = request.getParam("bucket");
|
||||||
|
|
||||||
|
@ -200,7 +252,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// DELETE Object
|
// DELETE Object
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectDELETE.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectDELETE.html
|
||||||
objectsPaths("DELETE /{bucket}").forEach(path ->
|
objectsPaths(authPath(HttpDelete.METHOD_NAME, "/{bucket}")).forEach(path ->
|
||||||
handlers.insert(path, (request) -> {
|
handlers.insert(path, (request) -> {
|
||||||
final String bucketName = request.getParam("bucket");
|
final String bucketName = request.getParam("bucket");
|
||||||
|
|
||||||
|
@ -218,7 +270,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// GET Object
|
// GET Object
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html
|
||||||
objectsPaths("GET /{bucket}").forEach(path ->
|
objectsPaths(authPath(HttpGet.METHOD_NAME, "/{bucket}")).forEach(path ->
|
||||||
handlers.insert(path, (request) -> {
|
handlers.insert(path, (request) -> {
|
||||||
final String bucketName = request.getParam("bucket");
|
final String bucketName = request.getParam("bucket");
|
||||||
|
|
||||||
|
@ -239,7 +291,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// HEAD Bucket
|
// HEAD Bucket
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketHEAD.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketHEAD.html
|
||||||
handlers.insert("HEAD /{bucket}", (request) -> {
|
handlers.insert(authPath(HttpHead.METHOD_NAME, "/{bucket}"), (request) -> {
|
||||||
String bucket = request.getParam("bucket");
|
String bucket = request.getParam("bucket");
|
||||||
if (Strings.hasText(bucket) && buckets.containsKey(bucket)) {
|
if (Strings.hasText(bucket) && buckets.containsKey(bucket)) {
|
||||||
return new Response(RestStatus.OK.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE);
|
return new Response(RestStatus.OK.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE);
|
||||||
|
@ -251,7 +303,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// GET Bucket (List Objects) Version 1
|
// GET Bucket (List Objects) Version 1
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGET.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGET.html
|
||||||
handlers.insert("GET /{bucket}/", (request) -> {
|
handlers.insert(authPath(HttpGet.METHOD_NAME, "/{bucket}/"), (request) -> {
|
||||||
final String bucketName = request.getParam("bucket");
|
final String bucketName = request.getParam("bucket");
|
||||||
|
|
||||||
final Bucket bucket = buckets.get(bucketName);
|
final Bucket bucket = buckets.get(bucketName);
|
||||||
|
@ -269,7 +321,7 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
// Delete Multiple Objects
|
// Delete Multiple Objects
|
||||||
//
|
//
|
||||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/multiobjectdeleteapi.html
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/multiobjectdeleteapi.html
|
||||||
handlers.insert("POST /", (request) -> {
|
handlers.insert(nonAuthPath(HttpPost.METHOD_NAME, "/"), (request) -> {
|
||||||
final List<String> deletes = new ArrayList<>();
|
final List<String> deletes = new ArrayList<>();
|
||||||
final List<String> errors = new ArrayList<>();
|
final List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
@ -292,7 +344,12 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
|
|
||||||
boolean found = false;
|
boolean found = false;
|
||||||
for (Bucket bucket : buckets.values()) {
|
for (Bucket bucket : buckets.values()) {
|
||||||
if (bucket.objects.remove(objectName) != null) {
|
if (bucket.objects.containsKey(objectName)) {
|
||||||
|
final Response authResponse = authenticateBucket(request, bucket);
|
||||||
|
if (authResponse != null) {
|
||||||
|
return authResponse;
|
||||||
|
}
|
||||||
|
bucket.objects.remove(objectName);
|
||||||
found = true;
|
found = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -311,23 +368,80 @@ public class AmazonS3Fixture extends AbstractHttpFixture {
|
||||||
return newInternalError(request.getId(), "Something is wrong with this POST multiple deletes request");
|
return newInternalError(request.getId(), "Something is wrong with this POST multiple deletes request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// non-authorized requests
|
||||||
|
|
||||||
|
TriFunction<String, String, String, Response> credentialResponseFunction = (profileName, key, token) -> {
|
||||||
|
final Date expiration = new Date(new Date().getTime() + TimeUnit.DAYS.toMillis(1));
|
||||||
|
final String response = "{"
|
||||||
|
+ "\"AccessKeyId\": \"" + key + "\","
|
||||||
|
+ "\"Expiration\": \"" + DateUtils.formatISO8601Date(expiration) + "\","
|
||||||
|
+ "\"RoleArn\": \"" + randomAsciiAlphanumOfLengthBetween(random, 1, 20) + "\","
|
||||||
|
+ "\"SecretAccessKey\": \"" + randomAsciiAlphanumOfLengthBetween(random, 1, 20) + "\","
|
||||||
|
+ "\"Token\": \"" + token + "\""
|
||||||
|
+ "}";
|
||||||
|
|
||||||
|
final Map<String, String> headers = new HashMap<>(contentType("application/json"));
|
||||||
|
return new Response(RestStatus.OK.getStatus(), headers, response.getBytes(UTF_8));
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET
|
||||||
|
//
|
||||||
|
// http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html
|
||||||
|
handlers.insert(nonAuthPath(HttpGet.METHOD_NAME, "/latest/meta-data/iam/security-credentials/"), (request) -> {
|
||||||
|
final String response = EC2_PROFILE;
|
||||||
|
|
||||||
|
final Map<String, String> headers = new HashMap<>(contentType("text/plain"));
|
||||||
|
return new Response(RestStatus.OK.getStatus(), headers, response.getBytes(UTF_8));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET
|
||||||
|
//
|
||||||
|
// http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html
|
||||||
|
handlers.insert(nonAuthPath(HttpGet.METHOD_NAME, "/latest/meta-data/iam/security-credentials/{profileName}"), (request) -> {
|
||||||
|
final String profileName = request.getParam("profileName");
|
||||||
|
if (EC2_PROFILE.equals(profileName) == false) {
|
||||||
|
return new Response(RestStatus.NOT_FOUND.getStatus(), new HashMap<>(), "unknown credentials".getBytes(UTF_8));
|
||||||
|
}
|
||||||
|
return credentialResponseFunction.apply(profileName, ec2Bucket.key, ec2Bucket.token);
|
||||||
|
});
|
||||||
|
|
||||||
return handlers;
|
return handlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String prop(Properties properties, String propertyName) {
|
||||||
|
return requireNonNull(properties.getProperty(propertyName),
|
||||||
|
"property '" + propertyName + "' is missing");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a S3 bucket.
|
* Represents a S3 bucket.
|
||||||
*/
|
*/
|
||||||
static class Bucket {
|
class Bucket {
|
||||||
|
|
||||||
/** Bucket name **/
|
/** Bucket name **/
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
|
||||||
/** Blobs contained in the bucket **/
|
/** Blobs contained in the bucket **/
|
||||||
final Map<String, byte[]> objects;
|
final Map<String, byte[]> objects;
|
||||||
|
|
||||||
Bucket(final String name) {
|
private Bucket(final String prefix, final boolean tokenRequired) {
|
||||||
this.name = Objects.requireNonNull(name);
|
this(prefix, prop(properties, prefix + "_key"),
|
||||||
|
tokenRequired ? prop(properties, prefix + "_session_token") : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bucket(final String prefix, final String key, final String token) {
|
||||||
|
this.name = prop(properties, prefix + "_bucket_name");
|
||||||
|
this.key = key;
|
||||||
|
this.token = token;
|
||||||
|
|
||||||
this.objects = ConcurrentCollections.newConcurrentMap();
|
this.objects = ConcurrentCollections.newConcurrentMap();
|
||||||
|
if (buckets.put(name, this) != null) {
|
||||||
|
throw new IllegalArgumentException("bucket " + name + " is already registered");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Integration tests for repository-s3
|
||||||
|
|
||||||
|
---
|
||||||
|
setup:
|
||||||
|
|
||||||
|
# Register repository with ec2 credentials
|
||||||
|
- do:
|
||||||
|
snapshot.create_repository:
|
||||||
|
repository: repository_ec2
|
||||||
|
body:
|
||||||
|
type: s3
|
||||||
|
settings:
|
||||||
|
bucket: ${ec2_bucket}
|
||||||
|
client: integration_test_ec2
|
||||||
|
base_path: ${ec2_base_path}
|
||||||
|
canned_acl: private
|
||||||
|
storage_class: standard
|
||||||
|
|
||||||
|
---
|
||||||
|
"Snapshot and Restore with repository-s3 using ec2 credentials":
|
||||||
|
|
||||||
|
# Get repository
|
||||||
|
- do:
|
||||||
|
snapshot.get_repository:
|
||||||
|
repository: repository_ec2
|
||||||
|
|
||||||
|
- match: { repository_ec2.settings.bucket : ${ec2_bucket} }
|
||||||
|
- match: { repository_ec2.settings.client : "integration_test_ec2" }
|
||||||
|
- match: { repository_ec2.settings.base_path : ${ec2_base_path} }
|
||||||
|
- match: { repository_ec2.settings.canned_acl : "private" }
|
||||||
|
- match: { repository_ec2.settings.storage_class : "standard" }
|
||||||
|
- is_false: repository_ec2.settings.access_key
|
||||||
|
- is_false: repository_ec2.settings.secret_key
|
||||||
|
- is_false: repository_ec2.settings.session_token
|
||||||
|
|
||||||
|
# Index documents
|
||||||
|
- do:
|
||||||
|
bulk:
|
||||||
|
refresh: true
|
||||||
|
body:
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 1
|
||||||
|
- snapshot: one
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 2
|
||||||
|
- snapshot: one
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 3
|
||||||
|
- snapshot: one
|
||||||
|
|
||||||
|
- do:
|
||||||
|
count:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
- match: {count: 3}
|
||||||
|
|
||||||
|
# Create a first snapshot
|
||||||
|
- do:
|
||||||
|
snapshot.create:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-one
|
||||||
|
wait_for_completion: true
|
||||||
|
|
||||||
|
- match: { snapshot.snapshot: snapshot-one }
|
||||||
|
- match: { snapshot.state : SUCCESS }
|
||||||
|
- match: { snapshot.include_global_state: true }
|
||||||
|
- match: { snapshot.shards.failed : 0 }
|
||||||
|
|
||||||
|
- do:
|
||||||
|
snapshot.status:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-one
|
||||||
|
|
||||||
|
- is_true: snapshots
|
||||||
|
- match: { snapshots.0.snapshot: snapshot-one }
|
||||||
|
- match: { snapshots.0.state : SUCCESS }
|
||||||
|
|
||||||
|
# Index more documents
|
||||||
|
- do:
|
||||||
|
bulk:
|
||||||
|
refresh: true
|
||||||
|
body:
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 4
|
||||||
|
- snapshot: two
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 5
|
||||||
|
- snapshot: two
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 6
|
||||||
|
- snapshot: two
|
||||||
|
- index:
|
||||||
|
_index: docs
|
||||||
|
_type: doc
|
||||||
|
_id: 7
|
||||||
|
- snapshot: two
|
||||||
|
|
||||||
|
- do:
|
||||||
|
count:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
- match: {count: 7}
|
||||||
|
|
||||||
|
# Create a second snapshot
|
||||||
|
- do:
|
||||||
|
snapshot.create:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-two
|
||||||
|
wait_for_completion: true
|
||||||
|
|
||||||
|
- match: { snapshot.snapshot: snapshot-two }
|
||||||
|
- match: { snapshot.state : SUCCESS }
|
||||||
|
- match: { snapshot.shards.failed : 0 }
|
||||||
|
|
||||||
|
- do:
|
||||||
|
snapshot.get:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-one,snapshot-two
|
||||||
|
|
||||||
|
- is_true: snapshots
|
||||||
|
- match: { snapshots.0.state : SUCCESS }
|
||||||
|
- match: { snapshots.1.state : SUCCESS }
|
||||||
|
|
||||||
|
# Delete the index
|
||||||
|
- do:
|
||||||
|
indices.delete:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
# Restore the second snapshot
|
||||||
|
- do:
|
||||||
|
snapshot.restore:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-two
|
||||||
|
wait_for_completion: true
|
||||||
|
|
||||||
|
- do:
|
||||||
|
count:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
- match: {count: 7}
|
||||||
|
|
||||||
|
# Delete the index again
|
||||||
|
- do:
|
||||||
|
indices.delete:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
# Restore the first snapshot
|
||||||
|
- do:
|
||||||
|
snapshot.restore:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-one
|
||||||
|
wait_for_completion: true
|
||||||
|
|
||||||
|
- do:
|
||||||
|
count:
|
||||||
|
index: docs
|
||||||
|
|
||||||
|
- match: {count: 3}
|
||||||
|
|
||||||
|
# Remove the snapshots
|
||||||
|
- do:
|
||||||
|
snapshot.delete:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-two
|
||||||
|
|
||||||
|
- do:
|
||||||
|
snapshot.delete:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: snapshot-one
|
||||||
|
|
||||||
|
---
|
||||||
|
"Register a repository with a non existing bucket":
|
||||||
|
|
||||||
|
- do:
|
||||||
|
catch: /repository_exception/
|
||||||
|
snapshot.create_repository:
|
||||||
|
repository: repository_ec2
|
||||||
|
body:
|
||||||
|
type: s3
|
||||||
|
settings:
|
||||||
|
bucket: zHHkfSqlbnBsbpSgvCYtxrEfFLqghXtyPvvvKPNBnRCicNHQLE
|
||||||
|
client: integration_test_temporary
|
||||||
|
|
||||||
|
---
|
||||||
|
"Register a repository with a non existing client":
|
||||||
|
|
||||||
|
- do:
|
||||||
|
catch: /repository_exception/
|
||||||
|
snapshot.create_repository:
|
||||||
|
repository: repository_ec2
|
||||||
|
body:
|
||||||
|
type: s3
|
||||||
|
settings:
|
||||||
|
bucket: repository_ec2
|
||||||
|
client: unknown
|
||||||
|
|
||||||
|
---
|
||||||
|
"Get a non existing snapshot":
|
||||||
|
|
||||||
|
- do:
|
||||||
|
catch: /snapshot_missing_exception/
|
||||||
|
snapshot.get:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: missing
|
||||||
|
|
||||||
|
---
|
||||||
|
"Delete a non existing snapshot":
|
||||||
|
|
||||||
|
- do:
|
||||||
|
catch: /snapshot_missing_exception/
|
||||||
|
snapshot.delete:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: missing
|
||||||
|
|
||||||
|
---
|
||||||
|
"Restore a non existing snapshot":
|
||||||
|
|
||||||
|
- do:
|
||||||
|
catch: /snapshot_restore_exception/
|
||||||
|
snapshot.restore:
|
||||||
|
repository: repository_ec2
|
||||||
|
snapshot: missing
|
||||||
|
wait_for_completion: true
|
||||||
|
|
||||||
|
---
|
||||||
|
teardown:
|
||||||
|
|
||||||
|
# Remove our repository
|
||||||
|
- do:
|
||||||
|
snapshot.delete_repository:
|
||||||
|
repository: repository_ec2
|
Loading…
Reference in New Issue