/* ================================================================== * OBRPluginService.java - Apr 21, 2014 2:36:06 PM * * Copyright 2007-2014 SolarNetwork.net Dev Team * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 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 * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA * 02111-1307 USA * ================================================================== */ package net.solarnetwork.node.setup.obr; import java.io.File; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.Version; import org.osgi.service.obr.Repository; import org.osgi.service.obr.RepositoryAdmin; import org.osgi.service.obr.Requirement; import org.osgi.service.obr.Resolver; import org.osgi.service.obr.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; import net.solarnetwork.node.backup.BackupManager; import net.solarnetwork.node.settings.SettingSpecifier; import net.solarnetwork.node.settings.SettingSpecifierProvider; import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier; import net.solarnetwork.node.setup.BundlePlugin; import net.solarnetwork.node.setup.LocalizedPlugin; import net.solarnetwork.node.setup.Plugin; import net.solarnetwork.node.setup.PluginProvisionException; import net.solarnetwork.node.setup.PluginProvisionStatus; import net.solarnetwork.node.setup.PluginQuery; import net.solarnetwork.node.setup.PluginService; import net.solarnetwork.support.SearchFilter; import net.solarnetwork.support.SearchFilter.CompareOperator; import net.solarnetwork.support.SearchFilter.LogicOperator; import net.solarnetwork.util.OptionalService; import net.solarnetwork.util.StringUtils; /** * OBR implementation of {@link PluginService}, using the Apache Felix OBR * implementation. * * <p> * The configurable properties of this class are: * </p> * * <dl class="class-properties"> * <dt>bundleContext</dt> * <dd>The OSGi {@link BundleContext} to enable installing/removing * plugins.</dd> * * <dt>repositoryAdmin</dt> * <dd>The {@link RepositoryAdmin} to manage all OBR actions with.</dd> * * <dt>repositories</dt> * <dd>The collection of {@link OBRRepository} instances managed by SolarNode. * For each configured {@link OBRRepository} this service will register an * associated {@code org.osgi.service.obr.Repository} instance with the * {@code repositoryAdmin} service.</dd> * * <dt>restrictingSymbolicNameFilters</dt> * <dd>An optional list of filters to include when * {@link #availablePlugins(PluginQuery, Locale)} or * {@link #installedPlugins(Locale)} are called that restricts the results to * those <em>starting with</em> this value. The idea here is to provide a way to * focus the results on just a core subset of all plugins so the results are * more relevant to users. The filters are <b>or-ed</b> together, so any filter * that matches allows the matching bundle to be used.</dd> * * <dt>exclusionSymbolicNameFilters</dt> * <dd>An optional list of symbolic bundle name <em>substrings</em> to exclude * from the results of {@link #availablePlugins(PluginQuery, Locale)}. The idea * here is to hide low-level plugins that would automatically be included by * user-facing plugins, making the results more relevant to users.</dd> * * <dt>backupManager</dt> * <dd>An optional {@link BackupManager} service. If configured, then automatic * backups will be initiated before any provisioning operation.</dd> * </dl> * * <dt>provisionTaskStatusMinimumKeepSeconds</dt> * <dd>The minimum number of seconds to hold provision tasks in memory after the * task has completed, to support the * {@link #statusForProvisioningOperation(String, Locale)} method. Defaults to * 10 minutes.</dd> * * @author matt * @version 1.0 */ public class OBRPluginService implements PluginService, SettingSpecifierProvider { private static final String PREVIEW_PROVISION_ID = "preview"; public static final String[] DEFAULT_RESTRICTING_SYMBOLIC_NAME_FILTER = { "net.solarnetwork.node" }; private static final String[] DEFAULT_EXCLUSION_SYMBOLIC_NAME_FILTERS = { ".mock", ".test", "net.solarnetwork.node.dao.", "net.solarnetwork.node.hw." }; private static final String[] DEFAULT_CORE_FEATURE_EXPRESSIONS = { "net\\.solarnetwork\\.node", "net\\.solarnetwork\\.node\\.dao(?:\\..*)*", "net\\.solarnetwork\\.node\\.setup(?:\\..*)*", "net\\.solarnetwork\\.node\\.settings(?:\\..*)*" }; private RepositoryAdmin repositoryAdmin; private BundleContext bundleContext; private List<OBRRepository> repositories; private String downloadPath = "app/main"; private String[] restrictingSymbolicNameFilters = DEFAULT_RESTRICTING_SYMBOLIC_NAME_FILTER; private String[] exclusionSymbolicNameFilters = DEFAULT_EXCLUSION_SYMBOLIC_NAME_FILTERS; private Pattern[] coreFeatureSymbolicNamePatterns = StringUtils .patterns(DEFAULT_CORE_FEATURE_EXPRESSIONS, 0); private OptionalService<BackupManager> backupManager; private long provisionTaskStatusMinimumKeepSeconds = 60L * 10L; // 10min private MessageSource messageSource; private TaskCleaner cleanerTask; private final ConcurrentMap<URL, OBRRepositoryStatus> repoStatusMap = new ConcurrentHashMap<URL, OBRRepositoryStatus>( 4); private final ConcurrentMap<String, OBRProvisionTask> provisionTaskMap = new ConcurrentHashMap<String, OBRProvisionTask>( 4); private final ExecutorService executorService = Executors.newSingleThreadExecutor(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final Logger log = LoggerFactory.getLogger(getClass()); private class TaskCleaner implements Runnable { @Override public void run() { Iterator<OBRProvisionTask> itr; final long now = System.currentTimeMillis(); final long min = provisionTaskStatusMinimumKeepSeconds * 1000L; for ( itr = provisionTaskMap.values().iterator(); itr.hasNext(); ) { OBRProvisionTask task = itr.next(); if ( task.getFuture().isDone() && (now - task.getStatus().getCreationDate()) > min ) { log.debug("Cleaning out old provision task status {}", task.getStatus().getProvisionID()); itr.remove(); } } } } @Override protected void finalize() throws Throwable { // just in case... clean up our timers destroy(); super.finalize(); } /** * Call to initialize the service, after all properties have been * configured. */ public void init() { if ( repositories != null && repositoryAdmin != null ) { for ( OBRRepository repo : repositories ) { configureOBRRepository(repo); } } } /** * Call to destory this service, cleaning up any resources. */ public void destroy() { executorService.shutdownNow(); scheduler.shutdownNow(); } @Override public synchronized void refreshAvailablePlugins() { if ( repositoryAdmin == null ) { return; } // by examining the source, found that to "refresh" we really just // add the same URL again. To pick up changes applied to the repository URLs, // however, we'll just remove all currently configured URLs and then add // the currently configured ones. Repository[] repos = repositoryAdmin.listRepositories(); if ( repos == null ) { return; } for ( Repository r : repos ) { try { repositoryAdmin.removeRepository(r.getURL()); } catch ( Exception e ) { log.warn("Unable to refresh OBR repository {}", r.getURL()); } } repoStatusMap.clear(); if ( repositories != null ) { for ( OBRRepository r : repositories ) { configureOBRRepository(r); } } } private OBRRepositoryStatus getOrCreateStatus(URL url) { synchronized ( repoStatusMap ) { OBRRepositoryStatus status = repoStatusMap.get(url); if ( status == null ) { status = new OBRRepositoryStatus(); status.setRepositoryURL(url); repoStatusMap.put(url, status); } return status; } } private synchronized void configureOBRRepository(OBRRepository repository) { if ( repository == null || repositoryAdmin == null ) { return; } Set<URL> configuredURLs = new HashSet<URL>(); for ( Repository repo : repositoryAdmin.listRepositories() ) { configuredURLs.add(repo.getURL()); } URL repoURL = repository.getURL(); if ( repoURL != null && !configuredURLs.contains(repoURL) ) { try { log.info("Adding OBR plugin repository {}", repoURL); repositoryAdmin.addRepository(repoURL); OBRRepositoryStatus status = getOrCreateStatus(repoURL); status.setConfigured(true); status.setException(null); } catch ( Exception e ) { OBRRepositoryStatus status = getOrCreateStatus(repoURL); status.setConfigured(false); status.setException(e); } } } @Override public List<Plugin> availablePlugins(PluginQuery query, Locale locale) { if ( repositoryAdmin == null ) { return Collections.emptyList(); } Resource[] resources = repositoryAdmin.discoverResources(getOBRFilter(query)); if ( resources == null || resources.length < 1 ) { return Collections.emptyList(); } if ( query.isLatestVersionOnly() ) { resources = getLatestVersions(resources); } // we need to know if we should includes a (normally) excluded plugin that is upgradable final Map<String, Bundle> installedBundles = installedBundles(); final List<Plugin> plugins = new ArrayList<Plugin>(resources.length); RLOOP: for ( Resource r : resources ) { final String uid = r.getSymbolicName(); if ( exclusionSymbolicNameFilters != null && exclusionSymbolicNameFilters.length > 0 ) { for ( String exclude : exclusionSymbolicNameFilters ) { if ( uid.contains(exclude) ) { // this plugin is normally excluded... but is it actually installed, and upgradable? Bundle installed = installedBundles.get(uid); if ( installed == null || r.getVersion().compareTo(installed.getVersion()) < 1 ) { // it's not installed, or the installed version is up to date, so exclude it continue RLOOP; } } } } Plugin p = new OBRResourcePlugin(r, (StringUtils.matches(coreFeatureSymbolicNamePatterns, uid) != null)); if ( locale != null ) { p = new LocalizedPlugin(p, locale); } plugins.add(p); } // sort the results lexically by their names Collections.sort(plugins, new Comparator<Plugin>() { @Override public int compare(Plugin o1, Plugin o2) { return o1.getInfo().getName().compareToIgnoreCase(o2.getInfo().getName()); } }); return plugins; } private Map<String, Bundle> installedBundles() { Bundle[] bundles = bundleContext.getBundles(); if ( bundles == null || bundles.length < 1 ) { return Collections.emptyMap(); } Map<String, Bundle> installedBundles = new HashMap<String, Bundle>(bundles.length); for ( Bundle b : bundles ) { String uid = b.getSymbolicName(); if ( uid != null && restrictingSymbolicNameFilters != null && restrictingSymbolicNameFilters.length > 0 ) { boolean allowed = false; for ( String filter : restrictingSymbolicNameFilters ) { if ( uid.startsWith(filter) ) { allowed = true; break; } } if ( !allowed ) { continue; } } installedBundles.put(uid, b); } return installedBundles; } @Override public List<Plugin> installedPlugins(Locale locale) { if ( bundleContext == null ) { return Collections.emptyList(); } Map<String, Bundle> installedBundles = installedBundles(); List<Plugin> results = new ArrayList<Plugin>(installedBundles.size()); for ( Bundle b : installedBundles.values() ) { Plugin p = new BundlePlugin(b, (StringUtils.matches(coreFeatureSymbolicNamePatterns, b.getSymbolicName()) != null)); if ( locale != null ) { p = new LocalizedPlugin(p, locale); } results.add(p); } return results; } private String getOBRFilter(final PluginQuery query) { Map<String, Object> filter = new LinkedHashMap<String, Object>(4); if ( query != null && query.getSimpleQuery() != null && query.getSimpleQuery().length() > 0 ) { SearchFilter id = new SearchFilter(Resource.SYMBOLIC_NAME, query.getSimpleQuery(), CompareOperator.SUBSTRING); filter.put("id", id); } if ( restrictingSymbolicNameFilters != null && restrictingSymbolicNameFilters.length > 0 ) { Map<String, Object> restrictions = new LinkedHashMap<String, Object>( restrictingSymbolicNameFilters.length); for ( String oneFilter : restrictingSymbolicNameFilters ) { SearchFilter restrict = new SearchFilter(Resource.SYMBOLIC_NAME, oneFilter, CompareOperator.SUBSTRING_AT_START); restrictions.put(oneFilter, restrict); } if ( restrictions.size() > 1 ) { filter.put("restrict", new SearchFilter(restrictions, LogicOperator.OR)); } else { filter.putAll(restrictions); } } String result = new SearchFilter(filter, LogicOperator.AND).asLDAPSearchFilterString(); if ( result == null || result.length() < 1 ) { return null; } return result; } private String generateProvisionID() { return UUID.randomUUID().toString(); } private void saveProvisionTask(OBRProvisionTask task) { provisionTaskMap.put(task.getStatus().getProvisionID(), task); } @Override public PluginProvisionStatus removePlugins(Collection<String> uids, Locale locale) { List<Plugin> pluginsToRemove = new ArrayList<Plugin>(uids.size()); Bundle[] bundles = bundleContext.getBundles(); for ( Bundle b : bundles ) { String uid = b.getSymbolicName(); if ( uids.contains(uid) ) { Plugin p = new BundlePlugin(b, (StringUtils.matches(coreFeatureSymbolicNamePatterns, b.getSymbolicName()) != null)); pluginsToRemove.add(p); } } OBRPluginProvisionStatus status = new OBRPluginProvisionStatus(generateProvisionID()); status.setPluginsToRemove(pluginsToRemove); OBRProvisionTask task = new OBRProvisionTask(bundleContext, status, new File(downloadPath), (backupManager != null ? backupManager.service() : null)); saveProvisionTask(task); Future<OBRPluginProvisionStatus> future = executorService.submit(task); task.setFuture(future); startCleanerTaskIfNeeded(); return new OBRPluginProvisionStatus(status); } @Override public synchronized PluginProvisionStatus installPlugins(Collection<String> uids, Locale locale) { OBRPluginProvisionStatus status = resolveInstall(uids, locale, generateProvisionID()); OBRProvisionTask task = new OBRProvisionTask(bundleContext, status, new File(downloadPath), (backupManager != null ? backupManager.service() : null)); saveProvisionTask(task); Future<OBRPluginProvisionStatus> future = executorService.submit(task); task.setFuture(future); startCleanerTaskIfNeeded(); // return a copy, like a snapshot, so we don't deal with threading return new OBRPluginProvisionStatus(status); } private void startCleanerTaskIfNeeded() { if ( cleanerTask == null ) { cleanerTask = new TaskCleaner(); log.debug("Scheduling TaskCleaner thread at fixed delay {} seconds", provisionTaskStatusMinimumKeepSeconds); scheduler.scheduleWithFixedDelay(cleanerTask, provisionTaskStatusMinimumKeepSeconds, provisionTaskStatusMinimumKeepSeconds, TimeUnit.SECONDS); } } @Override public PluginProvisionStatus statusForProvisioningOperation(String provisionID, Locale locale) { OBRProvisionTask task = provisionTaskMap.get(provisionID); return (task == null ? null : new OBRPluginProvisionStatus(task.getStatus())); } private SearchFilter filterForPluginUIDs(Collection<String> uids) { assert uids != null; Map<String, Object> f = new LinkedHashMap<String, Object>(uids.size()); for ( String uid : uids ) { f.put(uid, new SearchFilter(Resource.SYMBOLIC_NAME, uid, CompareOperator.EQUAL)); } return new SearchFilter(f, LogicOperator.OR); } @Override public PluginProvisionStatus previewInstallPlugins(Collection<String> uids, Locale locale) { return resolveInstall(uids, locale, PREVIEW_PROVISION_ID); } private Resource[] getLatestVersions(Resource[] resources) { Map<String, Resource> latestVersions = new LinkedHashMap<String, Resource>(resources.length); for ( Resource r : resources ) { Resource seenResource = latestVersions.get(r.getSymbolicName()); Version seenVersion = (seenResource == null ? null : seenResource.getVersion()); if ( seenVersion != null && seenVersion.compareTo(r.getVersion()) < 0 ) { // newer version... so remove older one from results latestVersions.remove(r.getSymbolicName()); } else if ( seenVersion != null ) { // skip older version continue; } latestVersions.put(r.getSymbolicName(), r); } return latestVersions.values().toArray(new Resource[latestVersions.size()]); } private OBRPluginProvisionStatus resolveInstall(Collection<String> uids, Locale locale, String provisionID) { if ( uids == null || uids.size() < 1 || repositoryAdmin == null ) { return new OBRPluginProvisionStatus(PREVIEW_PROVISION_ID); } // get a list of the Resources we want to install SearchFilter filter = filterForPluginUIDs(uids); Resource[] resources = repositoryAdmin.discoverResources(filter.asLDAPSearchFilterString()); if ( resources == null || resources.length < 1 ) { return new OBRPluginProvisionStatus(PREVIEW_PROVISION_ID); } // filter out duplicate, older versions resources = getLatestVersions(resources); // resolve the complete list of resources we need Resolver resolver = repositoryAdmin.resolver(); for ( Resource r : resources ) { resolver.add(r); } final boolean success = resolver.resolve(); if ( !success ) { StringBuilder buf = new StringBuilder(); Requirement[] failures = resolver.getUnsatisfiedRequirements(); // TODO: l10n if ( failures != null && failures.length > 0 ) { for ( Requirement r : failures ) { if ( buf.length() > 0 ) { buf.append(", "); } buf.append(r.getName()); if ( r.getComment() != null && r.getComment().length() > 0 ) { buf.append(" (").append(r.getComment()).append(")"); } } if ( failures.length == 1 ) { buf.insert(0, "The following requirement is not satisfied: "); } else { buf.insert(0, "The following requirements are not satisfied: "); } } else { buf.append("Unknown error"); } throw new PluginProvisionException(buf.toString()); } Resource[] requiredResources = resolver.getRequiredResources(); List<Plugin> toInstall = new ArrayList<Plugin>(resources.length + requiredResources.length); for ( Resource r : resources ) { toInstall.add(new OBRResourcePlugin(r, (StringUtils.matches(coreFeatureSymbolicNamePatterns, r.getSymbolicName()) != null))); } for ( Resource r : requiredResources ) { toInstall.add(new OBRResourcePlugin(r, (StringUtils.matches(coreFeatureSymbolicNamePatterns, r.getSymbolicName()) != null))); } OBRPluginProvisionStatus result = new OBRPluginProvisionStatus(provisionID); result.setPluginsToInstall(toInstall); return result; } /** * Call when an {@link OBRRepository} becomes available. * * @param repository * the repository */ public void onBind(OBRRepository repository) { configureOBRRepository(repository); } /** * Call when an {@link OBRRepository} is no longer available. * * @param repository * the repository */ public void onUnbind(OBRRepository repository) { if ( repository == null || repository.getURL() == null || repositoryAdmin == null ) { return; } for ( Repository repo : repositoryAdmin.listRepositories() ) { URL repoURL = repo.getURL(); if ( repoURL != null && repoURL.equals(repository.getURL()) ) { repositoryAdmin.removeRepository(repoURL); repoStatusMap.remove(repoURL); return; } } } // Settings @Override public String getSettingUID() { return getClass().getName(); } @Override public String getDisplayName() { return "OBR Plugin Service"; } @Override public MessageSource getMessageSource() { return messageSource; } @Override public List<SettingSpecifier> getSettingSpecifiers() { OBRPluginService defaults = new OBRPluginService(); List<SettingSpecifier> result = new ArrayList<SettingSpecifier>(); result.add(new BasicTextFieldSettingSpecifier("restrictingSymbolicNameFilter", defaults.getRestrictingSymbolicNameFilter())); return result; } // Accessors public void setRepositoryAdmin(RepositoryAdmin repositoryAdmin) { this.repositoryAdmin = repositoryAdmin; } public void setRepositories(List<OBRRepository> repositories) { this.repositories = repositories; } public String getRestrictingSymbolicNameFilter() { if ( this.restrictingSymbolicNameFilters == null ) { return null; } return StringUtils .commaDelimitedStringFromCollection(Arrays.asList(this.restrictingSymbolicNameFilters)); } public void setRestrictingSymbolicNameFilter(String restrictingSymbolicNameFilter) { Set<String> set = StringUtils.commaDelimitedStringToSet(restrictingSymbolicNameFilter); this.restrictingSymbolicNameFilters = (set.size() > 0 ? set.toArray(new String[set.size()]) : null); } public void setExclusionSymbolicNameFilters(String[] exclusionSymbolicNameFilters) { this.exclusionSymbolicNameFilters = exclusionSymbolicNameFilters; } public void setBundleContext(BundleContext bundleContext) { this.bundleContext = bundleContext; } public void setDownloadPath(String downloadPath) { this.downloadPath = downloadPath; } public void setProvisionTaskStatusMinimumKeepSeconds(long provisionTaskStatusMinimumKeepSeconds) { this.provisionTaskStatusMinimumKeepSeconds = provisionTaskStatusMinimumKeepSeconds; } public void setBackupManager(OptionalService<BackupManager> backupManager) { this.backupManager = backupManager; } /** * Set the {@code coreFeatureSymbolicNamePatterns} property via string * expressions. The expressions will be compiled into {@link Pattern} * objects and thus must be valid expressions according to that class. * * @param expressions * the expressions to use for {@code coreFeatureSymbolicNamePatterns} */ public void setCoreFeatureSymbolicNameExpressions(String[] expressions) { setCoreFeatureSymbolicNamePatterns(StringUtils.patterns(expressions, 0)); } /** * Get the {@code coreFeatureSymbolicNamePatterns} property as string * values. * * @return {@code coreFeatureSymbolicNamePatterns} as strings, or * <em>null</em> */ public String[] getCoreFeatureSymbolicNameExpressions() { return StringUtils.expressions(coreFeatureSymbolicNamePatterns); } /** * Set a list of regular expressions to use to determine if a plugin is a * "core feature" or not. The expressions will be matched against bundle * symbolic names; if a match is found the plugin will have its * {@link Plugin#isCoreFeature()} flag set to <em>true</em>. * * @param coreFeatureSymbolicNamePatterns * patterns to match against bundle symbolic names */ public void setCoreFeatureSymbolicNamePatterns(Pattern[] coreFeatureSymbolicNamePatterns) { this.coreFeatureSymbolicNamePatterns = coreFeatureSymbolicNamePatterns; } public void setMessageSource(MessageSource messageSource) { this.messageSource = messageSource; } }