@@ -1,105 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
package org.sonar.server.plugins; | |||
import java.io.BufferedInputStream; | |||
import java.io.BufferedOutputStream; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.io.OutputStream; | |||
import java.nio.file.Files; | |||
import java.nio.file.Path; | |||
import java.util.Optional; | |||
import java.util.jar.JarInputStream; | |||
import java.util.jar.Pack200; | |||
import java.util.zip.GZIPOutputStream; | |||
import org.sonar.api.config.Configuration; | |||
import org.sonar.api.server.ServerSide; | |||
import org.sonar.api.utils.log.Logger; | |||
import org.sonar.api.utils.log.Loggers; | |||
import org.sonar.api.utils.log.Profiler; | |||
import org.sonar.server.plugins.PluginFilesAndMd5.FileAndMd5; | |||
@ServerSide | |||
public class PluginCompressor { | |||
public static final String PROPERTY_PLUGIN_COMPRESSION_ENABLE = "sonar.pluginsCompression.enable"; | |||
private static final Logger LOG = Loggers.get(PluginCompressor.class); | |||
private final Configuration configuration; | |||
public PluginCompressor(Configuration configuration) { | |||
this.configuration = configuration; | |||
} | |||
public boolean enabled() { | |||
return configuration.getBoolean(PROPERTY_PLUGIN_COMPRESSION_ENABLE).orElse(false); | |||
} | |||
/** | |||
* @param loadedJar the JAR loaded by classloaders. It differs from {@code jar} | |||
* which is the initial location of JAR as seen by users | |||
*/ | |||
public PluginFilesAndMd5 compress(String key, File jar, File loadedJar) { | |||
Optional<File> compressed = compressJar(key, jar, loadedJar); | |||
return new PluginFilesAndMd5(new FileAndMd5(loadedJar), compressed.map(FileAndMd5::new).orElse(null)); | |||
} | |||
private Optional<File> compressJar(String key, File jar, File loadedJar) { | |||
if (!configuration.getBoolean(PROPERTY_PLUGIN_COMPRESSION_ENABLE).orElse(false)) { | |||
return Optional.empty(); | |||
} | |||
Path targetPack200 = getPack200Path(loadedJar.toPath()); | |||
Path sourcePack200Path = getPack200Path(jar.toPath()); | |||
// check if packed file was deployed alongside the jar. If that's the case, use it instead of generating it (SONAR-10395). | |||
if (sourcePack200Path.toFile().exists()) { | |||
try { | |||
LOG.debug("Found pack200: " + sourcePack200Path); | |||
Files.copy(sourcePack200Path, targetPack200); | |||
} catch (IOException e) { | |||
throw new IllegalStateException("Failed to copy pack200 file from " + sourcePack200Path + " to " + targetPack200, e); | |||
} | |||
} else { | |||
pack200(loadedJar.toPath(), targetPack200, key); | |||
} | |||
return Optional.of(targetPack200.toFile()); | |||
} | |||
private static void pack200(Path jarPath, Path toPack200Path, String pluginKey) { | |||
Profiler profiler = Profiler.create(LOG); | |||
profiler.startInfo("Compressing plugin " + pluginKey + " [pack200]"); | |||
try (JarInputStream in = new JarInputStream(new BufferedInputStream(Files.newInputStream(jarPath))); | |||
OutputStream out = new GZIPOutputStream(new BufferedOutputStream(Files.newOutputStream(toPack200Path)))) { | |||
Pack200.newPacker().pack(in, out); | |||
} catch (IOException e) { | |||
throw new IllegalStateException(String.format("Fail to pack200 plugin [%s] '%s' to '%s'", pluginKey, jarPath, toPack200Path), e); | |||
} | |||
profiler.stopInfo(); | |||
} | |||
private static Path getPack200Path(Path jar) { | |||
String jarFileName = jar.getFileName().toString(); | |||
String filename = jarFileName.substring(0, jarFileName.length() - 3) + "pack.gz"; | |||
return jar.resolveSibling(filename); | |||
} | |||
} |
@@ -19,8 +19,6 @@ | |||
*/ | |||
package org.sonar.server.plugins; | |||
import javax.annotation.CheckForNull; | |||
import javax.annotation.Nullable; | |||
import org.sonar.api.Plugin; | |||
import org.sonar.core.platform.PluginInfo; | |||
import org.sonar.core.plugin.PluginType; | |||
@@ -31,19 +29,17 @@ public class ServerPlugin { | |||
private final PluginType type; | |||
private final Plugin instance; | |||
private final FileAndMd5 jar; | |||
private final FileAndMd5 compressed; | |||
private final ClassLoader classloader; | |||
public ServerPlugin(PluginInfo pluginInfo, PluginType type, Plugin instance, FileAndMd5 jar, @Nullable FileAndMd5 compressed) { | |||
this(pluginInfo, type, instance, jar, compressed, instance.getClass().getClassLoader()); | |||
public ServerPlugin(PluginInfo pluginInfo, PluginType type, Plugin instance, FileAndMd5 jar) { | |||
this(pluginInfo, type, instance, jar, instance.getClass().getClassLoader()); | |||
} | |||
public ServerPlugin(PluginInfo pluginInfo, PluginType type, Plugin instance, FileAndMd5 jar, @Nullable FileAndMd5 compressed, ClassLoader classloader) { | |||
public ServerPlugin(PluginInfo pluginInfo, PluginType type, Plugin instance, FileAndMd5 jar, ClassLoader classloader) { | |||
this.pluginInfo = pluginInfo; | |||
this.type = type; | |||
this.instance = instance; | |||
this.jar = jar; | |||
this.compressed = compressed; | |||
this.classloader = classloader; | |||
} | |||
@@ -63,11 +59,6 @@ public class ServerPlugin { | |||
return jar; | |||
} | |||
@CheckForNull | |||
public FileAndMd5 getCompressed() { | |||
return compressed; | |||
} | |||
public ClassLoader getClassloader() { | |||
return classloader; | |||
} |
@@ -48,15 +48,13 @@ public class ServerPluginManager implements Startable { | |||
private final PluginJarLoader pluginJarLoader; | |||
private final PluginJarExploder pluginJarExploder; | |||
private final PluginClassLoader pluginClassLoader; | |||
private final PluginCompressor pluginCompressor; | |||
private final ServerPluginRepository pluginRepository; | |||
public ServerPluginManager(PluginClassLoader pluginClassLoader, PluginJarExploder pluginJarExploder, | |||
PluginJarLoader pluginJarLoader, PluginCompressor pluginCompressor, ServerPluginRepository pluginRepository) { | |||
PluginJarLoader pluginJarLoader, ServerPluginRepository pluginRepository) { | |||
this.pluginClassLoader = pluginClassLoader; | |||
this.pluginJarExploder = pluginJarExploder; | |||
this.pluginJarLoader = pluginJarLoader; | |||
this.pluginCompressor = pluginCompressor; | |||
this.pluginRepository = pluginRepository; | |||
} | |||
@@ -67,7 +65,7 @@ public class ServerPluginManager implements Startable { | |||
Collection<ExplodedPlugin> explodedPlugins = extractPlugins(loadedPlugins); | |||
Map<String, Plugin> instancesByKey = pluginClassLoader.load(explodedPlugins); | |||
Map<String, PluginType> typesByKey = getTypesByKey(loadedPlugins); | |||
List<ServerPlugin> plugins = compressAndCreateServerPlugins(explodedPlugins, instancesByKey, typesByKey); | |||
List<ServerPlugin> plugins = createServerPlugins(explodedPlugins, instancesByKey, typesByKey); | |||
pluginRepository.addPlugins(plugins); | |||
} | |||
@@ -88,12 +86,11 @@ public class ServerPluginManager implements Startable { | |||
return plugins.stream().map(pluginJarExploder::explode).collect(Collectors.toList()); | |||
} | |||
private List<ServerPlugin> compressAndCreateServerPlugins(Collection<ExplodedPlugin> explodedPlugins, Map<String, Plugin> instancesByKey, Map<String, PluginType> typseByKey) { | |||
private static List<ServerPlugin> createServerPlugins(Collection<ExplodedPlugin> explodedPlugins, Map<String, Plugin> instancesByKey, Map<String, PluginType> typesByKey) { | |||
List<ServerPlugin> plugins = new ArrayList<>(); | |||
for (ExplodedPlugin p : explodedPlugins) { | |||
PluginFilesAndMd5 installedPlugin = pluginCompressor.compress(p.getKey(), p.getPluginInfo().getNonNullJarFile(), p.getMain()); | |||
plugins.add(new ServerPlugin(p.getPluginInfo(), typseByKey.get(p.getKey()), instancesByKey.get(p.getKey()), | |||
installedPlugin.getLoadedJar(), installedPlugin.getCompressedJar())); | |||
plugins.add(new ServerPlugin(p.getPluginInfo(), typesByKey.get(p.getKey()), instancesByKey.get(p.getKey()), | |||
new PluginFilesAndMd5.FileAndMd5(p.getPluginInfo().getNonNullJarFile()))); | |||
} | |||
return plugins; | |||
} |
@@ -1,128 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
package org.sonar.server.plugins; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.nio.charset.StandardCharsets; | |||
import java.nio.file.Files; | |||
import java.nio.file.Path; | |||
import org.apache.commons.io.FileUtils; | |||
import org.apache.commons.lang.RandomStringUtils; | |||
import org.junit.Before; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.junit.rules.TemporaryFolder; | |||
import org.sonar.api.config.internal.MapSettings; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.sonar.server.plugins.PluginCompressor.PROPERTY_PLUGIN_COMPRESSION_ENABLE; | |||
public class PluginCompressorTest { | |||
@Rule | |||
public TemporaryFolder temp = new TemporaryFolder(); | |||
private MapSettings settings = new MapSettings(); | |||
@Before | |||
public void setUp() throws IOException { | |||
Path targetFolder = temp.newFolder("target").toPath(); | |||
Path targetJarPath = targetFolder.resolve("test.jar"); | |||
Files.createFile(targetJarPath); | |||
} | |||
@Test | |||
public void compress_jar_if_compression_enabled() throws IOException { | |||
File jar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
// the JAR is copied somewhere else in order to be loaded by classloaders | |||
File loadedJar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
settings.setProperty(PROPERTY_PLUGIN_COMPRESSION_ENABLE, true); | |||
PluginCompressor underTest = new PluginCompressor(settings.asConfig()); | |||
PluginFilesAndMd5 installedPlugin = underTest.compress("foo", jar, loadedJar); | |||
assertThat(installedPlugin.getLoadedJar().getFile().toPath()).isEqualTo(loadedJar.toPath()); | |||
assertThat(installedPlugin.getCompressedJar().getFile()) | |||
.exists() | |||
.isFile() | |||
.hasName("sonar-foo-plugin.pack.gz") | |||
.hasParent(loadedJar.getParentFile()); | |||
} | |||
@Test | |||
public void dont_compress_jar_if_compression_disable() throws IOException { | |||
File jar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
// the JAR is copied somewhere else in order to be loaded by classloaders | |||
File loadedJar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
settings.setProperty(PROPERTY_PLUGIN_COMPRESSION_ENABLE, false); | |||
PluginCompressor underTest = new PluginCompressor(settings.asConfig()); | |||
PluginFilesAndMd5 installedPlugin = underTest.compress("foo", jar, loadedJar); | |||
assertThat(installedPlugin.getLoadedJar().getFile().toPath()).isEqualTo(loadedJar.toPath()); | |||
assertThat(installedPlugin.getCompressedJar()).isNull(); | |||
assertThat(installedPlugin.getLoadedJar().getFile().getParentFile().listFiles()).containsOnly(loadedJar); | |||
} | |||
@Test | |||
public void copy_and_use_existing_packed_jar_if_compression_enabled() throws IOException { | |||
File jar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
File packedJar = touch(jar.getParentFile(), "sonar-foo-plugin.pack.gz"); | |||
// the JAR is copied somewhere else in order to be loaded by classloaders | |||
File loadedJar = touch(temp.newFolder(), "sonar-foo-plugin.jar"); | |||
settings.setProperty(PROPERTY_PLUGIN_COMPRESSION_ENABLE, true); | |||
PluginCompressor underTest = new PluginCompressor(settings.asConfig()); | |||
PluginFilesAndMd5 installedPlugin = underTest.compress("foo", jar, loadedJar); | |||
assertThat(installedPlugin.getLoadedJar().getFile().toPath()).isEqualTo(loadedJar.toPath()); | |||
assertThat(installedPlugin.getCompressedJar().getFile()) | |||
.exists() | |||
.isFile() | |||
.hasName(packedJar.getName()) | |||
.hasParent(loadedJar.getParentFile()) | |||
.hasSameTextualContentAs(packedJar); | |||
} | |||
private static File touch(File dir, String filename) throws IOException { | |||
File file = new File(dir, filename); | |||
FileUtils.write(file, RandomStringUtils.random(10), StandardCharsets.UTF_8); | |||
return file; | |||
} | |||
// | |||
// @Test | |||
// public void should_use_deployed_packed_file() throws IOException { | |||
// Path packedPath = sourceFolder.resolve("test.pack.gz"); | |||
// Files.write(packedPath, new byte[] {1, 2, 3}); | |||
// | |||
// settings.setProperty(PROPERTY_PLUGIN_COMPRESSION_ENABLE, true); | |||
// underTest = new PluginFileSystem(settings.asConfig()); | |||
// underTest.compressJar("key", sourceFolder, targetJarPath); | |||
// | |||
// assertThat(Files.list(targetFolder)).containsOnly(targetJarPath, targetFolder.resolve("test.pack.gz")); | |||
// assertThat(underTest.getPlugins()).hasSize(1); | |||
// assertThat(underTest.getPlugins().get("key").getFilename()).isEqualTo("test.pack.gz"); | |||
// | |||
// // check that the file was copied, not generated | |||
// assertThat(targetFolder.resolve("test.pack.gz")).hasSameContentAs(packedPath); | |||
// } | |||
} |
@@ -162,7 +162,7 @@ public class PluginUninstallerTest { | |||
private static ServerPlugin newPlugin(ServerPluginInfo pluginInfo) { | |||
return new ServerPlugin(pluginInfo, pluginInfo.getType(), mock(Plugin.class), | |||
mock(PluginFilesAndMd5.FileAndMd5.class), mock(PluginFilesAndMd5.FileAndMd5.class), mock(ClassLoader.class)); | |||
mock(PluginFilesAndMd5.FileAndMd5.class), mock(ClassLoader.class)); | |||
} | |||
private File copyTestPluginTo(String testPluginName, File toDir) throws IOException { |
@@ -21,9 +21,11 @@ package org.sonar.server.plugins; | |||
import com.google.common.collect.ImmutableMap; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.util.Arrays; | |||
import java.util.Collections; | |||
import java.util.Map; | |||
import org.jetbrains.annotations.NotNull; | |||
import org.junit.After; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
@@ -36,7 +38,7 @@ import org.sonar.server.plugins.PluginFilesAndMd5.FileAndMd5; | |||
import org.sonar.updatecenter.common.Version; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.tuple; | |||
import static org.junit.Assert.assertEquals; | |||
import static org.mockito.ArgumentMatchers.anyList; | |||
import static org.mockito.Mockito.mock; | |||
import static org.mockito.Mockito.when; | |||
@@ -50,9 +52,8 @@ public class ServerPluginManagerTest { | |||
private PluginClassLoader pluginClassLoader = mock(PluginClassLoader.class); | |||
private PluginJarExploder jarExploder = mock(PluginJarExploder.class); | |||
private PluginJarLoader jarLoader = mock(PluginJarLoader.class); | |||
private PluginCompressor pluginCompressor = mock(PluginCompressor.class); | |||
private ServerPluginRepository pluginRepository = new ServerPluginRepository(); | |||
private ServerPluginManager underTest = new ServerPluginManager(pluginClassLoader, jarExploder, jarLoader, pluginCompressor, pluginRepository); | |||
private ServerPluginManager underTest = new ServerPluginManager(pluginClassLoader, jarExploder, jarLoader, pluginRepository); | |||
@After | |||
public void tearDown() { | |||
@@ -69,18 +70,20 @@ public class ServerPluginManagerTest { | |||
Map<String, Plugin> instances = ImmutableMap.of("p1", mock(Plugin.class), "p2", mock(Plugin.class)); | |||
when(pluginClassLoader.load(anyList())).thenReturn(instances); | |||
PluginFilesAndMd5 p1Files = newPluginFilesAndMd5("p1"); | |||
PluginFilesAndMd5 p2Files = newPluginFilesAndMd5("p2"); | |||
when(pluginCompressor.compress("p1", new File("p1.jar"), new File("p1Exploded.jar"))).thenReturn(p1Files); | |||
when(pluginCompressor.compress("p2", new File("p2.jar"), new File("p2Exploded.jar"))).thenReturn(p2Files); | |||
underTest.start(); | |||
assertThat(pluginRepository.getPlugins()) | |||
.extracting(ServerPlugin::getPluginInfo, ServerPlugin::getCompressed, ServerPlugin::getJar, ServerPlugin::getInstance) | |||
.containsOnly(tuple(p1, p1Files.getCompressedJar(), p1Files.getLoadedJar(), instances.get("p1")), | |||
tuple(p2, p2Files.getCompressedJar(), p2Files.getLoadedJar(), instances.get("p2"))); | |||
assertEquals(2, pluginRepository.getPlugins().size()); | |||
assertEquals(p1, pluginRepository.getPlugin("p1").getPluginInfo()); | |||
assertEquals(newFileAndMd5(p1.getNonNullJarFile()).getFile(), pluginRepository.getPlugin("p1").getJar().getFile()); | |||
assertEquals(newFileAndMd5(p1.getNonNullJarFile()).getMd5(), pluginRepository.getPlugin("p1").getJar().getMd5()); | |||
assertEquals(instances.get("p1"), pluginRepository.getPlugin("p1").getInstance()); | |||
assertEquals(p2, pluginRepository.getPlugin("p2").getPluginInfo()); | |||
assertEquals(newFileAndMd5(p2.getNonNullJarFile()).getFile(), pluginRepository.getPlugin("p2").getJar().getFile()); | |||
assertEquals(newFileAndMd5(p2.getNonNullJarFile()).getMd5(), pluginRepository.getPlugin("p2").getJar().getMd5()); | |||
assertEquals(instances.get("p2"), pluginRepository.getPlugin("p2").getInstance()); | |||
assertThat(pluginRepository.getPlugins()).extracting(ServerPlugin::getPluginInfo) | |||
.allMatch(p -> logTester.logs().contains(String.format("Deploy %s / %s / %s", p.getName(), p.getVersion(), p.getImplementationBuild()))); | |||
@@ -90,7 +93,7 @@ public class ServerPluginManagerTest { | |||
ServerPluginInfo pluginInfo = mock(ServerPluginInfo.class); | |||
when(pluginInfo.getKey()).thenReturn(key); | |||
when(pluginInfo.getType()).thenReturn(EXTERNAL); | |||
when(pluginInfo.getNonNullJarFile()).thenReturn(new File(key + ".jar")); | |||
when(pluginInfo.getNonNullJarFile()).thenReturn(getJarFile(key)); | |||
when(pluginInfo.getName()).thenReturn(key + "_name"); | |||
Version version = mock(Version.class); | |||
when(version.getName()).thenReturn(key + "_version"); | |||
@@ -99,15 +102,18 @@ public class ServerPluginManagerTest { | |||
return pluginInfo; | |||
} | |||
private static PluginFilesAndMd5 newPluginFilesAndMd5(String name) { | |||
FileAndMd5 jar = mock(FileAndMd5.class); | |||
when(jar.getFile()).thenReturn(new File(name)); | |||
when(jar.getMd5()).thenReturn(name + "-md5"); | |||
FileAndMd5 compressed = mock(FileAndMd5.class); | |||
when(compressed.getFile()).thenReturn(new File(name + "-compressed")); | |||
when(compressed.getMd5()).thenReturn(name + "-compressed-md5"); | |||
@NotNull | |||
private static File getJarFile(String key) { | |||
File file = new File(key + ".jar"); | |||
try { | |||
file.createNewFile(); | |||
} catch (IOException e) { | |||
throw new RuntimeException(e); | |||
} | |||
return file; | |||
} | |||
return new PluginFilesAndMd5(jar, compressed); | |||
private static FileAndMd5 newFileAndMd5(File file) { | |||
return new PluginFilesAndMd5.FileAndMd5(file); | |||
} | |||
} |
@@ -133,6 +133,6 @@ public class ServerPluginRepositoryTest { | |||
} | |||
private ServerPlugin newPlugin(String key, PluginType type) { | |||
return new ServerPlugin(newPluginInfo(key), type, mock(Plugin.class), mock(FileAndMd5.class), mock(FileAndMd5.class), mock(ClassLoader.class)); | |||
return new ServerPlugin(newPluginInfo(key), type, mock(Plugin.class), mock(FileAndMd5.class), mock(ClassLoader.class)); | |||
} | |||
} |
@@ -86,6 +86,6 @@ public class GeneratePluginIndexTest { | |||
private ServerPlugin newInstalledPlugin(String key, boolean supportSonarLint) throws IOException { | |||
FileAndMd5 jar = new FileAndMd5(temp.newFile()); | |||
PluginInfo pluginInfo = new PluginInfo(key).setJarFile(jar.getFile()).setSonarLintSupported(supportSonarLint); | |||
return new ServerPlugin(pluginInfo, BUNDLED, null, jar, null, null); | |||
return new ServerPlugin(pluginInfo, BUNDLED, null, jar, null); | |||
} | |||
} |
@@ -210,7 +210,7 @@ public class RegisterPluginsTest { | |||
PluginInfo info = new PluginInfo(key) | |||
.setBasePlugin(basePlugin) | |||
.setJarFile(file); | |||
ServerPlugin serverPlugin = new ServerPlugin(info, type, null, jar, null, null); | |||
ServerPlugin serverPlugin = new ServerPlugin(info, type, null, jar, null); | |||
serverPluginRepository.addPlugin(serverPlugin); | |||
return serverPlugin; | |||
} |
@@ -23,6 +23,7 @@ import java.io.InputStream; | |||
import java.util.Optional; | |||
import org.apache.commons.io.FileUtils; | |||
import org.apache.commons.io.IOUtils; | |||
import org.sonar.api.server.ws.Change; | |||
import org.sonar.api.server.ws.Request; | |||
import org.sonar.api.server.ws.Response; | |||
import org.sonar.api.server.ws.WebService; | |||
@@ -32,9 +33,6 @@ import org.sonar.server.plugins.ServerPlugin; | |||
import org.sonar.server.plugins.ServerPluginRepository; | |||
public class DownloadAction implements PluginsWsAction { | |||
private static final String PACK200 = "pack200"; | |||
private static final String ACCEPT_COMPRESSIONS_PARAM = "acceptCompressions"; | |||
private static final String PLUGIN_PARAM = "plugin"; | |||
private final ServerPluginRepository pluginRepository; | |||
@@ -57,8 +55,7 @@ public class DownloadAction implements PluginsWsAction { | |||
.setDescription("The key identifying the plugin to download") | |||
.setExampleValue("cobol"); | |||
action.createParam(ACCEPT_COMPRESSIONS_PARAM) | |||
.setExampleValue(PACK200); | |||
action.setChangelog(new Change("9.8", "Parameter 'acceptCompressions' removed")); | |||
} | |||
@Override | |||
@@ -71,17 +68,10 @@ public class DownloadAction implements PluginsWsAction { | |||
} | |||
FileAndMd5 downloadedFile; | |||
FileAndMd5 compressedJar = file.get().getCompressed(); | |||
if (compressedJar != null && PACK200.equals(request.param(ACCEPT_COMPRESSIONS_PARAM))) { | |||
response.stream().setMediaType("application/octet-stream"); | |||
response.setHeader("Sonar-Compression", PACK200); | |||
response.setHeader("Sonar-UncompressedMD5", file.get().getJar().getMd5()); | |||
downloadedFile = compressedJar; | |||
} else { | |||
response.stream().setMediaType("application/java-archive"); | |||
downloadedFile = file.get().getJar(); | |||
} | |||
response.stream().setMediaType("application/java-archive"); | |||
downloadedFile = file.get().getJar(); | |||
response.setHeader("Sonar-MD5", downloadedFile.getMd5()); | |||
try (InputStream input = FileUtils.openInputStream(downloadedFile.getFile())) { | |||
IOUtils.copyLarge(input, response.stream().output()); |
@@ -24,9 +24,11 @@ import java.io.IOException; | |||
import java.util.Optional; | |||
import org.apache.commons.io.FileUtils; | |||
import org.apache.commons.io.IOUtils; | |||
import org.assertj.core.groups.Tuple; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.junit.rules.TemporaryFolder; | |||
import org.sonar.api.server.ws.Change; | |||
import org.sonar.api.server.ws.WebService; | |||
import org.sonar.core.platform.PluginInfo; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
@@ -61,7 +63,10 @@ public class DownloadActionTest { | |||
assertThat(def.since()).isEqualTo("7.2"); | |||
assertThat(def.params()) | |||
.extracting(WebService.Param::key) | |||
.containsExactlyInAnyOrder("plugin", "acceptCompressions"); | |||
.containsExactlyInAnyOrder("plugin"); | |||
assertThat(def.changelog()) | |||
.extracting(Change::getVersion, Change::getDescription) | |||
.containsExactlyInAnyOrder(new Tuple("9.8", "Parameter 'acceptCompressions' removed")); | |||
} | |||
@Test | |||
@@ -88,65 +93,9 @@ public class DownloadActionTest { | |||
verifySameContent(response, plugin.getJar().getFile()); | |||
} | |||
@Test | |||
public void return_uncompressed_jar_if_client_does_not_accept_compression() throws Exception { | |||
ServerPlugin plugin = newCompressedPlugin(); | |||
when(serverPluginRepository.findPlugin(plugin.getPluginInfo().getKey())).thenReturn(Optional.of(plugin)); | |||
TestResponse response = tester.newRequest() | |||
.setParam("plugin", plugin.getPluginInfo().getKey()) | |||
.execute(); | |||
assertThat(response.getHeader("Sonar-MD5")).isEqualTo(plugin.getJar().getMd5()); | |||
assertThat(response.getHeader("Sonar-Compression")).isNull(); | |||
assertThat(response.getHeader("Sonar-UncompressedMD5")).isNull(); | |||
assertThat(response.getMediaType()).isEqualTo("application/java-archive"); | |||
verifySameContent(response, plugin.getJar().getFile()); | |||
} | |||
@Test | |||
public void return_uncompressed_jar_if_client_requests_unsupported_compression() throws Exception { | |||
ServerPlugin plugin = newCompressedPlugin(); | |||
when(serverPluginRepository.findPlugin(plugin.getPluginInfo().getKey())).thenReturn(Optional.of(plugin)); | |||
TestResponse response = tester.newRequest() | |||
.setParam("plugin", plugin.getPluginInfo().getKey()) | |||
.setParam("acceptCompressions", "zip") | |||
.execute(); | |||
assertThat(response.getHeader("Sonar-MD5")).isEqualTo(plugin.getJar().getMd5()); | |||
assertThat(response.getHeader("Sonar-Compression")).isNull(); | |||
assertThat(response.getHeader("Sonar-UncompressedMD5")).isNull(); | |||
assertThat(response.getMediaType()).isEqualTo("application/java-archive"); | |||
verifySameContent(response, plugin.getJar().getFile()); | |||
} | |||
@Test | |||
public void return_compressed_jar_if_client_accepts_pack200() throws Exception { | |||
ServerPlugin plugin = newCompressedPlugin(); | |||
when(serverPluginRepository.findPlugin(plugin.getPluginInfo().getKey())).thenReturn(Optional.of(plugin)); | |||
TestResponse response = tester.newRequest() | |||
.setParam("plugin", plugin.getPluginInfo().getKey()) | |||
.setParam("acceptCompressions", "pack200") | |||
.execute(); | |||
assertThat(response.getHeader("Sonar-MD5")).isEqualTo(plugin.getCompressed().getMd5()); | |||
assertThat(response.getHeader("Sonar-UncompressedMD5")).isEqualTo(plugin.getJar().getMd5()); | |||
assertThat(response.getHeader("Sonar-Compression")).isEqualTo("pack200"); | |||
assertThat(response.getMediaType()).isEqualTo("application/octet-stream"); | |||
verifySameContent(response, plugin.getCompressed().getFile()); | |||
} | |||
private ServerPlugin newPlugin() throws IOException { | |||
FileAndMd5 jar = new FileAndMd5(temp.newFile()); | |||
return new ServerPlugin(new PluginInfo("foo"), PluginType.BUNDLED, null, jar, null, null); | |||
} | |||
private ServerPlugin newCompressedPlugin() throws IOException { | |||
FileAndMd5 jar = new FileAndMd5(temp.newFile()); | |||
FileAndMd5 compressedJar = new FileAndMd5(temp.newFile()); | |||
return new ServerPlugin(new PluginInfo("foo"), PluginType.BUNDLED, null, jar, compressedJar, null); | |||
return new ServerPlugin(new PluginInfo("foo"), PluginType.BUNDLED, null, jar, null); | |||
} | |||
private static void verifySameContent(TestResponse response, File file) throws IOException { |
@@ -211,13 +211,7 @@ public class InstalledActionTest { | |||
private ServerPlugin newInstalledPlugin(PluginInfo plugin, PluginType type) throws IOException { | |||
FileAndMd5 jar = new FileAndMd5(temp.newFile()); | |||
return new ServerPlugin(plugin, type, null, jar, null, null); | |||
} | |||
private ServerPlugin newInstalledPluginWithCompression(PluginInfo plugin) throws IOException { | |||
FileAndMd5 jar = new FileAndMd5(temp.newFile()); | |||
FileAndMd5 compressedJar = new FileAndMd5(temp.newFile()); | |||
return new ServerPlugin(plugin, PluginType.BUNDLED, null, jar, compressedJar, null); | |||
return new ServerPlugin(plugin, type, null, jar, null); | |||
} | |||
@Test | |||
@@ -267,56 +261,6 @@ public class InstalledActionTest { | |||
"}"); | |||
} | |||
@Test | |||
public void return_compression_fields_if_available() throws Exception { | |||
ServerPlugin plugin = newInstalledPluginWithCompression(new PluginInfo("foo") | |||
.setName("plugName") | |||
.setDescription("desc_it") | |||
.setVersion(Version.create("1.0")) | |||
.setLicense("license_hey") | |||
.setOrganizationName("org_name") | |||
.setOrganizationUrl("org_url") | |||
.setHomepageUrl("homepage_url") | |||
.setIssueTrackerUrl("issueTracker_url") | |||
.setImplementationBuild("sou_rev_sha1") | |||
.setDocumentationPath("static/documentation.md") | |||
.setSonarLintSupported(true)); | |||
when(serverPluginRepository.getPlugins()).thenReturn(singletonList(plugin)); | |||
db.pluginDbTester().insertPlugin( | |||
p -> p.setKee(plugin.getPluginInfo().getKey()), | |||
p -> p.setType(Type.EXTERNAL), | |||
p -> p.setUpdatedAt(100L)); | |||
String response = tester.newRequest().execute().getInput(); | |||
verifyNoInteractions(updateCenterMatrixFactory); | |||
assertJson(response).isSimilarTo( | |||
"{" + | |||
" \"plugins\":" + | |||
" [" + | |||
" {" + | |||
" \"key\": \"foo\"," + | |||
" \"name\": \"plugName\"," + | |||
" \"description\": \"desc_it\"," + | |||
" \"version\": \"1.0\"," + | |||
" \"license\": \"license_hey\"," + | |||
" \"organizationName\": \"org_name\"," + | |||
" \"organizationUrl\": \"org_url\",\n" + | |||
" \"editionBundled\": false," + | |||
" \"homepageUrl\": \"homepage_url\"," + | |||
" \"issueTrackerUrl\": \"issueTracker_url\"," + | |||
" \"implementationBuild\": \"sou_rev_sha1\"," + | |||
" \"sonarLintSupported\": true," + | |||
" \"documentationPath\": \"static/documentation.md\"," + | |||
" \"filename\": \"" + plugin.getJar().getFile().getName() + "\"," + | |||
" \"hash\": \"" + plugin.getJar().getMd5() + "\"," + | |||
" \"updatedAt\": 100" + | |||
" }" + | |||
" ]" + | |||
"}"); | |||
} | |||
@Test | |||
public void category_is_returned_when_in_additional_fields() throws Exception { | |||
String jarFilename = getClass().getSimpleName() + "/" + "some.jar"; | |||
@@ -433,7 +377,7 @@ public class InstalledActionTest { | |||
.setImplementationBuild("sou_rev_sha1"), | |||
PluginType.BUNDLED, | |||
null, | |||
new FileAndMd5(jar), new FileAndMd5(jar), null))); | |||
new FileAndMd5(jar), null))); | |||
db.pluginDbTester().insertPlugin( | |||
p -> p.setKee(pluginKey), | |||
p -> p.setType(Type.BUNDLED), | |||
@@ -506,7 +450,7 @@ public class InstalledActionTest { | |||
.setName(name) | |||
.setVersion(Version.create("1.0")); | |||
info.setJarFile(file); | |||
return new ServerPlugin(info, PluginType.BUNDLED, null, new FileAndMd5(file), null, null); | |||
return new ServerPlugin(info, PluginType.BUNDLED, null, new FileAndMd5(file), null); | |||
} | |||
} |
@@ -39,7 +39,6 @@ import org.sonar.server.platform.db.migration.charset.DatabaseCharsetChecker; | |||
import org.sonar.server.platform.db.migration.version.DatabaseVersion; | |||
import org.sonar.server.platform.web.WebPagesCache; | |||
import org.sonar.server.plugins.InstalledPluginReferentialFactory; | |||
import org.sonar.server.plugins.PluginCompressor; | |||
import org.sonar.server.plugins.PluginJarLoader; | |||
import org.sonar.server.plugins.ServerPluginJarExploder; | |||
import org.sonar.server.plugins.ServerPluginManager; | |||
@@ -75,7 +74,6 @@ public class PlatformLevel2 extends PlatformLevel { | |||
ServerPluginManager.class, | |||
ServerPluginJarExploder.class, | |||
PluginClassLoader.class, | |||
PluginCompressor.class, | |||
PluginClassloaderFactory.class, | |||
InstalledPluginReferentialFactory.class, | |||
WebServerExtensionInstaller.class, |
@@ -73,7 +73,7 @@ public class PlatformLevel2Test { | |||
verify(container).add(ServerPluginRepository.class); | |||
verify(container).add(DatabaseCharsetChecker.class); | |||
verify(container, times(23)).add(any()); | |||
verify(container, times(22)).add(any()); | |||
} | |||
@Test | |||
@@ -94,7 +94,7 @@ public class PlatformLevel2Test { | |||
verify(container).add(ServerPluginRepository.class); | |||
verify(container, never()).add(DatabaseCharsetChecker.class); | |||
verify(container, times(21)).add(any()); | |||
verify(container, times(20)).add(any()); | |||
} | |||
@@ -20,7 +20,6 @@ | |||
package org.sonar.scanner.bootstrap; | |||
import java.io.BufferedInputStream; | |||
import java.io.BufferedOutputStream; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.io.InputStream; | |||
@@ -28,10 +27,7 @@ import java.net.HttpURLConnection; | |||
import java.nio.file.Files; | |||
import java.util.Objects; | |||
import java.util.Optional; | |||
import java.util.jar.JarOutputStream; | |||
import java.util.jar.Pack200; | |||
import java.util.stream.Stream; | |||
import java.util.zip.GZIPInputStream; | |||
import org.apache.commons.codec.digest.DigestUtils; | |||
import org.apache.commons.io.FileUtils; | |||
import org.sonar.api.config.Configuration; | |||
@@ -48,9 +44,6 @@ public class PluginFiles { | |||
private static final Logger LOGGER = Loggers.get(PluginFiles.class); | |||
private static final String MD5_HEADER = "Sonar-MD5"; | |||
private static final String COMPRESSION_HEADER = "Sonar-Compression"; | |||
private static final String PACK200 = "pack200"; | |||
private static final String UNCOMPRESSED_MD5_HEADER = "Sonar-UncompressedMD5"; | |||
private final DefaultScannerWsClient wsClient; | |||
private final File cacheDir; | |||
@@ -94,13 +87,6 @@ public class PluginFiles { | |||
.setParam("plugin", plugin.key) | |||
.setTimeOutInMs(5 * 60_000); | |||
try { | |||
Class.forName("java.util.jar.Pack200"); | |||
request.setParam("acceptCompressions", PACK200); | |||
} catch (ClassNotFoundException e) { | |||
// ignore and don't use any compression | |||
} | |||
File downloadedFile = newTempFile(); | |||
LOGGER.debug("Download plugin '{}' to '{}'", plugin.key, downloadedFile); | |||
@@ -123,15 +109,9 @@ public class PluginFiles { | |||
// un-compress if needed | |||
String cacheMd5; | |||
File tempJar; | |||
Optional<String> compression = response.header(COMPRESSION_HEADER); | |||
if (compression.isPresent() && PACK200.equals(compression.get())) { | |||
tempJar = unpack200(plugin.key, downloadedFile); | |||
cacheMd5 = response.header(UNCOMPRESSED_MD5_HEADER).orElseThrow(() -> new IllegalStateException(format( | |||
"Fail to download plugin [%s]. Request to %s did not return header %s.", plugin.key, response.requestUrl(), UNCOMPRESSED_MD5_HEADER))); | |||
} else { | |||
tempJar = downloadedFile; | |||
cacheMd5 = expectedMd5.get(); | |||
} | |||
tempJar = downloadedFile; | |||
cacheMd5 = expectedMd5.get(); | |||
// put in cache | |||
File jarInCache = jarInCache(plugin.key, cacheMd5); | |||
@@ -177,18 +157,6 @@ public class PluginFiles { | |||
} | |||
} | |||
private File unpack200(String pluginKey, File compressedFile) { | |||
LOGGER.debug("Unpacking plugin {}", pluginKey); | |||
File jar = newTempFile(); | |||
try (InputStream input = new GZIPInputStream(new BufferedInputStream(FileUtils.openInputStream(compressedFile))); | |||
JarOutputStream output = new JarOutputStream(new BufferedOutputStream(FileUtils.openOutputStream(jar)))) { | |||
Pack200.newUnpacker().unpack(input, output); | |||
} catch (IOException e) { | |||
throw new IllegalStateException(format("Fail to download plugin [%s]. Pack200 error.", pluginKey), e); | |||
} | |||
return jar; | |||
} | |||
private static String computeMd5(File file) { | |||
try (InputStream fis = new BufferedInputStream(FileUtils.openInputStream(file))) { | |||
return DigestUtils.md5Hex(fis); |
@@ -19,26 +19,15 @@ | |||
*/ | |||
package org.sonar.scanner.bootstrap; | |||
import java.io.BufferedInputStream; | |||
import java.io.BufferedOutputStream; | |||
import java.io.ByteArrayOutputStream; | |||
import java.io.File; | |||
import java.io.IOException; | |||
import java.io.InputStream; | |||
import java.io.OutputStream; | |||
import java.nio.file.Files; | |||
import java.util.Collections; | |||
import java.util.Optional; | |||
import java.util.jar.JarInputStream; | |||
import java.util.jar.JarOutputStream; | |||
import java.util.jar.Pack200; | |||
import java.util.zip.GZIPInputStream; | |||
import java.util.zip.GZIPOutputStream; | |||
import javax.annotation.Nullable; | |||
import okhttp3.HttpUrl; | |||
import okhttp3.mockwebserver.MockResponse; | |||
import okhttp3.mockwebserver.MockWebServer; | |||
import okhttp3.mockwebserver.RecordedRequest; | |||
import okio.Buffer; | |||
import org.apache.commons.codec.digest.DigestUtils; | |||
import org.apache.commons.io.FileUtils; | |||
@@ -107,7 +96,7 @@ public class PluginFilesTest { | |||
verifySameContent(result, tempJar); | |||
HttpUrl requestedUrl = server.takeRequest().getRequestUrl(); | |||
assertThat(requestedUrl.encodedPath()).isEqualTo("/api/plugins/download"); | |||
assertThat(requestedUrl.encodedQuery()).isEqualTo("plugin=foo&acceptCompressions=pack200"); | |||
assertThat(requestedUrl.encodedQuery()).isEqualTo("plugin=foo"); | |||
// get from cache on second call | |||
result = underTest.get(plugin).get(); | |||
@@ -115,24 +104,6 @@ public class PluginFilesTest { | |||
assertThat(server.getRequestCount()).isOne(); | |||
} | |||
@Test | |||
public void download_compressed_and_add_uncompressed_to_cache_if_missing() throws Exception { | |||
FileAndMd5 jar = new FileAndMd5(); | |||
enqueueCompressedDownload(jar, true); | |||
InstalledPlugin plugin = newInstalledPlugin("foo", jar.md5); | |||
File result = underTest.get(plugin).get(); | |||
verifySameContentAfterCompression(jar.file, result); | |||
RecordedRequest recordedRequest = server.takeRequest(); | |||
assertThat(recordedRequest.getRequestUrl().queryParameter("acceptCompressions")).isEqualTo("pack200"); | |||
// get from cache on second call | |||
result = underTest.get(plugin).get(); | |||
verifySameContentAfterCompression(jar.file, result); | |||
assertThat(server.getRequestCount()).isOne(); | |||
} | |||
@Test | |||
public void return_empty_if_plugin_not_found_on_server() { | |||
server.enqueue(new MockResponse().setResponseCode(404)); | |||
@@ -153,16 +124,6 @@ public class PluginFilesTest { | |||
() -> underTest.get(plugin)); | |||
} | |||
@Test | |||
public void fail_if_integrity_of_compressed_download_is_not_valid() throws Exception { | |||
FileAndMd5 jar = new FileAndMd5(); | |||
enqueueCompressedDownload(jar, false); | |||
InstalledPlugin plugin = newInstalledPlugin("foo", jar.md5); | |||
expectISE("foo", "was expected to have checksum invalid_hash but had ", () -> underTest.get(plugin).get()); | |||
} | |||
@Test | |||
public void fail_if_md5_header_is_missing_from_response() throws IOException { | |||
File tempJar = temp.newFile(); | |||
@@ -172,19 +133,6 @@ public class PluginFilesTest { | |||
expectISE("foo", "did not return header Sonar-MD5", () -> underTest.get(plugin)); | |||
} | |||
@Test | |||
public void fail_if_compressed_download_cannot_be_uncompressed() { | |||
MockResponse response = new MockResponse().setBody("not binary"); | |||
response.setHeader("Sonar-MD5", DigestUtils.md5Hex("not binary")); | |||
response.setHeader("Sonar-UncompressedMD5", "abc"); | |||
response.setHeader("Sonar-Compression", "pack200"); | |||
server.enqueue(response); | |||
InstalledPlugin plugin = newInstalledPlugin("foo", "abc"); | |||
expectISE("foo", "Pack200 error", () -> underTest.get(plugin).get()); | |||
} | |||
@Test | |||
public void fail_if_server_returns_error() { | |||
server.enqueue(new MockResponse().setResponseCode(500)); | |||
@@ -260,26 +208,6 @@ public class PluginFilesTest { | |||
server.enqueue(response); | |||
} | |||
/** | |||
* Enqueue download of file with a MD5 that may not be returned (null) or not valid | |||
*/ | |||
private void enqueueCompressedDownload(FileAndMd5 jar, boolean validMd5) throws IOException { | |||
Buffer body = new Buffer(); | |||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(); | |||
try (JarInputStream in = new JarInputStream(new BufferedInputStream(Files.newInputStream(jar.file.toPath()))); | |||
OutputStream output = new GZIPOutputStream(new BufferedOutputStream(bytes))) { | |||
Pack200.newPacker().pack(in, output); | |||
} | |||
body.write(bytes.toByteArray()); | |||
MockResponse response = new MockResponse().setBody(body); | |||
response.setHeader("Sonar-MD5", validMd5 ? DigestUtils.md5Hex(bytes.toByteArray()) : "invalid_hash"); | |||
response.setHeader("Sonar-UncompressedMD5", jar.md5); | |||
response.setHeader("Sonar-Compression", "pack200"); | |||
server.enqueue(response); | |||
} | |||
private static InstalledPlugin newInstalledPlugin(String pluginKey, String fileChecksum) { | |||
InstalledPlugin plugin = new InstalledPlugin(); | |||
plugin.key = pluginKey; | |||
@@ -293,33 +221,6 @@ public class PluginFilesTest { | |||
assertThat(file1).hasSameContentAs(file2.file); | |||
} | |||
/** | |||
* Packing and unpacking a JAR generates a different file. | |||
*/ | |||
private void verifySameContentAfterCompression(File file1, File file2) throws IOException { | |||
assertThat(file1).isFile().exists(); | |||
assertThat(file2).isFile().exists(); | |||
assertThat(packAndUnpackJar(file1)).hasSameContentAs(packAndUnpackJar(file2)); | |||
} | |||
private File packAndUnpackJar(File source) throws IOException { | |||
File packed = temp.newFile(); | |||
try (JarInputStream in = new JarInputStream(new BufferedInputStream(Files.newInputStream(source.toPath()))); | |||
OutputStream out = new GZIPOutputStream(new BufferedOutputStream(Files.newOutputStream(packed.toPath())))) { | |||
Pack200.newPacker().pack(in, out); | |||
} | |||
File to = temp.newFile(); | |||
try (InputStream input = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(packed.toPath()))); | |||
JarOutputStream output = new JarOutputStream(new BufferedOutputStream(Files.newOutputStream(to.toPath())))) { | |||
Pack200.newUnpacker().unpack(input, output); | |||
} catch (IOException e) { | |||
throw new IllegalStateException(e); | |||
} | |||
return to; | |||
} | |||
private void expectISE(String pluginKey, String message, ThrowingCallable shouldRaiseThrowable) { | |||
assertThatThrownBy(shouldRaiseThrowable) | |||
.isInstanceOf(IllegalStateException.class) |
@@ -29,22 +29,8 @@ import javax.annotation.Generated; | |||
*/ | |||
@Generated("sonar-ws-generator") | |||
public class DownloadRequest { | |||
private String acceptCompressions; | |||
private String plugin; | |||
/** | |||
* Example value: "pack200" | |||
*/ | |||
public DownloadRequest setAcceptCompressions(String acceptCompressions) { | |||
this.acceptCompressions = acceptCompressions; | |||
return this; | |||
} | |||
public String getAcceptCompressions() { | |||
return acceptCompressions; | |||
} | |||
/** | |||
* This is a mandatory parameter. | |||
* Example value: "cobol" |