-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Add auth to mongo db container #5595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 66 commits
3c07308
a2c5c69
8ddd6b4
1795235
ee5e4fa
e06749d
1a2b1dc
8f68d71
58ab3f2
957cd51
991a5f8
4d9f360
72d2b30
c0fe7ba
509a454
d1d0367
184d4e2
fc45144
cfe5177
a74d766
7620cb8
4ca7c7b
aad0e76
b92d690
9a746df
5c3182e
5f3145b
2ea01ce
8b727f0
e714b26
5b5531d
d892006
7adc9f8
c136592
a773cc5
fb325d4
649dae2
cd23ddb
47c39e7
8e57d07
78ad147
7cdbad4
6ba608e
7a27fd1
49682c2
e97ed90
0972312
dac9070
ae49077
79267b0
a498102
42ef9e7
b61763c
9ff0da8
e5fda5e
69fb8cc
4e2ebc5
4441a2e
97c0512
eb9eb2b
779f5fc
bdd7e82
827a6a4
1c01a11
6e54adb
47138ac
c76ddd1
72bd52b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| package org.testcontainers.containers; | ||
|
|
||
| import com.github.dockerjava.api.command.InspectContainerResponse; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NonNull; | ||
| import lombok.SneakyThrows; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
@@ -27,7 +29,22 @@ public class MongoDBContainer extends GenericContainer<MongoDBContainer> { | |
|
|
||
| private static final int AWAIT_INIT_REPLICA_SET_ATTEMPTS = 60; | ||
|
|
||
| private static final String MONGODB_DATABASE_NAME_DEFAULT = "test"; | ||
| static final String DEFAULT_DATABASE_NAME = "test"; | ||
|
|
||
| private static final String DEFAULT_USER = "test"; | ||
|
|
||
| private static final String DEFAULT_PASSWORD = "test"; | ||
|
|
||
| static final String DEFAULT_AUTHENTICATION_DATABASE_NAME = "admin"; | ||
|
|
||
| private static final String AUTHENTICATION_KEY_FILE_NAME = "keyFile.key"; | ||
|
|
||
| private static final String AUTHENTICATION_KEY_FILE_NAME_CONTAINER_PATH = | ||
| "/usr/local/bin/" + AUTHENTICATION_KEY_FILE_NAME; | ||
|
|
||
| private String username = DEFAULT_USER; | ||
|
|
||
| private String password = DEFAULT_PASSWORD; | ||
|
|
||
| /** | ||
| * @deprecated use {@link MongoDBContainer(DockerImageName)} instead | ||
|
|
@@ -44,10 +61,40 @@ public MongoDBContainer(@NonNull final String dockerImageName) { | |
| public MongoDBContainer(final DockerImageName dockerImageName) { | ||
| super(dockerImageName); | ||
| dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); | ||
|
|
||
| withExposedPorts(MONGODB_INTERNAL_PORT); | ||
| withCommand("--replSet", "docker-rs"); | ||
| waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1)); | ||
| withClasspathResourceMapping( | ||
| AUTHENTICATION_KEY_FILE_NAME, | ||
| AUTHENTICATION_KEY_FILE_NAME_CONTAINER_PATH, | ||
| BindMode.READ_ONLY | ||
| ); | ||
| waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 2)); | ||
| } | ||
|
|
||
| @Override | ||
| protected void configure() { | ||
| addEnv("MONGO_INITDB_ROOT_USERNAME", username); | ||
| addEnv("MONGO_INITDB_ROOT_PASSWORD", password); | ||
| withCreateContainerCmdModifier(it -> it.withEntrypoint("bash")); | ||
| setCommand( | ||
| "-c", | ||
| "chown mongodb " + | ||
| AUTHENTICATION_KEY_FILE_NAME_CONTAINER_PATH + | ||
| ";chmod 400 " + | ||
| AUTHENTICATION_KEY_FILE_NAME_CONTAINER_PATH + | ||
| ";/usr/local/bin/docker-entrypoint.sh --keyFile " + | ||
| AUTHENTICATION_KEY_FILE_NAME_CONTAINER_PATH + | ||
| " --replSet docker-rs" | ||
| ); | ||
| } | ||
|
|
||
| public MongoDBContainer withUsername(final String username) { | ||
| this.username = username; | ||
| return self(); | ||
| } | ||
|
|
||
| public MongoDBContainer withPassword(final String password) { | ||
| this.password = password; | ||
| return self(); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -56,16 +103,16 @@ public MongoDBContainer(final DockerImageName dockerImageName) { | |
| * @return a connection url pointing to a mongodb instance | ||
| */ | ||
| public String getConnectionString() { | ||
| return String.format("mongodb://%s:%d", getHost(), getMappedPort(MONGODB_INTERNAL_PORT)); | ||
| return constructConnectionString(ConnectionString.builder().username(username).password(password).build()); | ||
| } | ||
|
|
||
| /** | ||
| * Gets a replica set url for the default {@value #MONGODB_DATABASE_NAME_DEFAULT} database. | ||
| * Gets a replica set url for the default {@value #DEFAULT_DATABASE_NAME} database. | ||
| * | ||
| * @return a replica set url. | ||
| */ | ||
| public String getReplicaSetUrl() { | ||
| return getReplicaSetUrl(MONGODB_DATABASE_NAME_DEFAULT); | ||
| return getReplicaSetUrl(ConnectionString.builder().username(username).password(password).build()); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -78,7 +125,22 @@ public String getReplicaSetUrl(final String databaseName) { | |
| if (!isRunning()) { | ||
| throw new IllegalStateException("MongoDBContainer should be started first"); | ||
| } | ||
| return getConnectionString() + "/" + databaseName; | ||
| return constructConnectionString( | ||
| ConnectionString.builder().databaseName(databaseName).username(username).password(password).build() | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Gets a replica set url for a provided {@link org.testcontainers.containers.MongoDBContainer.ConnectionString}. | ||
| * | ||
| * @param connectionString an object describing a connection string. | ||
| * @return a replica set url. | ||
| */ | ||
| public String getReplicaSetUrl(final ConnectionString connectionString) { | ||
| if (!isRunning()) { | ||
| throw new IllegalStateException("MongoDBContainer should be started first"); | ||
| } | ||
| return constructConnectionString(connectionString); | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -91,10 +153,20 @@ protected void containerIsStarted(InspectContainerResponse containerInfo, boolea | |
| } | ||
|
|
||
| private String[] buildMongoEvalCommand(final String command) { | ||
| final String authOptions = | ||
| " -u " + username + " -p " + password + " --authenticationDatabase " + DEFAULT_AUTHENTICATION_DATABASE_NAME; | ||
| return new String[] { | ||
| "sh", | ||
| "-c", | ||
| "mongosh mongo --eval \"" + command + "\" || mongo --eval \"" + command + "\"", | ||
| "mongosh mongo" + | ||
eddumelendez marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| authOptions + | ||
| " --eval \"" + | ||
| command + | ||
| "\" || mongo " + | ||
| authOptions + | ||
| " --eval \"" + | ||
| command + | ||
| "\"", | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -149,6 +221,18 @@ private void initReplicaSet() { | |
| checkMongoNodeExitCodeAfterWaiting(execResultWaitForMaster); | ||
| } | ||
|
|
||
| private String constructConnectionString(final ConnectionString connectionString) { | ||
| return String.format( | ||
| "mongodb://%s:%s@%s:%d/%s?authSource=%s", | ||
| connectionString.getUsername(), | ||
| connectionString.getPassword(), | ||
| getHost(), | ||
| getMappedPort(MONGODB_INTERNAL_PORT), | ||
| connectionString.getDatabaseName(), | ||
| DEFAULT_AUTHENTICATION_DATABASE_NAME | ||
| ); | ||
| } | ||
|
|
||
| public static class ReplicaSetInitializationException extends RuntimeException { | ||
|
|
||
| ReplicaSetInitializationException(final String errorMessage) { | ||
|
|
@@ -164,4 +248,16 @@ private boolean isReplicationSetAlreadyInitialized() { | |
| ); | ||
| return execCheckRsInit.getExitCode() == CONTAINER_EXIT_CODE_OK; | ||
| } | ||
|
|
||
| @Builder | ||
| @Getter | ||
| public static class ConnectionString { | ||
|
|
||
| @Builder.Default | ||
| private final String databaseName = DEFAULT_DATABASE_NAME; | ||
|
|
||
| private final String username; | ||
|
|
||
| private final String password; | ||
| } | ||
|
Comment on lines
+264
to
+274
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mongo already provides a convinient way to create a client. MongoClients.create(
MongoClientSettings.builder()
.credential(MongoCredential.createCredential(usernameRestrictedAccess, "admin", passwordRestrictedAccess.toCharArray()))
.applyConnectionString(new ConnectionString(String.format("mongodb://%s:%d", mongoDBContainer.getHost(), mongoDBContainer.getFirstMappedPort()) ))
.build()
)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure that it's a good idea here because:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was not proposing to use Mongo API in MongoDBContainer. Just sharing how it can be done from the outside as in this suggestion
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
TBH, I do not expect users to do that but who knows. I think our docs can be improved in order to make it clear
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We provide a URL string for a user via clear API that we take control of. I reckon it's not a good idea to constantly update docs mentioning "it does not support srv etc."? In my opionin, it's better not to provide a way to set srv.
I see your point, but it's not a part of our public API. This way a user can construct everything, but having an issue with it is on their hands
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I updated Java doc to make it clear
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry @silaev, but it is still unclear with me what is the issue with the approach suggested by @eddumelendez, since it is very close to how we do it for other modules. So you think users will struggle doing it this way and construct invalid URLs? Especially since users would normally use I thought @eddumelendez's point was about not exposing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @kiview The idea of this builder is to provide 3 params for now (might be more in the future): username, pass and db name for MongoDBContainer to generate a URL as simple as this: It’s similar to withUsername(...), withPassword(…) and withDatabaseName(...) etc. builder methods in the PostgreSQLContainer to generate a URL via getJdbcUrl(). As I understand, generating URL inside a container is @eddumelendez proposed a solution when a URL is generated outside of MongoDBContainer via MongoClientSettings which we do not control. Is it The second variant seems to me as quite complex and error prone, so I'm more comfortable with the first one. Correct me if I'm wrong, but the whole idea of Testcontainers is to simplify testing experience which, as I see it, the first variant follows
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @kiview , @eddumelendez
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi, thanks for ping us, I was thinking about it last week. There is a difference with the builders in database modules due to those are used by With latest changes in the
Testcontainers responsibility is to provide a ready to use service running in a container. There could be many configuration in the service itself which we do not master and we prefer a generic approach, meanwhile we are learning from uses cases, rather than do something at the beginning that can change later just because we are not aware of it. I know the answer doesn't answer the last question but I think it is our best effort, meanwhile we also learn about MongoDB and how to use it. So, the builder can be revisited in the future. I'm also aware that this will involve more changes than initially expected but it is due to the evolution of the module itself. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| privateOldE8+8pPloNORolrRpGU6E6Ps8rh6GAcThtIfnh0Nfiy+fGvROwqlEtFmAY4Xb6 | ||
| aDjb/cFoYSNIxh5DZaBD4hmllCkoAAl15WDKWEv+ELxj124KiyuzJUbu50iXYG+/ | ||
| g6oWzElAdnckXCj+9CVhuw1dq9LgaIOd3n7NsrKK4rG7BgrdRl3HSpexBgd4WPva | ||
| jtIcvg+eKBvUysACGSpCubFQa1VoRiU7G0h5TYPXVBpmvN7cFHKANNKyggDPKlih | ||
| SfnMuXPGaecBm9UkmpHJoPUuzrE5wXStbho6SQzlbSBoxxgOCAHaAPtn7d3scP1i | ||
| lk8hoIyNjbq37D2b15VP9+JxBgkqywjcm3Z7D5m+NSI22xYD44kNBxvAIFUtE1RS | ||
| qgTFizA2ORb73TGfhhy2vuIJdsn97dZAMFOayiJvdzyIQJ9027d5eAVUE/U9UQjP | ||
| 7BrHrJ+iV+PwggmIvwXDjFP7n0gs6tGmghfG/13y3lwpD+Xs84hcEbXitdns+8dE | ||
| lpnTkqUpGMexeuEuL4O5yfX46mVyT6+qvD+jb6y5oB1ydP/n3dmuWfoE3hv2rvVn | ||
| pFbPzuTF2mvIj0HTTmkNBCBh8Rq7McZ2vNW5nx3jdf8A+ICw6O9KlkemhHORIsIY | ||
| /HbsL1xjPs+gizMOddFwgfLovkQ9Oap7fAed+yl8JxqTWe5OMHZCDxyssZEOscnZ | ||
| xSWEXKWsWv2LLqtIdc++ZqrkvMWHNVqILcpe2upb7DbCVMjrsv5htXrYL7lgaItm | ||
| MpyP20q4ut7ja0YRwPITJyHNacJygAE/TViTL3K1JNXKHXCLWfIHGkVhYzc/9uv7 | ||
| 0nu5el6crurO57rQFC3T14huEQvouZl9SmHflkBFF7/kQeAJj10bmZWYae8mhdhb | ||
| zaJ2tUcsEgxEaZGrVK4f1NRAwBBY5t5AZMwLcYyIY1F1YZxw2BmrnPqr3GgSCcQ5 | ||
| eMo3u9jKXXYJ6Eb3xwNpFXaGFPS1TmTK9y1CU8fXfDOsr7ln |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,8 @@ | ||||||||||||||||||||||
| package org.testcontainers.containers; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import com.mongodb.BasicDBObject; | ||||||||||||||||||||||
| import com.mongodb.ConnectionString; | ||||||||||||||||||||||
| import com.mongodb.MongoCommandException; | ||||||||||||||||||||||
| import com.mongodb.ReadConcern; | ||||||||||||||||||||||
| import com.mongodb.ReadPreference; | ||||||||||||||||||||||
| import com.mongodb.TransactionOptions; | ||||||||||||||||||||||
|
|
@@ -8,12 +11,17 @@ | |||||||||||||||||||||
| import com.mongodb.client.MongoClient; | ||||||||||||||||||||||
| import com.mongodb.client.MongoClients; | ||||||||||||||||||||||
| import com.mongodb.client.MongoCollection; | ||||||||||||||||||||||
| import com.mongodb.client.MongoDatabase; | ||||||||||||||||||||||
| import com.mongodb.client.TransactionBody; | ||||||||||||||||||||||
| import org.bson.Document; | ||||||||||||||||||||||
| import org.junit.Test; | ||||||||||||||||||||||
| import org.testcontainers.utility.DockerImageName; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import java.util.Collections; | ||||||||||||||||||||||
| import java.util.Objects; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import static org.assertj.core.api.Assertions.assertThat; | ||||||||||||||||||||||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| public class MongoDBContainerTest { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -98,7 +106,8 @@ public void shouldTestDatabaseName() { | |||||||||||||||||||||
| try (final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"))) { | ||||||||||||||||||||||
| mongoDBContainer.start(); | ||||||||||||||||||||||
| final String databaseName = "my-db"; | ||||||||||||||||||||||
| assertThat(mongoDBContainer.getReplicaSetUrl(databaseName)).endsWith(databaseName); | ||||||||||||||||||||||
| assertThat(databaseName) | ||||||||||||||||||||||
| .isEqualTo(new ConnectionString(mongoDBContainer.getReplicaSetUrl(databaseName)).getDatabase()); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -109,4 +118,73 @@ public void supportsMongoDB_6() { | |||||||||||||||||||||
| executeTx(mongoDBContainer); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @Test | ||||||||||||||||||||||
| public void shouldTestAuthentication() { | ||||||||||||||||||||||
eddumelendez marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||
| final String usernameFullAccess = "my-name"; | ||||||||||||||||||||||
| final String passwordFullAccess = "my-pass"; | ||||||||||||||||||||||
| try ( | ||||||||||||||||||||||
| final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.4")) | ||||||||||||||||||||||
| .withUsername(usernameFullAccess) | ||||||||||||||||||||||
| .withPassword(passwordFullAccess) | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| mongoDBContainer.start(); | ||||||||||||||||||||||
| final ConnectionString connectionStringFullAccess = new ConnectionString( | ||||||||||||||||||||||
| mongoDBContainer.getReplicaSetUrl() | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| try (final MongoClient mongoSyncClientFullAccess = MongoClients.create(connectionStringFullAccess)) { | ||||||||||||||||||||||
| final MongoDatabase adminDatabase = mongoSyncClientFullAccess.getDatabase( | ||||||||||||||||||||||
| MongoDBContainer.DEFAULT_AUTHENTICATION_DATABASE_NAME | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| final MongoDatabase testDatabaseFullAccess = mongoSyncClientFullAccess.getDatabase( | ||||||||||||||||||||||
| MongoDBContainer.DEFAULT_DATABASE_NAME | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| final String collectionName = "my-collection"; | ||||||||||||||||||||||
| final Document document1 = new Document("abc", 1); | ||||||||||||||||||||||
| testDatabaseFullAccess.getCollection(collectionName).insertOne(document1); | ||||||||||||||||||||||
| final String usernameRestrictedAccess = usernameFullAccess + "-restricted"; | ||||||||||||||||||||||
| final String passwordRestrictedAccess = passwordFullAccess + "-restricted"; | ||||||||||||||||||||||
| runCommand( | ||||||||||||||||||||||
| adminDatabase, | ||||||||||||||||||||||
| new BasicDBObject("createUser", usernameRestrictedAccess).append("pwd", passwordRestrictedAccess), | ||||||||||||||||||||||
| "read" | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| try ( | ||||||||||||||||||||||
| final MongoClient mongoSyncRestrictedAccess = MongoClients.create( | ||||||||||||||||||||||
| mongoDBContainer.getReplicaSetUrl( | ||||||||||||||||||||||
| MongoDBContainer.ConnectionString | ||||||||||||||||||||||
| .builder() | ||||||||||||||||||||||
| .username(usernameRestrictedAccess) | ||||||||||||||||||||||
| .password(passwordRestrictedAccess) | ||||||||||||||||||||||
| .build() | ||||||||||||||||||||||
|
Comment on lines
+154
to
+159
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pls, see the above mentioned discussion about |
||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| final MongoCollection<Document> collection = mongoSyncRestrictedAccess | ||||||||||||||||||||||
| .getDatabase(MongoDBContainer.DEFAULT_DATABASE_NAME) | ||||||||||||||||||||||
| .getCollection(collectionName); | ||||||||||||||||||||||
| assertThat(collection.find().first()).isEqualTo(document1); | ||||||||||||||||||||||
| final Document document2 = new Document("abc", 2); | ||||||||||||||||||||||
| assertThatThrownBy(() -> collection.insertOne(document2)).isInstanceOf(MongoCommandException.class); | ||||||||||||||||||||||
| runCommand(adminDatabase, new BasicDBObject("updateUser", usernameRestrictedAccess), "readWrite"); | ||||||||||||||||||||||
| collection.insertOne(document2); | ||||||||||||||||||||||
| assertThat(collection.countDocuments()).isEqualTo(2); | ||||||||||||||||||||||
| assertThat(connectionStringFullAccess.getUsername()).isEqualTo(usernameFullAccess); | ||||||||||||||||||||||
| assertThat(new String(Objects.requireNonNull(connectionStringFullAccess.getPassword()))) | ||||||||||||||||||||||
| .isEqualTo(passwordFullAccess); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private void runCommand(MongoDatabase adminDatabase, BasicDBObject command, String role) { | ||||||||||||||||||||||
| adminDatabase.runCommand( | ||||||||||||||||||||||
| command.append( | ||||||||||||||||||||||
| "roles", | ||||||||||||||||||||||
| Collections.singletonList( | ||||||||||||||||||||||
| new BasicDBObject("role", role).append("db", MongoDBContainer.DEFAULT_DATABASE_NAME) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.