|
@@ -23,9 +23,12 @@ import static org.junit.Assert.assertTrue;
|
|
import static org.junit.Assert.fail;
|
|
import static org.junit.Assert.fail;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.when;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
|
import java.io.IOException;
|
|
import java.io.IOException;
|
|
|
|
+import java.net.NoRouteToHostException;
|
|
import java.net.URI;
|
|
import java.net.URI;
|
|
|
|
+import java.net.UnknownHostException;
|
|
import java.security.GeneralSecurityException;
|
|
import java.security.GeneralSecurityException;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.security.NoSuchAlgorithmException;
|
|
|
|
|
|
@@ -33,6 +36,9 @@ import org.apache.hadoop.conf.Configuration;
|
|
import org.apache.hadoop.crypto.key.KeyProvider;
|
|
import org.apache.hadoop.crypto.key.KeyProvider;
|
|
import org.apache.hadoop.crypto.key.KeyProvider.Options;
|
|
import org.apache.hadoop.crypto.key.KeyProvider.Options;
|
|
import org.apache.hadoop.crypto.key.KeyProviderCryptoExtension;
|
|
import org.apache.hadoop.crypto.key.KeyProviderCryptoExtension;
|
|
|
|
+import org.apache.hadoop.fs.CommonConfigurationKeysPublic;
|
|
|
|
+import org.apache.hadoop.net.ConnectTimeoutException;
|
|
|
|
+import org.apache.hadoop.security.AccessControlException;
|
|
import org.apache.hadoop.security.authentication.client.AuthenticationException;
|
|
import org.apache.hadoop.security.authentication.client.AuthenticationException;
|
|
import org.apache.hadoop.security.authorize.AuthorizationException;
|
|
import org.apache.hadoop.security.authorize.AuthorizationException;
|
|
import org.junit.Test;
|
|
import org.junit.Test;
|
|
@@ -47,14 +53,17 @@ public class TestLoadBalancingKMSClientProvider {
|
|
Configuration conf = new Configuration();
|
|
Configuration conf = new Configuration();
|
|
KeyProvider kp = new KMSClientProvider.Factory().createProvider(new URI(
|
|
KeyProvider kp = new KMSClientProvider.Factory().createProvider(new URI(
|
|
"kms://http@host1/kms/foo"), conf);
|
|
"kms://http@host1/kms/foo"), conf);
|
|
- assertTrue(kp instanceof KMSClientProvider);
|
|
|
|
- assertEquals("http://host1/kms/foo/v1/",
|
|
|
|
- ((KMSClientProvider) kp).getKMSUrl());
|
|
|
|
|
|
+ assertTrue(kp instanceof LoadBalancingKMSClientProvider);
|
|
|
|
+ KMSClientProvider[] providers =
|
|
|
|
+ ((LoadBalancingKMSClientProvider) kp).getProviders();
|
|
|
|
+ assertEquals(1, providers.length);
|
|
|
|
+ assertEquals(Sets.newHashSet("http://host1/kms/foo/v1/"),
|
|
|
|
+ Sets.newHashSet(providers[0].getKMSUrl()));
|
|
|
|
|
|
kp = new KMSClientProvider.Factory().createProvider(new URI(
|
|
kp = new KMSClientProvider.Factory().createProvider(new URI(
|
|
"kms://http@host1;host2;host3/kms/foo"), conf);
|
|
"kms://http@host1;host2;host3/kms/foo"), conf);
|
|
assertTrue(kp instanceof LoadBalancingKMSClientProvider);
|
|
assertTrue(kp instanceof LoadBalancingKMSClientProvider);
|
|
- KMSClientProvider[] providers =
|
|
|
|
|
|
+ providers =
|
|
((LoadBalancingKMSClientProvider) kp).getProviders();
|
|
((LoadBalancingKMSClientProvider) kp).getProviders();
|
|
assertEquals(3, providers.length);
|
|
assertEquals(3, providers.length);
|
|
assertEquals(Sets.newHashSet("http://host1/kms/foo/v1/",
|
|
assertEquals(Sets.newHashSet("http://host1/kms/foo/v1/",
|
|
@@ -320,4 +329,298 @@ public class TestLoadBalancingKMSClientProvider {
|
|
Mockito.verify(p1, Mockito.times(1)).warmUpEncryptedKeys(keyName);
|
|
Mockito.verify(p1, Mockito.times(1)).warmUpEncryptedKeys(keyName);
|
|
Mockito.verify(p2, Mockito.times(1)).warmUpEncryptedKeys(keyName);
|
|
Mockito.verify(p2, Mockito.times(1)).warmUpEncryptedKeys(keyName);
|
|
}
|
|
}
|
|
-}
|
|
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests whether retryPolicy fails immediately, after trying each provider
|
|
|
|
+ * once, on encountering IOException which is not SocketException.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesWithIOException() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ // Setting total failover attempts to .
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 10);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.getMetadata(Mockito.anyString()))
|
|
|
|
+ .thenThrow(new IOException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.getMetadata(Mockito.anyString()))
|
|
|
|
+ .thenThrow(new IOException("p2"));
|
|
|
|
+ KMSClientProvider p3 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p3.getMetadata(Mockito.anyString()))
|
|
|
|
+ .thenThrow(new IOException("p3"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+ when(p3.getKMSUrl()).thenReturn("p3");
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2, p3}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.getMetadata("test3");
|
|
|
|
+ fail("Should fail since all providers threw an IOException");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assertTrue(e instanceof IOException);
|
|
|
|
+ }
|
|
|
|
+ verify(kp.getProviders()[0], Mockito.times(1))
|
|
|
|
+ .getMetadata(Mockito.eq("test3"));
|
|
|
|
+ verify(kp.getProviders()[1], Mockito.times(1))
|
|
|
|
+ .getMetadata(Mockito.eq("test3"));
|
|
|
|
+ verify(kp.getProviders()[2], Mockito.times(1))
|
|
|
|
+ .getMetadata(Mockito.eq("test3"));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests that client doesn't retry once it encounters AccessControlException
|
|
|
|
+ * from first provider.
|
|
|
|
+ * This assumes all the kms servers are configured with identical access to
|
|
|
|
+ * keys.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesWithAccessControlException() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 3);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new AccessControlException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new IOException("p2"));
|
|
|
|
+ KMSClientProvider p3 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p3.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new IOException("p3"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+ when(p3.getKMSUrl()).thenReturn("p3");
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2, p3}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ fail("Should fail because provider p1 threw an AccessControlException");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assertTrue(e instanceof AccessControlException);
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.never()).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p3, Mockito.never()).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests that client doesn't retry once it encounters RunTimeException
|
|
|
|
+ * from first provider.
|
|
|
|
+ * This assumes all the kms servers are configured with identical access to
|
|
|
|
+ * keys.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesWithRuntimeException() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 3);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new RuntimeException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new IOException("p2"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ fail("Should fail since provider p1 threw RuntimeException");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assertTrue(e instanceof RuntimeException);
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.never()).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests the client retries until it finds a good provider.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesWithTimeoutsException() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 4);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new UnknownHostException("p2"));
|
|
|
|
+ KMSClientProvider p3 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p3.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new NoRouteToHostException("p3"));
|
|
|
|
+ KMSClientProvider p4 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p4.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenReturn(
|
|
|
|
+ new KMSClientProvider.KMSKeyVersion("test3", "v1", new byte[0]));
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+ when(p3.getKMSUrl()).thenReturn("p3");
|
|
|
|
+ when(p4.getKMSUrl()).thenReturn("p4");
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2, p3, p4}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ fail("Provider p4 should have answered the request.");
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p3, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p4, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests the operation succeeds second time after ConnectTimeoutException.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesSucceedsSecondTime() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 3);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p1"))
|
|
|
|
+ .thenReturn(new KMSClientProvider.KMSKeyVersion("test3", "v1",
|
|
|
|
+ new byte[0]));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p2"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ fail("Provider p1 should have answered the request second time.");
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(2)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests whether retryPolicy retries specified number of times.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesSpecifiedNumberOfTimes() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 10);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p2"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ fail("Should fail");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assert (e instanceof ConnectTimeoutException);
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(6)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.times(5)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests whether retryPolicy retries number of times equals to number of
|
|
|
|
+ * providers if conf kms.client.failover.max.attempts is not set.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesIfMaxAttemptsNotSet() throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p1"));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new ConnectTimeoutException("p2"));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ fail("Should fail");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assert (e instanceof ConnectTimeoutException);
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(2)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Tests that client reties each provider once, when it encounters
|
|
|
|
+ * AuthenticationException wrapped in an IOException from first provider.
|
|
|
|
+ * @throws Exception
|
|
|
|
+ */
|
|
|
|
+ @Test
|
|
|
|
+ public void testClientRetriesWithAuthenticationExceptionWrappedinIOException()
|
|
|
|
+ throws Exception {
|
|
|
|
+ Configuration conf = new Configuration();
|
|
|
|
+ conf.setInt(
|
|
|
|
+ CommonConfigurationKeysPublic.KMS_CLIENT_FAILOVER_MAX_RETRIES_KEY, 3);
|
|
|
|
+ KMSClientProvider p1 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p1.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new IOException(new AuthenticationException("p1")));
|
|
|
|
+ KMSClientProvider p2 = mock(KMSClientProvider.class);
|
|
|
|
+ when(p2.createKey(Mockito.anyString(), Mockito.any(Options.class)))
|
|
|
|
+ .thenThrow(new IOException(new AuthenticationException("p1")));
|
|
|
|
+
|
|
|
|
+ when(p1.getKMSUrl()).thenReturn("p1");
|
|
|
|
+ when(p2.getKMSUrl()).thenReturn("p2");
|
|
|
|
+
|
|
|
|
+ LoadBalancingKMSClientProvider kp = new LoadBalancingKMSClientProvider(
|
|
|
|
+ new KMSClientProvider[] {p1, p2}, 0, conf);
|
|
|
|
+ try {
|
|
|
|
+ kp.createKey("test3", new Options(conf));
|
|
|
|
+ fail("Should fail since provider p1 threw AuthenticationException");
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
+ assertTrue(e.getCause() instanceof AuthenticationException);
|
|
|
|
+ }
|
|
|
|
+ verify(p1, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ verify(p2, Mockito.times(1)).createKey(Mockito.eq("test3"),
|
|
|
|
+ Mockito.any(Options.class));
|
|
|
|
+ }
|
|
|
|
+}
|