Skip to content

Commit defd045

Browse files
authored
Modify permissions dialog for plugins
This commit modifies the handling of plugins that require special permissions to cover a case that was not previously covered. Relates #23742
1 parent fc8cb41 commit defd045

File tree

18 files changed

+630
-238
lines changed

18 files changed

+630
-238
lines changed

buildSrc/src/main/groovy/org/elasticsearch/gradle/plugin/PluginPropertiesExtension.groovy

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ class PluginPropertiesExtension {
3939
@Input
4040
String classname
4141

42+
@Input
43+
boolean hasNativeController = false
44+
4245
/** Indicates whether the plugin jar should be made available for the transport client. */
4346
@Input
4447
boolean hasClientJar = false

buildSrc/src/main/groovy/org/elasticsearch/gradle/plugin/PluginPropertiesTask.groovy

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ class PluginPropertiesTask extends Copy {
7979
'version': stringSnap(extension.version),
8080
'elasticsearchVersion': stringSnap(VersionProperties.elasticsearch),
8181
'javaVersion': project.targetCompatibility as String,
82-
'classname': extension.classname
82+
'classname': extension.classname,
83+
'hasNativeController': extension.hasNativeController
8384
]
8485
}
8586
}

buildSrc/src/main/resources/checkstyle_suppressions.xml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,6 @@
605605
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]JavaVersion.java" checks="LineLength" />
606606
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]Natives.java" checks="LineLength" />
607607
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]Security.java" checks="LineLength" />
608-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]Spawner.java" checks="LineLength" />
609608
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]StartupException.java" checks="LineLength" />
610609
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]SystemCallFilter.java" checks="LineLength" />
611610
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cli[/\\]Command.java" checks="LineLength" />
@@ -1565,14 +1564,12 @@
15651564
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]AnalysisPlugin.java" checks="LineLength" />
15661565
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]ClusterPlugin.java" checks="LineLength" />
15671566
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]DiscoveryPlugin.java" checks="LineLength" />
1568-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]DummyPluginInfo.java" checks="LineLength" />
15691567
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]IngestPlugin.java" checks="LineLength" />
15701568
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]InstallPluginCommand.java" checks="LineLength" />
15711569
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]ListPluginsCommand.java" checks="LineLength" />
15721570
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]MetaDataUpgrader.java" checks="LineLength" />
15731571
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]NetworkPlugin.java" checks="LineLength" />
15741572
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]Plugin.java" checks="LineLength" />
1575-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginInfo.java" checks="LineLength" />
15761573
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginSecurity.java" checks="LineLength" />
15771574
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginsService.java" checks="LineLength" />
15781575
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]ProgressInputStream.java" checks="LineLength" />
@@ -2388,7 +2385,6 @@
23882385
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]JNANativesTests.java" checks="LineLength" />
23892386
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]JarHellTests.java" checks="LineLength" />
23902387
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]MaxMapCountCheckTests.java" checks="LineLength" />
2391-
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]SpawnerTests.java" checks="LineLength" />
23922388
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]broadcast[/\\]BroadcastActionsIT.java" checks="LineLength" />
23932389
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bwcompat[/\\]OldIndexBackwardsCompatibilityIT.java" checks="LineLength" />
23942390
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bwcompat[/\\]RecoveryWithUnsupportedIndicesIT.java" checks="LineLength" />
@@ -3009,7 +3005,6 @@
30093005
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]nodesinfo[/\\]NodeInfoStreamingTests.java" checks="LineLength" />
30103006
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]nodesinfo[/\\]SimpleNodesInfoIT.java" checks="LineLength" />
30113007
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]operateAllIndices[/\\]DestructiveOperationsIT.java" checks="LineLength" />
3012-
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginInfoTests.java" checks="LineLength" />
30133008
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginsServiceTests.java" checks="LineLength" />
30143009
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]recovery[/\\]FullRollingRestartIT.java" checks="LineLength" />
30153010
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]recovery[/\\]RecoveriesCollectionTests.java" checks="LineLength" />
@@ -3949,11 +3944,8 @@
39493944
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]common[/\\]logging[/\\]EvilLoggerTests.java" checks="LineLength" />
39503945
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]env[/\\]NodeEnvironmentEvilTests.java" checks="LineLength" />
39513946
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]InstallPluginCommandTests.java" checks="LineLength" />
3952-
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]ListPluginsCommandTests.java" checks="LineLength" />
3953-
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]PluginSecurityTests.java" checks="LineLength" />
39543947
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]plugins[/\\]RemovePluginCommandTests.java" checks="LineLength" />
39553948
<suppress files="qa[/\\]evil-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]tribe[/\\]TribeUnitTests.java" checks="LineLength" />
3956-
<suppress files="qa[/\\]no-bootstrap-tests[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]bootstrap[/\\]SpawnerNoBootstrapTests.java" checks="LineLength" />
39573949
<suppress files="qa[/\\]smoke-test-client[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]smoketest[/\\]ESSmokeClientTestCase.java" checks="LineLength" />
39583950
<suppress files="qa[/\\]smoke-test-client[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]smoketest[/\\]SmokeTestClientIT.java" checks="LineLength" />
39593951
<suppress files="qa[/\\]smoke-test-http[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]http[/\\]ContextAndHeaderTransportIT.java" checks="LineLength" />

