Skip to content

Commit faa3af5

Browse files
authored
Implements full spec of gRPC endpoint. (#1042)
* Implements full spec of gRPC endpoint. Signed-off-by: Artur Souza <asouza.pro@gmail.com> * Fix test container dependencies. Signed-off-by: Artur Souza <asouza.pro@gmail.com> * Add support for /// in gRPC endpoint. Signed-off-by: Artur Souza <asouza.pro@gmail.com> * Update binding example and test to use confluentinc images. Signed-off-by: Artur Souza <asouza.pro@gmail.com> --------- Signed-off-by: Artur Souza <asouza.pro@gmail.com>
1 parent a074310 commit faa3af5

File tree

7 files changed

+239
-43
lines changed

7 files changed

+239
-43
lines changed

examples/src/main/java/io/dapr/examples/bindings/http/README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,8 @@ docker-compose -f ./src/main/java/io/dapr/examples/bindings/http/docker-compose-
7272
2. Run `docker ps` to see the container running locally:
7373

7474
```bash
75-
342d3522ca14 kafka-docker_kafka "start-kafka.sh" 14 hours ago Up About
76-
a minute 0.0.0.0:9092->9092/tcp kafka-docker_kafka_1
77-
0cd69dbe5e65 wurstmeister/zookeeper "/bin/sh -c '/usr/sb…" 8 days ago Up About
78-
a minute 22/tcp, 2888/tcp, 3888/tcp, 0.0.0.0:2181->2181/tcp kafka-docker_zookeeper_1
75+
26966aaabd82 confluentinc/cp-kafka:7.4.4 "/etc/confluent/dock…" About a minute ago Up About a minute 9092/tcp, 0.0.0.0:29092->29092/tcp deploy-kafka-1
76+
b95e7ad31707 confluentinc/cp-zookeeper:7.4.4 "/etc/confluent/dock…" 5 days ago Up 14 minutes 2888/tcp, 3888/tcp, 0.0.0.0:22181->2181/tcp deploy-zookeeper-1
7977
```
8078
Click [here](https://github.com/wurstmeister/kafka-docker) for more information about the kafka broker server.
8179

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
version: '2'
22
services:
33
zookeeper:
4-
image: wurstmeister/zookeeper:latest
4+
image: confluentinc/cp-zookeeper:7.4.4
5+
environment:
6+
ZOOKEEPER_CLIENT_PORT: 2181
7+
ZOOKEEPER_TICK_TIME: 2000
58
ports:
6-
- "2181:2181"
9+
- 22181:2181
10+
711
kafka:
8-
image: wurstmeister/kafka:latest
12+
image: confluentinc/cp-kafka:7.4.4
13+
depends_on:
14+
- zookeeper
915
ports:
10-
- "9092:9092"
16+
- 9092:9092
1117
environment:
12-
KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
13-
KAFKA_CREATE_TOPICS: "sample:1:1"
14-
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
18+
KAFKA_BROKER_ID: 1
19+
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
20+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
21+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
22+
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
23+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

sdk-tests/deploy/local-test.yml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
version: '3'
22
services:
33
zookeeper:
4-
image: wurstmeister/zookeeper:latest
4+
image: confluentinc/cp-zookeeper:7.4.4
5+
environment:
6+
ZOOKEEPER_CLIENT_PORT: 2181
7+
ZOOKEEPER_TICK_TIME: 2000
58
ports:
6-
- "2181:2181"
9+
- 22181:2181
10+
711
kafka:
12+
image: confluentinc/cp-kafka:7.4.4
813
depends_on:
914
- zookeeper
10-
image: wurstmeister/kafka:latest
1115
ports:
12-
- "9092:9092"
16+
- 9092:9092
1317
environment:
14-
KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1
15-
KAFKA_CREATE_TOPICS: "sample:1:1"
18+
KAFKA_BROKER_ID: 1
1619
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
20+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
21+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
22+
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
23+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
24+
1725
mongo:
1826
image: mongo
1927
ports:

sdk-tests/src/test/java/io/dapr/it/DaprPorts.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public void use() {
5858
if (this.grpcPort != null) {
5959
System.getProperties().setProperty(Properties.GRPC_PORT.getName(), String.valueOf(this.grpcPort));
6060
System.getProperties().setProperty(
61-
Properties.GRPC_ENDPOINT.getName(), "http://127.0.0.1:" + this.grpcPort);
61+
Properties.GRPC_ENDPOINT.getName(), "127.0.0.1:" + this.grpcPort);
6262
}
6363
}
6464

sdk/src/main/java/io/dapr/utils/NetworkUtils.java

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
import java.net.InetAddress;
2323
import java.net.InetSocketAddress;
2424
import java.net.Socket;
25-
import java.net.URI;
26-
import java.net.UnknownHostException;
25+
import java.util.regex.Pattern;
2726

2827
/**
2928
* Utility methods for network, internal to Dapr SDK.
@@ -32,13 +31,56 @@ public final class NetworkUtils {
3231

3332
private static final long RETRY_WAIT_MILLISECONDS = 1000;
3433

34+
// Thanks to https://ihateregex.io/expr/ipv6/
35+
private static final String IPV6_REGEX = "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|"
36+
+ "([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|"
37+
+ "([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|"
38+
+ "([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|"
39+
+ "([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|"
40+
+ ":((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|"
41+
+ "::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|"
42+
+ "1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|"
43+
+ "(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|"
44+
+ "(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
45+
46+
private static final Pattern IPV6_PATTERN = Pattern.compile(IPV6_REGEX, Pattern.CASE_INSENSITIVE);
47+
48+
// Don't accept "?" to avoid ambiguity with ?tls=true
49+
private static final String GRPC_ENDPOINT_FILENAME_REGEX_PART = "[^\0\\?]+";
50+
51+
private static final String GRPC_ENDPOINT_HOSTNAME_REGEX_PART = "(([A-Za-z0-9_\\-\\.]+)|(\\[" + IPV6_REGEX + "\\]))";
52+
53+
private static final String GRPC_ENDPOINT_DNS_AUTHORITY_REGEX_PART =
54+
"(?<dnsWithAuthority>dns://)(?<authorityEndpoint>" + GRPC_ENDPOINT_HOSTNAME_REGEX_PART + ":[0-9]+)?/";
55+
56+
private static final String GRPC_ENDPOINT_PARAM_REGEX_PART = "(\\?(?<param>tls\\=((true)|(false))))?";
57+
58+
private static final String GRPC_ENDPOINT_SOCKET_REGEX_PART =
59+
"(?<socket>((unix:)|(unix://)|(unix-abstract:))" + GRPC_ENDPOINT_FILENAME_REGEX_PART + ")";
60+
61+
private static final String GRPC_ENDPOINT_VSOCKET_REGEX_PART =
62+
"(?<vsocket>vsock:" + GRPC_ENDPOINT_HOSTNAME_REGEX_PART + ":[0-9]+)";
63+
private static final String GRPC_ENDPOINT_HOST_REGEX_PART =
64+
"((?<http>http://)|(?<https>https://)|(?<dns>dns:)|(" + GRPC_ENDPOINT_DNS_AUTHORITY_REGEX_PART + "))?"
65+
+ "(?<hostname>" + GRPC_ENDPOINT_HOSTNAME_REGEX_PART + ")?+"
66+
+ "(:(?<port>[0-9]+))?";
67+
68+
private static final String GRPC_ENDPOINT_REGEX = "^("
69+
+ "(" + GRPC_ENDPOINT_HOST_REGEX_PART + ")|"
70+
+ "(" + GRPC_ENDPOINT_SOCKET_REGEX_PART + ")|"
71+
+ "(" + GRPC_ENDPOINT_VSOCKET_REGEX_PART + ")"
72+
+ ")" + GRPC_ENDPOINT_PARAM_REGEX_PART + "$";
73+
74+
private static final Pattern GRPC_ENDPOINT_PATTERN = Pattern.compile(GRPC_ENDPOINT_REGEX, Pattern.CASE_INSENSITIVE);
75+
3576
private NetworkUtils() {
3677
}
3778

3879
/**
3980
* Tries to connect to a socket, retrying every 1 second.
40-
* @param host Host to connect to.
41-
* @param port Port to connect to.
81+
*
82+
* @param host Host to connect to.
83+
* @param port Port to connect to.
4284
* @param timeoutInMilliseconds Timeout in milliseconds to give up trying.
4385
* @throws InterruptedException If retry is interrupted.
4486
*/
@@ -60,26 +102,15 @@ public static void waitForSocket(String host, int port, int timeoutInMillisecond
60102

61103
/**
62104
* Creates a GRPC managed channel.
105+
*
63106
* @param interceptors Optional interceptors to add to the channel.
64107
* @return GRPC managed channel to communicate with the sidecar.
65108
*/
66109
public static ManagedChannel buildGrpcManagedChannel(ClientInterceptor... interceptors) {
67-
String address = Properties.SIDECAR_IP.get();
68-
int port = Properties.GRPC_PORT.get();
69-
boolean insecure = true;
70-
String grpcEndpoint = Properties.GRPC_ENDPOINT.get();
71-
if ((grpcEndpoint != null) && !grpcEndpoint.isEmpty()) {
72-
URI uri = URI.create(grpcEndpoint);
73-
insecure = uri.getScheme().equalsIgnoreCase("http");
74-
port = uri.getPort() > 0 ? uri.getPort() : (insecure ? 80 : 443);
75-
address = uri.getHost();
76-
if ((uri.getPath() != null) && !uri.getPath().isEmpty()) {
77-
address += uri.getPath();
78-
}
79-
}
80-
ManagedChannelBuilder<?> builder = ManagedChannelBuilder.forAddress(address, port)
110+
var settings = GrpcEndpointSettings.parse();
111+
ManagedChannelBuilder<?> builder = ManagedChannelBuilder.forTarget(settings.endpoint)
81112
.userAgent(Version.getSdkVersion());
82-
if (insecure) {
113+
if (!settings.secure) {
83114
builder = builder.usePlaintext();
84115
}
85116
if (interceptors != null && interceptors.length > 0) {
@@ -88,6 +119,73 @@ public static ManagedChannel buildGrpcManagedChannel(ClientInterceptor... interc
88119
return builder.build();
89120
}
90121

122+
// Not private to allow unit testing
123+
static final class GrpcEndpointSettings {
124+
final String endpoint;
125+
final boolean secure;
126+
127+
private GrpcEndpointSettings(String endpoint, boolean secure) {
128+
this.endpoint = endpoint;
129+
this.secure = secure;
130+
}
131+
132+
static GrpcEndpointSettings parse() {
133+
String address = Properties.SIDECAR_IP.get();
134+
int port = Properties.GRPC_PORT.get();
135+
boolean secure = false;
136+
String grpcEndpoint = Properties.GRPC_ENDPOINT.get();
137+
if ((grpcEndpoint != null) && !grpcEndpoint.isEmpty()) {
138+
var matcher = GRPC_ENDPOINT_PATTERN.matcher(grpcEndpoint);
139+
if (!matcher.matches()) {
140+
throw new IllegalArgumentException("Illegal gRPC endpoint: " + grpcEndpoint);
141+
}
142+
var parsedHost = matcher.group("hostname");
143+
if (parsedHost != null) {
144+
address = parsedHost;
145+
}
146+
147+
var https = matcher.group("https") != null;
148+
var http = matcher.group("http") != null;
149+
secure = https;
150+
151+
String parsedPort = matcher.group("port");
152+
if (parsedPort != null) {
153+
port = Integer.parseInt(parsedPort);
154+
} else {
155+
// This implements default port as 80 for http for backwards compatibility.
156+
port = http ? 80 : 443;
157+
}
158+
159+
String parsedParam = matcher.group("param");
160+
if ((http || https) && (parsedParam != null)) {
161+
throw new IllegalArgumentException("Query params is not supported in HTTP URI for gRPC endpoint.");
162+
}
163+
164+
if (parsedParam != null) {
165+
secure = parsedParam.equalsIgnoreCase("tls=true");
166+
}
167+
168+
var authorityEndpoint = matcher.group("authorityEndpoint");
169+
if (authorityEndpoint != null) {
170+
return new GrpcEndpointSettings(String.format("dns://%s/%s:%d", authorityEndpoint, address, port), secure);
171+
}
172+
173+
var socket = matcher.group("socket");
174+
if (socket != null) {
175+
return new GrpcEndpointSettings(socket, secure);
176+
}
177+
178+
var vsocket = matcher.group("vsocket");
179+
if (vsocket != null) {
180+
return new GrpcEndpointSettings(vsocket, secure);
181+
}
182+
}
183+
184+
return new GrpcEndpointSettings(String.format("dns:///%s:%d", address, port), secure);
185+
}
186+
187+
}
188+
91189
private static void callWithRetry(Runnable function, long retryTimeoutMilliseconds) throws InterruptedException {
92190
long started = System.currentTimeMillis();
93191
while (true) {
@@ -104,7 +202,7 @@ private static void callWithRetry(Runnable function, long retryTimeoutMillisecon
104202
long elapsed = System.currentTimeMillis() - started;
105203
if (elapsed >= retryTimeoutMilliseconds) {
106204
if (exception instanceof RuntimeException) {
107-
throw (RuntimeException)exception;
205+
throw (RuntimeException) exception;
108206
}
109207

110208
throw new RuntimeException(exception);
@@ -117,9 +215,14 @@ private static void callWithRetry(Runnable function, long retryTimeoutMillisecon
117215

118216
/**
119217
* Retrieve loopback address for the host.
218+
*
120219
* @return The loopback address String
121220
*/
122221
public static String getHostLoopbackAddress() {
123222
return InetAddress.getLoopbackAddress().getHostAddress();
124223
}
224+
225+
static boolean isIPv6(String ip) {
226+
return IPV6_PATTERN.matcher(ip).matches();
227+
}
125228
}

sdk/src/test/java/io/dapr/utils/NetworkUtilsTest.java

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@
22

33
import io.dapr.config.Properties;
44
import io.grpc.ManagedChannel;
5-
import io.grpc.testing.GrpcCleanupRule;
6-
import org.junit.Rule;
5+
import org.junit.Assert;
76
import org.junit.jupiter.api.AfterEach;
87
import org.junit.jupiter.api.Assertions;
98
import org.junit.jupiter.api.BeforeEach;
109
import org.junit.jupiter.api.Test;
1110

12-
import static org.mockito.Mockito.mock;
1311

1412
public class NetworkUtilsTest {
1513
private final int defaultGrpcPort = 4000;
@@ -74,4 +72,85 @@ public void testBuildGrpcManagedChannel_httpsEndpointWithPort() {
7472
String expectedAuthority = "example.com:3000";
7573
Assertions.assertEquals(expectedAuthority, channel.authority());
7674
}
75+
76+
@Test
77+
public void testGrpcEndpointParsing() {
78+
testGrpcEndpointParsingScenario(":5000", "dns:///127.0.0.1:5000", false);
79+
testGrpcEndpointParsingScenario(":5000?tls=true", "dns:///127.0.0.1:5000", true);
80+
testGrpcEndpointParsingScenario(":5000?tls=false", "dns:///127.0.0.1:5000", false);
81+
testGrpcEndpointParsingScenario("myhost:5000", "dns:///myhost:5000", false);
82+
testGrpcEndpointParsingScenario("myhost:5000?tls=true", "dns:///myhost:5000", true);
83+
testGrpcEndpointParsingScenario("myhost:5000?tls=false", "dns:///myhost:5000", false);
84+
testGrpcEndpointParsingScenario("myhost", "dns:///myhost:443", false);
85+
testGrpcEndpointParsingScenario("myhost?tls=true", "dns:///myhost:443", true);
86+
testGrpcEndpointParsingScenario("myhost?tls=false", "dns:///myhost:443", false);
87+
testGrpcEndpointParsingScenario("dns:myhost", "dns:///myhost:443", false);
88+
testGrpcEndpointParsingScenario("dns:myhost?tls=true", "dns:///myhost:443", true);
89+
testGrpcEndpointParsingScenario("dns:myhost?tls=false", "dns:///myhost:443", false);
90+
testGrpcEndpointParsingScenario("http://myhost", "dns:///myhost:80", false);
91+
testGrpcEndpointParsingScenario("http://myhost:443", "dns:///myhost:443", false);
92+
testGrpcEndpointParsingScenario("http://myhost:5000", "dns:///myhost:5000", false);
93+
testGrpcEndpointParsingScenario("http://myhost:8080", "dns:///myhost:8080", false);
94+
testGrpcEndpointParsingScenario("https://myhost", "dns:///myhost:443", true);
95+
testGrpcEndpointParsingScenario("https://myhost:443", "dns:///myhost:443", true);
96+
testGrpcEndpointParsingScenario("https://myhost:5000", "dns:///myhost:5000", true);
97+
testGrpcEndpointParsingScenario("dns:///myhost", "dns:///myhost:443", false);
98+
testGrpcEndpointParsingScenario("dns://myauthority:53/myhost", "dns://myauthority:53/myhost:443", false);
99+
testGrpcEndpointParsingScenario("dns://myauthority:53/myhost?tls=false", "dns://myauthority:53/myhost:443", false);
100+
testGrpcEndpointParsingScenario("dns://myauthority:53/myhost?tls=true", "dns://myauthority:53/myhost:443", true);
101+
testGrpcEndpointParsingScenario("unix:my.sock", "unix:my.sock", false);
102+
testGrpcEndpointParsingScenario("unix:my.sock?tls=true", "unix:my.sock", true);
103+
testGrpcEndpointParsingScenario("unix://my.sock", "unix://my.sock", false);
104+
testGrpcEndpointParsingScenario("unix://my.sock?tls=true", "unix://my.sock", true);
105+
testGrpcEndpointParsingScenario("unix-abstract:my.sock", "unix-abstract:my.sock", false);
106+
testGrpcEndpointParsingScenario("unix-abstract:my.sock?tls=true", "unix-abstract:my.sock", true);
107+
testGrpcEndpointParsingScenario("vsock:mycid:5000", "vsock:mycid:5000", false);
108+
testGrpcEndpointParsingScenario("vsock:mycid:5000?tls=true", "vsock:mycid:5000", true);
109+
testGrpcEndpointParsingScenario("[2001:db8:1f70::999:de8:7648:6e8]", "dns:///[2001:db8:1f70::999:de8:7648:6e8]:443", false);
110+
testGrpcEndpointParsingScenario("dns:[2001:db8:1f70::999:de8:7648:6e8]:5000", "dns:///[2001:db8:1f70::999:de8:7648:6e8]:5000", false);
111+
testGrpcEndpointParsingScenario("dns://myauthority:53/[2001:db8:1f70::999:de8:7648:6e8]", "dns://myauthority:53/[2001:db8:1f70::999:de8:7648:6e8]:443", false);
112+
testGrpcEndpointParsingScenario("https://[2001:db8:1f70::999:de8:7648:6e8]", "dns:///[2001:db8:1f70::999:de8:7648:6e8]:443", true);
113+
testGrpcEndpointParsingScenario("https://[2001:db8:1f70::999:de8:7648:6e8]:5000", "dns:///[2001:db8:1f70::999:de8:7648:6e8]:5000", true);
114+
}
115+
116+
@Test
117+
public void testGrpcEndpointParsingError() {
118+
testGrpcEndpointParsingErrorScenario("http://myhost?tls=true");
119+
testGrpcEndpointParsingErrorScenario("http://myhost?tls=false");
120+
testGrpcEndpointParsingErrorScenario("http://myhost:8080?tls=true");
121+
testGrpcEndpointParsingErrorScenario("http://myhost:443?tls=false");
122+
testGrpcEndpointParsingErrorScenario("https://myhost?tls=true");
123+
testGrpcEndpointParsingErrorScenario("https://myhost?tls=false");
124+
testGrpcEndpointParsingErrorScenario("https://myhost:8080?tls=true");
125+
testGrpcEndpointParsingErrorScenario("https://myhost:443?tls=false");
126+
testGrpcEndpointParsingErrorScenario("dns://myhost");
127+
testGrpcEndpointParsingErrorScenario("dns:[2001:db8:1f70::999:de8:7648:6e8]:5000?abc=[]");
128+
testGrpcEndpointParsingErrorScenario("dns:[2001:db8:1f70::999:de8:7648:6e8]:5000?abc=123");
129+
testGrpcEndpointParsingErrorScenario("host:5000/v1/dapr");
130+
testGrpcEndpointParsingErrorScenario("host:5000/?a=1");
131+
testGrpcEndpointParsingErrorScenario("inv-scheme://myhost");
132+
testGrpcEndpointParsingErrorScenario("inv-scheme:myhost:5000");
133+
}
134+
135+
private static void testGrpcEndpointParsingScenario(
136+
String grpcEndpointEnvValue,
137+
String expectedEndpoint,
138+
boolean expectSecure
139+
) {
140+
System.setProperty(Properties.GRPC_ENDPOINT.getName(), grpcEndpointEnvValue);
141+
var settings = NetworkUtils.GrpcEndpointSettings.parse();
142+
143+
Assertions.assertEquals(expectedEndpoint, settings.endpoint);
144+
Assertions.assertEquals(expectSecure, settings.secure);
145+
}
146+
147+
private static void testGrpcEndpointParsingErrorScenario(String grpcEndpointEnvValue) {
148+
try {
149+
System.setProperty(Properties.GRPC_ENDPOINT.getName(), grpcEndpointEnvValue);
150+
NetworkUtils.GrpcEndpointSettings.parse();
151+
Assert.fail();
152+
} catch (IllegalArgumentException e) {
153+
// Expected
154+
}
155+
}
77156
}

0 commit comments

Comments
 (0)