blob: 7c7d8d5aca43071e921b533cd4ed8e739f7b3a82 [file] [log] [blame]
// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.plugins;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
import com.google.gerrit.server.PluginUser;
import com.google.gerrit.server.cache.PersistentCacheFactory;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritRuntime;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.plugins.ServerPluginProvider.PluginDescription;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.AbstractMap;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.internal.storage.file.FileSnapshot;
import org.eclipse.jgit.lib.Config;
@Singleton
public class PluginLoader implements LifecycleListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public String getPluginName(Path srcPath) {
return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath));
}
private final Path pluginsDir;
private final Path dataDir;
private final Path tempDir;
private final PluginGuiceEnvironment env;
private final ServerInformationImpl srvInfoImpl;
private final PluginUser.Factory pluginUserFactory;
private final ConcurrentMap<String, Plugin> running = Maps.newConcurrentMap();
private final ConcurrentMap<String, Plugin> disabled = Maps.newConcurrentMap();
private final Map<String, FileSnapshot> broken = Maps.newHashMap();
private final Map<Plugin, CleanupHandle> cleanupHandles = Maps.newConcurrentMap();
private final Queue<Plugin> toCleanup = new ArrayDeque<>();
private final Provider<PluginCleanerTask> cleaner;
private final PluginScannerThread scanner;
private final Provider<String> urlProvider;
private final PersistentCacheFactory persistentCacheFactory;
private final boolean remoteAdmin;
private final MandatoryPluginsCollection mandatoryPlugins;
private final UniversalServerPluginProvider serverPluginFactory;
private final GerritRuntime gerritRuntime;
private final PluginOrderComparator pluginOrderComparator;
@Inject
public PluginLoader(
SitePaths sitePaths,
PluginGuiceEnvironment pe,
ServerInformationImpl sii,
PluginUser.Factory puf,
Provider<PluginCleanerTask> pct,
@GerritServerConfig Config cfg,
@CanonicalWebUrl Provider<String> provider,
PersistentCacheFactory cacheFactory,
UniversalServerPluginProvider pluginFactory,
MandatoryPluginsCollection mpc,
GerritRuntime gerritRuntime) {
pluginsDir = sitePaths.plugins_dir;
dataDir = sitePaths.data_dir;
tempDir = sitePaths.tmp_dir;
env = pe;
srvInfoImpl = sii;
pluginUserFactory = puf;
cleaner = pct;
urlProvider = provider;
persistentCacheFactory = cacheFactory;
serverPluginFactory = pluginFactory;
remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
mandatoryPlugins = mpc;
this.gerritRuntime = gerritRuntime;
ImmutableList<String> pluginOrderOverrides =
ImmutableList.copyOf(cfg.getStringList("plugins", null, "loadPriority"));
pluginOrderComparator = new PluginOrderComparator(pluginOrderOverrides);
long checkFrequency =
ConfigUtil.getTimeUnit(
cfg,
"plugins",
null,
"checkFrequency",
TimeUnit.MINUTES.toMillis(1),
TimeUnit.MILLISECONDS);
if (checkFrequency > 0) {
scanner = new PluginScannerThread(this, checkFrequency);
} else {
scanner = null;
}
}
public boolean isRemoteAdminEnabled() {
return remoteAdmin;
}
public void checkRemoteAdminEnabled() throws MethodNotAllowedException {
if (!remoteAdmin) {
throw new MethodNotAllowedException("remote plugin administration is disabled");
}
}
public Plugin get(String name) {
Plugin p = running.get(name);
if (p != null) {
return p;
}
return disabled.get(name);
}
public Iterable<Plugin> getPlugins(boolean all) {
if (!all) {
return running.values();
}
List<Plugin> plugins = new ArrayList<>(running.values());
plugins.addAll(disabled.values());
return plugins;
}
@CanIgnoreReturnValue
public String installPluginFromStream(String originalName, InputStream in)
throws IOException, PluginInstallException {
checkRemoteInstall();
String fileName = originalName;
Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName));
if (!originalName.equals(name)) {
logger.atWarning().log(
"Plugin provides its own name: <%s>, use it instead of the input name: <%s>",
name, originalName);
}
String fileExtension = getExtension(fileName);
Path dst = pluginsDir.resolve(name + fileExtension);
synchronized (this) {
Plugin active = running.get(name);
if (active != null) {
fileName = active.getSrcFile().getFileName().toString();
logger.atInfo().log("Replacing plugin %s", active.getName());
Path old = pluginsDir.resolve(".last_" + fileName);
Files.deleteIfExists(old);
Files.move(active.getSrcFile(), old);
}
Files.deleteIfExists(pluginsDir.resolve(fileName + ".disabled"));
Files.move(tmp, dst);
try {
Plugin plugin = runPlugin(name, dst, active);
if (active == null) {
logger.atInfo().log(
"Installed plugin %s%s",
plugin.getName(), plugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "");
}
} catch (PluginInstallException e) {
Files.deleteIfExists(dst);
throw e;
}
cleanInBackground();
}
return name;
}
private synchronized void unloadPlugin(Plugin plugin) {
persistentCacheFactory.onStop(plugin.getName());
String name = plugin.getName();
logger.atInfo().log("Unloading plugin %s, version %s", name, plugin.getVersion());
plugin.stop(env);
env.onStopPlugin(plugin);
running.remove(name);
disabled.remove(name);
toCleanup.add(plugin);
}
public void disablePlugins(Set<String> names) throws PluginInstallException {
if (!isRemoteAdminEnabled()) {
logger.atWarning().log(
"Remote plugin administration is disabled, ignoring disablePlugins(%s)", names);
return;
}
synchronized (this) {
for (String name : names) {
Plugin active = running.get(name);
if (active == null) {
continue;
}
if (active.getApiModule().isPresent()) {
throw new PluginInstallException(
String.format(
"Plugin %s has registered an ApiModule therefore it cannot be disabled", name));
}
if (mandatoryPlugins.contains(name)) {
logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
continue;
}
logger.atInfo().log("Disabling plugin %s", active.getName());
Path off =
active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
try {
Files.move(active.getSrcFile(), off);
} catch (IOException e) {
logger.atSevere().withCause(e).log("Failed to disable plugin");
// In theory we could still unload the plugin even if the rename
// failed. However, it would be reloaded on the next server startup,
// which is probably not what the user expects.
continue;
}
unloadPlugin(active);
try {
FileSnapshot snapshot = FileSnapshot.save(off.toFile());
Plugin offPlugin = loadPlugin(name, off, snapshot);
disabled.put(name, offPlugin);
} catch (Exception e) {
// This shouldn't happen, as the plugin was loaded earlier.
logger.atWarning().withCause(e.getCause()).log(
"Cannot load disabled plugin %s", active.getName());
}
}
cleanInBackground();
}
}
public void enablePlugins(Set<String> names) throws PluginInstallException {
if (!isRemoteAdminEnabled()) {
logger.atWarning().log(
"Remote plugin administration is disabled, ignoring enablePlugins(%s)", names);
return;
}
synchronized (this) {
for (String name : names) {
Plugin off = disabled.get(name);
if (off == null) {
continue;
}
logger.atInfo().log("Enabling plugin %s", name);
String n = off.getSrcFile().toFile().getName();
if (n.endsWith(".disabled")) {
n = n.substring(0, n.lastIndexOf('.'));
}
Path on = pluginsDir.resolve(n);
try {
Files.move(off.getSrcFile(), on);
} catch (IOException e) {
logger.atSevere().withCause(e).log("Failed to move plugin %s into place", name);
continue;
}
disabled.remove(name);
runPlugin(name, on, null);
}
cleanInBackground();
}
}
private void removeStalePluginFiles() {
DirectoryStream.Filter<Path> filter =
entry -> entry.getFileName().toString().startsWith("plugin_");
try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
for (Path file : files) {
logger.atInfo().log("Removing stale plugin file: %s", file.toFile().getName());
try {
Files.delete(file);
} catch (IOException e) {
logger.atSevere().log(
"Failed to remove stale plugin file %s: %s", file.toFile().getName(), e.getMessage());
}
}
} catch (IOException e) {
logger.atWarning().log("Unable to discover stale plugin files: %s", e.getMessage());
}
}
@Override
public synchronized void start() {
removeStalePluginFiles();
Path absolutePath = pluginsDir.toAbsolutePath();
if (!Files.exists(absolutePath)) {
logger.atInfo().log("%s does not exist; creating", absolutePath);
try {
Files.createDirectories(absolutePath);
} catch (IOException e) {
logger.atSevere().log("Failed to create %s: %s", absolutePath, e.getMessage());
}
}
logger.atInfo().log("Loading plugins from %s", absolutePath);
srvInfoImpl.state = ServerInformation.State.STARTUP;
rescan();
srvInfoImpl.state = ServerInformation.State.RUNNING;
if (scanner != null) {
scanner.start();
}
}
@Override
public void stop() {
if (scanner != null) {
scanner.end();
}
srvInfoImpl.state = ServerInformation.State.SHUTDOWN;
synchronized (this) {
for (Plugin p : running.values()) {
unloadPlugin(p);
}
running.clear();
disabled.clear();
broken.clear();
if (!toCleanup.isEmpty()) {
processPendingCleanups();
}
}
}
public void reload(List<String> names) throws InvalidPluginException, PluginInstallException {
synchronized (this) {
List<Plugin> reload = Lists.newArrayListWithCapacity(names.size());
List<String> bad = Lists.newArrayListWithExpectedSize(4);
for (String name : names) {
Plugin active = running.get(name);
if (active != null) {
reload.add(active);
} else {
bad.add(name);
}
}
if (!bad.isEmpty()) {
throw new InvalidPluginException(
String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad)));
}
for (Plugin active : reload) {
String name = active.getName();
try {
logger.atInfo().log("Reloading plugin %s", name);
Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
if (newPlugin != active) {
logger.atInfo().log(
"Reloaded plugin %s%s, version %s",
newPlugin.getName(),
newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
newPlugin.getVersion());
}
} catch (PluginInstallException e) {
logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name);
throw e;
}
}
cleanInBackground();
}
}
public synchronized void rescan() {
Set<String> loadedPlugins = new HashSet<>();
SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
if (!pluginsFiles.isEmpty()) {
syncDisabledPlugins(pluginsFiles);
Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
for (Map.Entry<String, Path> entry : jarsApiFirstSortedPluginsSet(activePlugins)) {
String name = entry.getKey();
Path path = entry.getValue();
String fileName = path.getFileName().toString();
if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
logger.atWarning().log(
"No Plugin provider was found that handles this file format: %s", fileName);
continue;
}
FileSnapshot brokenTime = broken.get(name);
if (brokenTime != null && !brokenTime.isModified(path.toFile())) {
continue;
}
Plugin active = running.get(name);
if (active != null && !active.isModified(path)) {
loadedPlugins.add(name);
continue;
}
if (active != null) {
logger.atInfo().log("Reloading plugin %s", active.getName());
}
try {
Plugin loadedPlugin = runPlugin(name, path, active);
if (!loadedPlugin.isDisabled()) {
loadedPlugins.add(name);
logger.atInfo().log(
"%s plugin %s%s, version %s",
active == null ? "Loaded" : "Reloaded",
loadedPlugin.getName(),
loadedPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
loadedPlugin.getVersion());
}
} catch (PluginInstallException e) {
logger.atWarning().withCause(e.getCause()).log("Cannot load plugin %s", name);
}
}
}
Set<String> missingMandatory = Sets.difference(mandatoryPlugins.asSet(), loadedPlugins);
if (!missingMandatory.isEmpty()) {
throw new MissingMandatoryPluginsException(missingMandatory);
}
cleanInBackground();
}
private void addAllEntries(Map<String, Path> from, TreeSet<Map.Entry<String, Path>> to) {
Iterator<Map.Entry<String, Path>> it = from.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Path> entry = it.next();
to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
}
}
private TreeSet<Map.Entry<String, Path>> jarsApiFirstSortedPluginsSet(
Map<String, Path> activePlugins) {
TreeSet<Map.Entry<String, Path>> sortedPlugins = Sets.newTreeSet(pluginOrderComparator);
addAllEntries(activePlugins, sortedPlugins);
return sortedPlugins;
}
private void syncDisabledPlugins(SetMultimap<String, Path> jars) {
stopRemovedPlugins(jars);
dropRemovedDisabledPlugins(jars);
}
@CanIgnoreReturnValue
private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin)
throws PluginInstallException {
FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
try {
boolean restartRequired = oldPlugin != null && !oldPlugin.canReload();
if (restartRequired && mandatoryPlugins.contains(name)) {
logger.atWarning().log("Restarting mandatory plugin %s not allowed", name);
return oldPlugin;
}
if (oldPlugin != null && oldPlugin.getApiModule().isPresent()) {
throw new PluginInstallException(
String.format(
"Plugin %s has registered an ApiModule therefore its restart/reload is not allowed",
name));
}
Plugin newPlugin = loadPlugin(name, plugin, snapshot);
if (newPlugin.getCleanupHandle() != null) {
cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
}
/*
* Pluggable plugin provider may have assigned a plugin name that could be
* actually different from the initial one assigned during scan. It is
* safer then to reassign it.
*/
name = newPlugin.getName();
boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
if (!reload && oldPlugin != null) {
unloadPlugin(oldPlugin);
}
if (!newPlugin.isDisabled()) {
try {
newPlugin.start(env);
} catch (Exception e) {
newPlugin.stop(env);
throw e;
}
}
if (reload) {
env.onReloadPlugin(oldPlugin, newPlugin);
unloadPlugin(oldPlugin);
} else if (!newPlugin.isDisabled()) {
env.onStartPlugin(newPlugin);
}
if (!newPlugin.isDisabled()) {
running.put(name, newPlugin);
} else {
disabled.put(name, newPlugin);
}
broken.remove(name);
return newPlugin;
} catch (Exception err) {
broken.put(name, snapshot);
throw new PluginInstallException(err);
}
}
private void stopRemovedPlugins(SetMultimap<String, Path> jars) {
Set<String> unload = Sets.newHashSet(running.keySet());
for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
for (Path path : entry.getValue()) {
if (!path.getFileName().toString().endsWith(".disabled")) {
unload.remove(entry.getKey());
}
}
}
for (String name : unload) {
Plugin runningPlugin = running.get(name);
if (runningPlugin.getApiModule().isPresent()) {
logger.atWarning().log("Cannot remove plugin %s as it has registered an ApiModule", name);
} else {
unloadPlugin(running.get(name));
}
}
}
private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) {
Set<String> unload = Sets.newHashSet(disabled.keySet());
for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
for (Path path : entry.getValue()) {
if (path.getFileName().toString().endsWith(".disabled")) {
unload.remove(entry.getKey());
}
}
}
for (String name : unload) {
disabled.remove(name);
}
}
@CanIgnoreReturnValue
synchronized int processPendingCleanups() {
Iterator<Plugin> iterator = toCleanup.iterator();
while (iterator.hasNext()) {
Plugin plugin = iterator.next();
iterator.remove();
CleanupHandle cleanupHandle = cleanupHandles.remove(plugin);
if (cleanupHandle != null) {
cleanupHandle.cleanup();
}
}
return toCleanup.size();
}
private void cleanInBackground() {
int cnt = toCleanup.size();
if (0 < cnt) {
cleaner.get().clean(cnt);
}
}
private String getExtension(String name) {
int ext = name.lastIndexOf('.');
return 0 < ext ? name.substring(ext) : "";
}
private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
throws InvalidPluginException {
String pluginName = srcPlugin.getFileName().toString();
if (isUiPlugin(pluginName)) {
return loadJsPlugin(name, srcPlugin, snapshot);
} else if (serverPluginFactory.handles(srcPlugin)) {
return loadServerPlugin(srcPlugin, snapshot);
} else {
throw new InvalidPluginException(
String.format("Unsupported plugin type: %s", srcPlugin.getFileName()));
}
}
private Path getPluginDataDir(String name) {
return dataDir.resolve(name);
}
private String getPluginCanonicalWebUrl(String name) {
String canonicalWebUrl = urlProvider.get();
if (Strings.isNullOrEmpty(canonicalWebUrl)) {
return "/plugins/" + name;
}
String url =
String.format(
"%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name);
return url;
}
private Plugin loadJsPlugin(String name, Path srcJar, FileSnapshot snapshot) {
return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
}
private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot)
throws InvalidPluginException {
String name = serverPluginFactory.getPluginName(scriptFile);
return serverPluginFactory.get(
scriptFile,
snapshot,
new PluginDescription(
pluginUserFactory.create(name),
getPluginCanonicalWebUrl(name),
getPluginDataDir(name),
gerritRuntime));
}
// Only one active plugin per plugin name can exist for each plugin name.
// Filter out disabled plugins and transform the multimap to a map
private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
for (String name : pluginPaths.keys()) {
for (Path pluginPath : pluginPaths.asMap().get(name)) {
if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
assert !activePlugins.containsKey(name);
activePlugins.put(name, pluginPath);
}
}
}
return activePlugins;
}
// Scan the $site_path/plugins directory and fetch all files and directories.
// The Key in returned multimap is the plugin name initially assigned from its filename.
// Values are the files. Plugins can optionally provide their name in MANIFEST file.
// If multiple plugin files provide the same plugin name, then only
// the first plugin remains active and all other plugins with the same
// name are disabled.
//
// NOTE: Bear in mind that the plugin name can be reassigned after load by the
// Server plugin provider.
public SetMultimap<String, Path> prunePlugins(Path pluginsDir) {
List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir);
SetMultimap<String, Path> map;
map = asMultimap(pluginPaths);
for (String plugin : map.keySet()) {
Collection<Path> files = map.asMap().get(plugin);
if (files.size() == 1) {
continue;
}
// retrieve enabled plugins
Iterable<Path> enabled = filterDisabledPlugins(files);
// If we have only one (the winner) plugin, nothing to do
if (!Iterables.skip(enabled, 1).iterator().hasNext()) {
continue;
}
Path winner = Iterables.getFirst(enabled, null);
assert winner != null;
// Disable all loser plugins by renaming their file names to
// "file.disabled" and replace the disabled files in the multimap.
List<Path> elementsToRemove = new ArrayList<>();
List<Path> elementsToAdd = new ArrayList<>();
for (Path loser : Iterables.skip(enabled, 1)) {
logger.atWarning().log(
"Plugin <%s> was disabled, because"
+ " another plugin <%s>"
+ " with the same name <%s> already exists",
loser, winner, plugin);
Path disabledPlugin = Path.of(loser + ".disabled");
elementsToAdd.add(disabledPlugin);
elementsToRemove.add(loser);
try {
Files.move(loser, disabledPlugin);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Failed to fully disable plugin %s", loser);
}
}
Iterables.removeAll(files, elementsToRemove);
Iterables.addAll(files, elementsToAdd);
}
return map;
}
private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
try {
return PluginUtil.listPlugins(pluginsDir);
} catch (IOException e) {
logger.atSevere().withCause(e).log("Cannot list %s", pluginsDir.toAbsolutePath());
return ImmutableList.of();
}
}
private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
}
@Nullable
public String getGerritPluginName(Path srcPath) {
String fileName = srcPath.getFileName().toString();
if (isUiPlugin(fileName)) {
return fileName.substring(0, fileName.lastIndexOf('.'));
}
if (serverPluginFactory.handles(srcPath)) {
return serverPluginFactory.getPluginName(srcPath);
}
return null;
}
private SetMultimap<String, Path> asMultimap(List<Path> plugins) {
SetMultimap<String, Path> map = LinkedHashMultimap.create();
for (Path srcPath : plugins) {
map.put(getPluginName(srcPath), srcPath);
}
return map;
}
private boolean isUiPlugin(String name) {
return isPlugin(name, "js");
}
private boolean isPlugin(String fileName, String ext) {
String fullExt = "." + ext;
return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled");
}
private void checkRemoteInstall() throws PluginInstallException {
if (!isRemoteAdminEnabled()) {
throw new PluginInstallException("remote installation is disabled");
}
}
}