buildSrc/src/main/resources/plugin-descriptor.properties

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ name=${name}
3030
# 'classname': the name of the class to load, fully-qualified.
3131
classname=${classname}
3232
#
33-
# 'java.version' version of java the code is built against
33+
# 'java.version': version of java the code is built against
3434
# use the system property java.specification.version
3535
# version string must be a sequence of nonnegative decimal integers
3636
# separated by "."'s and may have leading zeros
3737
java.version=${javaVersion}
3838
#
39-
# 'elasticsearch.version' version of elasticsearch compiled against
39+
# 'elasticsearch.version': version of elasticsearch compiled against
4040
elasticsearch.version=${elasticsearchVersion}
41+
### optional elements for plugins:
42+
#
43+
# 'has.native.controller': whether or not the plugin has a native controller
44+
has.native.controller=${hasNativeController}

core/src/main/java/org/elasticsearch/bootstrap/Spawner.java

Lines changed: 56 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919

2020
package org.elasticsearch.bootstrap;
2121

22-
import org.apache.lucene.util.Constants;
2322
import org.apache.lucene.util.IOUtils;
2423
import org.elasticsearch.env.Environment;
24+
import org.elasticsearch.plugins.PluginInfo;
25+
import org.elasticsearch.plugins.Platforms;
2526

2627
import java.io.Closeable;
2728
import java.io.IOException;
@@ -32,97 +33,89 @@
3233
import java.util.Collections;
3334
import java.util.List;
3435
import java.util.Locale;
36+
import java.util.concurrent.atomic.AtomicBoolean;
3537

