diff --git a/mauro-api/docker/all/micronaut/micronaut-startup.sh b/mauro-api/docker/all/micronaut/micronaut-startup.sh index 562b42cd4..c695f736a 100644 --- a/mauro-api/docker/all/micronaut/micronaut-startup.sh +++ b/mauro-api/docker/all/micronaut/micronaut-startup.sh @@ -1,9 +1,20 @@ #!/usr/bin/env bash set -e +ADD_PLUGINS="true" + +if [ "${PLUGINS_IS_MOUNTED}" == "true" ]; +then + if [ "$(ls -1 /home/app/plugins)" != "" ]; + then + echo "There are persisted plugins." + ADD_PLUGINS="false" + fi +fi + if [ -e /opt/init/micronaut ]; then - mkdir -p /home/app/plugins + mkdir -p /home/app/plugins || true pushd /opt/init/micronaut shopt -s nullglob @@ -20,8 +31,11 @@ then fi ;; *.jar) - echo "Adding ${f} as plugin" - cp -pf ${f} /home/app/plugins/. + if [ "${ADD_PLUGINS}" == "true" ]; + then + echo "Adding ${f} as plugin" + cp -pf ${f} /home/app/plugins/. + fi ;; *) echo "Copying ${f} to micronaut resources" @@ -74,5 +88,5 @@ echo "Starting Micronaut..." cd /home/app # Give the directory and contents to the micronaut user chown -R micronaut:micronaut /home/app -echo ${JAVA_BIN} "${JAVA_OPTS}" -cp "/home/app/application.jar" "${APPLICATION_MAIN_CLASS}" -gosu micronaut ${JAVA_BIN} ${JAVA_OPTS} -cp /home/app/application.jar "${APPLICATION_MAIN_CLASS}" +echo ${JAVA_BIN} "${JAVA_OPTS}" -cp "/home/app/application.jar" -DPLUGINS_IS_MOUNTED=${PLUGINS_IS_MOUNTED} "${APPLICATION_MAIN_CLASS}" +gosu micronaut ${JAVA_BIN} ${JAVA_OPTS} -cp /home/app/application.jar -DPLUGINS_IS_MOUNTED=${PLUGINS_IS_MOUNTED} "${APPLICATION_MAIN_CLASS}" diff --git a/mauro-api/docker/all/startup/docker-environment.sh b/mauro-api/docker/all/startup/docker-environment.sh index 90cde41ab..9d6beb42c 100644 --- a/mauro-api/docker/all/startup/docker-environment.sh +++ b/mauro-api/docker/all/startup/docker-environment.sh @@ -27,3 +27,17 @@ echo "Detected ${CPU_COUNT} cores" export DOCKER_SUBNET="$(ip -o -4 addr show 2>/dev/null | awk '/scope global/ {split($4,a,"/");split(a[1],b,".");printf "%d.%d.%d.0/%s\n",b[1],b[2],b[3],a[2];exit}')" echo "Docker subnet ${DOCKER_SUBNET}" + +if [ -e /home/app/plugins ]; +then + MOUNTED_PLUGINS_AT=$(df -T "/home/app/plugins" | awk 'NR==2 {print $NF}') + + if [ "${MOUNTED_PLUGINS_AT}" = "/" ]; + then + export PLUGINS_IS_MOUNTED="false" + else + export PLUGINS_IS_MOUNTED="true" + fi +else + export PLUGINS_IS_MOUNTED="false" +fi diff --git a/mauro-api/docker/noDB/micronaut/micronaut-startup.sh b/mauro-api/docker/noDB/micronaut/micronaut-startup.sh index 92debc3e1..d72ca7807 100644 --- a/mauro-api/docker/noDB/micronaut/micronaut-startup.sh +++ b/mauro-api/docker/noDB/micronaut/micronaut-startup.sh @@ -1,9 +1,20 @@ #!/usr/bin/env bash set -e +ADD_PLUGINS="true" + +if [ "${PLUGINS_IS_MOUNTED}" == "true" ]; +then + if [ "$(ls -1 /home/app/plugins)" != "" ]; + then + echo "There are persisted plugins." + ADD_PLUGINS="false" + fi +fi + if [ -e /opt/init/micronaut ]; then - mkdir -p /home/app/plugins + mkdir -p /home/app/plugins || true pushd /opt/init/micronaut shopt -s nullglob @@ -20,8 +31,11 @@ then fi ;; *.jar) - echo "Adding ${f} as plugin" - cp -pf ${f} /home/app/plugins/. + if [ "${ADD_PLUGINS}" == "true" ]; + then + echo "Adding ${f} as plugin" + cp -pf ${f} /home/app/plugins/. + fi ;; *) echo "Copying ${f} to micronaut resources" @@ -74,5 +88,5 @@ echo "Starting Micronaut..." cd /home/app # Give the directory and contents to the micronaut user chown -R micronaut:micronaut /home/app -echo ${JAVA_BIN} "${JAVA_OPTS}" -cp "/home/app/application.jar" "${APPLICATION_MAIN_CLASS}" -gosu micronaut ${JAVA_BIN} ${JAVA_OPTS} -cp /home/app/application.jar "${APPLICATION_MAIN_CLASS}" +echo ${JAVA_BIN} "${JAVA_OPTS}" -cp "/home/app/application.jar" -DPLUGINS_IS_MOUNTED=${PLUGINS_IS_MOUNTED} "${APPLICATION_MAIN_CLASS}" +gosu micronaut ${JAVA_BIN} ${JAVA_OPTS} -cp /home/app/application.jar -DPLUGINS_IS_MOUNTED=${PLUGINS_IS_MOUNTED} "${APPLICATION_MAIN_CLASS}" diff --git a/mauro-api/docker/noDB/startup/docker-environment.sh b/mauro-api/docker/noDB/startup/docker-environment.sh index 90cde41ab..9d6beb42c 100644 --- a/mauro-api/docker/noDB/startup/docker-environment.sh +++ b/mauro-api/docker/noDB/startup/docker-environment.sh @@ -27,3 +27,17 @@ echo "Detected ${CPU_COUNT} cores" export DOCKER_SUBNET="$(ip -o -4 addr show 2>/dev/null | awk '/scope global/ {split($4,a,"/");split(a[1],b,".");printf "%d.%d.%d.0/%s\n",b[1],b[2],b[3],a[2];exit}')" echo "Docker subnet ${DOCKER_SUBNET}" + +if [ -e /home/app/plugins ]; +then + MOUNTED_PLUGINS_AT=$(df -T "/home/app/plugins" | awk 'NR==2 {print $NF}') + + if [ "${MOUNTED_PLUGINS_AT}" = "/" ]; + then + export PLUGINS_IS_MOUNTED="false" + else + export PLUGINS_IS_MOUNTED="true" + fi +else + export PLUGINS_IS_MOUNTED="false" +fi diff --git a/mauro-api/src/main/groovy/org/maurodata/controller/admin/AdminController.groovy b/mauro-api/src/main/groovy/org/maurodata/controller/admin/AdminController.groovy index 52a0ab23e..e4754dcc5 100644 --- a/mauro-api/src/main/groovy/org/maurodata/controller/admin/AdminController.groovy +++ b/mauro-api/src/main/groovy/org/maurodata/controller/admin/AdminController.groovy @@ -4,6 +4,7 @@ import org.maurodata.api.Paths import org.maurodata.api.admin.AdminApi import org.maurodata.audit.Audit import org.maurodata.plugin.MauroPluginDTO +import org.maurodata.service.plugin.PluginRepositoryService import groovy.transform.CompileStatic import io.micronaut.http.HttpStatus @@ -14,6 +15,7 @@ import io.micronaut.http.annotation.Post import io.micronaut.http.exceptions.HttpStatusException import io.micronaut.runtime.EmbeddedApplication import io.micronaut.scheduling.TaskExecutors +import io.micronaut.scheduling.annotation.ExecuteOn import io.micronaut.security.annotation.Secured import io.micronaut.security.rules.SecurityRule import jakarta.inject.Inject @@ -22,7 +24,6 @@ import org.maurodata.domain.security.CatalogueUser import org.maurodata.persistence.security.EmailRepository import org.maurodata.plugin.MauroPluginService import org.maurodata.plugin.exporter.ModelExporterPlugin -import org.maurodata.plugin.exporter.ModelItemExporterPlugin import org.maurodata.plugin.importer.ImporterPlugin import org.maurodata.security.AccessControlService import org.maurodata.plugin.EmailPlugin @@ -47,6 +48,9 @@ class AdminController implements AdminApi { @Inject EmailService emailService + @Inject + PluginRepositoryService service + private final EmailRepository emailRepository @Inject @@ -98,6 +102,22 @@ class AdminController implements AdminApi { [] } + @Audit + @ExecuteOn(TaskExecutors.BLOCKING) + @Get(Paths.ADMIN_AVAILABLE_PROVIDERS_LIST) + List> available() { + accessControlService.checkAdministrator() + service.listAvailablePlugins() + } + + @Audit + @ExecuteOn(TaskExecutors.BLOCKING) + @Post(Paths.ADMIN_INSTALL_PROVIDER) + Map installPlugin(String plugin) { + accessControlService.checkAdministrator() + return service.installPlugin(plugin) + } + /** * This is new endpoint that can be used to test sending an email. You should provide a catalogue user with a * firstName, lastName, and emailAddress set. diff --git a/mauro-api/src/main/groovy/org/maurodata/service/plugin/PluginRepositoryService.groovy b/mauro-api/src/main/groovy/org/maurodata/service/plugin/PluginRepositoryService.groovy new file mode 100644 index 000000000..1ab6cb27e --- /dev/null +++ b/mauro-api/src/main/groovy/org/maurodata/service/plugin/PluginRepositoryService.groovy @@ -0,0 +1,317 @@ +package org.maurodata.service.plugin + +import org.maurodata.plugin.MauroPluginUtil + +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import groovy.xml.XmlSlurper +import groovy.xml.slurpersupport.GPathResult +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.exceptions.HttpStatusException +import jakarta.inject.Singleton + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.regex.Pattern + +@CompileStatic +@Singleton +@Slf4j +class PluginRepositoryService { + + private static final String REPO_BASE = "https://mauro-repository.com" + private static final String PLUGIN_PATH = "/libs-snapshot-local/org/maurodata/plugins/" + private static final String API_BASE = "${REPO_BASE}/api/maven/details${PLUGIN_PATH}" + + private final HttpClient httpClient + + PluginRepositoryService(@Client("/") HttpClient httpClient) { + this.httpClient = httpClient + } + + List> listAvailablePlugins() { + + List plugins = getPluginDirectories() + + plugins.collect {plugin -> + String version = getLatestVersion(plugin) + if (!version) return null + + String[] jarUrl = resolveJarUrl(plugin, version) + if (!jarUrl) return null + + [ + plugin : plugin, + version: version, + url : jarUrl[0] + ] as Map + }.findAll {it != null} + } + + private List getPluginDirectories() { + + String json = httpClient.toBlocking() + .retrieve(API_BASE) + + JsonSlurper slurper = new JsonSlurper() + + Map parsed = + (Map) slurper.parseText(json) + + List> files = + (List>) parsed.get("files") + + List directories = new ArrayList<>() + + for (Map file : files) { + + Object type = file.get("type") + Object name = file.get("name") + + if ("DIRECTORY".equals(type) && name instanceof String) { + directories.add((String) name) + } + } + + return directories + } + + private String getLatestVersion(String plugin) { + String metadataUrl = "${REPO_BASE}${PLUGIN_PATH}${plugin}/maven-metadata.xml" + + try { + String xml = httpClient.toBlocking().retrieve(metadataUrl) + + XmlSlurper slurper = new XmlSlurper() + GPathResult metadata = slurper.parseText(xml) + + GPathResult versioning = metadata.getProperty("versioning") as GPathResult + GPathResult latest = versioning.getProperty("latest") as GPathResult + + return latest.text() + } + catch (Exception ignored) { + return null + } + } + + private String[] resolveJarUrl(String plugin, String version) { + + String resolvedVersion = version + + if (version.endsWith("SNAPSHOT")) { + resolvedVersion = resolveSnapshotVersion(plugin, version) + if (resolvedVersion == null) return null + } + + String moduleFile = resolveModuleFilename(plugin, version, resolvedVersion) + if (moduleFile == null) return null + + return selectJarFromModule(plugin, version, resolvedVersion, moduleFile) + } + + private String resolveSnapshotVersion(String plugin, String version) { + + String metadataUrl = + "${REPO_BASE}${PLUGIN_PATH}${plugin}/${version}/maven-metadata.xml" + + try { + String xml = httpClient.toBlocking().retrieve(metadataUrl) + XmlSlurper slurper = new XmlSlurper() + GPathResult metadata = slurper.parseText(xml) + + GPathResult versioning = + (GPathResult) metadata.getProperty("versioning") + + GPathResult snapshotVersions = + (GPathResult) versioning.getProperty("snapshotVersions") + + GPathResult snapshotVersionList = + (GPathResult) snapshotVersions.getProperty("snapshotVersion") + + for (Object obj : snapshotVersionList) { + GPathResult entry = (GPathResult) obj + + GPathResult extensionResult = entry.getProperty("extension") as GPathResult + String extension = extensionResult.text() + + if ("jar".equals(extension)) { + + GPathResult valueResult = entry.getProperty("value") as GPathResult + return valueResult.text() + } + } + } + catch (Exception ignored) {} + + return null + } + + private static String resolveModuleFilename(String plugin, + String originalVersion, + String resolvedVersion) { + + if (!originalVersion.endsWith("SNAPSHOT")) { + return "${plugin}-${originalVersion}.module" + } + + return "${plugin}-${resolvedVersion}.module" + } + + private String[] selectJarFromModule(String plugin, + String originalVersion, + String resolvedVersion, + String moduleFile) { + + String moduleUrl = + "${REPO_BASE}${PLUGIN_PATH}${plugin}/${originalVersion}/${moduleFile}" + + try { + String moduleJson = httpClient.toBlocking().retrieve(moduleUrl) + + JsonSlurper slurper = new JsonSlurper() + Map parsed = + (Map) slurper.parseText(moduleJson) + + List> variants = + (List>) parsed.get("variants") + + Map selectedVariant = null + + for (String variantName : + ["shadowRuntimeElements", "runtimeElements", "apiElements"]) { + + for (Map variant : variants) { + if (variantName.equals(variant.get("name"))) { + selectedVariant = variant + break + } + } + if (selectedVariant != null) break + } + + if (selectedVariant == null) return null + + List> files = + (List>) selectedVariant.get("files") + + for (Map file : files) { + + Object nameObj = file.get("name") + Object urlObj = file.get("url") + + if (nameObj instanceof String && + urlObj instanceof String && + ((String) nameObj).endsWith(".jar")) { + + String fileName + if (originalVersion.endsWith("-SNAPSHOT")) { + String baseVersion = originalVersion.replace("-SNAPSHOT", "") + String timestampSuffix = resolvedVersion.replaceFirst(/^${baseVersion}-/, "") + fileName = (urlObj as String).replaceFirst(/-SNAPSHOT/, "-${timestampSuffix}") + } else { + fileName = urlObj as String + } + + + return [ + "${REPO_BASE}${PLUGIN_PATH}${plugin}/${originalVersion}/${fileName}", + "${REPO_BASE}${PLUGIN_PATH}${plugin}/${originalVersion}/${urlObj}", + plugin + ] as String[] + } + } + } + catch (Exception ignored) {} + + return null + } + + Map installPlugin(String plugin) { + String version = getLatestVersion(plugin) + if (!version) { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Plugin not found: ' + plugin) + } + + String[] jarUrl = resolveJarUrl(plugin, version) + if (!jarUrl) { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Could not resolve plugin to a file: ' + plugin) + } + + final Path pluginsDirPath = MauroPluginUtil.pluginsDirPath + + if (pluginsDirPath == null) { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Missing plugins directory') + } + + if (!Files.exists(pluginsDirPath)) { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Missing plugins directory: ' + pluginsDirPath.toString()) + } + + final String PLUGINS_IS_MOUNTED = System.getenv("PLUGINS_IS_MOUNTED") + if (PLUGINS_IS_MOUNTED != null && PLUGINS_IS_MOUNTED == 'false') { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Plugins directory is not using persistent storage') + } + + log.debug("Download ${jarUrl[0]} into ${pluginsDirPath} as ${jarUrl[1]}, removing any other ${jarUrl[2]}") + return downloadPluginJar(jarUrl, pluginsDirPath) + } + + private static Map downloadPluginJar(String[] jarUrl, Path dirPath) { + URL downloadUrl = new URL(jarUrl[0]) + URL saveUrl = new URL(jarUrl[1]) + String plugin = jarUrl[2] + + String path = saveUrl.getPath() + int lastSlash = path.lastIndexOf('/') + if (lastSlash == -1) { + throw new HttpStatusException(HttpStatus.UNPROCESSABLE_ENTITY, 'Malformed URL. Missing path: ' + saveUrl.toString()) + } + + String fileName = path.substring(lastSlash + 1) + Path filePath = dirPath.resolve(fileName) + + downloadUrl.withInputStream {input -> + Files.copy(input, filePath, StandardCopyOption.REPLACE_EXISTING) + } + + // Find all plugin files with the same naming + + Pattern pattern = Pattern.compile( + '^' + Pattern.quote(plugin) + '-\\d+\\.\\d+\\.\\d+.*\\.jar$' + ) + + List matches = new ArrayList<>() + + File[] files = dirPath.toFile().listFiles() + + if (files != null && files.length > 0) { + for (File file : files) { + if (file.isFile()) { + String name = file.getName() + if (pattern.matcher(name).matches() && !name.equalsIgnoreCase(fileName)) { + matches.add(file) + } + } + } + } + + matches.forEach {File toDelete -> + boolean deleted = toDelete.delete() + if (!deleted) { + throw new IOException("Failed to remove plugin file: " + toDelete.getName()) + } + } + + return [ + installed: plugin, + from : downloadUrl.toString(), + as : fileName, + removed : matches.collect {File removed -> removed.getName()} as List + ] as Map + } +} diff --git a/mauro-client/src/main/groovy/org/maurodata/api/Paths.groovy b/mauro-client/src/main/groovy/org/maurodata/api/Paths.groovy index 498a0a890..37777ad2a 100644 --- a/mauro-client/src/main/groovy/org/maurodata/api/Paths.groovy +++ b/mauro-client/src/main/groovy/org/maurodata/api/Paths.groovy @@ -18,6 +18,8 @@ interface Paths { String ADMIN_EMAILS = '/api/admin/emails' String ADMIN_EMAIL_RETRY = '/api/admin/emails/{emailId}/retry' String ADMIN_SHUTDOWN = '/api/admin/shutdown' + String ADMIN_AVAILABLE_PROVIDERS_LIST = '/api/admin/providers/available' + String ADMIN_INSTALL_PROVIDER = '/api/admin/provider/install/{plugin}' /* * ClassificationSchemeApi diff --git a/mauro-client/src/main/groovy/org/maurodata/api/admin/AdminApi.groovy b/mauro-client/src/main/groovy/org/maurodata/api/admin/AdminApi.groovy index 230ab1a0c..82bd582e1 100644 --- a/mauro-client/src/main/groovy/org/maurodata/api/admin/AdminApi.groovy +++ b/mauro-client/src/main/groovy/org/maurodata/api/admin/AdminApi.groovy @@ -59,4 +59,10 @@ interface AdminApi { @Post(Paths.ADMIN_SHUTDOWN) Boolean shutDown() + + @Get(Paths.ADMIN_AVAILABLE_PROVIDERS_LIST) + List available() + + @Post(Paths.ADMIN_INSTALL_PROVIDER) + Map installPlugin(String plugin) } diff --git a/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroApplicationContextConfigurer.groovy b/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroApplicationContextConfigurer.groovy index 3ef36fed0..40a4b475e 100644 --- a/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroApplicationContextConfigurer.groovy +++ b/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroApplicationContextConfigurer.groovy @@ -47,21 +47,7 @@ class MauroApplicationContextConfigurer implements ApplicationContextConfigurer @Override void configure(ApplicationContext applicationContext) { - URL url = getClass().getProtectionDomain().getCodeSource().getLocation() - Path baseDirPath = Paths.get(url.toURI()) - - final Path pluginsDirPath - - - if (Files.isDirectory(baseDirPath)) { - // Application is in an IDE - pluginsDirPath = findProjectRoot(baseDirPath)?.resolve("plugins") - log.debug("Application IDE Plugin base directory ${baseDirPath}") - } else { - // Application is in a packaged jar - pluginsDirPath = findAppRoot(baseDirPath.getParent())?.resolve("plugins") - log.debug("Application Plugin base directory ${baseDirPath}") - } + final Path pluginsDirPath = MauroPluginUtil.pluginsDirPath if (pluginsDirPath == null) { log.warn("Failed to locate plugins directory") @@ -73,31 +59,6 @@ class MauroApplicationContextConfigurer implements ApplicationContextConfigurer } } - private static Path findProjectRoot(final Path start) { - Path current = start - while (current != null) { - if (Files.exists(current.resolve("build.gradle")) || - Files.exists(current.resolve("pom.xml"))) { - return current - } - current = current.getParent() - } - return null - } - - private static Path findAppRoot(final Path start) { - Path current = start - while (current != null) { - if (Files.exists(current.resolve("resources")) || - Files.exists(current.resolve("plugins")) - ) { - return current - } - current = current.getParent() - } - return null - } - private void loadPlugins(final Path pluginsDirPath, final ApplicationContext applicationContext) { log.debug("Loading plugins") try (DirectoryStream files = Files.newDirectoryStream(pluginsDirPath)) { @@ -155,14 +116,14 @@ class MauroApplicationContextConfigurer implements ApplicationContextConfigurer ) for (ServiceDefinition definition : loader) { - if (!definition.isPresent()) { continue } + if (!definition.isPresent()) {continue} String className = definition.getName() try { Class klass = Class.forName(className, false, pluginLoader) - if(klass.getClassLoader() != pluginLoader) { continue } + if (klass.getClassLoader() != pluginLoader) {continue} BeanDefinitionReference ref = definition.load() @@ -172,12 +133,12 @@ class MauroApplicationContextConfigurer implements ApplicationContextConfigurer ((BeanDefinitionRegistry) applicationContext).registerBeanDefinition(beanDefinition as RuntimeBeanDefinition) - if(Profile.class.isAssignableFrom(beanType)) { + if (Profile.class.isAssignableFrom(beanType)) { log.info("Profile: ${ref}") - } else if(MauroPlugin.class.isAssignableFrom(beanType)) { + } else if (MauroPlugin.class.isAssignableFrom(beanType)) { log.info("MauroPlugin: ${ref}") } else { - if (ref.hasAnnotation(Controller) ) { + if (ref.hasAnnotation(Controller)) { log.info("Controller: ${ref}") Method[] methods = beanType.getMethods() methods.each {Method method -> diff --git a/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroPluginUtil.groovy b/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroPluginUtil.groovy new file mode 100644 index 000000000..00f052a22 --- /dev/null +++ b/mauro-domain/src/main/groovy/org/maurodata/plugin/MauroPluginUtil.groovy @@ -0,0 +1,57 @@ +package org.maurodata.plugin + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +@CompileStatic +@Slf4j +class MauroPluginUtil { + + private static Path findProjectRoot(final Path start) { + Path current = start + while (current != null) { + if (Files.exists(current.resolve("build.gradle")) || + Files.exists(current.resolve("pom.xml"))) { + return current + } + current = current.getParent() + } + return null + } + + private static Path findAppRoot(final Path start) { + Path current = start + while (current != null) { + if (Files.exists(current.resolve("resources")) || + Files.exists(current.resolve("plugins")) + ) { + return current + } + current = current.getParent() + } + return null + } + + static Path getPluginsDirPath() throws URISyntaxException { + URL url = MauroPluginUtil.getProtectionDomain().getCodeSource().getLocation() + Path baseDirPath = Paths.get(url.toURI()) + + final Path pluginsDirPath + + if (Files.isDirectory(baseDirPath)) { + // Application is in an IDE + pluginsDirPath = findProjectRoot(baseDirPath)?.resolve("plugins") + log.debug("Application IDE Plugin base directory ${baseDirPath}") + } else { + // Application is in a packaged jar + pluginsDirPath = findAppRoot(baseDirPath.getParent())?.resolve("plugins") + log.debug("Application Plugin base directory ${baseDirPath}") + } + + return pluginsDirPath + } +}