3638
/**
37-
* Spawns native plugin controller processes if present. Will only work prior to a system call filter being installed.
39+
* Spawns native plugin controller processes if present. Will only work prior to a system call
40+
* filter being installed.
3841
*/
3942
final class Spawner implements Closeable {
4043

41-
private static final String PROGRAM_NAME = Constants.WINDOWS ? "controller.exe" : "controller";
42-
private static final String PLATFORM_NAME = makePlatformName(Constants.OS_NAME, Constants.OS_ARCH);
43-
private static final String TMP_ENVVAR = "TMPDIR";
44-
45-
/**
44+
/*
4645
* References to the processes that have been spawned, so that we can destroy them.
4746
*/
4847
private final List<Process> processes = new ArrayList<>();
48+
private AtomicBoolean spawned = new AtomicBoolean();
4949

5050
@Override
5151
public void close() throws IOException {
52-
try {
53-
IOUtils.close(() -> processes.stream().map(s -> (Closeable)s::destroy).iterator());
54-
} finally {
55-
processes.clear();
56-
}
52+
IOUtils.close(() -> processes.stream().map(s -> (Closeable) s::destroy).iterator());
5753
}
5854

5955
/**
60-
* For each plugin, attempt to spawn the controller daemon. Silently ignore any plugins
61-
* that don't include a controller for the correct platform.
56+
* Spawns the native controllers for each plugin
57+
*
58+
* @param environment the node environment
59+
* @throws IOException if an I/O error occurs reading the plugins or spawning a native process
6260
*/
63-
void spawnNativePluginControllers(Environment environment) throws IOException {
64-
if (Files.exists(environment.pluginsFile())) {
65-
try (DirectoryStream<Path> stream = Files.newDirectoryStream(environment.pluginsFile())) {
66-
for (Path plugin : stream) {
67-
Path spawnPath = makeSpawnPath(plugin);
68-
if (Files.isRegularFile(spawnPath)) {
69-
spawnNativePluginController(spawnPath, environment.tmpFile());
70-
}
61+
void spawnNativePluginControllers(final Environment environment) throws IOException {
62+
if (!spawned.compareAndSet(false, true)) {
63+
throw new IllegalStateException("native controllers already spawned");
64+
}
65+
final Path pluginsFile = environment.pluginsFile();
66+
if (!Files.exists(pluginsFile)) {
67+
throw new IllegalStateException("plugins directory [" + pluginsFile + "] not found");
68+
}
69+
/*
70+
* For each plugin, attempt to spawn the controller daemon. Silently ignore any plugin that
71+
* don't include a controller for the correct platform.
72+
*/
73+
try (DirectoryStream<Path> stream = Files.newDirectoryStream(pluginsFile)) {
74+
for (final Path plugin : stream) {
75+
final PluginInfo info = PluginInfo.readFromProperties(plugin);
76+
final Path spawnPath = Platforms.nativeControllerPath(plugin);
77+
if (!Files.isRegularFile(spawnPath)) {
78+
continue;
79+
}
80+
if (!info.hasNativeController()) {
81+
final String message = String.format(
82+
Locale.ROOT,
83+
"plugin [%s] does not have permission to fork native controller",
84+
plugin.getFileName());
85+
throw new IllegalArgumentException(message);
7186
}
87+
final Process process =
88+
spawnNativePluginController(spawnPath, environment.tmpFile());
89+
processes.add(process);
7290
}
7391
}
7492
}
7593

7694
/**
77-
* Attempt to spawn the controller daemon for a given plugin. The spawned process
78-
* will remain connected to this JVM via its stdin, stdout and stderr, but the
79-
* references to these streams are not available to code outside this package.
95+
* Attempt to spawn the controller daemon for a given plugin. The spawned process will remain
96+
* connected to this JVM via its stdin, stdout, and stderr streams, but the references to these
97+
* streams are not available to code outside this package.
8098
*/
81-
private void spawnNativePluginController(Path spawnPath, Path tmpPath) throws IOException {
82-
ProcessBuilder pb = new ProcessBuilder(spawnPath.toString());
99+
private Process spawnNativePluginController(
100+
final Path spawnPath,
101+
final Path tmpPath) throws IOException {
102+
final ProcessBuilder pb = new ProcessBuilder(spawnPath.toString());
83103

84-
// The only environment variable passes on the path to the temporary directory
104+
// the only environment variable passes on the path to the temporary directory
85105
pb.environment().clear();
86-
pb.environment().put(TMP_ENVVAR, tmpPath.toString());
106+
pb.environment().put("TMPDIR", tmpPath.toString());
87107

88-
// The output stream of the Process object corresponds to the daemon's stdin
89-
processes.add(pb.start());
90-
}
91-
92-
List<Process> getProcesses() {
93-
return Collections.unmodifiableList(processes);
108+
// the output stream of the process object corresponds to the daemon's stdin
109+
return pb.start();
94110
}
95111

96112
/**
97-
* Make the full path to the program to be spawned.
113+
* The collection of processes representing spawned native controllers.
114+
*
115+
* @return the processes
98116
*/
99-
static Path makeSpawnPath(Path plugin) {
100-
return plugin.resolve("platform").resolve(PLATFORM_NAME).resolve("bin").resolve(PROGRAM_NAME);
117+
List<Process> getProcesses() {
118+
return Collections.unmodifiableList(processes);
101119
}
102120

103-
/**
104-
* Make the platform name in the format used in Kibana downloads, for example:
105-
* - darwin-x86_64
106-
* - linux-x86-64
107-
* - windows-x86_64
108-
* For *nix platforms this is more-or-less `uname -s`-`uname -m` converted to lower case.
109-
* However, for consistency between different operating systems on the same architecture
110-
* "amd64" is replaced with "x86_64" and "i386" with "x86".
111-
* For Windows it's "windows-" followed by either "x86" or "x86_64".
112-
*/
113-
static String makePlatformName(String osName, String osArch) {
114-
String os = osName.toLowerCase(Locale.ROOT);
115-
if (os.startsWith("windows")) {
116-
os = "windows";
117-
} else if (os.equals("mac os x")) {
118-
os = "darwin";
119-
}
120-
String cpu = osArch.toLowerCase(Locale.ROOT);
121-
if (cpu.equals("amd64")) {
122-
cpu = "x86_64";
123-
} else if (cpu.equals("i386")) {
124-
cpu = "x86";
125-
}
126-
return os + "-" + cpu;
127-
}
128121
}

core/src/main/java/org/elasticsearch/plugins/DummyPluginInfo.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@
2121
public class DummyPluginInfo extends PluginInfo {
2222

2323
private DummyPluginInfo(String name, String description, String version, String classname) {
24-
super(name, description, version, classname);
24+
super(name, description, version, classname, false);
2525
}
2626

27-
public static final DummyPluginInfo INSTANCE = new DummyPluginInfo(
28-
"dummy_plugin_name", "dummy plugin description", "dummy_plugin_version", "DummyPluginName");
27+
public static final DummyPluginInfo INSTANCE =
28+
new DummyPluginInfo(
29+
"dummy_plugin_name",
30+
"dummy plugin description",
31+
"dummy_plugin_version",
32+
"DummyPluginName");
2933
}

core/src/main/java/org/elasticsearch/plugins/InstallPluginCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ private PluginInfo verify(Terminal terminal, Path pluginRoot, boolean isBatch, E
458458
// if it exists, confirm or warn the user
459459
Path policy = pluginRoot.resolve(PluginInfo.ES_PLUGIN_POLICY);
460460
if (Files.exists(policy)) {
461-
PluginSecurity.readPolicy(policy, terminal, env, isBatch);
461+
PluginSecurity.readPolicy(info, policy, terminal, env::tmpFile, isBatch);
462462
}
463463

464464
return info;

core/src/main/java/org/elasticsearch/plugins/ListPluginsCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ protected void execute(Terminal terminal, OptionSet options, Environment env) th
6161
PluginInfo info = PluginInfo.readFromProperties(env.pluginsFile().resolve(plugin.toAbsolutePath()));
6262
terminal.println(Terminal.Verbosity.VERBOSE, info.toString());
6363
} catch (IllegalArgumentException e) {
64-
if (e.getMessage().contains("incompatible with Elasticsearch")) {
64+
if (e.getMessage().contains("incompatible with version")) {
6565
terminal.println("WARNING: " + e.getMessage());
6666
} else {
6767
throw e;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.plugins;
21+
22+
import org.apache.lucene.util.Constants;
23+
24+
import java.nio.file.Path;
25+
import java.util.Locale;
26+
27+
/**
28+
* Encapsulates platform-dependent methods for handling native components of plugins.
29+
*/
30+
public class Platforms {
31+
32+
private static final String PROGRAM_NAME = Constants.WINDOWS ? "controller.exe" : "controller";
33+
private static final String PLATFORM_NAME =
34+
Platforms.platformName(Constants.OS_NAME, Constants.OS_ARCH);
35+
36+
private Platforms() {}
37+
38+
/**
39+
* The path to the native controller for a plugin with native components.
40+
*/
41+
public static Path nativeControllerPath(Path plugin) {
42+
return plugin
43+
.resolve("platform")
44+
.resolve(PLATFORM_NAME)
45+
.resolve("bin")
46+
.resolve(PROGRAM_NAME);
47+
}
48+
49+
/**
50+
* Return the platform name based on the OS name and
51+
* - darwin-x86_64
52+
* - linux-x86-64
53+
* - windows-x86_64
54+
* For *nix platforms this is more-or-less `uname -s`-`uname -m` converted to lower case.
55+
* However, for consistency between different operating systems on the same architecture
56+
* "amd64" is replaced with "x86_64" and "i386" with "x86".
57+
* For Windows it's "windows-" followed by either "x86" or "x86_64".
58+
*/
59+
public static String platformName(final String osName, final String osArch) {
60+
final String lowerCaseOs = osName.toLowerCase(Locale.ROOT);
61+
final String normalizedOs;
62+
if (lowerCaseOs.startsWith("windows")) {
63+
normalizedOs = "windows";
64+
} else if (lowerCaseOs.equals("mac os x")) {
65+
normalizedOs = "darwin";
66+
} else {
67+
normalizedOs = lowerCaseOs;
68+
}
69+
70+
final String lowerCaseArch = osArch.toLowerCase(Locale.ROOT);
71+
final String normalizedArch;
72+
if (lowerCaseArch.equals("amd64")) {
73+
normalizedArch = "x86_64";
74+
} else if (lowerCaseArch.equals("i386")) {
75+
normalizedArch = "x86";
76+
} else {
77+
normalizedArch = lowerCaseArch;
78+
}
79+
80+
return normalizedOs + "-" + normalizedArch;
81+
}
82+
83+
}

0 commit comments

Comments
 (0)